Merge branch 'dev' into feature/cover_gestures
This commit is contained in:
commit
b0dd13b9a3
305 changed files with 10844 additions and 5383 deletions
6
.github/CONTRIBUTING.md
vendored
6
.github/CONTRIBUTING.md
vendored
|
@ -39,8 +39,8 @@ If you have knowledge of Android/Kotlin, feel free to to contribute to the proje
|
|||
- If you want to help out with an existing bug report, comment on the issue that you want to fix saying that you are going to try your hand at it.
|
||||
- If you want to add something, its recommended to open up an issue for what you want to change before you start working on it. That way I can determine if the addition will be merged in the first place, and generally gives a heads-up overall.
|
||||
- Do not bring non-free software into the project, such as Binary Blobs.
|
||||
- Stick to [F-Droid Including Guidelines](https://f-droid.org/wiki/page/Inclusion_Policy)
|
||||
- Make sure you stick to Auxio's styling with [ktlint](https://github.com/pinterest/ktlint). `ktlintformat` should run on every build.
|
||||
- Stick to [F-Droid Inclusion Guidelines](https://f-droid.org/wiki/page/Inclusion_Policy)
|
||||
- Make sure you stick to Auxio's styling, which should be auto-formatted on every build.
|
||||
- Please ***FULLY TEST*** your changes before creating a PR. Untested code will not be merged.
|
||||
- Java code will **NOT** be accepted. Kotlin only.
|
||||
- Only **Kotlin** will be accepted, except for the case that a UI component must be vendored in the project.
|
||||
- Keep your code up the date with the upstream and continue to maintain it after you create the PR. This makes it less of a hassle to merge.
|
||||
|
|
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -34,6 +34,7 @@ body:
|
|||
attributes:
|
||||
label: What android version do you use?
|
||||
options:
|
||||
- Android 14
|
||||
- Android 13
|
||||
- Android 12L
|
||||
- Android 12
|
||||
|
|
4
.github/workflows/android.yml
vendored
4
.github/workflows/android.yml
vendored
|
@ -23,8 +23,8 @@ jobs:
|
|||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
# - name: Test app with Gradle
|
||||
# run: ./gradlew app:testDebug
|
||||
- name: Test app with Gradle
|
||||
run: ./gradlew app:testDebug
|
||||
- name: Build debug APK with Gradle
|
||||
run: ./gradlew app:packageDebug
|
||||
- name: Upload debug APK artifact
|
||||
|
|
54
CHANGELOG.md
54
CHANGELOG.md
|
@ -1,5 +1,59 @@
|
|||
# Changelog
|
||||
|
||||
## dev
|
||||
|
||||
#### What's New
|
||||
- Added ability to rewind/skip tracks by swiping back/forward
|
||||
- Added support for demo release type
|
||||
|
||||
#### What's Changed
|
||||
- Albums linked to an artist only as a collaborator are no longer included
|
||||
in an artist's album count
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed certain FLAC files failing to play on some devices
|
||||
|
||||
|
||||
## 3.2.1
|
||||
|
||||
#### What's Improved
|
||||
- Added support for native M4A multi-value tags based on duplicate atoms
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed app restart being required when changing intelligent sorting
|
||||
or music separator settings
|
||||
- Fixed widget/notification actions not working on Android 14
|
||||
- Fixed app crash when using hebrew language
|
||||
- Fixed app crash when adding to a playlist while in the playlist detail view
|
||||
- Fixed music loading failing in some cases on Android 14
|
||||
|
||||
## 3.2.0
|
||||
|
||||
#### What's New
|
||||
- Item and sort menus have been refreshed with a cleaner look
|
||||
- Added ability to sort playlists
|
||||
- Added option to play song by itself in library/item details
|
||||
- Added error details when music loading fails
|
||||
|
||||
#### What's Improved
|
||||
- Made "Add to Playlist" action more prominent in selection toolbar
|
||||
- Fixed notification album covers not updating after changing the cover
|
||||
aspect ratio setting
|
||||
|
||||
#### What's Fixed
|
||||
- Playlist detail view now respects playback settings
|
||||
|
||||
|
||||
#### Dev/Meta
|
||||
- Revamped navigation backend
|
||||
|
||||
## 3.1.4
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed issue where one could not navigate to settings after navigating elsewhere
|
||||
- Fixed the queue list being non-scrollable in certain cases
|
||||
- Fixed negative ReplayGain adjustments not being applied
|
||||
|
||||
## 3.1.3
|
||||
|
||||
#### What's New
|
||||
|
|
21
README.md
21
README.md
|
@ -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.1.3">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.1.3&color=64B5F6&style=flat">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.2.0">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.2.0&color=64B5F6&style=flat">
|
||||
</a>
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
||||
|
@ -21,9 +21,7 @@
|
|||
|
||||
## About
|
||||
|
||||
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of [ExoPlayer](https://exoplayer.dev/), Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
|
||||
|
||||
I primarily built Auxio for myself, but you can use it too, I guess.
|
||||
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of modern media playback libraries, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
|
||||
|
||||
**The default branch is the development version of the repository. For a stable version, see the master branch.**
|
||||
|
||||
|
@ -42,7 +40,7 @@ I primarily built Auxio for myself, but you can use it too, I guess.
|
|||
|
||||
## Features
|
||||
|
||||
- [ExoPlayer](https://exoplayer.dev/)-based playback
|
||||
- Playback based on [Media3 ExoPlayer](https://developer.android.com/guide/topics/media/exoplayer)
|
||||
- Snappy UI derived from the latest Material Design guidelines
|
||||
- Opinionated UX that prioritizes ease of use over edge cases
|
||||
- Customizable behavior
|
||||
|
@ -60,21 +58,20 @@ precise/original dates, sort tags, and more
|
|||
- Headset autoplay
|
||||
- Stylish widgets that automatically adapt to their size
|
||||
- Completely private and offline
|
||||
- No rounded album covers (Unless you want them. Then you can.)
|
||||
- No rounded album covers (by default)
|
||||
|
||||
## Permissions
|
||||
|
||||
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your media files
|
||||
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background
|
||||
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
|
||||
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
|
||||
|
||||
## Building
|
||||
|
||||
Auxio relies on a custom version of ExoPlayer that enables some extra features. This adds some caveats to
|
||||
the build process:
|
||||
Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
|
||||
1. `cmake` and `ninja-build` must be installed before building the project.
|
||||
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
|
||||
download the external code.
|
||||
3. You are **unable** to build this project on windows, as the custom ExoPlayer build runs shell scripts that
|
||||
3. You are **unable** to build this project on windows, as the custom Media3 build runs shell scripts that
|
||||
will only work on unix-based systems.
|
||||
|
||||
## Contributing
|
||||
|
|
|
@ -6,7 +6,8 @@ plugins {
|
|||
id "kotlin-parcelize"
|
||||
id "dagger.hilt.android.plugin"
|
||||
id "kotlin-kapt"
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id "com.google.devtools.ksp"
|
||||
id "org.jetbrains.kotlin.android"
|
||||
}
|
||||
|
||||
android {
|
||||
|
@ -15,13 +16,13 @@ android {
|
|||
// it here so that binary stripping will work.
|
||||
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
|
||||
// NDK use is unified
|
||||
ndkVersion = "23.2.8568313"
|
||||
ndkVersion = "25.2.9519653"
|
||||
namespace "org.oxycblt.auxio"
|
||||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "3.1.3"
|
||||
versionCode 33
|
||||
versionName "3.2.1"
|
||||
versionCode 36
|
||||
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
|
@ -30,6 +31,7 @@ android {
|
|||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
@ -77,17 +79,17 @@ dependencies {
|
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
def coroutines_version = '1.7.1'
|
||||
def coroutines_version = '1.7.2'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
|
||||
|
||||
// --- SUPPORT ---
|
||||
|
||||
// General
|
||||
implementation "androidx.core:core-ktx:1.10.1"
|
||||
implementation "androidx.core:core-ktx:1.12.0"
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation "androidx.activity:activity-ktx:1.7.2"
|
||||
implementation "androidx.fragment:fragment-ktx:1.6.0"
|
||||
implementation "androidx.activity:activity-ktx:1.8.2"
|
||||
implementation "androidx.fragment:fragment-ktx:1.6.2"
|
||||
|
||||
// Components
|
||||
// Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on
|
||||
|
@ -99,7 +101,7 @@ dependencies {
|
|||
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||
|
||||
// Lifecycle
|
||||
def lifecycle_version = "2.6.1"
|
||||
def lifecycle_version = "2.6.2"
|
||||
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
|
@ -110,15 +112,15 @@ dependencies {
|
|||
implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
|
||||
|
||||
// Media
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
implementation "androidx.media:media:1.7.0"
|
||||
|
||||
// Preferences
|
||||
implementation "androidx.preference:preference-ktx:1.2.0"
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
// Database
|
||||
def room_version = '2.6.0-alpha02'
|
||||
def room_version = '2.6.1'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
kapt "androidx.room:room-compiler:$room_version"
|
||||
ksp "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
|
||||
// --- THIRD PARTY ---
|
||||
|
@ -126,6 +128,7 @@ dependencies {
|
|||
// Exoplayer (Vendored)
|
||||
implementation project(":media-lib-exoplayer")
|
||||
implementation project(":media-lib-decoder-ffmpeg")
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
||||
|
||||
// Image loading
|
||||
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||
|
@ -133,7 +136,7 @@ dependencies {
|
|||
// Material
|
||||
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
|
||||
// PR a fix.
|
||||
implementation "com.google.android.material:material:1.10.0-alpha04"
|
||||
implementation "com.google.android.material:material:1.10.0"
|
||||
|
||||
// Dependency Injection
|
||||
implementation "com.google.dagger:dagger:$hilt_version"
|
||||
|
@ -141,9 +144,15 @@ dependencies {
|
|||
implementation "com.google.dagger:hilt-android:$hilt_version"
|
||||
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
|
||||
|
||||
// Logging
|
||||
implementation 'com.jakewharton.timber:timber:5.0.1'
|
||||
|
||||
// Testing
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
testImplementation "io.mockk:mockk:1.13.7"
|
||||
testImplementation "org.robolectric:robolectric:4.9"
|
||||
testImplementation 'androidx.test:core-ktx:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
}
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* StubTest.kt is part of Auxio.
|
||||
*
|
||||
* 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.debug", appContext.packageName)
|
||||
}
|
||||
}
|
|
@ -1671,9 +1671,8 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
@Nullable
|
||||
@VisibleForTesting
|
||||
View findScrollingChild(View view) {
|
||||
if (view.getVisibility() != View.VISIBLE) {
|
||||
return null;
|
||||
}
|
||||
// MODIFICATION: Remove visibility check that broke nested scrolling in the queue sheet
|
||||
// due to it being set to invisible when completely hidden
|
||||
if (ViewCompat.isNestedScrollingEnabled(view)) {
|
||||
return view;
|
||||
}
|
||||
|
@ -1738,16 +1737,10 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
final boolean shouldHandleGestureInsets =
|
||||
VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto;
|
||||
|
||||
// If were not handling insets at all, don't apply the listener.
|
||||
if (!paddingBottomSystemWindowInsets
|
||||
&& !paddingLeftSystemWindowInsets
|
||||
&& !paddingRightSystemWindowInsets
|
||||
&& !marginLeftSystemWindowInsets
|
||||
&& !marginRightSystemWindowInsets
|
||||
&& !marginTopSystemWindowInsets
|
||||
&& !shouldHandleGestureInsets) {
|
||||
return;
|
||||
}
|
||||
// MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves
|
||||
// don't need peek height adjustments (Despite the fact that they still likely padding
|
||||
// the view, just without clipping anything)
|
||||
|
||||
ViewUtils.doOnApplyWindowInsets(
|
||||
child,
|
||||
new ViewUtils.OnApplyWindowInsetsListener() {
|
||||
|
@ -1759,7 +1752,16 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
Insets mandatoryGestureInsets =
|
||||
insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures());
|
||||
|
||||
insetTop = systemBarInsets.top;
|
||||
// MODIFICATION: Fix second order change of edge-to-edge fix where dialogs will not
|
||||
// use the nice-looking inset animation and instead blindly shift themselves downwards.
|
||||
// insetTop = systemBarInsets.top;
|
||||
|
||||
// MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves
|
||||
// don't need peek height adjustments (Despite the fact that they still likely padding
|
||||
// the view, just without clipping anything)
|
||||
// Intentionally uses getSystemWindowInsetBottom to apply padding properly when
|
||||
// adjustResize is used as the windowSoftInputMode.
|
||||
insetBottom = insets.getSystemWindowInsetBottom();
|
||||
|
||||
boolean isRtl = ViewUtils.isLayoutRtl(view);
|
||||
|
||||
|
@ -1768,9 +1770,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
int rightPadding = view.getPaddingRight();
|
||||
|
||||
if (paddingBottomSystemWindowInsets) {
|
||||
// Intentionally uses getSystemWindowInsetBottom to apply padding properly when
|
||||
// adjustResize is used as the windowSoftInputMode.
|
||||
insetBottom = insets.getSystemWindowInsetBottom();
|
||||
bottomPadding = initialPadding.bottom + insetBottom;
|
||||
}
|
||||
|
||||
|
@ -1811,11 +1810,10 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
gestureInsetBottom = mandatoryGestureInsets.bottom;
|
||||
}
|
||||
|
||||
// Don't update the peek height to be above the navigation bar or gestures if these
|
||||
// flags are off. It means the client is already handling it.
|
||||
if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) {
|
||||
updatePeekHeight(/* animate= */ false);
|
||||
}
|
||||
// MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves
|
||||
// don't need peek height adjustments (Despite the fact that they still likely padding
|
||||
// the view, just without clipping anything)
|
||||
updatePeekHeight(/* animate= */ false);
|
||||
return insets;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,549 @@
|
|||
/*
|
||||
* Copyright (C) 2015 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.bottomsheet;
|
||||
|
||||
import com.google.android.material.R;
|
||||
|
||||
import static com.google.android.material.color.MaterialColors.isColorLight;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.os.Build;
|
||||
import android.os.Build.VERSION;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import android.os.Bundle;
|
||||
import androidx.appcompat.app.AppCompatDialog;
|
||||
import android.util.TypedValue;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager.LayoutParams;
|
||||
import android.widget.FrameLayout;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StyleRes;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
import androidx.core.view.AccessibilityDelegateCompat;
|
||||
import androidx.core.view.OnApplyWindowInsetsListener;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.core.view.WindowInsetsControllerCompat;
|
||||
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
|
||||
import com.google.android.material.internal.EdgeToEdgeUtils;
|
||||
import com.google.android.material.motion.MaterialBackOrchestrator;
|
||||
import com.google.android.material.shape.MaterialShapeDrawable;
|
||||
|
||||
/**
|
||||
* Base class for {@link android.app.Dialog}s styled as a bottom sheet.
|
||||
*
|
||||
* <p>Edge to edge window flags are automatically applied if the {@link
|
||||
* android.R.attr#navigationBarColor} is transparent or translucent and {@code enableEdgeToEdge} is
|
||||
* true. These can be set in the theme that is passed to the constructor, or will be taken from the
|
||||
* theme of the context (ie. your application or activity theme).
|
||||
*
|
||||
* <p>In edge to edge mode, padding will be added automatically to the top when sliding under the
|
||||
* status bar. Padding can be applied automatically to the left, right, or bottom if any of
|
||||
* `paddingBottomSystemWindowInsets`, `paddingLeftSystemWindowInsets`, or
|
||||
* `paddingRightSystemWindowInsets` are set to true in the style.
|
||||
*
|
||||
* MODIFICATION: Replace all usages of BottomSheetBehavior with BackportBottomSheetBehavior
|
||||
*/
|
||||
public class BackportBottomSheetDialog extends AppCompatDialog {
|
||||
|
||||
private BackportBottomSheetBehavior<FrameLayout> behavior;
|
||||
|
||||
private FrameLayout container;
|
||||
private CoordinatorLayout coordinator;
|
||||
private FrameLayout bottomSheet;
|
||||
|
||||
boolean dismissWithAnimation;
|
||||
|
||||
boolean cancelable = true;
|
||||
private boolean canceledOnTouchOutside = true;
|
||||
private boolean canceledOnTouchOutsideSet;
|
||||
private EdgeToEdgeCallback edgeToEdgeCallback;
|
||||
private boolean edgeToEdgeEnabled;
|
||||
@Nullable private MaterialBackOrchestrator backOrchestrator;
|
||||
|
||||
public BackportBottomSheetDialog(@NonNull Context context) {
|
||||
this(context, 0);
|
||||
|
||||
edgeToEdgeEnabled =
|
||||
getContext()
|
||||
.getTheme()
|
||||
.obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
|
||||
.getBoolean(0, false);
|
||||
}
|
||||
|
||||
public BackportBottomSheetDialog(@NonNull Context context, @StyleRes int theme) {
|
||||
super(context, getThemeResId(context, theme));
|
||||
// We hide the title bar for any style configuration. Otherwise, there will be a gap
|
||||
// above the bottom sheet when it is expanded.
|
||||
supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
|
||||
edgeToEdgeEnabled =
|
||||
getContext()
|
||||
.getTheme()
|
||||
.obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
|
||||
.getBoolean(0, false);
|
||||
}
|
||||
|
||||
protected BackportBottomSheetDialog(
|
||||
@NonNull Context context, boolean cancelable, OnCancelListener cancelListener) {
|
||||
super(context, cancelable, cancelListener);
|
||||
supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||
this.cancelable = cancelable;
|
||||
|
||||
edgeToEdgeEnabled =
|
||||
getContext()
|
||||
.getTheme()
|
||||
.obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge})
|
||||
.getBoolean(0, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentView(@LayoutRes int layoutResId) {
|
||||
super.setContentView(wrapInBottomSheet(layoutResId, null, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Window window = getWindow();
|
||||
if (window != null) {
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
|
||||
// The status bar should always be transparent because of the window animation.
|
||||
window.setStatusBarColor(0);
|
||||
|
||||
window.addFlags(LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
|
||||
if (VERSION.SDK_INT < VERSION_CODES.M) {
|
||||
// It can be transparent for API 23 and above because we will handle switching the status
|
||||
// bar icons to light or dark as appropriate. For API 21 and API 22 we just set the
|
||||
// translucent status bar.
|
||||
window.addFlags(LayoutParams.FLAG_TRANSLUCENT_STATUS);
|
||||
}
|
||||
}
|
||||
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentView(View view) {
|
||||
super.setContentView(wrapInBottomSheet(0, view, null));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setContentView(View view, ViewGroup.LayoutParams params) {
|
||||
super.setContentView(wrapInBottomSheet(0, view, params));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCancelable(boolean cancelable) {
|
||||
super.setCancelable(cancelable);
|
||||
if (this.cancelable != cancelable) {
|
||||
this.cancelable = cancelable;
|
||||
if (behavior != null) {
|
||||
behavior.setHideable(cancelable);
|
||||
}
|
||||
if (getWindow() != null) {
|
||||
updateListeningForBackCallbacks();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
if (behavior != null && behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
behavior.setState(BackportBottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
Window window = getWindow();
|
||||
if (window != null) {
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
|
||||
// If the navigation bar is transparent at all the BottomSheet should be edge to edge.
|
||||
boolean drawEdgeToEdge =
|
||||
edgeToEdgeEnabled && Color.alpha(window.getNavigationBarColor()) < 255;
|
||||
if (container != null) {
|
||||
container.setFitsSystemWindows(!drawEdgeToEdge);
|
||||
}
|
||||
if (coordinator != null) {
|
||||
coordinator.setFitsSystemWindows(!drawEdgeToEdge);
|
||||
}
|
||||
WindowCompat.setDecorFitsSystemWindows(window, !drawEdgeToEdge);
|
||||
}
|
||||
if (edgeToEdgeCallback != null) {
|
||||
edgeToEdgeCallback.setWindow(window);
|
||||
}
|
||||
}
|
||||
|
||||
updateListeningForBackCallbacks();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetachedFromWindow() {
|
||||
if (edgeToEdgeCallback != null) {
|
||||
edgeToEdgeCallback.setWindow(null);
|
||||
}
|
||||
|
||||
if (backOrchestrator != null) {
|
||||
backOrchestrator.stopListeningForBackCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function can be called from a few different use cases, including Swiping the dialog down
|
||||
* or calling `dismiss()` from a `BackportBottomSheetDialogFragment`, tapping outside a dialog, etc...
|
||||
*
|
||||
* <p>The default animation to dismiss this dialog is a fade-out transition through a
|
||||
* windowAnimation. Call {@link #setDismissWithAnimation(true)} if you want to utilize the
|
||||
* BottomSheet animation instead.
|
||||
*
|
||||
* <p>If this function is called from a swipe down interaction, or dismissWithAnimation is false,
|
||||
* then keep the default behavior.
|
||||
*
|
||||
* <p>Else, since this is a terminal event which will finish this dialog, we override the attached
|
||||
* {@link BackportBottomSheetBehavior.BottomSheetCallback} to call this function, after {@link
|
||||
* BackportBottomSheetBehavior#STATE_HIDDEN} is set. This will enforce the swipe down animation before
|
||||
* canceling this dialog.
|
||||
*/
|
||||
@Override
|
||||
public void cancel() {
|
||||
BackportBottomSheetBehavior<FrameLayout> behavior = getBehavior();
|
||||
|
||||
if (!dismissWithAnimation || behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
super.cancel();
|
||||
} else {
|
||||
behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCanceledOnTouchOutside(boolean cancel) {
|
||||
super.setCanceledOnTouchOutside(cancel);
|
||||
if (cancel && !cancelable) {
|
||||
cancelable = true;
|
||||
}
|
||||
canceledOnTouchOutside = cancel;
|
||||
canceledOnTouchOutsideSet = true;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public BackportBottomSheetBehavior<FrameLayout> getBehavior() {
|
||||
if (behavior == null) {
|
||||
// The content hasn't been set, so the behavior doesn't exist yet. Let's create it.
|
||||
ensureContainerAndBehavior();
|
||||
}
|
||||
return behavior;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set to perform the swipe down animation when dismissing instead of the window animation for the
|
||||
* dialog.
|
||||
*
|
||||
* @param dismissWithAnimation True if swipe down animation should be used when dismissing.
|
||||
*/
|
||||
public void setDismissWithAnimation(boolean dismissWithAnimation) {
|
||||
this.dismissWithAnimation = dismissWithAnimation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if dismissing will perform the swipe down animation on the bottom sheet, rather than
|
||||
* the window animation for the dialog.
|
||||
*/
|
||||
public boolean getDismissWithAnimation() {
|
||||
return dismissWithAnimation;
|
||||
}
|
||||
|
||||
/** Returns if edge to edge behavior is enabled for this dialog. */
|
||||
public boolean getEdgeToEdgeEnabled() {
|
||||
return edgeToEdgeEnabled;
|
||||
}
|
||||
|
||||
/** Creates the container layout which must exist to find the behavior */
|
||||
private FrameLayout ensureContainerAndBehavior() {
|
||||
if (container == null) {
|
||||
container =
|
||||
(FrameLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null);
|
||||
|
||||
coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
|
||||
bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet);
|
||||
|
||||
// MODIFICATION: Override layout-specified BottomSheetBehavior w/BackportBottomSheetBehavior
|
||||
behavior = BackportBottomSheetBehavior.from(bottomSheet);
|
||||
behavior.addBottomSheetCallback(bottomSheetCallback);
|
||||
behavior.setHideable(cancelable);
|
||||
|
||||
backOrchestrator = new MaterialBackOrchestrator(behavior, bottomSheet);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
private View wrapInBottomSheet(
|
||||
int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) {
|
||||
ensureContainerAndBehavior();
|
||||
CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
|
||||
if (layoutResId != 0 && view == null) {
|
||||
view = getLayoutInflater().inflate(layoutResId, coordinator, false);
|
||||
}
|
||||
|
||||
if (edgeToEdgeEnabled) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(
|
||||
bottomSheet,
|
||||
new OnApplyWindowInsetsListener() {
|
||||
@Override
|
||||
public WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) {
|
||||
if (edgeToEdgeCallback != null) {
|
||||
behavior.removeBottomSheetCallback(edgeToEdgeCallback);
|
||||
}
|
||||
|
||||
if (insets != null) {
|
||||
edgeToEdgeCallback = new EdgeToEdgeCallback(bottomSheet, insets);
|
||||
edgeToEdgeCallback.setWindow(getWindow());
|
||||
behavior.addBottomSheetCallback(edgeToEdgeCallback);
|
||||
}
|
||||
|
||||
return insets;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bottomSheet.removeAllViews();
|
||||
if (params == null) {
|
||||
bottomSheet.addView(view);
|
||||
} else {
|
||||
bottomSheet.addView(view, params);
|
||||
}
|
||||
// We treat the CoordinatorLayout as outside the dialog though it is technically inside
|
||||
coordinator
|
||||
.findViewById(R.id.touch_outside)
|
||||
.setOnClickListener(
|
||||
new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
if (cancelable && isShowing() && shouldWindowCloseOnTouchOutside()) {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Handle accessibility events
|
||||
ViewCompat.setAccessibilityDelegate(
|
||||
bottomSheet,
|
||||
new AccessibilityDelegateCompat() {
|
||||
@Override
|
||||
public void onInitializeAccessibilityNodeInfo(
|
||||
View host, @NonNull AccessibilityNodeInfoCompat info) {
|
||||
super.onInitializeAccessibilityNodeInfo(host, info);
|
||||
if (cancelable) {
|
||||
info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS);
|
||||
info.setDismissable(true);
|
||||
} else {
|
||||
info.setDismissable(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean performAccessibilityAction(View host, int action, Bundle args) {
|
||||
if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) {
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
return super.performAccessibilityAction(host, action, args);
|
||||
}
|
||||
});
|
||||
bottomSheet.setOnTouchListener(
|
||||
new View.OnTouchListener() {
|
||||
@Override
|
||||
public boolean onTouch(View view, MotionEvent event) {
|
||||
// Consume the event and prevent it from falling through
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
private void updateListeningForBackCallbacks() {
|
||||
if (backOrchestrator == null) {
|
||||
return;
|
||||
}
|
||||
if (cancelable) {
|
||||
backOrchestrator.startListeningForBackCallbacks();
|
||||
} else {
|
||||
backOrchestrator.stopListeningForBackCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
boolean shouldWindowCloseOnTouchOutside() {
|
||||
if (!canceledOnTouchOutsideSet) {
|
||||
TypedArray a =
|
||||
getContext().obtainStyledAttributes(new int[] {android.R.attr.windowCloseOnTouchOutside});
|
||||
canceledOnTouchOutside = a.getBoolean(0, true);
|
||||
a.recycle();
|
||||
canceledOnTouchOutsideSet = true;
|
||||
}
|
||||
return canceledOnTouchOutside;
|
||||
}
|
||||
|
||||
private static int getThemeResId(@NonNull Context context, int themeId) {
|
||||
if (themeId == 0) {
|
||||
// If the provided theme is 0, then retrieve the dialogTheme from our theme
|
||||
TypedValue outValue = new TypedValue();
|
||||
if (context.getTheme().resolveAttribute(R.attr.bottomSheetDialogTheme, outValue, true)) {
|
||||
themeId = outValue.resourceId;
|
||||
} else {
|
||||
// bottomSheetDialogTheme is not provided; we default to our light theme
|
||||
themeId = R.style.Theme_Design_Light_BottomSheetDialog;
|
||||
}
|
||||
}
|
||||
return themeId;
|
||||
}
|
||||
|
||||
void removeDefaultCallback() {
|
||||
behavior.removeBottomSheetCallback(bottomSheetCallback);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private BackportBottomSheetBehavior.BottomSheetCallback bottomSheetCallback =
|
||||
new BackportBottomSheetBehavior.BottomSheetCallback() {
|
||||
@Override
|
||||
public void onStateChanged(
|
||||
@NonNull View bottomSheet, @BackportBottomSheetBehavior.State int newState) {
|
||||
if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSlide(@NonNull View bottomSheet, float slideOffset) {}
|
||||
};
|
||||
|
||||
private static class EdgeToEdgeCallback extends BackportBottomSheetBehavior.BottomSheetCallback {
|
||||
|
||||
@Nullable private final Boolean lightBottomSheet;
|
||||
@NonNull private final WindowInsetsCompat insetsCompat;
|
||||
|
||||
@Nullable private Window window;
|
||||
private boolean lightStatusBar;
|
||||
|
||||
private EdgeToEdgeCallback(
|
||||
@NonNull final View bottomSheet, @NonNull WindowInsetsCompat insetsCompat) {
|
||||
this.insetsCompat = insetsCompat;
|
||||
|
||||
// Try to find the background color to automatically change the status bar icons so they will
|
||||
// still be visible when the bottomsheet slides underneath the status bar.
|
||||
ColorStateList backgroundTint;
|
||||
MaterialShapeDrawable msd = BackportBottomSheetBehavior.from(bottomSheet).getMaterialShapeDrawable();
|
||||
if (msd != null) {
|
||||
backgroundTint = msd.getFillColor();
|
||||
} else {
|
||||
backgroundTint = ViewCompat.getBackgroundTintList(bottomSheet);
|
||||
}
|
||||
|
||||
if (backgroundTint != null) {
|
||||
// First check for a tint
|
||||
lightBottomSheet = isColorLight(backgroundTint.getDefaultColor());
|
||||
} else if (bottomSheet.getBackground() instanceof ColorDrawable) {
|
||||
// Then check for the background color
|
||||
lightBottomSheet = isColorLight(((ColorDrawable) bottomSheet.getBackground()).getColor());
|
||||
} else {
|
||||
// Otherwise don't change the status bar color
|
||||
lightBottomSheet = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStateChanged(@NonNull View bottomSheet, int newState) {
|
||||
setPaddingForPosition(bottomSheet);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
|
||||
setPaddingForPosition(bottomSheet);
|
||||
}
|
||||
|
||||
@Override
|
||||
void onLayout(@NonNull View bottomSheet) {
|
||||
setPaddingForPosition(bottomSheet);
|
||||
}
|
||||
|
||||
void setWindow(@Nullable Window window) {
|
||||
if (this.window == window) {
|
||||
return;
|
||||
}
|
||||
this.window = window;
|
||||
if (window != null) {
|
||||
WindowInsetsControllerCompat insetsController =
|
||||
WindowCompat.getInsetsController(window, window.getDecorView());
|
||||
lightStatusBar = insetsController.isAppearanceLightStatusBars();
|
||||
}
|
||||
}
|
||||
|
||||
private void setPaddingForPosition(View bottomSheet) {
|
||||
if (bottomSheet.getTop() < insetsCompat.getSystemWindowInsetTop()) {
|
||||
// If the bottomsheet is light, we should set light status bar so the icons are visible
|
||||
// since the bottomsheet is now under the status bar.
|
||||
if (window != null) {
|
||||
EdgeToEdgeUtils.setLightStatusBar(
|
||||
window, lightBottomSheet == null ? lightStatusBar : lightBottomSheet);
|
||||
}
|
||||
// Smooth transition into status bar when drawing edge to edge.
|
||||
bottomSheet.setPadding(
|
||||
bottomSheet.getPaddingLeft(),
|
||||
(insetsCompat.getSystemWindowInsetTop() - bottomSheet.getTop()),
|
||||
bottomSheet.getPaddingRight(),
|
||||
bottomSheet.getPaddingBottom());
|
||||
} else if (bottomSheet.getTop() != 0) {
|
||||
// Reset the status bar icons to the original color because the bottomsheet is not under the
|
||||
// status bar.
|
||||
if (window != null) {
|
||||
EdgeToEdgeUtils.setLightStatusBar(window, lightStatusBar);
|
||||
}
|
||||
bottomSheet.setPadding(
|
||||
bottomSheet.getPaddingLeft(),
|
||||
0,
|
||||
bottomSheet.getPaddingRight(),
|
||||
bottomSheet.getPaddingBottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use {@link EdgeToEdgeUtils#setLightStatusBar(Window, boolean)} instead
|
||||
*/
|
||||
@Deprecated
|
||||
public static void setLightStatusBar(@NonNull View view, boolean isLight) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
int flags = view.getSystemUiVisibility();
|
||||
if (isLight) {
|
||||
flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
|
||||
} else {
|
||||
flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
|
||||
}
|
||||
view.setSystemUiVisibility(flags);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright (C) 2015 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.bottomsheet;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import androidx.appcompat.app.AppCompatDialogFragment;
|
||||
import android.view.View;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Modal bottom sheet. This is a version of {@link androidx.fragment.app.DialogFragment} that shows
|
||||
* a bottom sheet using {@link BackportBottomSheetDialog} instead of a floating dialog.
|
||||
*/
|
||||
public class BackportBottomSheetDialogFragment extends AppCompatDialogFragment {
|
||||
|
||||
/**
|
||||
* Tracks if we are waiting for a dismissAllowingStateLoss or a regular dismiss once the
|
||||
* BottomSheet is hidden and onStateChanged() is called.
|
||||
*/
|
||||
private boolean waitingForDismissAllowingStateLoss;
|
||||
|
||||
public BackportBottomSheetDialogFragment() {}
|
||||
|
||||
@SuppressLint("ValidFragment")
|
||||
public BackportBottomSheetDialogFragment(@LayoutRes int contentLayoutId) {
|
||||
super(contentLayoutId);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
|
||||
return new BackportBottomSheetDialog(getContext(), getTheme());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dismiss() {
|
||||
if (!tryDismissWithAnimation(false)) {
|
||||
super.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dismissAllowingStateLoss() {
|
||||
if (!tryDismissWithAnimation(true)) {
|
||||
super.dismissAllowingStateLoss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible,
|
||||
* false otherwise.
|
||||
*/
|
||||
private boolean tryDismissWithAnimation(boolean allowingStateLoss) {
|
||||
Dialog baseDialog = getDialog();
|
||||
if (baseDialog instanceof BackportBottomSheetDialog) {
|
||||
BackportBottomSheetDialog dialog = (BackportBottomSheetDialog) baseDialog;
|
||||
BackportBottomSheetBehavior<?> behavior = dialog.getBehavior();
|
||||
if (behavior.isHideable() && dialog.getDismissWithAnimation()) {
|
||||
dismissWithAnimation(behavior, allowingStateLoss);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void dismissWithAnimation(
|
||||
@NonNull BackportBottomSheetBehavior<?> behavior, boolean allowingStateLoss) {
|
||||
waitingForDismissAllowingStateLoss = allowingStateLoss;
|
||||
|
||||
if (behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
dismissAfterAnimation();
|
||||
} else {
|
||||
if (getDialog() instanceof BackportBottomSheetDialog) {
|
||||
((BackportBottomSheetDialog) getDialog()).removeDefaultCallback();
|
||||
}
|
||||
behavior.addBottomSheetCallback(new BottomSheetDismissCallback());
|
||||
behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private void dismissAfterAnimation() {
|
||||
if (waitingForDismissAllowingStateLoss) {
|
||||
super.dismissAllowingStateLoss();
|
||||
} else {
|
||||
super.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
private class BottomSheetDismissCallback extends BackportBottomSheetBehavior.BottomSheetCallback {
|
||||
|
||||
@Override
|
||||
public void onStateChanged(@NonNull View bottomSheet, int newState) {
|
||||
if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
dismissAfterAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSlide(@NonNull View bottomSheet, float slideOffset) {}
|
||||
}
|
||||
}
|
|
@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeSettings
|
|||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* A simple, rational music player for android.
|
||||
|
@ -44,6 +45,10 @@ class Auxio : Application() {
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
|
||||
// Migrate any settings that may have changed in an app update.
|
||||
imageSettings.migrate()
|
||||
playbackSettings.migrate()
|
||||
|
|
|
@ -65,14 +65,14 @@ object IntegerTable {
|
|||
const val REPEAT_MODE_ALL = 0xA101
|
||||
/** RepeatMode.TRACK */
|
||||
const val REPEAT_MODE_TRACK = 0xA102
|
||||
/** PlaybackMode.IN_GENRE */
|
||||
const val PLAYBACK_MODE_IN_GENRE = 0xA103
|
||||
/** PlaybackMode.IN_ARTIST */
|
||||
const val PLAYBACK_MODE_IN_ARTIST = 0xA104
|
||||
/** PlaybackMode.IN_ALBUM */
|
||||
const val PLAYBACK_MODE_IN_ALBUM = 0xA105
|
||||
/** PlaybackMode.ALL_SONGS */
|
||||
const val PLAYBACK_MODE_ALL_SONGS = 0xA106
|
||||
// /** PlaybackMode.IN_GENRE (No longer used but still reserved) */
|
||||
// const val PLAYBACK_MODE_IN_GENRE = 0xA103
|
||||
// /** PlaybackMode.IN_ARTIST (No longer used but still reserved) */
|
||||
// const val PLAYBACK_MODE_IN_ARTIST = 0xA104
|
||||
// /** PlaybackMode.IN_ALBUM (No longer used but still reserved) */
|
||||
// const val PLAYBACK_MODE_IN_ALBUM = 0xA105
|
||||
// /** PlaybackMode.ALL_SONGS (No longer used but still reserved) */
|
||||
// const val PLAYBACK_MODE_ALL_SONGS = 0xA106
|
||||
/** MusicMode.SONGS */
|
||||
const val MUSIC_MODE_SONGS = 0xA10B
|
||||
/** MusicMode.ALBUMS */
|
||||
|
@ -101,8 +101,6 @@ object IntegerTable {
|
|||
const val SORT_BY_TRACK = 0xA117
|
||||
/** Sort.Mode.ByDateAdded */
|
||||
const val SORT_BY_DATE_ADDED = 0xA118
|
||||
/** Sort.Mode.None */
|
||||
const val SORT_BY_NONE = 0xA11F
|
||||
/** ReplayGainMode.Off (No longer used but still reserved) */
|
||||
// const val REPLAY_GAIN_MODE_OFF = 0xA110
|
||||
/** ReplayGainMode.Track */
|
||||
|
@ -123,4 +121,16 @@ object IntegerTable {
|
|||
const val COVER_MODE_MEDIA_STORE = 0xA11D
|
||||
/** CoverMode.Quality */
|
||||
const val COVER_MODE_QUALITY = 0xA11E
|
||||
/** PlaySong.FromAll */
|
||||
const val PLAY_SONG_FROM_ALL = 0xA11F
|
||||
/** PlaySong.FromAlbum */
|
||||
const val PLAY_SONG_FROM_ALBUM = 0xA120
|
||||
/** PlaySong.FromArtist */
|
||||
const val PLAY_SONG_FROM_ARTIST = 0xA121
|
||||
/** PlaySong.FromGenre */
|
||||
const val PLAY_SONG_FROM_GENRE = 0xA122
|
||||
/** PlaySong.FromPlaylist */
|
||||
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
|
||||
/** PlaySong.ByItself */
|
||||
const val PLAY_SONG_BY_ITSELF = 0xA124
|
||||
}
|
||||
|
|
|
@ -68,8 +68,8 @@ class MainActivity : AppCompatActivity() {
|
|||
logD("Activity created")
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
startService(Intent(this, IndexerService::class.java))
|
||||
startService(Intent(this, PlaybackService::class.java))
|
||||
|
|
|
@ -26,11 +26,9 @@ import androidx.activity.OnBackPressedCallback
|
|||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
|
@ -40,44 +38,47 @@ import kotlin.math.max
|
|||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.detail.Show
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.Outer
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.Panel
|
||||
import org.oxycblt.auxio.playback.OpenPanel
|
||||
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* A wrapper around the home fragment that shows the playback fragment and controls the more
|
||||
* high-level navigation features.
|
||||
* A wrapper around the home fragment that shows the playback fragment and high-level navigation.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainFragment :
|
||||
ViewBindingFragment<FragmentMainBinding>(),
|
||||
ViewTreeObserver.OnPreDrawListener,
|
||||
NavController.OnDestinationChangedListener {
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val selectionModel: SelectionViewModel by activityViewModels()
|
||||
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val listModel: ListViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private var sheetBackCallback: SheetBackPressedCallback? = null
|
||||
private var detailBackCallback: DetailBackPressedCallback? = null
|
||||
private var selectionBackCallback: SelectionBackPressedCallback? = null
|
||||
private var exploreBackCallback: ExploreBackPressedCallback? = null
|
||||
private var selectionNavigationListener: DialogAwareNavigationListener? = null
|
||||
private var lastInsets: WindowInsets? = null
|
||||
private var elevationNormal = 0f
|
||||
private var initialNavDestinationChange = true
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -100,28 +101,19 @@ class MainFragment :
|
|||
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
|
||||
// that instantiating these callbacks in their respective fragments would result in the
|
||||
// correct order.
|
||||
val sheetBackCallback =
|
||||
sheetBackCallback =
|
||||
SheetBackPressedCallback(
|
||||
playbackSheetBehavior = playbackSheetBehavior,
|
||||
queueSheetBehavior = queueSheetBehavior)
|
||||
.also { sheetBackCallback = it }
|
||||
playbackSheetBehavior = playbackSheetBehavior,
|
||||
queueSheetBehavior = queueSheetBehavior)
|
||||
val detailBackCallback =
|
||||
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
|
||||
val selectionBackCallback =
|
||||
SelectionBackPressedCallback(selectionModel).also { selectionBackCallback = it }
|
||||
val exploreBackCallback =
|
||||
ExploreBackPressedCallback(binding.exploreNavHost).also { exploreBackCallback = it }
|
||||
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
|
||||
|
||||
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
|
||||
|
||||
// --- UI SETUP ---
|
||||
val context = requireActivity()
|
||||
// Override the back pressed listener so we can map back navigation to collapsing
|
||||
// navigation, navigation out of detail views, etc.
|
||||
context.onBackPressedDispatcher.apply {
|
||||
addCallback(viewLifecycleOwner, exploreBackCallback)
|
||||
addCallback(viewLifecycleOwner, selectionBackCallback)
|
||||
addCallback(viewLifecycleOwner, detailBackCallback)
|
||||
addCallback(viewLifecycleOwner, sheetBackCallback)
|
||||
}
|
||||
|
||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
lastInsets = insets
|
||||
|
@ -159,8 +151,14 @@ class MainFragment :
|
|||
}
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
// This has to be done here instead of the playback panel to make sure that it's prioritized
|
||||
// by StateFlow over any detail fragment.
|
||||
// FIXME: This is a consequence of sharing events across several consumers. There has to be
|
||||
// a better way of doing this.
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
|
||||
collectImmediately(selectionModel.selected, selectionBackCallback::invalidateEnabled)
|
||||
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
|
||||
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
|
||||
}
|
||||
|
@ -170,17 +168,30 @@ class MainFragment :
|
|||
val binding = requireBinding()
|
||||
// Once we add the destination change callback, we will receive another initialization call,
|
||||
// so handle that by resetting the flag.
|
||||
initialNavDestinationChange = false
|
||||
binding.exploreNavHost.findNavController().addOnDestinationChangedListener(this)
|
||||
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
|
||||
.attach(binding.exploreNavHost.findNavController())
|
||||
// Listener could still reasonably fire even if we clear the binding, attach/detach
|
||||
// our pre-draw listener our listener in onStart/onStop respectively.
|
||||
binding.playbackSheet.viewTreeObserver.addOnPreDrawListener(this@MainFragment)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Override the back pressed listener so we can map back navigation to collapsing
|
||||
// navigation, navigation out of detail views, etc. We have to do this here in
|
||||
// onResume or otherwise the FragmentManager will have precedence.
|
||||
requireActivity().onBackPressedDispatcher.apply {
|
||||
addCallback(viewLifecycleOwner, requireNotNull(selectionBackCallback))
|
||||
addCallback(viewLifecycleOwner, requireNotNull(detailBackCallback))
|
||||
addCallback(viewLifecycleOwner, requireNotNull(sheetBackCallback))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
val binding = requireBinding()
|
||||
binding.exploreNavHost.findNavController().removeOnDestinationChangedListener(this)
|
||||
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
|
||||
.release(binding.exploreNavHost.findNavController())
|
||||
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
}
|
||||
|
||||
|
@ -189,10 +200,14 @@ class MainFragment :
|
|||
sheetBackCallback = null
|
||||
detailBackCallback = null
|
||||
selectionBackCallback = null
|
||||
exploreBackCallback = null
|
||||
selectionNavigationListener = null
|
||||
}
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
|
||||
// sheets continually getting stuck. I need something with even more frequent updates,
|
||||
// or otherwise bottom sheets get stuck.
|
||||
|
||||
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||
// listener functionality. Just update all transitions before every draw. Should
|
||||
// probably be cheap enough.
|
||||
|
@ -283,21 +298,29 @@ class MainFragment :
|
|||
return true
|
||||
}
|
||||
|
||||
override fun onDestinationChanged(
|
||||
controller: NavController,
|
||||
destination: NavDestination,
|
||||
arguments: Bundle?
|
||||
) {
|
||||
// Drop the initial call by NavController that simply provides us with the current
|
||||
// destination. This would cause the selection state to be lost every time the device
|
||||
// rotates.
|
||||
requireNotNull(exploreBackCallback) { "ExploreBackPressedCallback was not available" }
|
||||
.invalidateEnabled()
|
||||
if (!initialNavDestinationChange) {
|
||||
initialNavDestinationChange = true
|
||||
return
|
||||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongAlbumDetails,
|
||||
is Show.ArtistDetails,
|
||||
is Show.AlbumDetails -> playbackModel.openMain()
|
||||
is Show.SongDetails,
|
||||
is Show.SongArtistDecision,
|
||||
is Show.AlbumArtistDecision,
|
||||
is Show.GenreDetails,
|
||||
is Show.PlaylistDetails,
|
||||
null -> {}
|
||||
}
|
||||
selectionModel.drop()
|
||||
}
|
||||
|
||||
private fun handleShowOuter(outer: Outer?) {
|
||||
val directions =
|
||||
when (outer) {
|
||||
is Outer.Settings -> MainFragmentDirections.preferences()
|
||||
is Outer.About -> MainFragmentDirections.about()
|
||||
null -> return
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
homeModel.showOuter.consume()
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
|
@ -308,13 +331,13 @@ class MainFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handlePanel(panel: Panel?) {
|
||||
private fun handlePanel(panel: OpenPanel?) {
|
||||
if (panel == null) return
|
||||
logD("Trying to update panel to $panel")
|
||||
when (panel) {
|
||||
is Panel.Main -> tryClosePlaybackPanel()
|
||||
is Panel.Playback -> tryOpenPlaybackPanel()
|
||||
is Panel.Queue -> tryOpenQueuePanel()
|
||||
OpenPanel.MAIN -> tryClosePlaybackPanel()
|
||||
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
|
||||
OpenPanel.QUEUE -> tryOpenQueuePanel()
|
||||
}
|
||||
playbackModel.openPanel.consume()
|
||||
}
|
||||
|
@ -458,11 +481,10 @@ class MainFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private inner class SelectionBackPressedCallback(
|
||||
private val selectionModel: SelectionViewModel
|
||||
) : OnBackPressedCallback(false) {
|
||||
private inner class SelectionBackPressedCallback(private val listModel: ListViewModel) :
|
||||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (selectionModel.drop()) {
|
||||
if (listModel.dropSelection()) {
|
||||
logD("Dropped selection")
|
||||
}
|
||||
}
|
||||
|
@ -471,23 +493,4 @@ class MainFragment :
|
|||
isEnabled = selection.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ExploreBackPressedCallback(
|
||||
private val exploreNavHost: FragmentContainerView
|
||||
) : OnBackPressedCallback(false) {
|
||||
// Note: We cannot cache the NavController in a variable since it's current destination
|
||||
// value goes stale for some reason.
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
exploreNavHost.findNavController().navigateUp()
|
||||
logD("Forwarded back navigation to explore nav host")
|
||||
}
|
||||
|
||||
fun invalidateEnabled() {
|
||||
val exploreNavController = exploreNavHost.findNavController()
|
||||
isEnabled =
|
||||
exploreNavController.currentDestination?.id !=
|
||||
exploreNavController.graph.startDestinationId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,8 +20,6 @@ package org.oxycblt.auxio.detail
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
|
@ -39,26 +37,24 @@ import org.oxycblt.auxio.list.Divider
|
|||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
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.MusicViewModel
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
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.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -71,10 +67,11 @@ class AlbumDetailFragment :
|
|||
ListFragment<Song, FragmentDetailBinding>(),
|
||||
AlbumDetailHeaderAdapter.Listener,
|
||||
DetailListAdapter.Listener<Song> {
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel 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.
|
||||
private val args: AlbumDetailFragmentArgs by navArgs()
|
||||
|
@ -101,16 +98,18 @@ class AlbumDetailFragment :
|
|||
|
||||
// --- UI SETUP --
|
||||
binding.detailNormalToolbar.apply {
|
||||
inflateMenu(R.menu.menu_album_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@AlbumDetailFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
}
|
||||
|
||||
binding.detailRecycler.apply {
|
||||
adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
|
||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item = detailModel.albumList.value[it - 1]
|
||||
val item = detailModel.albumSongList.value[it - 1]
|
||||
item is Divider || item is Header || item is Disc
|
||||
} else {
|
||||
true
|
||||
|
@ -122,14 +121,14 @@ class AlbumDetailFragment :
|
|||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setAlbum(args.albumUid)
|
||||
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
||||
collectImmediately(detailModel.albumList, ::updateList)
|
||||
collectImmediately(detailModel.albumSongList, ::updateList)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist)
|
||||
collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
|
@ -138,52 +137,15 @@ class AlbumDetailFragment :
|
|||
binding.detailRecycler.adapter = null
|
||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
||||
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||
detailModel.albumInstructions.consume()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentAlbum = unlikelyToBeNull(detailModel.currentAlbum.value)
|
||||
return when (item.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(currentAlbum)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(currentAlbum)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
onNavigateToParentArtist()
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(currentAlbum)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(currentAlbum)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
detailModel.albumSongInstructions.consume()
|
||||
}
|
||||
|
||||
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)
|
||||
playbackModel.play(item, detailModel.playInAlbumWith)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Song, anchor: View) {
|
||||
openMusicMenu(anchor, R.menu.menu_album_song_actions, item)
|
||||
override fun onOpenMenu(item: Song) {
|
||||
listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith)
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
|
@ -194,31 +156,8 @@ class AlbumDetailFragment :
|
|||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu(anchor: View) {
|
||||
openMenu(anchor, R.menu.menu_album_sort) {
|
||||
// Select the corresponding sort mode option
|
||||
val sort = detailModel.albumSongSort
|
||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||
// Select the corresponding sort direction option
|
||||
val directionItemId =
|
||||
when (sort.direction) {
|
||||
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
||||
Sort.Direction.DESCENDING -> R.id.option_sort_dec
|
||||
}
|
||||
unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true
|
||||
setOnMenuItemClickListener { item ->
|
||||
item.isChecked = !item.isChecked
|
||||
detailModel.albumSongSort =
|
||||
when (item.itemId) {
|
||||
// Sort direction options
|
||||
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
||||
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
|
||||
// Any other option is a sort mode
|
||||
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
override fun onNavigateToParentArtist() {
|
||||
|
@ -236,7 +175,7 @@ class AlbumDetailFragment :
|
|||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
albumListAdapter.update(list, detailModel.albumInstructions.consume())
|
||||
albumListAdapter.update(list, detailModel.albumSongInstructions.consume())
|
||||
}
|
||||
|
||||
private fun handleShow(show: Show?) {
|
||||
|
@ -275,22 +214,20 @@ class AlbumDetailFragment :
|
|||
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
}
|
||||
|
||||
// Always launch a new ArtistDetailFragment.
|
||||
is Show.ArtistDetails -> {
|
||||
logD("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDetails -> {
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.song.uid))
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDetails -> {
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.album.uid))
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails,
|
||||
is Show.PlaylistDetails -> {
|
||||
|
@ -300,6 +237,20 @@ class AlbumDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleMenu(menu: Menu?) {
|
||||
if (menu == null) return
|
||||
val directions =
|
||||
when (menu) {
|
||||
is Menu.ForSong -> AlbumDetailFragmentDirections.openSongMenu(menu.parcel)
|
||||
is Menu.ForAlbum -> AlbumDetailFragmentDirections.openAlbumMenu(menu.parcel)
|
||||
is Menu.ForSelection -> AlbumDetailFragmentDirections.openSelectionMenu(menu.parcel)
|
||||
is Menu.ForArtist,
|
||||
is Menu.ForGenre,
|
||||
is Menu.ForPlaylist -> error("Unexpected menu $menu")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
albumListAdapter.setSelected(selected.toSet())
|
||||
|
||||
|
@ -312,21 +263,20 @@ class AlbumDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
AlbumDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray()))
|
||||
musicModel.playlistDecision.consume()
|
||||
private fun handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
AlbumDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Delete -> error("Unexpected decision $decision")
|
||||
null -> {}
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
|
@ -334,21 +284,25 @@ class AlbumDetailFragment :
|
|||
song.takeIf { parent == detailModel.currentAlbum.value }, isPlaying)
|
||||
}
|
||||
|
||||
private fun handlePlayFromArtist(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from artist dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid))
|
||||
}
|
||||
|
||||
private fun handlePlayFromGenre(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from genre dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid))
|
||||
private fun handlePlaybackDecision(decision: PlaybackDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
AlbumDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
AlbumDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun scrollToAlbumSong(song: Song) {
|
||||
// Calculate where the item for the currently played song is
|
||||
val pos = detailModel.albumList.value.indexOf(song)
|
||||
val pos = detailModel.albumSongList.value.indexOf(song)
|
||||
|
||||
if (pos != -1) {
|
||||
// Only scroll if the song is within this album.
|
||||
|
|
|
@ -20,8 +20,6 @@ package org.oxycblt.auxio.detail
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
|
@ -39,8 +37,8 @@ import org.oxycblt.auxio.list.Divider
|
|||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
|
@ -48,15 +46,14 @@ import org.oxycblt.auxio.music.MusicParent
|
|||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -69,8 +66,8 @@ class ArtistDetailFragment :
|
|||
ListFragment<Music, FragmentDetailBinding>(),
|
||||
DetailHeaderAdapter.Listener,
|
||||
DetailListAdapter.Listener<Music> {
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
// Information about what artist to display is initially within the navigation arguments
|
||||
|
@ -99,9 +96,12 @@ class ArtistDetailFragment :
|
|||
|
||||
// --- UI SETUP ---
|
||||
binding.detailNormalToolbar.apply {
|
||||
inflateMenu(R.menu.menu_parent_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@ArtistDetailFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
}
|
||||
|
||||
binding.detailRecycler.apply {
|
||||
|
@ -109,7 +109,7 @@ class ArtistDetailFragment :
|
|||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item =
|
||||
detailModel.artistList.value.getOrElse(it - 1) {
|
||||
detailModel.artistSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is Divider || item is Header
|
||||
|
@ -123,14 +123,14 @@ class ArtistDetailFragment :
|
|||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setArtist(args.artistUid)
|
||||
collectImmediately(detailModel.currentArtist, ::updateArtist)
|
||||
collectImmediately(detailModel.artistList, ::updateList)
|
||||
collectImmediately(detailModel.artistSongList, ::updateList)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handlePlaylistDecision)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist)
|
||||
collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
|
@ -139,63 +139,21 @@ class ArtistDetailFragment :
|
|||
binding.detailRecycler.adapter = null
|
||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
||||
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||
detailModel.artistInstructions.consume()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
||||
return when (item.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(currentArtist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(currentArtist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(currentArtist)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(currentArtist)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
detailModel.artistSongInstructions.consume()
|
||||
}
|
||||
|
||||
override fun onRealClick(item: Music) {
|
||||
when (item) {
|
||||
is Album -> detailModel.showAlbum(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 Artist
|
||||
// to play from.
|
||||
playbackModel.playFromArtist(
|
||||
item, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
}
|
||||
is Song -> playbackModel.play(item, detailModel.playInArtistWith)
|
||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Music, anchor: View) {
|
||||
override fun onOpenMenu(item: Music) {
|
||||
when (item) {
|
||||
is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item)
|
||||
is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item)
|
||||
is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith)
|
||||
is Album -> listModel.openMenu(R.menu.artist_album, item)
|
||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
@ -208,33 +166,8 @@ class ArtistDetailFragment :
|
|||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu(anchor: View) {
|
||||
openMenu(anchor, R.menu.menu_artist_sort) {
|
||||
// Select the corresponding sort mode option
|
||||
val sort = detailModel.artistSongSort
|
||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||
// Select the corresponding sort direction option
|
||||
val directionItemId =
|
||||
when (sort.direction) {
|
||||
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
||||
Sort.Direction.DESCENDING -> R.id.option_sort_dec
|
||||
}
|
||||
unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true
|
||||
setOnMenuItemClickListener { item ->
|
||||
item.isChecked = !item.isChecked
|
||||
|
||||
detailModel.artistSongSort =
|
||||
when (item.itemId) {
|
||||
// Sort direction options
|
||||
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
||||
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
|
||||
// Any other option is a sort mode
|
||||
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(ArtistDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
private fun updateArtist(artist: Artist?) {
|
||||
|
@ -243,24 +176,12 @@ class ArtistDetailFragment :
|
|||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
requireBinding().detailNormalToolbar.apply {
|
||||
title = artist.name.resolve(requireContext())
|
||||
|
||||
// Disable options that make no sense with an empty artist
|
||||
val playable = artist.songs.isNotEmpty()
|
||||
if (!playable) {
|
||||
logD("Artist is empty, disabling playback/playlist/share options")
|
||||
}
|
||||
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_playlist_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_share).isEnabled = playable
|
||||
}
|
||||
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
|
||||
artistHeaderAdapter.setParent(artist)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
artistListAdapter.update(list, detailModel.artistInstructions.consume())
|
||||
artistListAdapter.update(list, detailModel.artistSongInstructions.consume())
|
||||
}
|
||||
|
||||
private fun handleShow(show: Show?) {
|
||||
|
@ -300,8 +221,16 @@ class ArtistDetailFragment :
|
|||
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
}
|
||||
is Show.SongArtistDetails,
|
||||
is Show.AlbumArtistDetails,
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails,
|
||||
is Show.PlaylistDetails -> {
|
||||
error("Unexpected show command $show")
|
||||
|
@ -310,6 +239,21 @@ class ArtistDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleMenu(menu: Menu?) {
|
||||
if (menu == null) return
|
||||
val directions =
|
||||
when (menu) {
|
||||
is Menu.ForSong -> ArtistDetailFragmentDirections.openSongMenu(menu.parcel)
|
||||
is Menu.ForAlbum -> ArtistDetailFragmentDirections.openAlbumMenu(menu.parcel)
|
||||
is Menu.ForArtist -> ArtistDetailFragmentDirections.openArtistMenu(menu.parcel)
|
||||
is Menu.ForSelection ->
|
||||
ArtistDetailFragmentDirections.openSelectionMenu(menu.parcel)
|
||||
is Menu.ForGenre,
|
||||
is Menu.ForPlaylist -> error("Unexpected menu $menu")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
artistListAdapter.setSelected(selected.toSet())
|
||||
|
||||
|
@ -322,21 +266,20 @@ class ArtistDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
ArtistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray()))
|
||||
musicModel.playlistDecision.consume()
|
||||
private fun handlePlaylistDecision(decision: PlaylistDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
ArtistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Delete -> error("Unexpected decision $decision")
|
||||
null -> {}
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
|
@ -354,15 +297,17 @@ class ArtistDetailFragment :
|
|||
artistListAdapter.setPlaying(playingItem, isPlaying)
|
||||
}
|
||||
|
||||
private fun handlePlayFromArtist(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from artist dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid))
|
||||
}
|
||||
|
||||
private fun handlePlayFromGenre(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from genre dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid))
|
||||
private fun handlePlaybackDecision(decision: PlaybackDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist ->
|
||||
error("Unexpected playback decision $decision")
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
ArtistDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,7 +59,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
|
||||
if (!isInEditMode) {
|
||||
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findTitleView(): TextView {
|
||||
|
|
|
@ -36,24 +36,25 @@ import org.oxycblt.auxio.detail.list.SortHeader
|
|||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
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.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.ReleaseType
|
||||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
|
||||
|
@ -65,12 +66,15 @@ import org.oxycblt.auxio.util.logW
|
|||
class DetailViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val audioPropertiesFactory: AudioProperties.Factory,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val playbackSettings: PlaybackSettings
|
||||
) : ViewModel(), MusicRepository.UpdateListener {
|
||||
private val _toShow = MutableEvent<Show>()
|
||||
/**
|
||||
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||
*/
|
||||
val toShow: Event<Show>
|
||||
get() = _toShow
|
||||
|
||||
|
@ -94,23 +98,23 @@ constructor(
|
|||
val currentAlbum: StateFlow<Album?>
|
||||
get() = _currentAlbum
|
||||
|
||||
private val _albumList = MutableStateFlow(listOf<Item>())
|
||||
private val _albumSongList = MutableStateFlow(listOf<Item>())
|
||||
/** The current list data derived from [currentAlbum]. */
|
||||
val albumList: StateFlow<List<Item>>
|
||||
get() = _albumList
|
||||
private val _albumInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [albumList] in the UI. */
|
||||
val albumInstructions: Event<UpdateInstructions>
|
||||
get() = _albumInstructions
|
||||
val albumSongList: StateFlow<List<Item>>
|
||||
get() = _albumSongList
|
||||
|
||||
/** The current [Sort] used for [Song]s in [albumList]. */
|
||||
var albumSongSort: Sort
|
||||
get() = musicSettings.albumSongSort
|
||||
set(value) {
|
||||
musicSettings.albumSongSort = value
|
||||
// Refresh the album list to reflect the new sort.
|
||||
currentAlbum.value?.let { refreshAlbumList(it, true) }
|
||||
}
|
||||
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [albumSongList] in the UI. */
|
||||
val albumSongInstructions: Event<UpdateInstructions>
|
||||
get() = _albumSongInstructions
|
||||
|
||||
/** The current [Sort] used for [Song]s in [albumSongList]. */
|
||||
val albumSongSort: Sort
|
||||
get() = listSettings.albumSongSort
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Album] details. */
|
||||
val playInAlbumWith
|
||||
get() = playbackSettings.inParentPlaybackMode ?: PlaySong.FromAlbum
|
||||
|
||||
// --- ARTIST ---
|
||||
|
||||
|
@ -119,23 +123,28 @@ constructor(
|
|||
val currentArtist: StateFlow<Artist?>
|
||||
get() = _currentArtist
|
||||
|
||||
private val _artistList = MutableStateFlow(listOf<Item>())
|
||||
private val _artistSongList = MutableStateFlow(listOf<Item>())
|
||||
/** The current list derived from [currentArtist]. */
|
||||
val artistList: StateFlow<List<Item>> = _artistList
|
||||
private val _artistInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [artistList] in the UI. */
|
||||
val artistInstructions: Event<UpdateInstructions>
|
||||
get() = _artistInstructions
|
||||
val artistSongList: StateFlow<List<Item>> = _artistSongList
|
||||
|
||||
/** The current [Sort] used for [Song]s in [artistList]. */
|
||||
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val artistSongInstructions: Event<UpdateInstructions>
|
||||
get() = _artistSongInstructions
|
||||
|
||||
/** The current [Sort] used for [Song]s in [artistSongList]. */
|
||||
var artistSongSort: Sort
|
||||
get() = musicSettings.artistSongSort
|
||||
get() = listSettings.artistSongSort
|
||||
set(value) {
|
||||
musicSettings.artistSongSort = value
|
||||
listSettings.artistSongSort = value
|
||||
// Refresh the artist list to reflect the new sort.
|
||||
currentArtist.value?.let { refreshArtistList(it, true) }
|
||||
}
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
|
||||
val playInArtistWith
|
||||
get() = playbackSettings.inParentPlaybackMode ?: PlaySong.FromArtist(currentArtist.value)
|
||||
|
||||
// --- GENRE ---
|
||||
|
||||
private val _currentGenre = MutableStateFlow<Genre?>(null)
|
||||
|
@ -143,23 +152,28 @@ constructor(
|
|||
val currentGenre: StateFlow<Genre?>
|
||||
get() = _currentGenre
|
||||
|
||||
private val _genreList = MutableStateFlow(listOf<Item>())
|
||||
private val _genreSongList = MutableStateFlow(listOf<Item>())
|
||||
/** The current list data derived from [currentGenre]. */
|
||||
val genreList: StateFlow<List<Item>> = _genreList
|
||||
private val _genreInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [artistList] in the UI. */
|
||||
val genreInstructions: Event<UpdateInstructions>
|
||||
get() = _genreInstructions
|
||||
val genreSongList: StateFlow<List<Item>> = _genreSongList
|
||||
|
||||
/** The current [Sort] used for [Song]s in [genreList]. */
|
||||
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val genreSongInstructions: Event<UpdateInstructions>
|
||||
get() = _genreSongInstructions
|
||||
|
||||
/** The current [Sort] used for [Song]s in [genreSongList]. */
|
||||
var genreSongSort: Sort
|
||||
get() = musicSettings.genreSongSort
|
||||
get() = listSettings.genreSongSort
|
||||
set(value) {
|
||||
musicSettings.genreSongSort = value
|
||||
listSettings.genreSongSort = value
|
||||
// Refresh the genre list to reflect the new sort.
|
||||
currentGenre.value?.let { refreshGenreList(it, true) }
|
||||
}
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
|
||||
val playInGenreWith
|
||||
get() = playbackSettings.inParentPlaybackMode ?: PlaySong.FromGenre(currentGenre.value)
|
||||
|
||||
// --- PLAYLIST ---
|
||||
|
||||
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
||||
|
@ -167,13 +181,14 @@ constructor(
|
|||
val currentPlaylist: StateFlow<Playlist?>
|
||||
get() = _currentPlaylist
|
||||
|
||||
private val _playlistList = MutableStateFlow(listOf<Item>())
|
||||
private val _playlistSongList = MutableStateFlow(listOf<Item>())
|
||||
/** The current list data derived from [currentPlaylist] */
|
||||
val playlistList: StateFlow<List<Item>> = _playlistList
|
||||
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [playlistList] in the UI. */
|
||||
val playlistInstructions: Event<UpdateInstructions>
|
||||
get() = _playlistInstructions
|
||||
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
|
||||
|
||||
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for updating [playlistSongList] in the UI. */
|
||||
val playlistSongInstructions: Event<UpdateInstructions>
|
||||
get() = _playlistSongInstructions
|
||||
|
||||
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
|
||||
/**
|
||||
|
@ -183,12 +198,11 @@ constructor(
|
|||
val editedPlaylist: StateFlow<List<Song>?>
|
||||
get() = _editedPlaylist
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
|
||||
val playInPlaylistWith
|
||||
get() =
|
||||
playbackSettings.inParentPlaybackMode
|
||||
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
|
@ -241,32 +255,74 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the details (properties) of a [Song].
|
||||
*
|
||||
* @param song The [Song] to navigate with.
|
||||
*/
|
||||
fun showSong(song: Song) = showImpl(Show.SongDetails(song))
|
||||
|
||||
/**
|
||||
* Navigate to the [Album] details of the given [Song], scrolling to the given [Song] as well.
|
||||
*
|
||||
* @param song The [Song] to navigate with.
|
||||
*/
|
||||
fun showAlbum(song: Song) = showImpl(Show.SongAlbumDetails(song))
|
||||
|
||||
/**
|
||||
* Navigate to the details of an [Album].
|
||||
*
|
||||
* @param album The [Album] to navigate with.
|
||||
*/
|
||||
fun showAlbum(album: Album) = showImpl(Show.AlbumDetails(album))
|
||||
|
||||
/**
|
||||
* Navigate to the details of one of the [Artist]s of a [Song] using the corresponding choice
|
||||
* dialog. If there is only one artist, this call is identical to [showArtist].
|
||||
*
|
||||
* @param song The [Song] to navigate with.
|
||||
*/
|
||||
fun showArtist(song: Song) =
|
||||
showImpl(
|
||||
if (song.artists.size > 1) {
|
||||
Show.SongArtistDetails(song)
|
||||
Show.SongArtistDecision(song)
|
||||
} else {
|
||||
Show.ArtistDetails(song.artists.first())
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigate to the details of one of the [Artist]s of an [Album] using the corresponding choice
|
||||
* dialog. If there is only one artist, this call is identical to [showArtist].
|
||||
*
|
||||
* @param album The [Album] to navigate with.
|
||||
*/
|
||||
fun showArtist(album: Album) =
|
||||
showImpl(
|
||||
if (album.artists.size > 1) {
|
||||
Show.AlbumArtistDetails(album)
|
||||
Show.AlbumArtistDecision(album)
|
||||
} else {
|
||||
Show.ArtistDetails(album.artists.first())
|
||||
})
|
||||
|
||||
/**
|
||||
* Navigate to the details of an [Artist].
|
||||
*
|
||||
* @param artist The [Artist] to navigate with.
|
||||
*/
|
||||
fun showArtist(artist: Artist) = showImpl(Show.ArtistDetails(artist))
|
||||
|
||||
/**
|
||||
* Navigate to the details of a [Genre].
|
||||
*
|
||||
* @param genre The [Genre] to navigate with.
|
||||
*/
|
||||
fun showGenre(genre: Genre) = showImpl(Show.GenreDetails(genre))
|
||||
|
||||
/**
|
||||
* Navigate to the details of a [Playlist].
|
||||
*
|
||||
* @param playlist The [Playlist] to navigate with.
|
||||
*/
|
||||
fun showPlaylist(playlist: Playlist) = showImpl(Show.PlaylistDetails(playlist))
|
||||
|
||||
private fun showImpl(show: Show) {
|
||||
|
@ -293,7 +349,7 @@ constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentAlbum] from it's [Music.UID]. [currentAlbum] and [albumList] will be
|
||||
* Set a new [currentAlbum] from it's [Music.UID]. [currentAlbum] and [albumSongList] will be
|
||||
* updated to align with the new [Album].
|
||||
*
|
||||
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||
|
@ -308,7 +364,17 @@ constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentArtist] from it's [Music.UID]. [currentArtist] and [artistList] will be
|
||||
* Apply a new [Sort] to [albumSongList].
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyAlbumSongSort(sort: Sort) {
|
||||
listSettings.albumSongSort = sort
|
||||
_currentAlbum.value?.let { refreshAlbumList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentArtist] from it's [Music.UID]. [currentArtist] and [artistSongList] will be
|
||||
* updated to align with the new [Artist].
|
||||
*
|
||||
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||
|
@ -323,7 +389,17 @@ constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentGenre] from it's [Music.UID]. [currentGenre] and [genreList] will be
|
||||
* Apply a new [Sort] to [artistSongList].
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyArtistSongSort(sort: Sort) {
|
||||
listSettings.artistSongSort = sort
|
||||
_currentArtist.value?.let { refreshArtistList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentGenre] from it's [Music.UID]. [currentGenre] and [genreSongList] will be
|
||||
* updated to align with the new album.
|
||||
*
|
||||
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||
|
@ -337,6 +413,16 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new [Sort] to [genreSongList].
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyGenreSongSort(sort: Sort) {
|
||||
listSettings.genreSongSort = sort
|
||||
_currentGenre.value?.let { refreshGenreList(it, true) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentPlaylist] from it's [Music.UID]. If the [Music.UID] differs,
|
||||
* [currentPlaylist] and [currentPlaylist] will be updated to align with the new album.
|
||||
|
@ -394,6 +480,17 @@ constructor(
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a [Sort] to the edited playlist. Does nothing if not in an editing session.
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyPlaylistSongSort(sort: Sort) {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
|
||||
refreshPlaylistList(playlist, UpdateInstructions.Replace(2))
|
||||
}
|
||||
|
||||
/**
|
||||
* (Visually) move a song in the current playlist. Does nothing if not in an editing session.
|
||||
*
|
||||
|
@ -402,7 +499,6 @@ constructor(
|
|||
* @return true if the song was moved, false otherwise.
|
||||
*/
|
||||
fun movePlaylistSongs(from: Int, to: Int): Boolean {
|
||||
// TODO: Song re-sorting
|
||||
val playlist = _currentPlaylist.value ?: return false
|
||||
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
||||
val realFrom = from - 2
|
||||
|
@ -486,8 +582,8 @@ constructor(
|
|||
}
|
||||
|
||||
logD("Update album list to ${list.size} items with $instructions")
|
||||
_albumInstructions.put(instructions)
|
||||
_albumList.value = list
|
||||
_albumSongInstructions.put(instructions)
|
||||
_albumSongList.value = list
|
||||
}
|
||||
|
||||
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
|
||||
|
@ -511,6 +607,7 @@ constructor(
|
|||
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
||||
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
|
||||
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
|
||||
is ReleaseType.Demo -> AlbumGrouping.DEMOS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -549,8 +646,8 @@ constructor(
|
|||
}
|
||||
|
||||
logD("Updating artist list to ${list.size} items with $instructions")
|
||||
_artistInstructions.put(instructions)
|
||||
_artistList.value = list.toList()
|
||||
_artistSongInstructions.put(instructions)
|
||||
_artistSongList.value = list.toList()
|
||||
}
|
||||
|
||||
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
|
||||
|
@ -575,8 +672,8 @@ constructor(
|
|||
list.addAll(genreSongSort.songs(genre.songs))
|
||||
|
||||
logD("Updating genre list to ${list.size} items with $instructions")
|
||||
_genreInstructions.put(instructions)
|
||||
_genreList.value = list
|
||||
_genreSongInstructions.put(instructions)
|
||||
_genreSongList.value = list
|
||||
}
|
||||
|
||||
private fun refreshPlaylistList(
|
||||
|
@ -595,8 +692,8 @@ constructor(
|
|||
}
|
||||
|
||||
logD("Updating playlist list to ${list.size} items with $instructions")
|
||||
_playlistInstructions.put(instructions)
|
||||
_playlistList.value = list
|
||||
_playlistSongInstructions.put(instructions)
|
||||
_playlistSongList.value = list
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -613,6 +710,7 @@ constructor(
|
|||
SOUNDTRACKS(R.string.lbl_soundtracks),
|
||||
DJMIXES(R.string.lbl_mixes),
|
||||
MIXTAPES(R.string.lbl_mixtapes),
|
||||
DEMOS(R.string.lbl_demos),
|
||||
APPEARANCES(R.string.lbl_appears_on),
|
||||
LIVE(R.string.lbl_live_group),
|
||||
REMIXES(R.string.lbl_remix_group),
|
||||
|
@ -624,13 +722,68 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A command for navigation to detail views. These can be handled partially if a certain command
|
||||
* cannot occur in a specific view.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface Show {
|
||||
/**
|
||||
* Navigate to the details (properties) of a [Song].
|
||||
*
|
||||
* @param song The [Song] to navigate with.
|
||||
*/
|
||||
data class SongDetails(val song: Song) : Show
|
||||
|
||||
/**
|
||||
* Navigate to the details of an [Album].
|
||||
*
|
||||
* @param album The [Album] to navigate with.
|
||||
*/
|
||||
data class AlbumDetails(val album: Album) : Show
|
||||
|
||||
/**
|
||||
* Navigate to the [Album] details of the given [Song], scrolling to the given [Song] as well.
|
||||
*
|
||||
* @param song The [Song] to navigate with.
|
||||
*/
|
||||
data class SongAlbumDetails(val song: Song) : Show
|
||||
|
||||
/**
|
||||
* Navigate to the details of an [Artist].
|
||||
*
|
||||
* @param artist The [Artist] to navigate with.
|
||||
*/
|
||||
data class ArtistDetails(val artist: Artist) : Show
|
||||
data class SongArtistDetails(val song: Song) : Show
|
||||
data class AlbumArtistDetails(val album: Album) : Show
|
||||
|
||||
/**
|
||||
* Navigate to the details of one of the [Artist]s of a [Song] using the corresponding choice
|
||||
* dialog.
|
||||
*
|
||||
* @param song The [Song] to navigate with.
|
||||
*/
|
||||
data class SongArtistDecision(val song: Song) : Show
|
||||
|
||||
/**
|
||||
* Navigate to the details of one of the [Artist]s of an [Album] using the corresponding
|
||||
* decision dialog.
|
||||
*
|
||||
* @param album The [Album] to navigate with.
|
||||
*/
|
||||
data class AlbumArtistDecision(val album: Album) : Show
|
||||
|
||||
/**
|
||||
* Navigate to the details of a [Genre].
|
||||
*
|
||||
* @param genre The [Genre] to navigate with.
|
||||
*/
|
||||
data class GenreDetails(val genre: Genre) : Show
|
||||
|
||||
/**
|
||||
* Navigate to the details of a [Playlist].
|
||||
*
|
||||
* @param playlist The [Playlist] to navigate with.
|
||||
*/
|
||||
data class PlaylistDetails(val playlist: Playlist) : Show
|
||||
}
|
||||
|
|
|
@ -20,8 +20,6 @@ package org.oxycblt.auxio.detail
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
|
@ -39,8 +37,8 @@ import org.oxycblt.auxio.list.Divider
|
|||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
|
@ -48,15 +46,14 @@ import org.oxycblt.auxio.music.MusicParent
|
|||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -69,8 +66,8 @@ class GenreDetailFragment :
|
|||
ListFragment<Music, FragmentDetailBinding>(),
|
||||
DetailHeaderAdapter.Listener,
|
||||
DetailListAdapter.Listener<Music> {
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
// Information about what genre to display is initially within the navigation arguments
|
||||
|
@ -97,9 +94,12 @@ class GenreDetailFragment :
|
|||
|
||||
// --- UI SETUP ---
|
||||
binding.detailNormalToolbar.apply {
|
||||
inflateMenu(R.menu.menu_parent_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@GenreDetailFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
}
|
||||
|
||||
binding.detailRecycler.apply {
|
||||
|
@ -107,7 +107,7 @@ class GenreDetailFragment :
|
|||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item =
|
||||
detailModel.genreList.value.getOrElse(it - 1) {
|
||||
detailModel.genreSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is Divider || item is Header
|
||||
|
@ -121,14 +121,14 @@ class GenreDetailFragment :
|
|||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setGenre(args.genreUid)
|
||||
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
|
||||
collectImmediately(detailModel.genreList, ::updateList)
|
||||
collectImmediately(detailModel.genreSongList, ::updateList)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist)
|
||||
collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
|
@ -137,63 +137,21 @@ class GenreDetailFragment :
|
|||
binding.detailRecycler.adapter = null
|
||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
||||
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||
detailModel.genreInstructions.consume()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
|
||||
return when (item.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(currentGenre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(currentGenre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(currentGenre)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(currentGenre)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
detailModel.genreSongInstructions.consume()
|
||||
}
|
||||
|
||||
override fun onRealClick(item: Music) {
|
||||
when (item) {
|
||||
is Artist -> detailModel.showArtist(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.
|
||||
playbackModel.playFromGenre(
|
||||
item, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
}
|
||||
is Song -> playbackModel.play(item, detailModel.playInGenreWith)
|
||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Music, anchor: View) {
|
||||
override fun onOpenMenu(item: Music) {
|
||||
when (item) {
|
||||
is Artist -> openMusicMenu(anchor, R.menu.menu_parent_actions, item)
|
||||
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
||||
is Artist -> listModel.openMenu(R.menu.parent, item)
|
||||
is Song -> listModel.openMenu(R.menu.song, item, detailModel.playInGenreWith)
|
||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
@ -206,31 +164,8 @@ class GenreDetailFragment :
|
|||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu(anchor: View) {
|
||||
openMenu(anchor, R.menu.menu_genre_sort) {
|
||||
// Select the corresponding sort mode option
|
||||
val sort = detailModel.genreSongSort
|
||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||
// Select the corresponding sort direction option
|
||||
val directionItemId =
|
||||
when (sort.direction) {
|
||||
Sort.Direction.ASCENDING -> R.id.option_sort_asc
|
||||
Sort.Direction.DESCENDING -> R.id.option_sort_dec
|
||||
}
|
||||
unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true
|
||||
setOnMenuItemClickListener { item ->
|
||||
item.isChecked = !item.isChecked
|
||||
detailModel.genreSongSort =
|
||||
when (item.itemId) {
|
||||
// Sort direction options
|
||||
R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
|
||||
R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
|
||||
// Any other option is a sort mode
|
||||
else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(GenreDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
private fun updatePlaylist(genre: Genre?) {
|
||||
|
@ -244,7 +179,7 @@ class GenreDetailFragment :
|
|||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
genreListAdapter.update(list, detailModel.genreInstructions.consume())
|
||||
genreListAdapter.update(list, detailModel.genreSongInstructions.consume())
|
||||
}
|
||||
|
||||
private fun handleShow(show: Show?) {
|
||||
|
@ -277,15 +212,15 @@ class GenreDetailFragment :
|
|||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDetails -> {
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.song.uid))
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDetails -> {
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.album.uid))
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
logD("Navigated to this genre")
|
||||
|
@ -298,6 +233,20 @@ class GenreDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleMenu(menu: Menu?) {
|
||||
if (menu == null) return
|
||||
val directions =
|
||||
when (menu) {
|
||||
is Menu.ForSong -> GenreDetailFragmentDirections.openSongMenu(menu.parcel)
|
||||
is Menu.ForArtist -> GenreDetailFragmentDirections.openArtistMenu(menu.parcel)
|
||||
is Menu.ForGenre -> GenreDetailFragmentDirections.openGenreMenu(menu.parcel)
|
||||
is Menu.ForSelection -> GenreDetailFragmentDirections.openSelectionMenu(menu.parcel)
|
||||
is Menu.ForAlbum,
|
||||
is Menu.ForPlaylist -> error("Unexpected menu $menu")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
genreListAdapter.setSelected(selected.toSet())
|
||||
|
||||
|
@ -311,20 +260,19 @@ class GenreDetailFragment :
|
|||
}
|
||||
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
GenreDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray()))
|
||||
musicModel.playlistDecision.consume()
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
GenreDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Delete -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
is PlaylistDecision.New,
|
||||
is PlaylistDecision.Rename,
|
||||
is PlaylistDecision.Delete -> error("Unexpected decision $decision")
|
||||
null -> {}
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
|
@ -342,15 +290,16 @@ class GenreDetailFragment :
|
|||
genreListAdapter.setPlaying(playingItem, isPlaying)
|
||||
}
|
||||
|
||||
private fun handlePlayFromArtist(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from artist dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid))
|
||||
}
|
||||
|
||||
private fun handlePlayFromGenre(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from genre dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid))
|
||||
private fun handlePlaybackDecision(decision: PlaybackDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
GenreDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,10 +21,7 @@ package org.oxycblt.auxio.detail
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
|
@ -43,22 +40,23 @@ import org.oxycblt.auxio.list.Divider
|
|||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -70,10 +68,9 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
class PlaylistDetailFragment :
|
||||
ListFragment<Song, FragmentDetailBinding>(),
|
||||
DetailHeaderAdapter.Listener,
|
||||
PlaylistDetailListAdapter.Listener,
|
||||
NavController.OnDestinationChangedListener {
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
PlaylistDetailListAdapter.Listener {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
// Information about what playlist to display is initially within the navigation arguments
|
||||
|
@ -82,7 +79,7 @@ class PlaylistDetailFragment :
|
|||
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
|
||||
private val playlistListAdapter = PlaylistDetailListAdapter(this)
|
||||
private var touchHelper: ItemTouchHelper? = null
|
||||
private var initialNavDestinationChange = false
|
||||
private var editNavigationListener: DialogAwareNavigationListener? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -100,11 +97,16 @@ class PlaylistDetailFragment :
|
|||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailNormalToolbar.apply {
|
||||
inflateMenu(R.menu.menu_playlist_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
|
||||
binding.detailEditToolbar.apply {
|
||||
|
@ -121,7 +123,7 @@ class PlaylistDetailFragment :
|
|||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item =
|
||||
detailModel.playlistList.value.getOrElse(it - 1) {
|
||||
detailModel.playlistSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is Divider || item is Header
|
||||
|
@ -135,28 +137,42 @@ class PlaylistDetailFragment :
|
|||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setPlaylist(args.playlistUid)
|
||||
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
|
||||
collectImmediately(detailModel.playlistList, ::updateList)
|
||||
collectImmediately(detailModel.playlistSongList, ::updateList)
|
||||
collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(playbackModel.artistPickerSong.flow, ::handlePlayFromArtist)
|
||||
collect(playbackModel.genrePickerSong.flow, ::handlePlayFromGenre)
|
||||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (item.itemId == R.id.action_save) {
|
||||
detailModel.savePlaylistEdit()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// Once we add the destination change callback, we will receive another initialization call,
|
||||
// so handle that by resetting the flag.
|
||||
initialNavDestinationChange = false
|
||||
findNavController().addOnDestinationChangedListener(this)
|
||||
requireNotNull(editNavigationListener) { "NavigationListener was not available" }
|
||||
.attach(findNavController())
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
findNavController().removeOnDestinationChangedListener(this)
|
||||
requireNotNull(editNavigationListener) { "NavigationListener was not available" }
|
||||
.release(findNavController())
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
|
@ -166,76 +182,20 @@ class PlaylistDetailFragment :
|
|||
binding.detailRecycler.adapter = null
|
||||
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
|
||||
// during list initialization and crash the app. Could happen if the user is fast enough.
|
||||
detailModel.playlistInstructions.consume()
|
||||
}
|
||||
|
||||
override fun onDestinationChanged(
|
||||
controller: NavController,
|
||||
destination: NavDestination,
|
||||
arguments: Bundle?
|
||||
) {
|
||||
// Drop the initial call by NavController that simply provides us with the current
|
||||
// destination. This would cause the selection state to be lost every time the device
|
||||
// rotates.
|
||||
if (!initialNavDestinationChange) {
|
||||
initialNavDestinationChange = true
|
||||
return
|
||||
}
|
||||
// Drop any pending playlist edits when navigating away. This could actually happen
|
||||
// if the user is quick enough.
|
||||
detailModel.dropPlaylistEdit()
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentPlaylist = unlikelyToBeNull(detailModel.currentPlaylist.value)
|
||||
return when (item.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(currentPlaylist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(currentPlaylist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_rename -> {
|
||||
musicModel.renamePlaylist(currentPlaylist)
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
musicModel.deletePlaylist(currentPlaylist)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(currentPlaylist)
|
||||
true
|
||||
}
|
||||
R.id.action_save -> {
|
||||
detailModel.savePlaylistEdit()
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
detailModel.playlistSongInstructions.consume()
|
||||
editNavigationListener = null
|
||||
}
|
||||
|
||||
override fun onRealClick(item: Song) {
|
||||
playbackModel.playFromPlaylist(item, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
playbackModel.play(item, detailModel.playInPlaylistWith)
|
||||
}
|
||||
|
||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Song, anchor: View) {
|
||||
openMusicMenu(anchor, R.menu.menu_playlist_song_actions, item)
|
||||
override fun onOpenMenu(item: Song) {
|
||||
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
|
@ -250,7 +210,9 @@ class PlaylistDetailFragment :
|
|||
detailModel.startPlaylistEdit()
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu(anchor: View) {}
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
private fun updatePlaylist(playlist: Playlist?) {
|
||||
if (playlist == null) {
|
||||
|
@ -259,30 +221,20 @@ class PlaylistDetailFragment :
|
|||
return
|
||||
}
|
||||
val binding = requireBinding()
|
||||
binding.detailNormalToolbar.apply {
|
||||
title = playlist.name.resolve(requireContext())
|
||||
// Disable options that make no sense with an empty playlist
|
||||
val playable = playlist.songs.isNotEmpty()
|
||||
if (!playable) {
|
||||
logD("Playlist is empty, disabling playback/share options")
|
||||
}
|
||||
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_share).isEnabled = playable
|
||||
}
|
||||
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
|
||||
binding.detailEditToolbar.title =
|
||||
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
|
||||
playlistHeaderAdapter.setParent(playlist)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
playlistListAdapter.update(list, detailModel.playlistInstructions.consume())
|
||||
playlistListAdapter.update(list, detailModel.playlistSongInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateEditedList(editedPlaylist: List<Song>?) {
|
||||
playlistListAdapter.setEditing(editedPlaylist != null)
|
||||
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
|
||||
selectionModel.drop()
|
||||
listModel.dropSelection()
|
||||
|
||||
if (editedPlaylist != null) {
|
||||
logD("Updating save button state")
|
||||
|
@ -301,38 +253,31 @@ class PlaylistDetailFragment :
|
|||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
||||
// Songs should be scrolled to if the album matches, or a new detail
|
||||
// fragment should be launched otherwise.
|
||||
is Show.SongAlbumDetails -> {
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
||||
// If the album matches, no need to do anything. Otherwise launch a new
|
||||
// detail fragment.
|
||||
is Show.AlbumDetails -> {
|
||||
logD("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
|
||||
// Always launch a new ArtistDetailFragment.
|
||||
is Show.ArtistDetails -> {
|
||||
logD("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDetails -> {
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.song.uid))
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDetails -> {
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.album.uid))
|
||||
.navigateSafe(
|
||||
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
logD("Navigated to this playlist")
|
||||
|
@ -345,6 +290,22 @@ class PlaylistDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleMenu(menu: Menu?) {
|
||||
if (menu == null) return
|
||||
val directions =
|
||||
when (menu) {
|
||||
is Menu.ForSong -> PlaylistDetailFragmentDirections.openSongMenu(menu.parcel)
|
||||
is Menu.ForPlaylist ->
|
||||
PlaylistDetailFragmentDirections.openPlaylistMenu(menu.parcel)
|
||||
is Menu.ForSelection ->
|
||||
PlaylistDetailFragmentDirections.openSelectionMenu(menu.parcel)
|
||||
is Menu.ForArtist,
|
||||
is Menu.ForAlbum,
|
||||
is Menu.ForGenre -> error("Unexpected menu $menu")
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
playlistListAdapter.setSelected(selected.toSet())
|
||||
|
||||
|
@ -357,23 +318,24 @@ class PlaylistDetailFragment :
|
|||
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
if (decision == null) return
|
||||
when (decision) {
|
||||
is PlaylistDecision.Rename -> {
|
||||
logD("Renaming ${decision.playlist}")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid))
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Rename -> {
|
||||
logD("Renaming ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.renamePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
logD("Deleting ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
PlaylistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
is PlaylistDecision.New -> error("Unexpected playlist decision $decision")
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
logD("Deleting ${decision.playlist}")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid))
|
||||
}
|
||||
is PlaylistDecision.Add,
|
||||
is PlaylistDecision.New -> error("Unexpected decision $decision")
|
||||
}
|
||||
musicModel.playlistDecision.consume()
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
|
@ -382,17 +344,22 @@ class PlaylistDetailFragment :
|
|||
song.takeIf { parent == detailModel.currentPlaylist.value }, isPlaying)
|
||||
}
|
||||
|
||||
private fun handlePlayFromArtist(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from artist dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromArtist(song.uid))
|
||||
private fun handlePlaybackDecision(decision: PlaybackDecision?) {
|
||||
if (decision == null) return
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun handlePlayFromGenre(song: Song?) {
|
||||
if (song == null) return
|
||||
logD("Launching play from genre dialog for $song")
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.playFromGenre(song.uid))
|
||||
}
|
||||
private fun updateMultiToolbar() {
|
||||
val id =
|
||||
when {
|
||||
|
@ -400,7 +367,7 @@ class PlaylistDetailFragment :
|
|||
logD("Currently editing playlist, showing edit toolbar")
|
||||
R.id.detail_edit_toolbar
|
||||
}
|
||||
selectionModel.selected.value.isNotEmpty() -> {
|
||||
listModel.selected.value.isNotEmpty() -> {
|
||||
logD("Currently selecting, showing selection toolbar")
|
||||
R.id.detail_selection_toolbar
|
||||
}
|
||||
|
|
|
@ -38,18 +38,18 @@ import org.oxycblt.auxio.music.info.Name
|
|||
import org.oxycblt.auxio.music.metadata.AudioProperties
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.concatLocalized
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that shows information about a Song.
|
||||
* A [ViewBindingMaterialDialogFragment] that shows information about a Song.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||
class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBinding>() {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
// Information about what song to display is initially within the navigation arguments
|
||||
// as a UID, as that is the only safe way to parcel an song.
|
||||
|
@ -69,8 +69,8 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
binding.detailProperties.adapter = detailAdapter
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setSong(args.songUid)
|
||||
detailModel.toShow.consume()
|
||||
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
|
||||
collectImmediately(detailModel.toShow.flow, ::handleShow)
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?, info: AudioProperties?) {
|
||||
|
@ -126,16 +126,6 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleShow(show: Show?) {
|
||||
if (show == null) return
|
||||
if (show is Show.SongDetails) {
|
||||
logD("Navigated to this song")
|
||||
detailModel.toShow.consume()
|
||||
} else {
|
||||
error("Unexpected show command $show")
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Music> T.zipName(context: Context): String {
|
||||
val name = name
|
||||
return if (name is Name.Known && name.sort != null) {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail.picker
|
||||
package org.oxycblt.auxio.detail.decision
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* NavigationPickerViewModel.kt is part of Auxio.
|
||||
* DetailDecisionViewModel.kt is part of Auxio.
|
||||
*
|
||||
* 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
|
||||
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail.picker
|
||||
package org.oxycblt.auxio.detail.decision
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
|
@ -33,12 +33,13 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [ViewModel] that stores the current information required for navigation picker dialogs
|
||||
* A [ViewModel] that stores choice information for [ShowArtistDialog], and possibly others in the
|
||||
* future.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@HiltViewModel
|
||||
class NavigationPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||
class DetailPickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||
ViewModel(), MusicRepository.UpdateListener {
|
||||
private val _artistChoices = MutableStateFlow<ArtistShowChoices?>(null)
|
||||
/** The current set of [Artist] choices to show in the picker, or null if to show nothing. */
|
||||
|
@ -49,6 +50,11 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
|
|||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.deviceLibrary) return
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
|
@ -57,11 +63,6 @@ class NavigationPickerViewModel @Inject constructor(private val musicRepository:
|
|||
logD("Updated artist choices: ${_artistChoices.value}")
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the [Music.UID] of the item to show artist choices for.
|
||||
*
|
||||
|
@ -105,16 +106,16 @@ sealed interface ArtistShowChoices {
|
|||
class FromSong(val song: Song) : ArtistShowChoices {
|
||||
override val uid = song.uid
|
||||
override val choices = song.artists
|
||||
|
||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
||||
newLibrary.findSong(uid)?.let { FromSong(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Backing implementation of [ArtistShowChoices] that is based on an [AlbumArtistShowChoices].
|
||||
*/
|
||||
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
|
||||
data class FromAlbum(val album: Album) : ArtistShowChoices {
|
||||
override val uid = album.uid
|
||||
override val choices = album.artists
|
||||
|
||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
||||
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail.picker
|
||||
package org.oxycblt.auxio.detail.decision
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
|
@ -33,19 +33,20 @@ import org.oxycblt.auxio.detail.DetailViewModel
|
|||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A picker [ViewBindingDialogFragment] intended for when the [Artist] to show is ambiguous.
|
||||
* A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ShowArtistDialog :
|
||||
ViewBindingDialogFragment<DialogMusicChoicesBinding>(), ClickableListListener<Artist> {
|
||||
ViewBindingMaterialDialogFragment<DialogMusicChoicesBinding>(), ClickableListListener<Artist> {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val pickerModel: NavigationPickerViewModel by viewModels()
|
||||
private val pickerModel: DetailPickerViewModel by viewModels()
|
||||
// Information about what artists to show choices for is initially within the navigation
|
||||
// arguments as UIDs, as that is the only safe way to parcel an artist.
|
||||
private val args: ShowArtistDialogArgs by navArgs()
|
||||
|
@ -66,14 +67,9 @@ class ShowArtistDialog :
|
|||
adapter = choiceAdapter
|
||||
}
|
||||
|
||||
detailModel.toShow.consume()
|
||||
pickerModel.setArtistChoiceUid(args.itemUid)
|
||||
collectImmediately(pickerModel.artistChoices) {
|
||||
if (it != null) {
|
||||
choiceAdapter.update(it.choices, UpdateInstructions.Replace(0))
|
||||
} else {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
collectImmediately(pickerModel.artistChoices, ::updateChoices)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogMusicChoicesBinding) {
|
||||
|
@ -82,8 +78,17 @@ class ShowArtistDialog :
|
|||
}
|
||||
|
||||
override fun onClick(item: Artist, viewHolder: RecyclerView.ViewHolder) {
|
||||
findNavController().navigateUp()
|
||||
// User made a choice, navigate to the artist.
|
||||
detailModel.showArtist(item)
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
|
||||
private fun updateChoices(choices: ArtistShowChoices?) {
|
||||
if (choices == null) {
|
||||
logD("No choices to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
choiceAdapter.update(choices.choices, UpdateInstructions.Diff)
|
||||
}
|
||||
}
|
|
@ -41,6 +41,7 @@ class ArtistDetailHeaderAdapter(private val listener: Listener) :
|
|||
DetailHeaderAdapter<Artist, ArtistDetailHeaderViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
ArtistDetailHeaderViewHolder.from(parent)
|
||||
|
||||
override fun onBindHeader(holder: ArtistDetailHeaderViewHolder, parent: Artist) =
|
||||
holder.bind(parent, listener)
|
||||
}
|
||||
|
@ -70,7 +71,11 @@ private constructor(private val binding: ItemDetailHeaderBinding) :
|
|||
binding.detailInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
||||
if (artist.explicitAlbums.isNotEmpty()) {
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
|
||||
} else {
|
||||
binding.context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
|
||||
} else {
|
||||
|
|
|
@ -30,7 +30,9 @@ import org.oxycblt.auxio.util.logD
|
|||
abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder> :
|
||||
RecyclerView.Adapter<VH>() {
|
||||
private var currentParent: T? = null
|
||||
|
||||
final override fun getItemCount() = 1
|
||||
|
||||
final override fun onBindViewHolder(holder: VH, position: Int) =
|
||||
onBindHeader(holder, requireNotNull(currentParent))
|
||||
|
||||
|
|
|
@ -82,7 +82,7 @@ abstract class DetailListAdapter(
|
|||
* Called when the button in a [SortHeader] item is pressed, requesting that the sort menu
|
||||
* should be opened.
|
||||
*/
|
||||
fun onOpenSortMenu(anchor: View)
|
||||
fun onOpenSortMenu()
|
||||
}
|
||||
|
||||
protected companion object {
|
||||
|
@ -132,7 +132,7 @@ private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
|||
// Add a Tooltip based on the content description so that the purpose of this
|
||||
// button can be clear.
|
||||
TooltipCompat.setTooltipText(this, contentDescription)
|
||||
setOnClickListener(listener::onOpenSortMenu)
|
||||
setOnClickListener { listener.onOpenSortMenu() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -170,10 +170,25 @@ private class EditHeaderViewHolder private constructor(private val binding: Item
|
|||
TooltipCompat.setTooltipText(this, contentDescription)
|
||||
setOnClickListener { listener.onStartEdit() }
|
||||
}
|
||||
binding.headerSort.apply {
|
||||
TooltipCompat.setTooltipText(this, contentDescription)
|
||||
setOnClickListener { listener.onOpenSortMenu() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateEditing(editing: Boolean) {
|
||||
binding.headerEdit.isEnabled = !editing
|
||||
binding.headerEdit.apply {
|
||||
isVisible = !editing
|
||||
isClickable = !editing
|
||||
isFocusable = !editing
|
||||
jumpDrawablesToCurrentState()
|
||||
}
|
||||
binding.headerSort.apply {
|
||||
isVisible = editing
|
||||
isClickable = editing
|
||||
isFocusable = editing
|
||||
jumpDrawablesToCurrentState()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -211,6 +226,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
|
|||
PlaylistDetailListAdapter.ViewHolder {
|
||||
override val enabled: Boolean
|
||||
get() = binding.songDragHandle.isVisible
|
||||
|
||||
override val root = binding.root
|
||||
override val body = binding.body
|
||||
override val delete = binding.background
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* AlbumSongSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.detail.sort
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AlbumSongSortDialog : SortDialog() {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
||||
}
|
||||
|
||||
override fun getInitialSort() = detailModel.albumSongSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
detailModel.applyAlbumSongSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() = listOf(Sort.Mode.ByDisc, Sort.Mode.ByTrack)
|
||||
|
||||
private fun updateAlbum(album: Album?) {
|
||||
if (album == null) {
|
||||
logD("No album to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ArtistSongSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.detail.sort
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ArtistSongSortDialog : SortDialog() {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collectImmediately(detailModel.currentArtist, ::updateArtist)
|
||||
}
|
||||
|
||||
override fun getInitialSort() = detailModel.artistSongSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
detailModel.applyArtistSongSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(Sort.Mode.ByName, Sort.Mode.ByAlbum, Sort.Mode.ByDate, Sort.Mode.ByDuration)
|
||||
|
||||
private fun updateArtist(artist: Artist?) {
|
||||
if (artist == null) {
|
||||
logD("No artist to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* GenreSongSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.detail.sort
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class GenreSongSortDialog : SortDialog() {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collectImmediately(detailModel.currentGenre, ::updateGenre)
|
||||
}
|
||||
|
||||
override fun getInitialSort() = detailModel.genreSongSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
detailModel.applyGenreSongSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(
|
||||
Sort.Mode.ByName,
|
||||
Sort.Mode.ByArtist,
|
||||
Sort.Mode.ByAlbum,
|
||||
Sort.Mode.ByDate,
|
||||
Sort.Mode.ByDuration)
|
||||
|
||||
private fun updateGenre(genre: Genre?) {
|
||||
if (genre == null) {
|
||||
logD("No genre to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* PlaylistSongSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.detail.sort
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PlaylistSongSortDialog : SortDialog() {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
|
||||
}
|
||||
|
||||
override fun getInitialSort() = null
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
detailModel.applyPlaylistSongSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(
|
||||
Sort.Mode.ByName,
|
||||
Sort.Mode.ByArtist,
|
||||
Sort.Mode.ByAlbum,
|
||||
Sort.Mode.ByDate,
|
||||
Sort.Mode.ByDuration)
|
||||
|
||||
private fun updatePlaylist(genre: Playlist?) {
|
||||
if (genre == null) {
|
||||
logD("No genre to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ErrorDetailsDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.openInBrowser
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
* A dialog that shows a stack trace for a music loading error.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Extend to other errors
|
||||
*/
|
||||
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
|
||||
private val args: ErrorDetailsDialogArgs by navArgs()
|
||||
private var clipboardManager: ClipboardManager? = null
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
builder
|
||||
.setTitle(R.string.lbl_error_info)
|
||||
.setPositiveButton(R.string.lbl_report) { _, _ ->
|
||||
requireContext().openInBrowser(LINK_ISSUES)
|
||||
}
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogErrorDetailsBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
clipboardManager = requireContext().getSystemServiceCompat(ClipboardManager::class)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.errorStackTrace.text = args.error.stackTraceToString().trimEnd('\n')
|
||||
binding.errorCopy.setOnClickListener { copyStackTrace() }
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogErrorDetailsBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
clipboardManager = null
|
||||
}
|
||||
|
||||
private fun copyStackTrace() {
|
||||
requireNotNull(clipboardManager) { "Clipboard was unavailable" }
|
||||
.setPrimaryClip(
|
||||
ClipData.newPlainText("Exception Stack Trace", args.error.stackTraceToString()))
|
||||
// A copy notice is shown by the system from Android 13 onwards
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
requireContext().showToast(R.string.lbl_copied)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The URL to the bug report issue form */
|
||||
const val LINK_ISSUES =
|
||||
"https://github.com/OxygenCobalt/Auxio/issues/new" +
|
||||
"?assignees=OxygenCobalt&labels=bug&projects=&template=bug-crash-report.yml"
|
||||
}
|
||||
}
|
|
@ -51,7 +51,7 @@ constructor(
|
|||
// Apply the new configuration possibly set in flipTo. This should occur even if
|
||||
// a flip was canceled by a hide.
|
||||
pendingConfig?.run {
|
||||
this@FlipFloatingActionButton.logD("Applying pending configuration")
|
||||
logD("Applying pending configuration")
|
||||
setImageResource(iconRes)
|
||||
contentDescription = context.getString(contentDescriptionRes)
|
||||
setOnClickListener(clickListener)
|
||||
|
|
|
@ -26,7 +26,6 @@ import androidx.activity.result.ActivityResultLauncher
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.MenuCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.iterator
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
|
@ -54,13 +53,13 @@ import org.oxycblt.auxio.home.list.PlaylistListFragment
|
|||
import org.oxycblt.auxio.home.list.SongListFragment
|
||||
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectionFragment
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.NoAudioPermissionException
|
||||
import org.oxycblt.auxio.music.NoMusicException
|
||||
|
@ -75,7 +74,6 @@ import org.oxycblt.auxio.util.lazyReflectedField
|
|||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
||||
|
@ -86,7 +84,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
@AndroidEntryPoint
|
||||
class HomeFragment :
|
||||
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
|
@ -100,9 +98,9 @@ class HomeFragment :
|
|||
// Orientation change will wipe whatever transition we were using prior, which will
|
||||
// result in no transition when the user navigates back. Make sure we re-initialize
|
||||
// our transitions.
|
||||
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1)
|
||||
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -1)
|
||||
if (axis > -1) {
|
||||
setupAxisTransitions(axis)
|
||||
applyAxisTransition(axis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -170,18 +168,19 @@ class HomeFragment :
|
|||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
||||
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||
collect(musicModel.playlistDecision.flow, ::handleDecision)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
val enter = enterTransition
|
||||
if (enter is MaterialSharedAxis) {
|
||||
outState.putInt(KEY_LAST_TRANSITION_AXIS, enter.axis)
|
||||
val transition = enterTransition
|
||||
if (transition is MaterialSharedAxis) {
|
||||
outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis)
|
||||
}
|
||||
|
||||
super.onSaveInstanceState(outState)
|
||||
|
@ -214,67 +213,48 @@ class HomeFragment :
|
|||
// Handle main actions (Search, Settings, About)
|
||||
R.id.action_search -> {
|
||||
logD("Navigating to search")
|
||||
setupAxisTransitions(MaterialSharedAxis.Z)
|
||||
applyAxisTransition(MaterialSharedAxis.Z)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.search())
|
||||
true
|
||||
}
|
||||
R.id.action_settings -> {
|
||||
logD("Navigating to preferences")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.preferences())
|
||||
homeModel.showSettings()
|
||||
true
|
||||
}
|
||||
R.id.action_about -> {
|
||||
logD("Navigating to about")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.about())
|
||||
homeModel.showAbout()
|
||||
true
|
||||
}
|
||||
|
||||
// Handle sort menu
|
||||
R.id.submenu_sorting -> {
|
||||
R.id.action_sort -> {
|
||||
// Junk click event when opening the menu
|
||||
true
|
||||
}
|
||||
R.id.option_sort_asc -> {
|
||||
logD("Switching to ascending sorting")
|
||||
item.isChecked = true
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel
|
||||
.getSortForTab(homeModel.currentTabMode.value)
|
||||
.withDirection(Sort.Direction.ASCENDING))
|
||||
true
|
||||
}
|
||||
R.id.option_sort_dec -> {
|
||||
logD("Switching to descending sorting")
|
||||
item.isChecked = true
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel
|
||||
.getSortForTab(homeModel.currentTabMode.value)
|
||||
.withDirection(Sort.Direction.DESCENDING))
|
||||
val directions =
|
||||
when (homeModel.currentTabType.value) {
|
||||
MusicType.SONGS -> HomeFragmentDirections.sortSongs()
|
||||
MusicType.ALBUMS -> HomeFragmentDirections.sortAlbums()
|
||||
MusicType.ARTISTS -> HomeFragmentDirections.sortArtists()
|
||||
MusicType.GENRES -> HomeFragmentDirections.sortGenres()
|
||||
MusicType.PLAYLISTS -> HomeFragmentDirections.sortPlaylists()
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
val newMode = Sort.Mode.fromItemId(item.itemId)
|
||||
if (newMode != null) {
|
||||
// Sorting option was selected, mark it as selected and update the mode
|
||||
logD("Updating sort mode")
|
||||
item.isChecked = true
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel.getSortForTab(homeModel.currentTabMode.value).withMode(newMode))
|
||||
true
|
||||
} else {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPager(binding: FragmentHomeBinding) {
|
||||
binding.homePager.adapter =
|
||||
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
|
||||
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
|
||||
|
||||
val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams
|
||||
if (homeModel.currentTabModes.size == 1) {
|
||||
if (homeModel.currentTabTypes.size == 1) {
|
||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||
// behavior.
|
||||
logD("Single tab shown, disabling TabLayout")
|
||||
|
@ -292,81 +272,26 @@ class HomeFragment :
|
|||
TabLayoutMediator(
|
||||
binding.homeTabs,
|
||||
binding.homePager,
|
||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabModes))
|
||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
|
||||
.attach()
|
||||
}
|
||||
|
||||
private fun updateCurrentTab(tabMode: MusicMode) {
|
||||
private fun updateCurrentTab(tabType: MusicType) {
|
||||
val binding = requireBinding()
|
||||
// Update the sort options to align with those allowed by the tab
|
||||
val isVisible: (Int) -> Boolean =
|
||||
when (tabMode) {
|
||||
// Disallow sorting by count for songs
|
||||
MusicMode.SONGS -> {
|
||||
logD("Using song-specific menu options")
|
||||
({ id -> id != R.id.option_sort_count })
|
||||
}
|
||||
// Disallow sorting by album for albums
|
||||
MusicMode.ALBUMS -> {
|
||||
logD("Using album-specific menu options")
|
||||
({ id -> id != R.id.option_sort_album })
|
||||
}
|
||||
// Only allow sorting by name, count, and duration for parents
|
||||
else -> {
|
||||
logD("Using parent-specific menu options")
|
||||
({ id ->
|
||||
id == R.id.option_sort_asc ||
|
||||
id == R.id.option_sort_dec ||
|
||||
id == R.id.option_sort_name ||
|
||||
id == R.id.option_sort_count ||
|
||||
id == R.id.option_sort_duration
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
val sortMenu =
|
||||
unlikelyToBeNull(binding.homeNormalToolbar.menu.findItem(R.id.submenu_sorting).subMenu)
|
||||
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||
|
||||
for (option in sortMenu) {
|
||||
val isCurrentMode = option.itemId == toHighlight.mode.itemId
|
||||
val isCurrentlyAscending =
|
||||
option.itemId == R.id.option_sort_asc &&
|
||||
toHighlight.direction == Sort.Direction.ASCENDING
|
||||
val isCurrentlyDescending =
|
||||
option.itemId == R.id.option_sort_dec &&
|
||||
toHighlight.direction == Sort.Direction.DESCENDING
|
||||
// Check the corresponding direction and mode sort options to align with
|
||||
// the current sort of the tab.
|
||||
if (isCurrentMode || isCurrentlyAscending || isCurrentlyDescending) {
|
||||
logD(
|
||||
"Checking $option option [mode: $isCurrentMode asc: $isCurrentlyAscending dec: $isCurrentlyDescending]")
|
||||
// Note: We cannot inline this boolean assignment since it unchecks all other radio
|
||||
// buttons (even when setting it to false), which would result in nothing being
|
||||
// selected.
|
||||
option.isChecked = true
|
||||
}
|
||||
|
||||
// Disable options that are not allowed by the isVisible lambda
|
||||
option.isVisible = isVisible(option.itemId)
|
||||
if (!option.isVisible) {
|
||||
logD("Hiding $option option")
|
||||
}
|
||||
}
|
||||
|
||||
// Update the scrolling view in AppBarLayout to align with the current tab's
|
||||
// scrolling state. This prevents the lift state from being confused as one
|
||||
// goes between different tabs.
|
||||
binding.homeAppbar.liftOnScrollTargetViewId =
|
||||
when (tabMode) {
|
||||
MusicMode.SONGS -> R.id.home_song_recycler
|
||||
MusicMode.ALBUMS -> R.id.home_album_recycler
|
||||
MusicMode.ARTISTS -> R.id.home_artist_recycler
|
||||
MusicMode.GENRES -> R.id.home_genre_recycler
|
||||
MusicMode.PLAYLISTS -> R.id.home_playlist_recycler
|
||||
when (tabType) {
|
||||
MusicType.SONGS -> R.id.home_song_recycler
|
||||
MusicType.ALBUMS -> R.id.home_album_recycler
|
||||
MusicType.ARTISTS -> R.id.home_artist_recycler
|
||||
MusicType.GENRES -> R.id.home_genre_recycler
|
||||
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
|
||||
}
|
||||
|
||||
if (tabMode != MusicMode.PLAYLISTS) {
|
||||
if (tabType != MusicType.PLAYLISTS) {
|
||||
logD("Flipping to shuffle button")
|
||||
binding.homeFab.flipTo(R.drawable.ic_shuffle_off_24, R.string.desc_shuffle_all) {
|
||||
playbackModel.shuffleAll()
|
||||
|
@ -405,7 +330,7 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) {
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
|
||||
if (error == null) {
|
||||
logD("Received ok response")
|
||||
binding.homeFab.show()
|
||||
|
@ -417,13 +342,13 @@ class HomeFragment :
|
|||
val context = requireContext()
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
binding.homeIndexingActions.visibility = View.VISIBLE
|
||||
when (error) {
|
||||
is NoAudioPermissionException -> {
|
||||
logD("Showing permission prompt")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||
// Configure the action to act as a permission launcher.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
binding.homeIndexingTry.apply {
|
||||
text = context.getString(R.string.lbl_grant)
|
||||
setOnClickListener {
|
||||
requireNotNull(storagePermissionLauncher) {
|
||||
|
@ -432,26 +357,34 @@ class HomeFragment :
|
|||
.launch(PERMISSION_READ_AUDIO)
|
||||
}
|
||||
}
|
||||
binding.homeIndexingMore.visibility = View.GONE
|
||||
}
|
||||
is NoMusicException -> {
|
||||
logD("Showing no music error")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
binding.homeIndexingTry.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.refresh() }
|
||||
}
|
||||
binding.homeIndexingMore.visibility = View.GONE
|
||||
}
|
||||
else -> {
|
||||
logD("Showing generic error")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
binding.homeIndexingTry.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.rescan() }
|
||||
}
|
||||
binding.homeIndexingMore.apply {
|
||||
visibility = View.VISIBLE
|
||||
setOnClickListener {
|
||||
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -460,7 +393,7 @@ class HomeFragment :
|
|||
// Remove all content except for the progress indicator.
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
||||
binding.homeIndexingAction.visibility = View.INVISIBLE
|
||||
binding.homeIndexingActions.visibility = View.INVISIBLE
|
||||
|
||||
when (progress) {
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
|
@ -483,33 +416,27 @@ class HomeFragment :
|
|||
|
||||
private fun handleDecision(decision: PlaylistDecision?) {
|
||||
if (decision == null) return
|
||||
when (decision) {
|
||||
is PlaylistDecision.New -> {
|
||||
logD("Creating new playlist")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
HomeFragmentDirections.newPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray()))
|
||||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.New -> {
|
||||
logD("Creating new playlist")
|
||||
HomeFragmentDirections.newPlaylist(decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
logD("Renaming ${decision.playlist}")
|
||||
HomeFragmentDirections.renamePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
logD("Deleting ${decision.playlist}")
|
||||
HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} to a playlist")
|
||||
HomeFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
logD("Renaming ${decision.playlist}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.renamePlaylist(decision.playlist.uid))
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
logD("Deleting ${decision.playlist}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.deletePlaylist(decision.playlist.uid))
|
||||
}
|
||||
is PlaylistDecision.Add -> {
|
||||
logD("Adding ${decision.songs.size} to a playlist")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
HomeFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray()))
|
||||
}
|
||||
}
|
||||
musicModel.playlistDecision.consume()
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||
|
@ -532,44 +459,40 @@ class HomeFragment :
|
|||
logD("Navigating to ${show.song}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
||||
// Songs should be scrolled to if the album matches, or a new detail
|
||||
// fragment should be launched otherwise.
|
||||
is Show.SongAlbumDetails -> {
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
setupAxisTransitions(MaterialSharedAxis.X)
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
||||
// If the album matches, no need to do anything. Otherwise launch a new
|
||||
// detail fragment.
|
||||
is Show.AlbumDetails -> {
|
||||
logD("Navigating to ${show.album}")
|
||||
setupAxisTransitions(MaterialSharedAxis.X)
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
|
||||
// Always launch a new ArtistDetailFragment.
|
||||
is Show.ArtistDetails -> {
|
||||
logD("Navigating to ${show.artist}")
|
||||
setupAxisTransitions(MaterialSharedAxis.X)
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDetails -> {
|
||||
is Show.SongArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showArtists(show.song.uid))
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDetails -> {
|
||||
is Show.AlbumArtistDecision -> {
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showArtists(show.album.uid))
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
logD("Navigating to ${show.genre}")
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
logD("Navigating to ${show.playlist}")
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
|
||||
}
|
||||
|
@ -577,6 +500,20 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleMenu(menu: Menu?) {
|
||||
if (menu == null) return
|
||||
val directions =
|
||||
when (menu) {
|
||||
is Menu.ForSong -> HomeFragmentDirections.openSongMenu(menu.parcel)
|
||||
is Menu.ForAlbum -> HomeFragmentDirections.openAlbumMenu(menu.parcel)
|
||||
is Menu.ForArtist -> HomeFragmentDirections.openArtistMenu(menu.parcel)
|
||||
is Menu.ForGenre -> HomeFragmentDirections.openGenreMenu(menu.parcel)
|
||||
is Menu.ForPlaylist -> HomeFragmentDirections.openPlaylistMenu(menu.parcel)
|
||||
is Menu.ForSelection -> HomeFragmentDirections.openSelectionMenu(menu.parcel)
|
||||
}
|
||||
findNavController().navigateSafe(directions)
|
||||
}
|
||||
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
val binding = requireBinding()
|
||||
if (selected.isNotEmpty()) {
|
||||
|
@ -591,7 +528,7 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun setupAxisTransitions(axis: Int) {
|
||||
private fun applyAxisTransition(axis: Int) {
|
||||
// Sanity check to avoid in-correct axis transitions
|
||||
check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) {
|
||||
"Not expecting Y axis transition"
|
||||
|
@ -612,25 +549,25 @@ class HomeFragment :
|
|||
* [FragmentStateAdapter].
|
||||
*/
|
||||
private class HomePagerAdapter(
|
||||
private val tabs: List<MusicMode>,
|
||||
private val tabs: List<MusicType>,
|
||||
fragmentManager: FragmentManager,
|
||||
lifecycleOwner: LifecycleOwner
|
||||
) : FragmentStateAdapter(fragmentManager, lifecycleOwner.lifecycle) {
|
||||
override fun getItemCount() = tabs.size
|
||||
|
||||
override fun createFragment(position: Int): Fragment =
|
||||
when (tabs[position]) {
|
||||
MusicMode.SONGS -> SongListFragment()
|
||||
MusicMode.ALBUMS -> AlbumListFragment()
|
||||
MusicMode.ARTISTS -> ArtistListFragment()
|
||||
MusicMode.GENRES -> GenreListFragment()
|
||||
MusicMode.PLAYLISTS -> PlaylistListFragment()
|
||||
MusicType.SONGS -> SongListFragment()
|
||||
MusicType.ALBUMS -> AlbumListFragment()
|
||||
MusicType.ARTISTS -> ArtistListFragment()
|
||||
MusicType.GENRES -> GenreListFragment()
|
||||
MusicType.PLAYLISTS -> PlaylistListFragment()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||
const val KEY_LAST_TRANSITION_AXIS =
|
||||
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
||||
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
@ -75,9 +75,9 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
|||
logD("Old tabs: $oldTabs")
|
||||
|
||||
// The playlist tab is now parsed, but it needs to be made visible.
|
||||
val playlistIndex = oldTabs.indexOfFirst { it.mode == MusicMode.PLAYLISTS }
|
||||
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
|
||||
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
|
||||
oldTabs[playlistIndex] = Tab.Visible(MusicMode.PLAYLISTS)
|
||||
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
|
||||
logD("New tabs: $oldTabs")
|
||||
|
||||
sharedPreferences.edit {
|
||||
|
|
|
@ -24,16 +24,17 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
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.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
|
@ -49,73 +50,98 @@ class HomeViewModel
|
|||
@Inject
|
||||
constructor(
|
||||
private val homeSettings: HomeSettings,
|
||||
private val listSettings: ListSettings,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings
|
||||
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
|
||||
|
||||
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||
private val _songList = MutableStateFlow(listOf<Song>())
|
||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val songsList: StateFlow<List<Song>>
|
||||
get() = _songsList
|
||||
private val _songsInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [songsList] in the UI. */
|
||||
val songsInstructions: Event<UpdateInstructions>
|
||||
get() = _songsInstructions
|
||||
val songList: StateFlow<List<Song>>
|
||||
get() = _songList
|
||||
|
||||
private val _albumsLists = MutableStateFlow(listOf<Album>())
|
||||
private val _songInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [songList] in the UI. */
|
||||
val songInstructions: Event<UpdateInstructions>
|
||||
get() = _songInstructions
|
||||
|
||||
/** The current [Sort] used for [songList]. */
|
||||
val songSort: Sort
|
||||
get() = listSettings.songSort
|
||||
|
||||
/** The [PlaySong] instructions to use when playing a [Song]. */
|
||||
val playWith
|
||||
get() = playbackSettings.playInListWith
|
||||
|
||||
private val _albumList = MutableStateFlow(listOf<Album>())
|
||||
/** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val albumsList: StateFlow<List<Album>>
|
||||
get() = _albumsLists
|
||||
private val _albumsInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [albumsList] in the UI. */
|
||||
val albumsInstructions: Event<UpdateInstructions>
|
||||
get() = _albumsInstructions
|
||||
val albumList: StateFlow<List<Album>>
|
||||
get() = _albumList
|
||||
|
||||
private val _artistsList = MutableStateFlow(listOf<Artist>())
|
||||
private val _albumInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [albumList] in the UI. */
|
||||
val albumInstructions: Event<UpdateInstructions>
|
||||
get() = _albumInstructions
|
||||
|
||||
/** The current [Sort] used for [albumList]. */
|
||||
val albumSort: Sort
|
||||
get() = listSettings.albumSort
|
||||
|
||||
private val _artistList = MutableStateFlow(listOf<Artist>())
|
||||
/**
|
||||
* A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that
|
||||
* if "Hide collaborators" is on, this list will not include collaborator [Artist]s.
|
||||
*/
|
||||
val artistsList: MutableStateFlow<List<Artist>>
|
||||
get() = _artistsList
|
||||
private val _artistsInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [artistsList] in the UI. */
|
||||
val artistsInstructions: Event<UpdateInstructions>
|
||||
get() = _artistsInstructions
|
||||
val artistList: MutableStateFlow<List<Artist>>
|
||||
get() = _artistList
|
||||
|
||||
private val _genresList = MutableStateFlow(listOf<Genre>())
|
||||
private val _artistInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [artistList] in the UI. */
|
||||
val artistInstructions: Event<UpdateInstructions>
|
||||
get() = _artistInstructions
|
||||
|
||||
/** The current [Sort] used for [artistList]. */
|
||||
val artistSort: Sort
|
||||
get() = listSettings.artistSort
|
||||
|
||||
private val _genreList = MutableStateFlow(listOf<Genre>())
|
||||
/** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val genresList: StateFlow<List<Genre>>
|
||||
get() = _genresList
|
||||
private val _genresInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [genresList] in the UI. */
|
||||
val genresInstructions: Event<UpdateInstructions>
|
||||
get() = _genresInstructions
|
||||
val genreList: StateFlow<List<Genre>>
|
||||
get() = _genreList
|
||||
|
||||
private val _playlistsList = MutableStateFlow(listOf<Playlist>())
|
||||
private val _genreInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [genreList] in the UI. */
|
||||
val genreInstructions: Event<UpdateInstructions>
|
||||
get() = _genreInstructions
|
||||
|
||||
/** The current [Sort] used for [genreList]. */
|
||||
val genreSort: Sort
|
||||
get() = listSettings.genreSort
|
||||
|
||||
private val _playlistList = MutableStateFlow(listOf<Playlist>())
|
||||
/** A list of [Playlist]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val playlistsList: StateFlow<List<Playlist>>
|
||||
get() = _playlistsList
|
||||
private val _playlistsInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [genresList] in the UI. */
|
||||
val playlistsInstructions: Event<UpdateInstructions>
|
||||
get() = _playlistsInstructions
|
||||
val playlistList: StateFlow<List<Playlist>>
|
||||
get() = _playlistList
|
||||
|
||||
/** The [MusicMode] to use when playing a [Song] from the UI. */
|
||||
val playbackMode: MusicMode
|
||||
get() = playbackSettings.inListPlaybackMode
|
||||
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [genreList] in the UI. */
|
||||
val playlistInstructions: Event<UpdateInstructions>
|
||||
get() = _playlistInstructions
|
||||
|
||||
/** The current [Sort] used for [genreList]. */
|
||||
val playlistSort: Sort
|
||||
get() = listSettings.playlistSort
|
||||
|
||||
/**
|
||||
* A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible
|
||||
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
|
||||
* [Tab]s.
|
||||
*/
|
||||
var currentTabModes = makeTabModes()
|
||||
var currentTabTypes = makeTabTypes()
|
||||
private set
|
||||
|
||||
private val _currentTabMode = MutableStateFlow(currentTabModes[0])
|
||||
/** The [MusicMode] of the currently shown [Tab]. */
|
||||
val currentTabMode: StateFlow<MusicMode> = _currentTabMode
|
||||
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
|
||||
/** The [MusicType] of the currently shown [Tab]. */
|
||||
val currentTabType: StateFlow<MusicType> = _currentTabType
|
||||
|
||||
private val _shouldRecreate = MutableEvent<Unit>()
|
||||
/**
|
||||
|
@ -130,6 +156,10 @@ constructor(
|
|||
/** A marker for whether the user is fast-scrolling in the home view or not. */
|
||||
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||
|
||||
private val _showOuter = MutableEvent<Outer>()
|
||||
val showOuter: Event<Outer>
|
||||
get() = _showOuter
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
homeSettings.registerListener(this)
|
||||
|
@ -147,13 +177,13 @@ constructor(
|
|||
logD("Refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
_songsInstructions.put(UpdateInstructions.Diff)
|
||||
_songsList.value = musicSettings.songSort.songs(deviceLibrary.songs)
|
||||
_albumsInstructions.put(UpdateInstructions.Diff)
|
||||
_albumsLists.value = musicSettings.albumSort.albums(deviceLibrary.albums)
|
||||
_artistsInstructions.put(UpdateInstructions.Diff)
|
||||
_artistsList.value =
|
||||
musicSettings.artistSort.artists(
|
||||
_songInstructions.put(UpdateInstructions.Diff)
|
||||
_songList.value = listSettings.songSort.songs(deviceLibrary.songs)
|
||||
_albumInstructions.put(UpdateInstructions.Diff)
|
||||
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
|
||||
_artistInstructions.put(UpdateInstructions.Diff)
|
||||
_artistList.value =
|
||||
listSettings.artistSort.artists(
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
logD("Filtering collaborator artists")
|
||||
// Hide Collaborators is enabled, filter out collaborators.
|
||||
|
@ -162,22 +192,22 @@ constructor(
|
|||
logD("Using all artists")
|
||||
deviceLibrary.artists
|
||||
})
|
||||
_genresInstructions.put(UpdateInstructions.Diff)
|
||||
_genresList.value = musicSettings.genreSort.genres(deviceLibrary.genres)
|
||||
_genreInstructions.put(UpdateInstructions.Diff)
|
||||
_genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
|
||||
}
|
||||
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
logD("Refreshing playlists")
|
||||
_playlistsInstructions.put(UpdateInstructions.Diff)
|
||||
_playlistsList.value = musicSettings.playlistSort.playlists(userLibrary.playlists)
|
||||
_playlistInstructions.put(UpdateInstructions.Diff)
|
||||
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabsChanged() {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
currentTabModes = makeTabModes()
|
||||
logD("Updating tabs: ${currentTabMode.value}")
|
||||
currentTabTypes = makeTabTypes()
|
||||
logD("Updating tabs: ${currentTabType.value}")
|
||||
_shouldRecreate.put(Unit)
|
||||
}
|
||||
|
||||
|
@ -189,69 +219,68 @@ constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the preferred [Sort] for a given [Tab].
|
||||
* Apply a new [Sort] to [songList].
|
||||
*
|
||||
* @param tabMode The [MusicMode] of the [Tab] desired.
|
||||
* @return The [Sort] preferred for that [Tab]
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun getSortForTab(tabMode: MusicMode) =
|
||||
when (tabMode) {
|
||||
MusicMode.SONGS -> musicSettings.songSort
|
||||
MusicMode.ALBUMS -> musicSettings.albumSort
|
||||
MusicMode.ARTISTS -> musicSettings.artistSort
|
||||
MusicMode.GENRES -> musicSettings.genreSort
|
||||
MusicMode.PLAYLISTS -> musicSettings.playlistSort
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// Can simply re-sort the current list of items without having to access the library.
|
||||
when (val mode = _currentTabMode.value) {
|
||||
MusicMode.SONGS -> {
|
||||
logD("Updating song [$mode] sort mode to $sort")
|
||||
musicSettings.songSort = sort
|
||||
_songsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_songsList.value = sort.songs(_songsList.value)
|
||||
}
|
||||
MusicMode.ALBUMS -> {
|
||||
logD("Updating album [$mode] sort mode to $sort")
|
||||
musicSettings.albumSort = sort
|
||||
_albumsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_albumsLists.value = sort.albums(_albumsLists.value)
|
||||
}
|
||||
MusicMode.ARTISTS -> {
|
||||
logD("Updating artist [$mode] sort mode to $sort")
|
||||
musicSettings.artistSort = sort
|
||||
_artistsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_artistsList.value = sort.artists(_artistsList.value)
|
||||
}
|
||||
MusicMode.GENRES -> {
|
||||
logD("Updating genre [$mode] sort mode to $sort")
|
||||
musicSettings.genreSort = sort
|
||||
_genresInstructions.put(UpdateInstructions.Replace(0))
|
||||
_genresList.value = sort.genres(_genresList.value)
|
||||
}
|
||||
MusicMode.PLAYLISTS -> {
|
||||
logD("Updating playlist [$mode] sort mode to $sort")
|
||||
musicSettings.playlistSort = sort
|
||||
_playlistsInstructions.put(UpdateInstructions.Replace(0))
|
||||
_playlistsList.value = sort.playlists(_playlistsList.value)
|
||||
}
|
||||
}
|
||||
fun applySongSort(sort: Sort) {
|
||||
listSettings.songSort = sort
|
||||
_songInstructions.put(UpdateInstructions.Replace(0))
|
||||
_songList.value = listSettings.songSort.songs(_songList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update [currentTabMode] to reflect a new ViewPager2 position
|
||||
* Apply a new [Sort] to [albumList].
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyAlbumSort(sort: Sort) {
|
||||
listSettings.albumSort = sort
|
||||
_albumInstructions.put(UpdateInstructions.Replace(0))
|
||||
_albumList.value = listSettings.albumSort.albums(_albumList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new [Sort] to [artistList].
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyArtistSort(sort: Sort) {
|
||||
listSettings.artistSort = sort
|
||||
_artistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_artistList.value = listSettings.artistSort.artists(_artistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new [Sort] to [genreList].
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyGenreSort(sort: Sort) {
|
||||
listSettings.genreSort = sort
|
||||
_genreInstructions.put(UpdateInstructions.Replace(0))
|
||||
_genreList.value = listSettings.genreSort.genres(_genreList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new [Sort] to [playlistList].
|
||||
*
|
||||
* @param sort The [Sort] to apply.
|
||||
*/
|
||||
fun applyPlaylistSort(sort: Sort) {
|
||||
listSettings.playlistSort = sort
|
||||
_playlistInstructions.put(UpdateInstructions.Replace(0))
|
||||
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update [currentTabType] to reflect a new ViewPager2 position
|
||||
*
|
||||
* @param pagerPos The new position of the ViewPager2 instance.
|
||||
*/
|
||||
fun synchronizeTabPosition(pagerPos: Int) {
|
||||
logD("Updating current tab to ${currentTabModes[pagerPos]}")
|
||||
_currentTabMode.value = currentTabModes[pagerPos]
|
||||
logD("Updating current tab to ${currentTabTypes[pagerPos]}")
|
||||
_currentTabType.value = currentTabTypes[pagerPos]
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -264,12 +293,26 @@ constructor(
|
|||
_isFastScrolling.value = isFastScrolling
|
||||
}
|
||||
|
||||
fun showSettings() {
|
||||
_showOuter.put(Outer.Settings)
|
||||
}
|
||||
|
||||
fun showAbout() {
|
||||
_showOuter.put(Outer.About)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of [MusicMode]s representing a simpler version of the [Tab] configuration.
|
||||
* Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
|
||||
*
|
||||
* @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in
|
||||
* @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
|
||||
* the same way as the configuration.
|
||||
*/
|
||||
private fun makeTabModes() =
|
||||
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
private fun makeTabTypes() =
|
||||
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
||||
}
|
||||
|
||||
sealed interface Outer {
|
||||
data object Settings : Outer
|
||||
|
||||
data object About : Outer
|
||||
}
|
||||
|
|
|
@ -123,8 +123,11 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
|
|||
}
|
||||
|
||||
override fun isAutoMirrored(): Boolean = true
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {}
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
||||
private fun updatePath() {
|
||||
|
|
|
@ -21,7 +21,6 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
@ -32,14 +31,13 @@ import org.oxycblt.auxio.detail.DetailViewModel
|
|||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
@ -59,10 +57,10 @@ class AlbumListFragment :
|
|||
FastScrollRecyclerView.Listener,
|
||||
FastScrollRecyclerView.PopupProvider {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val albumAdapter = AlbumAdapter(this)
|
||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
||||
private val formatterSb = StringBuilder(64)
|
||||
|
@ -81,8 +79,8 @@ class AlbumListFragment :
|
|||
listener = this@AlbumListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.albumsList, ::updateAlbums)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collectImmediately(homeModel.albumList, ::updateAlbums)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
@ -97,9 +95,9 @@ class AlbumListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val album = homeModel.albumsList.value[pos]
|
||||
val album = homeModel.albumList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
|
||||
return when (homeModel.albumSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> album.name.thumb
|
||||
|
||||
|
@ -141,12 +139,12 @@ class AlbumListFragment :
|
|||
detailModel.showAlbum(item)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Album, anchor: View) {
|
||||
openMusicMenu(anchor, R.menu.menu_album_actions, item)
|
||||
override fun onOpenMenu(item: Album) {
|
||||
listModel.openMenu(R.menu.album, item)
|
||||
}
|
||||
|
||||
private fun updateAlbums(albums: List<Album>) {
|
||||
albumAdapter.update(albums, homeModel.albumsInstructions.consume())
|
||||
albumAdapter.update(albums, homeModel.albumInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
|
|
@ -20,7 +20,6 @@ package org.oxycblt.auxio.home.list
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
@ -30,21 +29,20 @@ import org.oxycblt.auxio.detail.DetailViewModel
|
|||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
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.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import org.oxycblt.auxio.util.positiveOrNull
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Artist]s.
|
||||
|
@ -57,10 +55,10 @@ class ArtistListFragment :
|
|||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.Listener {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val artistAdapter = ArtistAdapter(this)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
|
@ -76,8 +74,8 @@ class ArtistListFragment :
|
|||
listener = this@ArtistListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.artistsList, ::updateArtists)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collectImmediately(homeModel.artistList, ::updateArtists)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
@ -92,9 +90,9 @@ class ArtistListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val artist = homeModel.artistsList.value[pos]
|
||||
val artist = homeModel.artistList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
|
||||
return when (homeModel.artistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> artist.name.thumb
|
||||
|
||||
|
@ -102,7 +100,7 @@ class ArtistListFragment :
|
|||
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||
|
||||
// Count -> Use song count
|
||||
is Sort.Mode.ByCount -> artist.songs.size.nonZeroOrNull()?.toString()
|
||||
is Sort.Mode.ByCount -> artist.songs.size.positiveOrNull()?.toString()
|
||||
|
||||
// Unsupported sort, error gracefully
|
||||
else -> null
|
||||
|
@ -117,12 +115,12 @@ class ArtistListFragment :
|
|||
detailModel.showArtist(item)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Artist, anchor: View) {
|
||||
openMusicMenu(anchor, R.menu.menu_parent_actions, item)
|
||||
override fun onOpenMenu(item: Artist) {
|
||||
listModel.openMenu(R.menu.parent, item)
|
||||
}
|
||||
|
||||
private fun updateArtists(artists: List<Artist>) {
|
||||
artistAdapter.update(artists, homeModel.artistsInstructions.consume())
|
||||
artistAdapter.update(artists, homeModel.artistInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
|
|
@ -20,7 +20,6 @@ package org.oxycblt.auxio.home.list
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
@ -30,14 +29,13 @@ import org.oxycblt.auxio.detail.DetailViewModel
|
|||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.GenreViewHolder
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
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.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
@ -56,10 +54,10 @@ class GenreListFragment :
|
|||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.Listener {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val genreAdapter = GenreAdapter(this)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
|
@ -75,8 +73,8 @@ class GenreListFragment :
|
|||
listener = this@GenreListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.genresList, ::updateGenres)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collectImmediately(homeModel.genreList, ::updateGenres)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
@ -91,9 +89,9 @@ class GenreListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val genre = homeModel.genresList.value[pos]
|
||||
val genre = homeModel.genreList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
|
||||
return when (homeModel.genreSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> genre.name.thumb
|
||||
|
||||
|
@ -116,12 +114,12 @@ class GenreListFragment :
|
|||
detailModel.showGenre(item)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Genre, anchor: View) {
|
||||
openMusicMenu(anchor, R.menu.menu_parent_actions, item)
|
||||
override fun onOpenMenu(item: Genre) {
|
||||
listModel.openMenu(R.menu.parent, item)
|
||||
}
|
||||
|
||||
private fun updateGenres(genres: List<Genre>) {
|
||||
genreAdapter.update(genres, homeModel.genresInstructions.consume())
|
||||
genreAdapter.update(genres, homeModel.genreInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
|
|
@ -20,7 +20,6 @@ package org.oxycblt.auxio.home.list
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.R
|
||||
|
@ -29,13 +28,12 @@ import org.oxycblt.auxio.detail.DetailViewModel
|
|||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
|
@ -54,10 +52,10 @@ class PlaylistListFragment :
|
|||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.Listener {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val playlistAdapter = PlaylistAdapter(this)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
|
@ -73,8 +71,8 @@ class PlaylistListFragment :
|
|||
listener = this@PlaylistListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.playlistsList, ::updatePlaylists)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collectImmediately(homeModel.playlistList, ::updatePlaylists)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
@ -89,9 +87,9 @@ class PlaylistListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val playlist = homeModel.playlistsList.value[pos]
|
||||
val playlist = homeModel.playlistList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
|
||||
return when (homeModel.playlistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> playlist.name.thumb
|
||||
|
||||
|
@ -114,12 +112,12 @@ class PlaylistListFragment :
|
|||
detailModel.showPlaylist(item)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Playlist, anchor: View) {
|
||||
openMusicMenu(anchor, R.menu.menu_playlist_actions, item)
|
||||
override fun onOpenMenu(item: Playlist) {
|
||||
listModel.openMenu(R.menu.playlist, item)
|
||||
}
|
||||
|
||||
private fun updatePlaylists(playlists: List<Playlist>) {
|
||||
playlistAdapter.update(playlists, homeModel.playlistsInstructions.consume())
|
||||
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
|
|
@ -21,24 +21,21 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.Formatter
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
@ -58,10 +55,9 @@ class SongListFragment :
|
|||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.Listener {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
override val detailModel: DetailViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val selectionModel: SelectionViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
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)
|
||||
|
@ -80,8 +76,8 @@ class SongListFragment :
|
|||
listener = this@SongListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.songsList, ::updateSongs)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
collectImmediately(homeModel.songList, ::updateSongs)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
@ -96,11 +92,11 @@ class SongListFragment :
|
|||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val song = homeModel.songsList.value[pos]
|
||||
val song = homeModel.songList.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.
|
||||
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
|
||||
return when (homeModel.songSort.mode) {
|
||||
// Name -> Use name
|
||||
is Sort.Mode.ByName -> song.name.thumb
|
||||
|
||||
|
@ -139,15 +135,15 @@ class SongListFragment :
|
|||
}
|
||||
|
||||
override fun onRealClick(item: Song) {
|
||||
playbackModel.playFrom(item, homeModel.playbackMode)
|
||||
playbackModel.play(item, homeModel.playWith)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Song, anchor: View) {
|
||||
openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
||||
override fun onOpenMenu(item: Song) {
|
||||
listModel.openMenu(R.menu.song, item, homeModel.playWith)
|
||||
}
|
||||
|
||||
private fun updateSongs(songs: List<Song>) {
|
||||
songAdapter.update(songs, homeModel.songsInstructions.consume())
|
||||
songAdapter.update(songs, homeModel.songInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* AlbumSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.sort
|
||||
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [HomeViewModel.albumList].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AlbumSortDialog : SortDialog() {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun getInitialSort() = homeModel.albumSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
homeModel.applyAlbumSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(
|
||||
Sort.Mode.ByName,
|
||||
Sort.Mode.ByArtist,
|
||||
Sort.Mode.ByDate,
|
||||
Sort.Mode.ByDuration,
|
||||
Sort.Mode.ByCount,
|
||||
Sort.Mode.ByDateAdded)
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ArtistSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.sort
|
||||
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [HomeViewModel.artistList].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ArtistSortDialog : SortDialog() {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun getInitialSort() = homeModel.artistSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
homeModel.applyArtistSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(Sort.Mode.ByName, Sort.Mode.ByDuration, Sort.Mode.ByCount)
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* GenreSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.sort
|
||||
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [HomeViewModel.genreList].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class GenreSortDialog : SortDialog() {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun getInitialSort() = homeModel.genreSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
homeModel.applyGenreSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(Sort.Mode.ByName, Sort.Mode.ByDuration, Sort.Mode.ByCount)
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* PlaylistSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.sort
|
||||
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [HomeViewModel.playlistList].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PlaylistSortDialog : SortDialog() {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun getInitialSort() = homeModel.playlistSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
homeModel.applyPlaylistSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(Sort.Mode.ByName, Sort.Mode.ByDuration, Sort.Mode.ByCount)
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* SongSortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.sort
|
||||
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [HomeViewModel.songList].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class SongSortDialog : SortDialog() {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun getInitialSort() = homeModel.songSort
|
||||
|
||||
override fun applyChosenSort(sort: Sort) {
|
||||
homeModel.applySongSort(sort)
|
||||
}
|
||||
|
||||
override fun getModeChoices() =
|
||||
listOf(
|
||||
Sort.Mode.ByName,
|
||||
Sort.Mode.ByArtist,
|
||||
Sort.Mode.ByAlbum,
|
||||
Sort.Mode.ByDate,
|
||||
Sort.Mode.ByDuration,
|
||||
Sort.Mode.ByDateAdded)
|
||||
}
|
|
@ -22,7 +22,7 @@ import android.content.Context
|
|||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
|
||||
/**
|
||||
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
|
||||
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.MusicMode
|
|||
* @param tabs Current tab configuration from settings
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
|
||||
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||
|
||||
|
@ -41,23 +41,23 @@ class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicMode>) :
|
|||
val string: Int
|
||||
|
||||
when (tabs[position]) {
|
||||
MusicMode.SONGS -> {
|
||||
MusicType.SONGS -> {
|
||||
icon = R.drawable.ic_song_24
|
||||
string = R.string.lbl_songs
|
||||
}
|
||||
MusicMode.ALBUMS -> {
|
||||
MusicType.ALBUMS -> {
|
||||
icon = R.drawable.ic_album_24
|
||||
string = R.string.lbl_albums
|
||||
}
|
||||
MusicMode.ARTISTS -> {
|
||||
MusicType.ARTISTS -> {
|
||||
icon = R.drawable.ic_artist_24
|
||||
string = R.string.lbl_artists
|
||||
}
|
||||
MusicMode.GENRES -> {
|
||||
MusicType.GENRES -> {
|
||||
icon = R.drawable.ic_genre_24
|
||||
string = R.string.lbl_genres
|
||||
}
|
||||
MusicMode.PLAYLISTS -> {
|
||||
MusicType.PLAYLISTS -> {
|
||||
icon = R.drawable.ic_playlist_24
|
||||
string = R.string.lbl_playlists
|
||||
}
|
||||
|
|
|
@ -18,30 +18,30 @@
|
|||
|
||||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A representation of a library tab suitable for configuration.
|
||||
*
|
||||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
* @param type The type of list in the home view this instance corresponds to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed class Tab(open val mode: MusicMode) {
|
||||
sealed class Tab(open val type: MusicType) {
|
||||
/**
|
||||
* A visible tab. This will be visible in the home and tab configuration views.
|
||||
*
|
||||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
* @param type The type of list in the home view this instance corresponds to.
|
||||
*/
|
||||
data class Visible(override val mode: MusicMode) : Tab(mode)
|
||||
data class Visible(override val type: MusicType) : Tab(type)
|
||||
|
||||
/**
|
||||
* A visible tab. This will be visible in the tab configuration view, but not in the home view.
|
||||
*
|
||||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
* @param type The type of list in the home view this instance corresponds to.
|
||||
*/
|
||||
data class Invisible(override val mode: MusicMode) : Tab(mode)
|
||||
data class Invisible(override val type: MusicType) : Tab(type)
|
||||
|
||||
companion object {
|
||||
// Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs
|
||||
|
@ -67,14 +67,14 @@ sealed class Tab(open val mode: MusicMode) {
|
|||
*/
|
||||
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_1100
|
||||
|
||||
/** Maps between the integer code in the tab sequence and it's [MusicMode]. */
|
||||
/** Maps between the integer code in the tab sequence and it's [MusicType]. */
|
||||
private val MODE_TABLE =
|
||||
arrayOf(
|
||||
MusicMode.SONGS,
|
||||
MusicMode.ALBUMS,
|
||||
MusicMode.ARTISTS,
|
||||
MusicMode.GENRES,
|
||||
MusicMode.PLAYLISTS)
|
||||
MusicType.SONGS,
|
||||
MusicType.ALBUMS,
|
||||
MusicType.ARTISTS,
|
||||
MusicType.GENRES,
|
||||
MusicType.PLAYLISTS)
|
||||
|
||||
/**
|
||||
* Convert an array of [Tab]s into it's integer representation.
|
||||
|
@ -84,7 +84,7 @@ sealed class Tab(open val mode: MusicMode) {
|
|||
*/
|
||||
fun toIntCode(tabs: Array<Tab>): Int {
|
||||
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||
val distinct = tabs.distinctBy { it.mode }
|
||||
val distinct = tabs.distinctBy { it.type }
|
||||
if (tabs.size != distinct.size) {
|
||||
logW(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
|
@ -95,8 +95,8 @@ sealed class Tab(open val mode: MusicMode) {
|
|||
for (tab in distinct) {
|
||||
val bin =
|
||||
when (tab) {
|
||||
is Visible -> 1.shl(3) or MODE_TABLE.indexOf(tab.mode)
|
||||
is Invisible -> MODE_TABLE.indexOf(tab.mode)
|
||||
is Visible -> 1.shl(3) or MODE_TABLE.indexOf(tab.type)
|
||||
is Invisible -> MODE_TABLE.indexOf(tab.type)
|
||||
}
|
||||
|
||||
sequence = sequence or bin.shl(shift)
|
||||
|
@ -131,7 +131,7 @@ sealed class Tab(open val mode: MusicMode) {
|
|||
}
|
||||
|
||||
// Make sure there are no duplicate tabs
|
||||
val distinct = tabs.distinctBy { it.mode }
|
||||
val distinct = tabs.distinctBy { it.type }
|
||||
if (tabs.size != distinct.size) {
|
||||
logW(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
|
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.ItemTabBinding
|
||||
import org.oxycblt.auxio.list.EditClickListListener
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
|
@ -42,7 +42,9 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
private set
|
||||
|
||||
override fun getItemCount() = tabs.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
|
||||
holder.bind(tabs[position], listener)
|
||||
}
|
||||
|
@ -107,14 +109,14 @@ class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
|||
fun bind(tab: Tab, listener: EditClickListListener<Tab>) {
|
||||
listener.bind(tab, this, dragHandle = binding.tabDragHandle)
|
||||
binding.tabCheckBox.apply {
|
||||
// Update the CheckBox name to align with the mode
|
||||
// Update the CheckBox name to align with the type
|
||||
setText(
|
||||
when (tab.mode) {
|
||||
MusicMode.SONGS -> R.string.lbl_songs
|
||||
MusicMode.ALBUMS -> R.string.lbl_albums
|
||||
MusicMode.ARTISTS -> R.string.lbl_artists
|
||||
MusicMode.GENRES -> R.string.lbl_genres
|
||||
MusicMode.PLAYLISTS -> R.string.lbl_playlists
|
||||
when (tab.type) {
|
||||
MusicType.SONGS -> R.string.lbl_songs
|
||||
MusicType.ALBUMS -> R.string.lbl_albums
|
||||
MusicType.ARTISTS -> R.string.lbl_artists
|
||||
MusicType.GENRES -> R.string.lbl_genres
|
||||
MusicType.PLAYLISTS -> R.string.lbl_playlists
|
||||
})
|
||||
|
||||
// Unlike in other adapters, we update the checked state alongside
|
||||
|
|
|
@ -30,17 +30,18 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
||||
import org.oxycblt.auxio.home.HomeSettings
|
||||
import org.oxycblt.auxio.list.EditClickListListener
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
|
||||
* A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab]
|
||||
* configuration.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class TabCustomizeDialog :
|
||||
ViewBindingDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
|
||||
ViewBindingMaterialDialogFragment<DialogTabsBinding>(), EditClickListListener<Tab> {
|
||||
private val tabAdapter = TabAdapter(this)
|
||||
private var touchHelper: ItemTouchHelper? = null
|
||||
@Inject lateinit var homeSettings: HomeSettings
|
||||
|
@ -90,13 +91,13 @@ class TabCustomizeDialog :
|
|||
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 }
|
||||
val index = tabAdapter.tabs.indexOfFirst { it.type == item.type }
|
||||
val old = tabAdapter.tabs[index]
|
||||
val new =
|
||||
when (old) {
|
||||
// Invert the visibility of the tab
|
||||
is Tab.Visible -> Tab.Invisible(old.mode)
|
||||
is Tab.Invisible -> Tab.Visible(old.mode)
|
||||
is Tab.Visible -> Tab.Invisible(old.type)
|
||||
is Tab.Invisible -> Tab.Visible(old.type)
|
||||
}
|
||||
logD("Flipping tab visibility [from: $old to: $new]")
|
||||
tabAdapter.setTab(index, new)
|
||||
|
|
|
@ -83,35 +83,40 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
private val image: ImageView
|
||||
|
||||
data class PlaybackIndicator(
|
||||
private data class PlaybackIndicator(
|
||||
val view: ImageView,
|
||||
val playingDrawable: AnimationDrawable,
|
||||
val pausedDrawable: Drawable
|
||||
)
|
||||
|
||||
private val playbackIndicator: PlaybackIndicator?
|
||||
private val selectionBadge: ImageView?
|
||||
|
||||
private val sizing: Int
|
||||
@DimenRes private val iconSizeRes: Int?
|
||||
@DimenRes private val cornerRadiusRes: Int?
|
||||
@DimenRes private var cornerRadiusRes: Int?
|
||||
|
||||
private var fadeAnimator: ValueAnimator? = null
|
||||
private val indicatorMatrix = Matrix()
|
||||
private val indicatorMatrixSrc = RectF()
|
||||
private val indicatorMatrixDst = RectF()
|
||||
|
||||
private data class Cover(
|
||||
val songs: Collection<Song>,
|
||||
val desc: String,
|
||||
@DrawableRes val errorRes: Int
|
||||
)
|
||||
|
||||
private var currentCover: Cover? = null
|
||||
|
||||
init {
|
||||
// Obtain some StyledImageView attributes to use later when theming the custom view.
|
||||
@SuppressLint("CustomViewStyleable")
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
|
||||
|
||||
val sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
|
||||
sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
|
||||
iconSizeRes = SIZING_ICON_SIZE[sizing]
|
||||
cornerRadiusRes =
|
||||
if (uiSettings.roundMode) {
|
||||
SIZING_CORNER_RADII[sizing]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
cornerRadiusRes = getCornerRadiusRes()
|
||||
|
||||
val playbackIndicatorEnabled =
|
||||
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
|
||||
|
@ -161,19 +166,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
playbackIndicator?.run { addView(view) }
|
||||
|
||||
// Add backgrounds to each child for visual consistency
|
||||
for (child in children) {
|
||||
child.apply {
|
||||
// If there are rounded corners, we want to make sure view content will be cropped
|
||||
// with it.
|
||||
clipToOutline = this != image
|
||||
background =
|
||||
MaterialShapeDrawable().apply {
|
||||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
||||
setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
applyBackgroundsToChildren()
|
||||
|
||||
// The selection badge has it's own background we don't want overridden, add it after
|
||||
// all other elements.
|
||||
|
@ -261,6 +254,29 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
}
|
||||
|
||||
private fun getCornerRadiusRes() =
|
||||
if (!isInEditMode && uiSettings.roundMode) {
|
||||
SIZING_CORNER_RADII[sizing]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun applyBackgroundsToChildren() {
|
||||
// Add backgrounds to each child for visual consistency
|
||||
for (child in children) {
|
||||
child.apply {
|
||||
// If there are rounded corners, we want to make sure view content will be cropped
|
||||
// with it.
|
||||
clipToOutline = this != image
|
||||
background =
|
||||
MaterialShapeDrawable().apply {
|
||||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
||||
setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidateRootAlpha() {
|
||||
alpha = if (isEnabled || isSelected) 1f else 0.5f
|
||||
}
|
||||
|
@ -401,6 +417,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
CoilUtils.dispose(image)
|
||||
imageLoader.enqueue(request.build())
|
||||
contentDescription = desc
|
||||
currentCover = Cover(songs, desc, errorRes)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -39,7 +39,7 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
|
|||
|
||||
interface Listener {
|
||||
/** Called when [coverMode] changes. */
|
||||
fun onCoverModeChanged() {}
|
||||
fun onImageSettingsChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,9 +77,10 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
||||
if (key == getString(R.string.set_key_cover_mode)) {
|
||||
logD("Dispatching cover mode setting change")
|
||||
listener.onCoverModeChanged()
|
||||
if (key == getString(R.string.set_key_cover_mode) ||
|
||||
key == getString(R.string.set_key_square_covers)) {
|
||||
logD("Dispatching image setting change")
|
||||
listener.onImageSettingsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ import okio.buffer
|
|||
import okio.source
|
||||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
|
|
@ -18,25 +18,9 @@
|
|||
|
||||
package org.oxycblt.auxio.list
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.MenuCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
* A Fragment containing a selectable list.
|
||||
|
@ -45,15 +29,6 @@ import org.oxycblt.auxio.util.showToast
|
|||
*/
|
||||
abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
||||
SelectionFragment<VB>(), SelectableListListener<T> {
|
||||
protected abstract val detailModel: DetailViewModel
|
||||
private var currentMenu: PopupMenu? = null
|
||||
|
||||
override fun onDestroyBinding(binding: VB) {
|
||||
super.onDestroyBinding(binding)
|
||||
currentMenu?.dismiss()
|
||||
currentMenu = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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].
|
||||
|
@ -63,9 +38,9 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
abstract fun onRealClick(item: T)
|
||||
|
||||
final override fun onClick(item: T, viewHolder: RecyclerView.ViewHolder) {
|
||||
if (selectionModel.selected.value.isNotEmpty()) {
|
||||
if (listModel.selected.value.isNotEmpty()) {
|
||||
// Map clicking an item to selecting an item when items are already selected.
|
||||
selectionModel.select(item)
|
||||
listModel.select(item)
|
||||
} else {
|
||||
// Delegate to the concrete implementation when we don't select the item.
|
||||
onRealClick(item)
|
||||
|
@ -73,307 +48,6 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
|
|||
}
|
||||
|
||||
final override fun onSelect(item: T) {
|
||||
selectionModel.select(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Song]. This menu will be managed by the Fragment and closed
|
||||
* when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
*
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param song The [Song] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
|
||||
logD("Launching new song menu: ${song.name}")
|
||||
|
||||
openMenu(anchor, menuRes) {
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
detailModel.showArtist(song)
|
||||
true
|
||||
}
|
||||
R.id.action_go_album -> {
|
||||
detailModel.showAlbum(song.album)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(song)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(song)
|
||||
true
|
||||
}
|
||||
R.id.action_song_detail -> {
|
||||
detailModel.showSong(song)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Album]. This menu will be managed by the Fragment and
|
||||
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
*
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param album The [Album] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
|
||||
logD("Launching new album menu: ${album.name}")
|
||||
|
||||
openMenu(anchor, menuRes) {
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(album)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(album)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
detailModel.showArtist(album)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(album)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(album)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Artist]. This menu will be managed by the Fragment and
|
||||
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
*
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param artist The [Artist] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
|
||||
logD("Launching new artist menu: ${artist.name}")
|
||||
|
||||
openMenu(anchor, menuRes) {
|
||||
val playable = artist.songs.isNotEmpty()
|
||||
if (!playable) {
|
||||
logD("Artist is empty, disabling playback/playlist/share options")
|
||||
}
|
||||
menu.findItem(R.id.action_play).isEnabled = playable
|
||||
menu.findItem(R.id.action_shuffle).isEnabled = playable
|
||||
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_playlist_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_share).isEnabled = playable
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(artist)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(artist)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(artist)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(artist)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Genre]. This menu will be managed by the Fragment and
|
||||
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
*
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param genre The [Genre] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
|
||||
logD("Launching new genre menu: ${genre.name}")
|
||||
|
||||
openMenu(anchor, menuRes) {
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(genre)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(genre)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_playlist_add -> {
|
||||
musicModel.addToPlaylist(genre)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(genre)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Playlist]. This menu will be managed by the Fragment and
|
||||
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
*
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param playlist The [Playlist] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, playlist: Playlist) {
|
||||
logD("Launching new playlist menu: ${playlist.name}")
|
||||
|
||||
openMenu(anchor, menuRes) {
|
||||
val playable = playlist.songs.isNotEmpty()
|
||||
menu.findItem(R.id.action_play).isEnabled = playable
|
||||
menu.findItem(R.id.action_shuffle).isEnabled = playable
|
||||
menu.findItem(R.id.action_play_next).isEnabled = playable
|
||||
menu.findItem(R.id.action_queue_add).isEnabled = playable
|
||||
menu.findItem(R.id.action_share).isEnabled = playable
|
||||
|
||||
setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(playlist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(playlist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_rename -> {
|
||||
musicModel.renamePlaylist(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
musicModel.deletePlaylist(playlist)
|
||||
true
|
||||
}
|
||||
R.id.action_share -> {
|
||||
requireContext().share(playlist)
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu. This menu will be managed by the Fragment and closed when the view is destroyed.
|
||||
* If a menu is already opened, this call is ignored.
|
||||
*
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param block A block that is ran within [PopupMenu] that allows further configuration.
|
||||
*/
|
||||
protected fun openMenu(anchor: View, @MenuRes menuRes: Int, block: PopupMenu.() -> Unit) {
|
||||
if (currentMenu != null) {
|
||||
logD("Menu already present, not launching")
|
||||
return
|
||||
}
|
||||
|
||||
logD("Opening popup menu menu")
|
||||
|
||||
currentMenu =
|
||||
PopupMenu(requireContext(), anchor).apply {
|
||||
inflate(menuRes)
|
||||
MenuCompat.setGroupDividerEnabled(menu, true)
|
||||
block()
|
||||
setOnDismissListener { currentMenu = null }
|
||||
show()
|
||||
}
|
||||
listModel.select(item)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* TestingUtil.kt is part of Auxio.
|
||||
* ListModule.kt is part of Auxio.
|
||||
*
|
||||
* 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
|
||||
|
@ -16,13 +16,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.util
|
||||
package org.oxycblt.auxio.list
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
private val VM_CLEAR_METHOD =
|
||||
ViewModel::class.java.getDeclaredMethod("clear").apply { isAccessible = true }
|
||||
|
||||
fun ViewModel.forceClear() {
|
||||
VM_CLEAR_METHOD.invoke(this)
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface ListModule {
|
||||
@Binds fun settings(settings: ListSettingsImpl): ListSettings
|
||||
}
|
148
app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt
Normal file
148
app/src/main/java/org/oxycblt/auxio/list/ListSettings.kt
Normal file
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ListSettings.kt is part of Auxio.
|
||||
*
|
||||
* 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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
|
||||
interface ListSettings : Settings<Unit> {
|
||||
/** 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 Playlist lists. */
|
||||
var playlistSort: 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 a Genre's Song list. */
|
||||
var genreSongSort: Sort
|
||||
}
|
||||
|
||||
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
|
||||
Settings.Impl<Unit>(context), ListSettings {
|
||||
override var songSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_genres_sort), value.intCode)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override var playlistSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(getString(R.string.set_key_playlists_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_playlists_sort), value.intCode)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override var albumSongSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByDisc, Sort.Direction.ASCENDING)
|
||||
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, Sort.Direction.DESCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
}
|
230
app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt
Normal file
230
app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt
Normal file
|
@ -0,0 +1,230 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ListViewModel.kt is part of Auxio.
|
||||
*
|
||||
* 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
|
||||
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [ViewModel] that orchestrates menu dialogs and selection state.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ListViewModel
|
||||
@Inject
|
||||
constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) :
|
||||
ViewModel(), MusicRepository.UpdateListener {
|
||||
private val _selected = MutableStateFlow(listOf<Music>())
|
||||
/** The currently selected items. These are ordered in earliest selected and latest selected. */
|
||||
val selected: StateFlow<List<Music>>
|
||||
get() = _selected
|
||||
|
||||
private val _menu = MutableEvent<Menu>()
|
||||
/**
|
||||
* A [Menu] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||
*/
|
||||
val menu: Event<Menu> = _menu
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val userLibrary = musicRepository.userLibrary ?: return
|
||||
// Sanitize the selection to remove items that no longer exist and thus
|
||||
// won't appear in any list.
|
||||
_selected.value =
|
||||
_selected.value.mapNotNull {
|
||||
when (it) {
|
||||
is Song -> deviceLibrary.findSong(it.uid)
|
||||
is Album -> deviceLibrary.findAlbum(it.uid)
|
||||
is Artist -> deviceLibrary.findArtist(it.uid)
|
||||
is Genre -> deviceLibrary.findGenre(it.uid)
|
||||
is Playlist -> userLibrary.findPlaylist(it.uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a new [Music] item. If this item is already within the selected items, the item will
|
||||
* be removed. Otherwise, it will be added.
|
||||
*
|
||||
* @param music The [Music] item to select.
|
||||
*/
|
||||
fun select(music: Music) {
|
||||
if (music is MusicParent && music.songs.isEmpty()) {
|
||||
logD("Cannot select empty parent, ignoring operation")
|
||||
return
|
||||
}
|
||||
|
||||
val selected = _selected.value.toMutableList()
|
||||
if (!selected.remove(music)) {
|
||||
logD("Adding $music to selection")
|
||||
selected.add(music)
|
||||
} else {
|
||||
logD("Removed $music from selection")
|
||||
}
|
||||
|
||||
_selected.value = selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection and return it.
|
||||
*
|
||||
* @return A list of [Song]s collated from each item selected.
|
||||
*/
|
||||
fun peekSelection() =
|
||||
_selected.value.flatMap {
|
||||
when (it) {
|
||||
is Song -> listOf(it)
|
||||
is Album -> listSettings.albumSongSort.songs(it.songs)
|
||||
is Artist -> listSettings.artistSongSort.songs(it.songs)
|
||||
is Genre -> listSettings.genreSongSort.songs(it.songs)
|
||||
is Playlist -> it.songs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection and return it.
|
||||
*
|
||||
* @return A list of [Song]s collated from each item selected.
|
||||
*/
|
||||
fun takeSelection(): List<Song> {
|
||||
logD("Taking selection")
|
||||
return peekSelection().also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection.
|
||||
*
|
||||
* @return true if the prior selection was non-empty, false otherwise.
|
||||
*/
|
||||
fun dropSelection(): Boolean {
|
||||
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu for a [Song]. This is not a popup menu, instead actually a dialog of menu options
|
||||
* with additional information.
|
||||
*
|
||||
* @param menuRes The resource of the menu to use.
|
||||
* @param song The [Song] to show.
|
||||
* @param playWith A [PlaySong] command to give context to what "Play" and "Shuffle" actions
|
||||
* should do.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) {
|
||||
logD("Opening menu for $song")
|
||||
openImpl(Menu.ForSong(menuRes, song, playWith))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu for a [Album]. This is not a popup menu, instead actually a dialog of menu
|
||||
* options with additional information.
|
||||
*
|
||||
* @param menuRes The resource of the menu to use.
|
||||
* @param album The [Album] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, album: Album) {
|
||||
logD("Opening menu for $album")
|
||||
openImpl(Menu.ForAlbum(menuRes, album))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu for a [Artist]. This is not a popup menu, instead actually a dialog of menu
|
||||
* options with additional information.
|
||||
*
|
||||
* @param menuRes The resource of the menu to use.
|
||||
* @param artist The [Artist] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
|
||||
logD("Opening menu for $artist")
|
||||
openImpl(Menu.ForArtist(menuRes, artist))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu for a [Genre]. This is not a popup menu, instead actually a dialog of menu
|
||||
* options with additional information.
|
||||
*
|
||||
* @param menuRes The resource of the menu to use.
|
||||
* @param genre The [Genre] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
|
||||
logD("Opening menu for $genre")
|
||||
openImpl(Menu.ForGenre(menuRes, genre))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu for a [Playlist]. This is not a popup menu, instead actually a dialog of menu
|
||||
* options with additional information.
|
||||
*
|
||||
* @param menuRes The resource of the menu to use.
|
||||
* @param playlist The [Playlist] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
|
||||
logD("Opening menu for $playlist")
|
||||
openImpl(Menu.ForPlaylist(menuRes, playlist))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu for a [Song] selection. This is not a popup menu, instead actually a dialog of
|
||||
* menu options with additional information.
|
||||
*
|
||||
* @param menuRes The resource of the menu to use.
|
||||
* @param songs The [Song] selection to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, songs: List<Song>) {
|
||||
logD("Opening menu for ${songs.size} songs")
|
||||
openImpl(Menu.ForSelection(menuRes, songs))
|
||||
}
|
||||
|
||||
private fun openImpl(menu: Menu) {
|
||||
val existing = _menu.flow.value
|
||||
if (existing != null) {
|
||||
logW("Already opening $existing, ignoring $menu")
|
||||
return
|
||||
}
|
||||
_menu.put(menu)
|
||||
}
|
||||
}
|
|
@ -115,9 +115,8 @@ 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 [T] item to open a menu for.
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
*/
|
||||
fun onOpenMenu(item: T, anchor: View)
|
||||
fun onOpenMenu(item: T)
|
||||
|
||||
/**
|
||||
* Called when an item in the list requests that it be selected.
|
||||
|
@ -148,6 +147,6 @@ interface SelectableListListener<in T> : ClickableListListener<T> {
|
|||
true
|
||||
}
|
||||
// Map the menu button to the menu opening listener.
|
||||
menuButton.setOnClickListener { onOpenMenu(item, it) }
|
||||
menuButton.setOnClickListener { onOpenMenu(item) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.selection
|
||||
package org.oxycblt.auxio.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
|
@ -36,7 +36,7 @@ import org.oxycblt.auxio.util.showToast
|
|||
*/
|
||||
abstract class SelectionFragment<VB : ViewBinding> :
|
||||
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
|
||||
protected abstract val selectionModel: SelectionViewModel
|
||||
protected abstract val listModel: ListViewModel
|
||||
protected abstract val musicModel: MusicViewModel
|
||||
protected abstract val playbackModel: PlaybackViewModel
|
||||
|
||||
|
@ -46,8 +46,11 @@ abstract class SelectionFragment<VB : ViewBinding> :
|
|||
super.onBindingCreated(binding, savedInstanceState)
|
||||
getSelectionToolbar(binding)?.apply {
|
||||
// Add cancel and menu item listeners to manage what occurs with the selection.
|
||||
setNavigationOnClickListener { selectionModel.drop() }
|
||||
setNavigationOnClickListener { listModel.dropSelection() }
|
||||
setOnMenuItemClickListener(this@SelectionFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(R.menu.selection, listModel.peekSelection())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,31 +62,16 @@ abstract class SelectionFragment<VB : ViewBinding> :
|
|||
override fun onMenuItemClick(item: MenuItem) =
|
||||
when (item.itemId) {
|
||||
R.id.action_selection_play_next -> {
|
||||
playbackModel.playNext(selectionModel.take())
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_selection_queue_add -> {
|
||||
playbackModel.addToQueue(selectionModel.take())
|
||||
playbackModel.playNext(listModel.takeSelection())
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_selection_playlist_add -> {
|
||||
musicModel.addToPlaylist(selectionModel.take())
|
||||
true
|
||||
}
|
||||
R.id.action_selection_play -> {
|
||||
playbackModel.play(selectionModel.take())
|
||||
true
|
||||
}
|
||||
R.id.action_selection_shuffle -> {
|
||||
playbackModel.shuffle(selectionModel.take())
|
||||
true
|
||||
}
|
||||
R.id.action_selection_share -> {
|
||||
requireContext().share(selectionModel.take())
|
||||
musicModel.addToPlaylist(listModel.takeSelection())
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
// TODO: Re-add the automatic selection handling
|
||||
}
|
|
@ -37,6 +37,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
diffCallback: DiffUtil.ItemCallback<T>
|
||||
) : RecyclerView.Adapter<VH>() {
|
||||
@Suppress("LeakingThis") private val differ = FlexibleListDiffer(this, diffCallback)
|
||||
|
||||
final override fun getItemCount() = differ.currentList.size
|
||||
/** The current list stored by the adapter's differ instance. */
|
||||
val currentList: List<T>
|
||||
|
@ -69,7 +70,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
*/
|
||||
sealed interface UpdateInstructions {
|
||||
/** Use an asynchronous diff. Useful for unpredictable updates, but looks chaotic and janky. */
|
||||
object Diff : UpdateInstructions
|
||||
data object Diff : UpdateInstructions
|
||||
|
||||
/**
|
||||
* Visually replace all items from a given point. More visually coherent than [Diff].
|
||||
|
@ -118,6 +119,7 @@ private class FlexibleListDiffer<T>(
|
|||
|
||||
private class MainThreadExecutor : Executor {
|
||||
val mHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun execute(command: Runnable) {
|
||||
mHandler.post(command)
|
||||
}
|
||||
|
|
109
app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt
Normal file
109
app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Menu.kt is part of Auxio.
|
||||
*
|
||||
* 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.menu
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.MenuRes
|
||||
import kotlinx.parcelize.Parcelize
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
|
||||
/**
|
||||
* Command to navigate to a specific menu dialog configuration.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface Menu {
|
||||
/** The menu resource to inflate in the menu dialog. */
|
||||
@get:MenuRes val res: Int
|
||||
/** A [Parcel] version of this instance that can be used as a navigation argument. */
|
||||
val parcel: Parcel
|
||||
|
||||
sealed interface Parcel : Parcelable
|
||||
|
||||
/** Navigate to a [Song] menu dialog. */
|
||||
class ForSong(@MenuRes override val res: Int, val song: Song, val playWith: PlaySong) : Menu {
|
||||
override val parcel: Parcel
|
||||
get() {
|
||||
val playWithUid =
|
||||
when (playWith) {
|
||||
is PlaySong.FromArtist -> playWith.which?.uid
|
||||
is PlaySong.FromGenre -> playWith.which?.uid
|
||||
is PlaySong.FromPlaylist -> playWith.which.uid
|
||||
is PlaySong.FromAll,
|
||||
is PlaySong.FromAlbum,
|
||||
is PlaySong.ByItself -> null
|
||||
}
|
||||
|
||||
return Parcel(res, song.uid, playWith.intCode, playWithUid)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Parcel(
|
||||
val res: Int,
|
||||
val songUid: Music.UID,
|
||||
val playWithCode: Int,
|
||||
val playWithUid: Music.UID?
|
||||
) : Menu.Parcel
|
||||
}
|
||||
|
||||
/** Navigate to a [Album] menu dialog. */
|
||||
class ForAlbum(@MenuRes override val res: Int, val album: Album) : Menu {
|
||||
override val parcel
|
||||
get() = Parcel(res, album.uid)
|
||||
|
||||
@Parcelize data class Parcel(val res: Int, val albumUid: Music.UID) : Menu.Parcel
|
||||
}
|
||||
|
||||
/** Navigate to a [Artist] menu dialog. */
|
||||
class ForArtist(@MenuRes override val res: Int, val artist: Artist) : Menu {
|
||||
override val parcel
|
||||
get() = Parcel(res, artist.uid)
|
||||
|
||||
@Parcelize data class Parcel(val res: Int, val artistUid: Music.UID) : Menu.Parcel
|
||||
}
|
||||
|
||||
/** Navigate to a [Genre] menu dialog. */
|
||||
class ForGenre(@MenuRes override val res: Int, val genre: Genre) : Menu {
|
||||
override val parcel
|
||||
get() = Parcel(res, genre.uid)
|
||||
|
||||
@Parcelize data class Parcel(val res: Int, val genreUid: Music.UID) : Menu.Parcel
|
||||
}
|
||||
|
||||
/** Navigate to a [Playlist] menu dialog. */
|
||||
class ForPlaylist(@MenuRes override val res: Int, val playlist: Playlist) : Menu {
|
||||
override val parcel
|
||||
get() = Parcel(res, playlist.uid)
|
||||
|
||||
@Parcelize data class Parcel(val res: Int, val playlistUid: Music.UID) : Menu.Parcel
|
||||
}
|
||||
|
||||
class ForSelection(@MenuRes override val res: Int, val songs: List<Song>) : Menu {
|
||||
override val parcel: Parcel
|
||||
get() = Parcel(res, songs.map { it.uid })
|
||||
|
||||
@Parcelize data class Parcel(val res: Int, val songUids: List<Music.UID>) : Menu.Parcel
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* MenuDialogFragment.kt is part of Auxio.
|
||||
*
|
||||
* 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.menu
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.view.menu.MenuBuilder
|
||||
import androidx.core.view.children
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.databinding.DialogMenuBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingBottomSheetDialogFragment] that displays basic music information and a series of
|
||||
* options.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Extend the amount of music info shown in the dialog
|
||||
*/
|
||||
abstract class MenuDialogFragment<M : Menu> :
|
||||
ViewBindingBottomSheetDialogFragment<DialogMenuBinding>(), ClickableListListener<MenuItem> {
|
||||
protected abstract val menuModel: MenuViewModel
|
||||
protected abstract val listModel: ListViewModel
|
||||
private val menuAdapter = MenuItemAdapter(@Suppress("LeakingThis") this)
|
||||
|
||||
abstract val parcel: Menu.Parcel
|
||||
|
||||
/**
|
||||
* Get the options to disable in the context of the currently shown [M].
|
||||
*
|
||||
* @param menu The currently-shown menu [M].
|
||||
*/
|
||||
abstract fun getDisabledItemIds(menu: M): Set<Int>
|
||||
|
||||
/**
|
||||
* Update the displayed information about the currently shown [M].
|
||||
*
|
||||
* @param binding The [DialogMenuBinding] to bind information to.
|
||||
* @param menu The currently-shown menu [M].
|
||||
*/
|
||||
abstract fun updateMenu(binding: DialogMenuBinding, menu: M)
|
||||
|
||||
/**
|
||||
* Forward the clicked [MenuItem] to it's corresponding handler in another module.
|
||||
*
|
||||
* @param item The [MenuItem] that was clicked.
|
||||
* @param menu The currently-shown menu [M].
|
||||
*/
|
||||
abstract fun onClick(item: MenuItem, menu: M)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogMenuBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: DialogMenuBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.menuName.isSelected = true
|
||||
binding.menuInfo.isSelected = true
|
||||
binding.menuOptionRecycler.apply {
|
||||
adapter = menuAdapter
|
||||
itemAnimator = null
|
||||
}
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
listModel.menu.consume()
|
||||
menuModel.setMenu(parcel)
|
||||
collectImmediately(menuModel.currentMenu, this::updateMenu)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogMenuBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.menuName.isSelected = false
|
||||
binding.menuInfo.isSelected = false
|
||||
binding.menuOptionRecycler.adapter = null
|
||||
}
|
||||
|
||||
private fun updateMenu(menu: Menu?) {
|
||||
if (menu == null) {
|
||||
logD("No menu to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST") val casted = menu as? M
|
||||
check(casted != null) { "Unexpected menu instance ${menu::class.simpleName}" }
|
||||
|
||||
// We need to inflate the menu on every menu update since it might have changed
|
||||
// what options are available (ex. if an artist with no songs has had new songs added).
|
||||
// Since we don't have (and don't want) a dummy view to inflate this menu, just
|
||||
// depend on the AndroidX Toolbar internal API and hope for the best.
|
||||
@SuppressLint("RestrictedApi") val builder = MenuBuilder(requireContext())
|
||||
MenuInflater(requireContext()).inflate(casted.res, builder)
|
||||
|
||||
// Disable any menu options as specified by the impl
|
||||
val disabledIds = getDisabledItemIds(casted)
|
||||
val visible =
|
||||
builder.children.mapTo(mutableListOf()) {
|
||||
it.isEnabled = !disabledIds.contains(it.itemId)
|
||||
it
|
||||
}
|
||||
menuAdapter.update(visible, UpdateInstructions.Diff)
|
||||
|
||||
// Delegate to impl how to show music
|
||||
updateMenu(requireBinding(), casted)
|
||||
}
|
||||
|
||||
final override fun onClick(item: MenuItem, viewHolder: RecyclerView.ViewHolder) {
|
||||
// All option selections close the dialog currently.
|
||||
// TODO: This should change if the app is 100% migrated to menu dialogs
|
||||
findNavController().navigateUp()
|
||||
// Delegate to impl on how to handle items
|
||||
@Suppress("UNCHECKED_CAST") onClick(item, menuModel.currentMenu.value as M)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,376 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* MenuDialogFragmentImpl.kt is part of Auxio.
|
||||
*
|
||||
* 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.menu
|
||||
|
||||
import android.view.MenuItem
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMenuBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.share
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [Song].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class SongMenuDialogFragment : MenuDialogFragment<Menu.ForSong>() {
|
||||
override val menuModel: MenuViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val args: SongMenuDialogFragmentArgs by navArgs()
|
||||
|
||||
override val parcel
|
||||
get() = args.parcel
|
||||
|
||||
// Nothing to disable in song menus.
|
||||
override fun getDisabledItemIds(menu: Menu.ForSong) = setOf<Int>()
|
||||
|
||||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForSong) {
|
||||
val context = requireContext()
|
||||
binding.menuCover.bind(menu.song)
|
||||
binding.menuType.text = getString(R.string.lbl_song)
|
||||
binding.menuName.text = menu.song.name.resolve(context)
|
||||
binding.menuInfo.text = menu.song.artists.resolveNames(context)
|
||||
}
|
||||
|
||||
override fun onClick(item: MenuItem, menu: Menu.ForSong) {
|
||||
when (item.itemId) {
|
||||
R.id.action_play -> playbackModel.playExplicit(menu.song, menu.playWith)
|
||||
R.id.action_shuffle -> playbackModel.shuffleExplicit(menu.song, menu.playWith)
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(menu.song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(menu.song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_playlist_add -> musicModel.addToPlaylist(menu.song)
|
||||
R.id.action_artist_details -> detailModel.showArtist(menu.song)
|
||||
R.id.action_album_details -> detailModel.showAlbum(menu.song.album)
|
||||
R.id.action_share -> requireContext().share(menu.song)
|
||||
R.id.action_detail -> detailModel.showSong(menu.song)
|
||||
else -> error("Unexpected menu item selected $item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [AlbumMenuDialogFragment].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
|
||||
override val menuModel: MenuViewModel by viewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val args: AlbumMenuDialogFragmentArgs by navArgs()
|
||||
|
||||
override val parcel
|
||||
get() = args.parcel
|
||||
|
||||
// Nothing to disable in album menus.
|
||||
override fun getDisabledItemIds(menu: Menu.ForAlbum) = setOf<Int>()
|
||||
|
||||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
|
||||
val context = requireContext()
|
||||
binding.menuCover.bind(menu.album)
|
||||
binding.menuType.text = getString(menu.album.releaseType.stringRes)
|
||||
binding.menuName.text = menu.album.name.resolve(context)
|
||||
binding.menuInfo.text = menu.album.artists.resolveNames(context)
|
||||
}
|
||||
|
||||
override fun onClick(item: MenuItem, menu: Menu.ForAlbum) {
|
||||
when (item.itemId) {
|
||||
R.id.action_play -> playbackModel.play(menu.album)
|
||||
R.id.action_shuffle -> playbackModel.shuffle(menu.album)
|
||||
R.id.action_detail -> detailModel.showAlbum(menu.album)
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(menu.album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(menu.album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_artist_details -> detailModel.showArtist(menu.album)
|
||||
R.id.action_playlist_add -> musicModel.addToPlaylist(menu.album)
|
||||
R.id.action_share -> requireContext().share(menu.album)
|
||||
else -> error("Unexpected menu item selected $item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [Artist].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ArtistMenuDialogFragment : MenuDialogFragment<Menu.ForArtist>() {
|
||||
override val menuModel: MenuViewModel by viewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val args: ArtistMenuDialogFragmentArgs by navArgs()
|
||||
|
||||
override val parcel
|
||||
get() = args.parcel
|
||||
|
||||
override fun getDisabledItemIds(menu: Menu.ForArtist) =
|
||||
if (menu.artist.songs.isEmpty()) {
|
||||
// Disable any operations that require some kind of songs to work with, as there won't
|
||||
// be any in an empty artist.
|
||||
setOf(
|
||||
R.id.action_play,
|
||||
R.id.action_shuffle,
|
||||
R.id.action_play_next,
|
||||
R.id.action_queue_add,
|
||||
R.id.action_playlist_add,
|
||||
R.id.action_share)
|
||||
} else {
|
||||
setOf()
|
||||
}
|
||||
|
||||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForArtist) {
|
||||
val context = requireContext()
|
||||
binding.menuCover.bind(menu.artist)
|
||||
binding.menuType.text = getString(R.string.lbl_artist)
|
||||
binding.menuName.text = menu.artist.name.resolve(context)
|
||||
binding.menuInfo.text =
|
||||
getString(
|
||||
R.string.fmt_two,
|
||||
if (menu.artist.explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, menu.artist.explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (menu.artist.songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, menu.artist.songs.size)
|
||||
} else {
|
||||
getString(R.string.def_song_count)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onClick(item: MenuItem, menu: Menu.ForArtist) {
|
||||
when (item.itemId) {
|
||||
R.id.action_play -> playbackModel.play(menu.artist)
|
||||
R.id.action_shuffle -> playbackModel.shuffle(menu.artist)
|
||||
R.id.action_detail -> detailModel.showArtist(menu.artist)
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(menu.artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(menu.artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_playlist_add -> musicModel.addToPlaylist(menu.artist)
|
||||
R.id.action_share -> requireContext().share(menu.artist)
|
||||
else -> error("Unexpected menu item $item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [Genre].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class GenreMenuDialogFragment : MenuDialogFragment<Menu.ForGenre>() {
|
||||
override val menuModel: MenuViewModel by viewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val args: GenreMenuDialogFragmentArgs by navArgs()
|
||||
|
||||
override val parcel
|
||||
get() = args.parcel
|
||||
|
||||
override fun getDisabledItemIds(menu: Menu.ForGenre) = setOf<Int>()
|
||||
|
||||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForGenre) {
|
||||
val context = requireContext()
|
||||
binding.menuCover.bind(menu.genre)
|
||||
binding.menuType.text = getString(R.string.lbl_genre)
|
||||
binding.menuName.text = menu.genre.name.resolve(context)
|
||||
binding.menuInfo.text =
|
||||
getString(
|
||||
R.string.fmt_two,
|
||||
context.getPlural(R.plurals.fmt_artist_count, menu.genre.artists.size),
|
||||
context.getPlural(R.plurals.fmt_song_count, menu.genre.songs.size))
|
||||
}
|
||||
|
||||
override fun onClick(item: MenuItem, menu: Menu.ForGenre) {
|
||||
when (item.itemId) {
|
||||
R.id.action_play -> playbackModel.play(menu.genre)
|
||||
R.id.action_shuffle -> playbackModel.shuffle(menu.genre)
|
||||
R.id.action_detail -> detailModel.showGenre(menu.genre)
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(menu.genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(menu.genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_playlist_add -> musicModel.addToPlaylist(menu.genre)
|
||||
R.id.action_share -> requireContext().share(menu.genre)
|
||||
else -> error("Unexpected menu item $item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [Playlist].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class PlaylistMenuDialogFragment : MenuDialogFragment<Menu.ForPlaylist>() {
|
||||
override val menuModel: MenuViewModel by viewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val args: PlaylistMenuDialogFragmentArgs by navArgs()
|
||||
|
||||
override val parcel
|
||||
get() = args.parcel
|
||||
|
||||
override fun getDisabledItemIds(menu: Menu.ForPlaylist) =
|
||||
if (menu.playlist.songs.isEmpty()) {
|
||||
// Disable any operations that require some kind of songs to work with, as there won't
|
||||
// be any in an empty playlist.
|
||||
setOf(
|
||||
R.id.action_play,
|
||||
R.id.action_shuffle,
|
||||
R.id.action_play_next,
|
||||
R.id.action_queue_add,
|
||||
R.id.action_playlist_add,
|
||||
R.id.action_share)
|
||||
} else {
|
||||
setOf()
|
||||
}
|
||||
|
||||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForPlaylist) {
|
||||
val context = requireContext()
|
||||
binding.menuCover.bind(menu.playlist)
|
||||
binding.menuType.text = getString(R.string.lbl_playlist)
|
||||
binding.menuName.text = menu.playlist.name.resolve(context)
|
||||
binding.menuInfo.text =
|
||||
if (menu.playlist.songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, menu.playlist.songs.size)
|
||||
} else {
|
||||
getString(R.string.def_song_count)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(item: MenuItem, menu: Menu.ForPlaylist) {
|
||||
when (item.itemId) {
|
||||
R.id.action_play -> playbackModel.play(menu.playlist)
|
||||
R.id.action_shuffle -> playbackModel.shuffle(menu.playlist)
|
||||
R.id.action_detail -> detailModel.showPlaylist(menu.playlist)
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(menu.playlist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(menu.playlist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_rename -> musicModel.renamePlaylist(menu.playlist)
|
||||
R.id.action_delete -> musicModel.deletePlaylist(menu.playlist)
|
||||
R.id.action_share -> requireContext().share(menu.playlist)
|
||||
else -> error("Unexpected menu item $item")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [Song] selection.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class SelectionMenuDialogFragment : MenuDialogFragment<Menu.ForSelection>() {
|
||||
override val menuModel: MenuViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val args: SelectionMenuDialogFragmentArgs by navArgs()
|
||||
|
||||
override val parcel
|
||||
get() = args.parcel
|
||||
|
||||
// Nothing to disable in song menus.
|
||||
override fun getDisabledItemIds(menu: Menu.ForSelection) = setOf<Int>()
|
||||
|
||||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForSelection) {
|
||||
binding.menuCover.bind(
|
||||
menu.songs, getString(R.string.desc_selection_image), R.drawable.ic_song_24)
|
||||
binding.menuType.text = getString(R.string.lbl_selection)
|
||||
binding.menuName.text =
|
||||
requireContext().getPlural(R.plurals.fmt_song_count, menu.songs.size)
|
||||
binding.menuInfo.text = menu.songs.sumOf { it.durationMs }.formatDurationMs(true)
|
||||
}
|
||||
|
||||
override fun onClick(item: MenuItem, menu: Menu.ForSelection) {
|
||||
listModel.dropSelection()
|
||||
when (item.itemId) {
|
||||
R.id.action_play -> playbackModel.play(menu.songs)
|
||||
R.id.action_shuffle -> playbackModel.shuffle(menu.songs)
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(menu.songs)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(menu.songs)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_playlist_add -> musicModel.addToPlaylist(menu.songs)
|
||||
R.id.action_share -> requireContext().share(menu.songs)
|
||||
else -> error("Unexpected menu item selected $item")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* MenuItemAdapter.kt is part of Auxio.
|
||||
*
|
||||
* 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.menu
|
||||
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import org.oxycblt.auxio.databinding.ItemMenuOptionBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* Displays a list of [MenuItem]s as custom list items.
|
||||
*
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MenuItemAdapter(private val listener: ClickableListListener<MenuItem>) :
|
||||
FlexibleListAdapter<MenuItem, MenuItemViewHolder>(MenuItemViewHolder.DIFF_CALLBACK) {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
MenuItemViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: MenuItemViewHolder, position: Int) {
|
||||
holder.bind(getItem(position), listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DialogRecyclerView.ViewHolder] that displays a [MenuItem].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MenuItemViewHolder private constructor(private val binding: ItemMenuOptionBinding) :
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
*
|
||||
* @param item The new [MenuItem] to bind.
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(item: MenuItem, listener: ClickableListListener<MenuItem>) {
|
||||
listener.bind(item, this)
|
||||
binding.title.apply {
|
||||
text = item.title
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, null, null, null)
|
||||
isEnabled = item.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: ViewGroup) =
|
||||
MenuItemViewHolder(ItemMenuOptionBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : DiffUtil.ItemCallback<MenuItem>() {
|
||||
override fun areItemsTheSame(oldItem: MenuItem, newItem: MenuItem) =
|
||||
oldItem == newItem
|
||||
|
||||
override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) =
|
||||
oldItem.title == newItem.title
|
||||
}
|
||||
}
|
||||
}
|
104
app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt
Normal file
104
app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt
Normal file
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* MenuViewModel.kt is part of Auxio.
|
||||
*
|
||||
* 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.menu
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* Manages the state information for [MenuDialogFragment] implementations.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MenuViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||
ViewModel(), MusicRepository.UpdateListener {
|
||||
private val _currentMenu = MutableStateFlow<Menu?>(null)
|
||||
/** The current [Menu] information being shown in a dialog. */
|
||||
val currentMenu: StateFlow<Menu?> = _currentMenu
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
_currentMenu.value = _currentMenu.value?.let { unpackParcel(it.parcel) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
fun setMenu(parcel: Menu.Parcel) {
|
||||
_currentMenu.value = unpackParcel(parcel)
|
||||
if (_currentMenu.value == null) {
|
||||
logW("Given menu parcel $parcel was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unpackParcel(parcel: Menu.Parcel) =
|
||||
when (parcel) {
|
||||
is Menu.ForSong.Parcel -> unpackSongParcel(parcel)
|
||||
is Menu.ForAlbum.Parcel -> unpackAlbumParcel(parcel)
|
||||
is Menu.ForArtist.Parcel -> unpackArtistParcel(parcel)
|
||||
is Menu.ForGenre.Parcel -> unpackGenreParcel(parcel)
|
||||
is Menu.ForPlaylist.Parcel -> unpackPlaylistParcel(parcel)
|
||||
is Menu.ForSelection.Parcel -> unpackSelectionParcel(parcel)
|
||||
}
|
||||
|
||||
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
|
||||
val song = musicRepository.deviceLibrary?.findSong(parcel.songUid) ?: return null
|
||||
val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
|
||||
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
|
||||
return Menu.ForSong(parcel.res, song, playWith)
|
||||
}
|
||||
|
||||
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? {
|
||||
val album = musicRepository.deviceLibrary?.findAlbum(parcel.albumUid) ?: return null
|
||||
return Menu.ForAlbum(parcel.res, album)
|
||||
}
|
||||
|
||||
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? {
|
||||
val artist = musicRepository.deviceLibrary?.findArtist(parcel.artistUid) ?: return null
|
||||
return Menu.ForArtist(parcel.res, artist)
|
||||
}
|
||||
|
||||
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? {
|
||||
val genre = musicRepository.deviceLibrary?.findGenre(parcel.genreUid) ?: return null
|
||||
return Menu.ForGenre(parcel.res, genre)
|
||||
}
|
||||
|
||||
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? {
|
||||
val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null
|
||||
return Menu.ForPlaylist(parcel.res, playlist)
|
||||
}
|
||||
|
||||
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
||||
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
|
||||
return Menu.ForSelection(parcel.res, songs)
|
||||
}
|
||||
}
|
|
@ -164,7 +164,11 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
|||
binding.parentInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
||||
if (artist.explicitAlbums.isNotEmpty()) {
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
|
||||
} else {
|
||||
binding.context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
|
||||
} else {
|
||||
|
@ -199,7 +203,7 @@ class ArtistViewHolder private constructor(private val binding: ItemParentBindin
|
|||
object : SimpleDiffCallback<Artist>() {
|
||||
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
||||
oldItem.name == newItem.name &&
|
||||
oldItem.albums.size == newItem.albums.size &&
|
||||
oldItem.explicitAlbums.size == newItem.explicitAlbums.size &&
|
||||
oldItem.songs.size == newItem.songs.size
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,132 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* SelectionViewModel.kt is part of Auxio.
|
||||
*
|
||||
* 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.selection
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewModel] that manages the current selection.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SelectionViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings
|
||||
) : ViewModel(), MusicRepository.UpdateListener {
|
||||
private val _selected = MutableStateFlow(listOf<Music>())
|
||||
/** the currently selected items. These are ordered in earliest selected and latest selected. */
|
||||
val selected: StateFlow<List<Music>>
|
||||
get() = _selected
|
||||
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
val userLibrary = musicRepository.userLibrary ?: return
|
||||
// Sanitize the selection to remove items that no longer exist and thus
|
||||
// won't appear in any list.
|
||||
_selected.value =
|
||||
_selected.value.mapNotNull {
|
||||
when (it) {
|
||||
is Song -> deviceLibrary.findSong(it.uid)
|
||||
is Album -> deviceLibrary.findAlbum(it.uid)
|
||||
is Artist -> deviceLibrary.findArtist(it.uid)
|
||||
is Genre -> deviceLibrary.findGenre(it.uid)
|
||||
is Playlist -> userLibrary.findPlaylist(it.uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a new [Music] item. If this item is already within the selected items, the item will
|
||||
* be removed. Otherwise, it will be added.
|
||||
*
|
||||
* @param music The [Music] item to select.
|
||||
*/
|
||||
fun select(music: Music) {
|
||||
if (music is MusicParent && music.songs.isEmpty()) {
|
||||
logD("Cannot select empty parent, ignoring operation")
|
||||
return
|
||||
}
|
||||
|
||||
val selected = _selected.value.toMutableList()
|
||||
if (!selected.remove(music)) {
|
||||
logD("Adding $music to selection")
|
||||
selected.add(music)
|
||||
} else {
|
||||
logD("Removed $music from selection")
|
||||
}
|
||||
|
||||
_selected.value = selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection and return it.
|
||||
*
|
||||
* @return A list of [Song]s collated from each item selected.
|
||||
*/
|
||||
fun take(): List<Song> {
|
||||
logD("Taking selection")
|
||||
return _selected.value
|
||||
.flatMap {
|
||||
when (it) {
|
||||
is Song -> listOf(it)
|
||||
is Album -> musicSettings.albumSongSort.songs(it.songs)
|
||||
is Artist -> musicSettings.artistSongSort.songs(it.songs)
|
||||
is Genre -> musicSettings.genreSongSort.songs(it.songs)
|
||||
is Playlist -> it.songs
|
||||
}
|
||||
}
|
||||
.also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection.
|
||||
*
|
||||
* @return true if the prior selection was non-empty, false otherwise.
|
||||
*/
|
||||
fun drop(): Boolean {
|
||||
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Sort.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
|
@ -16,9 +16,8 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list
|
||||
package org.oxycblt.auxio.list.sort
|
||||
|
||||
import androidx.annotation.IdRes
|
||||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
|
@ -26,7 +25,6 @@ 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.MusicParent
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Date
|
||||
|
@ -42,22 +40,6 @@ import org.oxycblt.auxio.music.info.Disc
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class Sort(val mode: Mode, val direction: Direction) {
|
||||
/**
|
||||
* Create a new [Sort] with the same [mode], but a different [Direction].
|
||||
*
|
||||
* @param direction The new [Direction] to sort in.
|
||||
* @return A new sort with the same mode, but with the new [Direction] value applied.
|
||||
*/
|
||||
fun withDirection(direction: Direction) = Sort(mode, direction)
|
||||
|
||||
/**
|
||||
* Create a new [Sort] with the same [direction] value, but different [mode] value.
|
||||
*
|
||||
* @param mode Tbe new mode to use for the Sort.
|
||||
* @return A new sort with the same [direction] value, but with the new [mode] applied.
|
||||
*/
|
||||
fun withMode(mode: Mode) = Sort(mode, direction)
|
||||
|
||||
/**
|
||||
* Sort a list of [Song]s.
|
||||
*
|
||||
|
@ -163,8 +145,8 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
sealed interface Mode {
|
||||
/** The integer representation of this sort mode. */
|
||||
val intCode: Int
|
||||
/** The item ID of this sort mode in menu resources. */
|
||||
val itemId: Int
|
||||
/** The string resource of the human-readable name of this sort mode. */
|
||||
val stringRes: Int
|
||||
|
||||
/**
|
||||
* Get a [Comparator] that sorts [Song]s according to this [Mode].
|
||||
|
@ -216,12 +198,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
*
|
||||
* @see Music.name
|
||||
*/
|
||||
object ByName : Mode {
|
||||
data object ByName : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_NAME
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_name
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_name
|
||||
|
||||
override fun getSongComparator(direction: Direction) =
|
||||
compareByDynamic(direction, BasicComparator.SONG)
|
||||
|
@ -244,12 +226,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
*
|
||||
* @see Album.name
|
||||
*/
|
||||
object ByAlbum : Mode {
|
||||
data object ByAlbum : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_ALBUM
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_album
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_album
|
||||
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
|
@ -264,12 +246,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
*
|
||||
* @see Artist.name
|
||||
*/
|
||||
object ByArtist : Mode {
|
||||
data object ByArtist : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_ARTIST
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_artist
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_artist
|
||||
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
|
@ -293,12 +275,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
* @see Song.date
|
||||
* @see Album.dates
|
||||
*/
|
||||
object ByDate : Mode {
|
||||
data object ByDate : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_YEAR
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_year
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_date
|
||||
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
|
@ -315,12 +297,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
}
|
||||
|
||||
/** Sort by the duration of an item. */
|
||||
object ByDuration : Mode {
|
||||
data object ByDuration : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_DURATION
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_duration
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_duration
|
||||
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
|
@ -345,17 +327,13 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
compareBy(BasicComparator.PLAYLIST))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the amount of songs an item contains. Only available for [MusicParent]s.
|
||||
*
|
||||
* @see MusicParent.songs
|
||||
*/
|
||||
object ByCount : Mode {
|
||||
/** Sort by the amount of songs an item contains. Only available for MusicParents. */
|
||||
data object ByCount : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_COUNT
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_count
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_song_count
|
||||
|
||||
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
|
||||
MultiComparator(
|
||||
|
@ -381,12 +359,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
*
|
||||
* @see Song.disc
|
||||
*/
|
||||
object ByDisc : Mode {
|
||||
data object ByDisc : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_DISC
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_disc
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_disc
|
||||
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
|
@ -400,12 +378,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
*
|
||||
* @see Song.track
|
||||
*/
|
||||
object ByTrack : Mode {
|
||||
data object ByTrack : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_TRACK
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_track
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_track
|
||||
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
|
@ -420,12 +398,12 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
* @see Song.dateAdded
|
||||
* @see Album.dates
|
||||
*/
|
||||
object ByDateAdded : Mode {
|
||||
data object ByDateAdded : Mode {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_DATE_ADDED
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_date_added
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_date_added
|
||||
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
|
@ -458,27 +436,6 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
ByDateAdded.intCode -> ByDateAdded
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a menu item ID into a [Mode].
|
||||
*
|
||||
* @param itemId The menu resource ID to convert
|
||||
* @return A [Mode] corresponding to the given ID, or null if the ID is invalid.
|
||||
* @see itemId
|
||||
*/
|
||||
fun fromItemId(@IdRes itemId: Int) =
|
||||
when (itemId) {
|
||||
ByName.itemId -> ByName
|
||||
ByAlbum.itemId -> ByAlbum
|
||||
ByArtist.itemId -> ByArtist
|
||||
ByDate.itemId -> ByDate
|
||||
ByDuration.itemId -> ByDuration
|
||||
ByCount.itemId -> ByCount
|
||||
ByDisc.itemId -> ByDisc
|
||||
ByTrack.itemId -> ByTrack
|
||||
ByDateAdded.itemId -> ByDateAdded
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
113
app/src/main/java/org/oxycblt/auxio/list/sort/SortDialog.kt
Normal file
113
app/src/main/java/org/oxycblt/auxio/list/sort/SortDialog.kt
Normal file
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* SortDialog.kt is part of Auxio.
|
||||
*
|
||||
* 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.sort
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.button.MaterialButtonToggleGroup
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogSortBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
abstract class SortDialog :
|
||||
ViewBindingBottomSheetDialogFragment<DialogSortBinding>(),
|
||||
ClickableListListener<Sort.Mode>,
|
||||
MaterialButtonToggleGroup.OnButtonCheckedListener {
|
||||
private val modeAdapter = SortModeAdapter(@Suppress("LeakingThis") this)
|
||||
|
||||
abstract fun getInitialSort(): Sort?
|
||||
|
||||
abstract fun applyChosenSort(sort: Sort)
|
||||
|
||||
abstract fun getModeChoices(): List<Sort.Mode>
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = DialogSortBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: DialogSortBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.root.setOnApplyWindowInsetsListener { v, insets ->
|
||||
v.updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
|
||||
insets
|
||||
}
|
||||
binding.sortModeRecycler.adapter = modeAdapter
|
||||
binding.sortDirectionGroup.addOnButtonCheckedListener(this)
|
||||
binding.sortCancel.setOnClickListener { dismiss() }
|
||||
binding.sortSave.setOnClickListener {
|
||||
applyChosenSort(requireNotNull(getCurrentSort()))
|
||||
dismiss()
|
||||
}
|
||||
|
||||
// --- STATE SETUP ---
|
||||
modeAdapter.update(getModeChoices(), UpdateInstructions.Diff)
|
||||
|
||||
val initial = getInitialSort()
|
||||
if (initial != null) {
|
||||
modeAdapter.setSelected(initial.mode)
|
||||
val directionId =
|
||||
when (initial.direction) {
|
||||
Sort.Direction.ASCENDING -> R.id.sort_direction_asc
|
||||
Sort.Direction.DESCENDING -> R.id.sort_direction_dsc
|
||||
}
|
||||
binding.sortDirectionGroup.check(directionId)
|
||||
}
|
||||
updateButtons()
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogSortBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.sortDirectionGroup.removeOnButtonCheckedListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(item: Sort.Mode, viewHolder: RecyclerView.ViewHolder) {
|
||||
modeAdapter.setSelected(item)
|
||||
updateButtons()
|
||||
}
|
||||
|
||||
override fun onButtonChecked(
|
||||
group: MaterialButtonToggleGroup?,
|
||||
checkedId: Int,
|
||||
isChecked: Boolean
|
||||
) {
|
||||
updateButtons()
|
||||
}
|
||||
|
||||
private fun updateButtons() {
|
||||
val binding = requireBinding()
|
||||
binding.sortSave.isEnabled = getCurrentSort() != getInitialSort()
|
||||
}
|
||||
|
||||
private fun getCurrentSort(): Sort? {
|
||||
val initial = getInitialSort()
|
||||
val mode = modeAdapter.currentMode ?: initial?.mode ?: return null
|
||||
val direction =
|
||||
when (requireBinding().sortDirectionGroup.checkedButtonId) {
|
||||
R.id.sort_direction_asc -> Sort.Direction.ASCENDING
|
||||
R.id.sort_direction_dsc -> Sort.Direction.DESCENDING
|
||||
else -> initial?.direction ?: return null
|
||||
}
|
||||
return Sort(mode, direction)
|
||||
}
|
||||
}
|
120
app/src/main/java/org/oxycblt/auxio/list/sort/SortModeAdapter.kt
Normal file
120
app/src/main/java/org/oxycblt/auxio/list/sort/SortModeAdapter.kt
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* SortModeAdapter.kt is part of Auxio.
|
||||
*
|
||||
* 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.sort
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import org.oxycblt.auxio.databinding.ItemSortModeBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* A [FlexibleListAdapter] that displays a list of [Sort.Mode]s.
|
||||
*
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SortModeAdapter(private val listener: ClickableListListener<Sort.Mode>) :
|
||||
FlexibleListAdapter<Sort.Mode, SortModeViewHolder>(SortModeViewHolder.DIFF_CALLBACK) {
|
||||
/** The currently selected [Sort.Mode] item in this adapter. */
|
||||
var currentMode: Sort.Mode? = null
|
||||
private set
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
SortModeViewHolder.from(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: SortModeViewHolder, position: Int) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: SortModeViewHolder, position: Int, payload: List<Any>) {
|
||||
val mode = getItem(position)
|
||||
if (payload.isEmpty()) {
|
||||
holder.bind(mode, listener)
|
||||
}
|
||||
holder.setSelected(mode == currentMode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a new [Sort.Mode] option, unselecting the prior one. Does nothing if [mode] equals
|
||||
* [currentMode].
|
||||
*
|
||||
* @param mode The new [Sort.Mode] to select. Should be in the adapter data.
|
||||
*/
|
||||
fun setSelected(mode: Sort.Mode) {
|
||||
if (mode == currentMode) return
|
||||
val oldMode = currentList.indexOf(currentMode)
|
||||
val newMode = currentList.indexOf(mode)
|
||||
currentMode = mode
|
||||
if (oldMode > -1) {
|
||||
notifyItemChanged(oldMode, PAYLOAD_SELECTION_CHANGED)
|
||||
}
|
||||
notifyItemChanged(newMode, PAYLOAD_SELECTION_CHANGED)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PAYLOAD_SELECTION_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DialogRecyclerView.ViewHolder] that displays a [Sort.Mode].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SortModeViewHolder private constructor(private val binding: ItemSortModeBinding) :
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
*
|
||||
* @param mode The new [Sort.Mode] to bind.
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(mode: Sort.Mode, listener: ClickableListListener<Sort.Mode>) {
|
||||
listener.bind(mode, this)
|
||||
binding.sortRadio.text = binding.context.getString(mode.stringRes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if this view should be shown as selected or not.
|
||||
*
|
||||
* @param selected True if selected, false if not.
|
||||
*/
|
||||
fun setSelected(selected: Boolean) {
|
||||
binding.sortRadio.isChecked = selected
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(parent: View) =
|
||||
SortModeViewHolder(ItemSortModeBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFF_CALLBACK =
|
||||
object : DiffUtil.ItemCallback<Sort.Mode>() {
|
||||
override fun areItemsTheSame(oldItem: Sort.Mode, newItem: Sort.Mode) =
|
||||
oldItem == newItem
|
||||
|
||||
override fun areContentsTheSame(oldItem: Sort.Mode, newItem: Sort.Mode) =
|
||||
oldItem == newItem
|
||||
}
|
||||
}
|
||||
}
|
|
@ -47,7 +47,7 @@ sealed interface IndexingState {
|
|||
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
|
||||
* will be null.
|
||||
*/
|
||||
data class Completed(val error: Throwable?) : IndexingState
|
||||
data class Completed(val error: Exception?) : IndexingState
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -57,7 +57,7 @@ sealed interface IndexingState {
|
|||
*/
|
||||
sealed interface IndexingProgress {
|
||||
/** Other work is being done that does not have a defined progress. */
|
||||
object Indeterminate : IndexingProgress
|
||||
data object Indeterminate : IndexingProgress
|
||||
|
||||
/**
|
||||
* Songs are currently being loaded.
|
||||
|
|
|
@ -80,23 +80,23 @@ sealed interface Music : Item {
|
|||
class UID
|
||||
private constructor(
|
||||
private val format: Format,
|
||||
private val mode: MusicMode,
|
||||
private val type: MusicType,
|
||||
private val uuid: UUID
|
||||
) : Parcelable {
|
||||
// Cache the hashCode for HashMap efficiency.
|
||||
@IgnoredOnParcel private var hashCode = format.hashCode()
|
||||
|
||||
init {
|
||||
hashCode = 31 * hashCode + mode.hashCode()
|
||||
hashCode = 31 * hashCode + type.hashCode()
|
||||
hashCode = 31 * hashCode + uuid.hashCode()
|
||||
}
|
||||
|
||||
override fun hashCode() = hashCode
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
other is UID && format == other.format && mode == other.mode && uuid == other.uuid
|
||||
other is UID && format == other.format && type == other.type && uuid == other.uuid
|
||||
|
||||
override fun toString() = "${format.namespace}:${mode.intCode.toString(16)}-$uuid"
|
||||
override fun toString() = "${format.namespace}:${type.intCode.toString(16)}-$uuid"
|
||||
|
||||
/**
|
||||
* Internal marker of [Music.UID] format type.
|
||||
|
@ -124,23 +124,23 @@ sealed interface Music : Item {
|
|||
* Creates an Auxio-style [UID] of random composition. Used if there is no
|
||||
* non-subjective, unlikely-to-change metadata of the music.
|
||||
*
|
||||
* @param mode The analogous [MusicMode] of the item that created this [UID].
|
||||
* @param type The analogous [MusicType] of the item that created this [UID].
|
||||
*/
|
||||
fun auxio(mode: MusicMode): UID {
|
||||
return UID(Format.AUXIO, mode, UUID.randomUUID())
|
||||
fun auxio(type: MusicType): UID {
|
||||
return UID(Format.AUXIO, type, UUID.randomUUID())
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
|
||||
* unlikely-to-change metadata of the music.
|
||||
*
|
||||
* @param mode The analogous [MusicMode] of the item that created this [UID].
|
||||
* @param type The analogous [MusicType] of the item that created this [UID].
|
||||
* @param updates Block to update the [MessageDigest] hash with the metadata of the
|
||||
* item. Make sure the metadata hashed semantically aligns with the format
|
||||
* specification.
|
||||
* @return A new auxio-style [UID].
|
||||
*/
|
||||
fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID {
|
||||
fun auxio(type: MusicType, updates: MessageDigest.() -> Unit): UID {
|
||||
val digest =
|
||||
MessageDigest.getInstance("SHA-256").run {
|
||||
updates()
|
||||
|
@ -170,19 +170,19 @@ sealed interface Music : Item {
|
|||
.or(digest[13].toLong().and(0xFF).shl(16))
|
||||
.or(digest[14].toLong().and(0xFF).shl(8))
|
||||
.or(digest[15].toLong().and(0xFF)))
|
||||
return UID(Format.AUXIO, mode, uuid)
|
||||
return UID(Format.AUXIO, type, uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
|
||||
* extracted from a file.
|
||||
*
|
||||
* @param mode The analogous [MusicMode] of the item that created this [UID].
|
||||
* @param type The analogous [MusicType] of the item that created this [UID].
|
||||
* @param mbid The analogous MusicBrainz ID for this item that was extracted from a
|
||||
* file.
|
||||
* @return A new MusicBrainz-style [UID].
|
||||
*/
|
||||
fun musicBrainz(mode: MusicMode, mbid: UUID) = UID(Format.MUSICBRAINZ, mode, mbid)
|
||||
fun musicBrainz(type: MusicType, mbid: UUID) = UID(Format.MUSICBRAINZ, type, mbid)
|
||||
|
||||
/**
|
||||
* Convert a [UID]'s string representation back into a concrete [UID] instance.
|
||||
|
@ -210,10 +210,10 @@ sealed interface Music : Item {
|
|||
return null
|
||||
}
|
||||
|
||||
val mode =
|
||||
MusicMode.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null
|
||||
val type =
|
||||
MusicType.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null
|
||||
val uuid = ids[1].toUuidOrNull() ?: return null
|
||||
return UID(format, mode, uuid)
|
||||
return UID(format, type, uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -317,12 +317,6 @@ interface Album : MusicParent {
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface Artist : MusicParent {
|
||||
/**
|
||||
* All of the [Album]s this artist is credited to from [explicitAlbums] and [implicitAlbums].
|
||||
* Note that any [Song] credited to this artist will have it's [Album] considered to be
|
||||
* "indirectly" linked to this [Artist], and thus included in this list.
|
||||
*/
|
||||
val albums: Collection<Album>
|
||||
/** Albums directly credited to this [Artist] via a "Album Artist" tag. */
|
||||
val explicitAlbums: Collection<Album>
|
||||
/** Albums indirectly credited to this [Artist] via an "Artist" tag. */
|
||||
|
|
|
@ -28,5 +28,6 @@ import javax.inject.Singleton
|
|||
@InstallIn(SingletonComponent::class)
|
||||
interface MusicModule {
|
||||
@Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
|
||||
|
||||
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@ import org.oxycblt.auxio.music.cache.CacheRepository
|
|||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
import org.oxycblt.auxio.music.fs.MediaStoreExtractor
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.music.metadata.Separators
|
||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
||||
import org.oxycblt.auxio.music.user.MutableUserLibrary
|
||||
import org.oxycblt.auxio.music.user.UserLibrary
|
||||
|
@ -223,7 +225,8 @@ constructor(
|
|||
private val mediaStoreExtractor: MediaStoreExtractor,
|
||||
private val tagExtractor: TagExtractor,
|
||||
private val deviceLibraryFactory: DeviceLibrary.Factory,
|
||||
private val userLibraryFactory: UserLibrary.Factory
|
||||
private val userLibraryFactory: UserLibrary.Factory,
|
||||
private val musicSettings: MusicSettings
|
||||
) : MusicRepository {
|
||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
||||
|
@ -356,6 +359,8 @@ constructor(
|
|||
}
|
||||
|
||||
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
|
||||
// TODO: Find a way to break up this monster of a method, preferably as another class.
|
||||
|
||||
val start = System.currentTimeMillis()
|
||||
// Make sure we have permissions before going forward. Theoretically this would be better
|
||||
// done at the UI level, but that intertwines logic and display too much.
|
||||
|
@ -365,17 +370,29 @@ constructor(
|
|||
throw NoAudioPermissionException()
|
||||
}
|
||||
|
||||
// Obtain configuration information
|
||||
val constraints =
|
||||
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
|
||||
val separators = Separators.from(musicSettings.separators)
|
||||
val nameFactory =
|
||||
if (musicSettings.intelligentSorting) {
|
||||
Name.Known.IntelligentFactory
|
||||
} else {
|
||||
Name.Known.SimpleFactory
|
||||
}
|
||||
|
||||
// Begin with querying MediaStore and the music cache. The former is needed for Auxio
|
||||
// to figure out what songs are (probably) on the device, and the latter will be needed
|
||||
// for discovery (described later). These have no shared state, so they are done in
|
||||
// parallel.
|
||||
logD("Starting MediaStore query")
|
||||
emitIndexingProgress(IndexingProgress.Indeterminate)
|
||||
|
||||
val mediaStoreQueryJob =
|
||||
worker.scope.async {
|
||||
val query =
|
||||
try {
|
||||
mediaStoreExtractor.query()
|
||||
mediaStoreExtractor.query(constraints)
|
||||
} catch (e: Exception) {
|
||||
// Normally, errors in an async call immediately bubble up to the Looper
|
||||
// and crash the app. Thus, we have to wrap any error into a Result
|
||||
|
@ -444,7 +461,8 @@ constructor(
|
|||
worker.scope.async(Dispatchers.Default) {
|
||||
val deviceLibrary =
|
||||
try {
|
||||
deviceLibraryFactory.create(completeSongs, processedSongs)
|
||||
deviceLibraryFactory.create(
|
||||
completeSongs, processedSongs, separators, nameFactory)
|
||||
} catch (e: Exception) {
|
||||
processedSongs.close(e)
|
||||
return@async Result.failure(e)
|
||||
|
@ -517,7 +535,7 @@ constructor(
|
|||
logD("Awaiting DeviceLibrary creation")
|
||||
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
|
||||
logD("Starting UserLibrary creation")
|
||||
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary)
|
||||
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
|
||||
|
||||
// Loading process is functionally done, indicate such
|
||||
logD(
|
||||
|
|
|
@ -24,7 +24,6 @@ import androidx.core.content.edit
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.music.fs.Directory
|
||||
import org.oxycblt.auxio.music.fs.MusicDirectories
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
|
@ -44,26 +43,9 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
/** 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
|
||||
var separators: String
|
||||
/** Whether to enable more advanced sorting by articles and numbers. */
|
||||
val intelligentSorting: Boolean
|
||||
// TODO: Move sort settings to list module
|
||||
/** 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 [Playlist] lists. */
|
||||
var playlistSort: 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 a [Genre]'s [Song] list. */
|
||||
var genreSongSort: Sort
|
||||
|
||||
interface Listener {
|
||||
/** Called when a setting controlling how music is loaded has changed. */
|
||||
|
@ -103,7 +85,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
override val shouldBeObserving: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
|
||||
|
||||
override var multiValueSeparators: String
|
||||
override var separators: 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), "") ?: ""
|
||||
|
@ -117,113 +99,6 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
override val intelligentSorting: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true)
|
||||
|
||||
override var songSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_genres_sort), value.intCode)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override var playlistSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(getString(R.string.set_key_playlists_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_playlists_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, Sort.Direction.ASCENDING)
|
||||
|
||||
// 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, Sort.Direction.DESCENDING)
|
||||
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, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: MusicSettings.Listener) {
|
||||
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
|
||||
// (just need to manipulate data)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* MusicMode.kt is part of Auxio.
|
||||
* MusicType.kt is part of Auxio.
|
||||
*
|
||||
* 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
|
||||
|
@ -21,20 +21,20 @@ package org.oxycblt.auxio.music
|
|||
import org.oxycblt.auxio.IntegerTable
|
||||
|
||||
/**
|
||||
* Represents a data configuration corresponding to a specific type of [Music],
|
||||
* General configuration enum to control what kind of music is being worked with.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class MusicMode {
|
||||
/** Configure with respect to [Song] instances. */
|
||||
enum class MusicType {
|
||||
/** @see Song */
|
||||
SONGS,
|
||||
/** Configure with respect to [Album] instances. */
|
||||
/** @see Album */
|
||||
ALBUMS,
|
||||
/** Configure with respect to [Artist] instances. */
|
||||
/** @see Artist */
|
||||
ARTISTS,
|
||||
/** Configure with respect to [Genre] instances. */
|
||||
/** @see Genre */
|
||||
GENRES,
|
||||
/** Configure with respect to [Playlist] instances. */
|
||||
/** @see Playlist */
|
||||
PLAYLISTS;
|
||||
|
||||
/**
|
||||
|
@ -54,11 +54,11 @@ enum class MusicMode {
|
|||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a [MusicMode] integer representation into an instance.
|
||||
* Convert a [MusicType] integer representation into an instance.
|
||||
*
|
||||
* @param intCode An integer representation of a [MusicMode]
|
||||
* @return The corresponding [MusicMode], or null if the [MusicMode] is invalid.
|
||||
* @see MusicMode.intCode
|
||||
* @param intCode An integer representation of a [MusicType]
|
||||
* @return The corresponding [MusicType], or null if the [MusicType] is invalid.
|
||||
* @see MusicType.intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
|
@ -26,6 +26,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
@ -39,8 +40,8 @@ import org.oxycblt.auxio.util.logD
|
|||
class MusicViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings
|
||||
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
|
||||
|
||||
private val _indexingState = MutableStateFlow<IndexingState?>(null)
|
||||
|
@ -53,6 +54,10 @@ constructor(
|
|||
get() = _statistics
|
||||
|
||||
private val _playlistDecision = MutableEvent<PlaylistDecision>()
|
||||
/**
|
||||
* A [PlaylistDecision] command that is awaiting a view capable of responding to it. Null if
|
||||
* none currently.
|
||||
*/
|
||||
val playlistDecision: Event<PlaylistDecision>
|
||||
get() = _playlistDecision
|
||||
|
||||
|
@ -163,7 +168,7 @@ constructor(
|
|||
*/
|
||||
fun addToPlaylist(album: Album, playlist: Playlist? = null) {
|
||||
logD("Adding $album to playlist")
|
||||
addToPlaylist(musicSettings.albumSongSort.songs(album.songs), playlist)
|
||||
addToPlaylist(listSettings.albumSongSort.songs(album.songs), playlist)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -174,7 +179,7 @@ constructor(
|
|||
*/
|
||||
fun addToPlaylist(artist: Artist, playlist: Playlist? = null) {
|
||||
logD("Adding $artist to playlist")
|
||||
addToPlaylist(musicSettings.artistSongSort.songs(artist.songs), playlist)
|
||||
addToPlaylist(listSettings.artistSongSort.songs(artist.songs), playlist)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -185,7 +190,7 @@ constructor(
|
|||
*/
|
||||
fun addToPlaylist(genre: Genre, playlist: Playlist? = null) {
|
||||
logD("Adding $genre to playlist")
|
||||
addToPlaylist(musicSettings.genreSongSort.songs(genre.songs), playlist)
|
||||
addToPlaylist(listSettings.genreSongSort.songs(genre.songs), playlist)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -222,9 +227,37 @@ constructor(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation command for when a [Playlist] must have some operation performed on it by the user.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface PlaylistDecision {
|
||||
/**
|
||||
* Navigate to a dialog that allows a user to pick a name for a new [Playlist].
|
||||
*
|
||||
* @param songs The [Song]s to contain in the new [Playlist].
|
||||
*/
|
||||
data class New(val songs: List<Song>) : PlaylistDecision
|
||||
|
||||
/**
|
||||
* Navigate to a dialog that allows a user to rename an existing [Playlist].
|
||||
*
|
||||
* @param playlist The playlist to act on.
|
||||
*/
|
||||
data class Rename(val playlist: Playlist) : PlaylistDecision
|
||||
|
||||
/**
|
||||
* Navigate to a dialog that confirms the deletion of an existing [Playlist].
|
||||
*
|
||||
* @param playlist The playlist to act on.
|
||||
*/
|
||||
data class Delete(val playlist: Playlist) : PlaylistDecision
|
||||
|
||||
/**
|
||||
* Navigate to a dialog that allows the user to add [Song]s to a [Playlist].
|
||||
*
|
||||
* @param songs The [Song]s to add to the chosen [Playlist].
|
||||
*/
|
||||
data class Add(val songs: List<Song>) : PlaylistDecision
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date
|
|||
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||
|
||||
@Database(entities = [CachedSong::class], version = 32, exportSchema = false)
|
||||
@Database(entities = [CachedSong::class], version = 36, exportSchema = false)
|
||||
abstract class CacheDatabase : RoomDatabase() {
|
||||
abstract fun cachedSongsDao(): CachedSongsDao
|
||||
}
|
||||
|
@ -40,7 +40,9 @@ abstract class CacheDatabase : RoomDatabase() {
|
|||
@Dao
|
||||
interface CachedSongsDao {
|
||||
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
|
||||
|
||||
@Query("DELETE FROM CachedSong") suspend fun nukeSongs()
|
||||
|
||||
@Insert suspend fun insertSongs(songs: List<CachedSong>)
|
||||
}
|
||||
|
||||
|
@ -61,9 +63,9 @@ data class CachedSong(
|
|||
/** @see RawSong */
|
||||
var durationMs: Long,
|
||||
/** @see RawSong.replayGainTrackAdjustment */
|
||||
val replayGainTrackAdjustment: Float?,
|
||||
val replayGainTrackAdjustment: Float? = null,
|
||||
/** @see RawSong.replayGainAlbumAdjustment */
|
||||
val replayGainAlbumAdjustment: Float?,
|
||||
val replayGainAlbumAdjustment: Float? = null,
|
||||
/** @see RawSong.musicBrainzId */
|
||||
var musicBrainzId: String? = null,
|
||||
/** @see RawSong.name */
|
||||
|
|
|
@ -100,6 +100,7 @@ private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
|
|||
}
|
||||
|
||||
override var invalidated = false
|
||||
|
||||
override fun populate(rawSong: RawSong): Boolean {
|
||||
// For a cached raw song to be used, it must exist within the cache and have matching
|
||||
// addition and modification timestamps. Technically the addition timestamp doesn't
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.music.decision
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
|
@ -33,9 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
|
|||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
|
@ -45,7 +46,7 @@ import org.oxycblt.auxio.util.showToast
|
|||
*/
|
||||
@AndroidEntryPoint
|
||||
class AddToPlaylistDialog :
|
||||
ViewBindingDialogFragment<DialogMusicChoicesBinding>(),
|
||||
ViewBindingMaterialDialogFragment<DialogMusicChoicesBinding>(),
|
||||
ClickableListListener<PlaylistChoice>,
|
||||
NewPlaylistFooterAdapter.Listener {
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
|
@ -73,6 +74,7 @@ class AddToPlaylistDialog :
|
|||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
pickerModel.setSongsToAdd(args.songUids)
|
||||
musicModel.playlistDecision.consume()
|
||||
collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs)
|
||||
collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices)
|
||||
}
|
||||
|
@ -89,7 +91,16 @@ class AddToPlaylistDialog :
|
|||
}
|
||||
|
||||
override fun onNewPlaylist() {
|
||||
musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return)
|
||||
// TODO: This is a temporary fix. Eventually I want to make this navigate away and
|
||||
// instead have primary fragments launch navigation to the new playlist dialog.
|
||||
// This should be better design (dialog layering is uh... probably not good) and
|
||||
// preserves the existing navigation system.
|
||||
// I could also roll some kind of new playlist textbox into the dialog, but that's
|
||||
// a lot harder.
|
||||
val songs = pickerModel.currentSongsToAdd.value ?: return
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
AddToPlaylistDialogDirections.newPlaylist(songs.map { it.uid }.toTypedArray()))
|
||||
}
|
||||
|
||||
private fun updatePendingSongs(songs: List<Song>?) {
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.music.decision
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
|
@ -30,19 +30,19 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that asks the user to confirm the deletion of a [Playlist].
|
||||
* A [ViewBindingMaterialDialogFragment] that asks the user to confirm the deletion of a [Playlist].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBinding>() {
|
||||
class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlaylistBinding>() {
|
||||
private val pickerModel: PlaylistPickerViewModel by viewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
// Information about what playlist to name for is initially within the navigation arguments
|
||||
|
@ -71,6 +71,7 @@ class DeletePlaylistDialog : ViewBindingDialogFragment<DialogDeletePlaylistBindi
|
|||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
musicModel.playlistDecision.consume()
|
||||
pickerModel.setPlaylistToDelete(args.playlistUid)
|
||||
collectImmediately(pickerModel.currentPlaylistToDelete, ::updatePlaylistToDelete)
|
||||
}
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.music.decision
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
|
@ -30,7 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() {
|
||||
class NewPlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNameBinding>() {
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val pickerModel: PlaylistPickerViewModel by viewModels()
|
||||
// Information about what playlist to name for is initially within the navigation arguments
|
||||
|
@ -83,6 +83,7 @@ class NewPlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>()
|
|||
binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) }
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
musicModel.playlistDecision.consume()
|
||||
pickerModel.setPendingPlaylist(requireContext(), args.songUids)
|
||||
collectImmediately(pickerModel.currentPendingPlaylist, ::updatePendingPlaylist)
|
||||
collectImmediately(pickerModel.chosenName, ::updateChosenName)
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.music.decision
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.music.decision
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.music.decision
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
|
@ -260,9 +260,9 @@ sealed interface ChosenName {
|
|||
/** The current name already exists. */
|
||||
data class AlreadyExists(val prior: String) : ChosenName
|
||||
/** The current name is empty. */
|
||||
object Empty : ChosenName
|
||||
data object Empty : ChosenName
|
||||
/** The current name only consists of whitespace. */
|
||||
object Blank : ChosenName
|
||||
data object Blank : ChosenName
|
||||
}
|
||||
|
||||
/**
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.music.decision
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
|
@ -31,7 +31,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
@ -43,7 +43,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class RenamePlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding>() {
|
||||
class RenamePlaylistDialog : ViewBindingMaterialDialogFragment<DialogPlaylistNameBinding>() {
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val pickerModel: PlaylistPickerViewModel by viewModels()
|
||||
// Information about what playlist to name for is initially within the navigation arguments
|
||||
|
@ -74,6 +74,7 @@ class RenamePlaylistDialog : ViewBindingDialogFragment<DialogPlaylistNameBinding
|
|||
binding.playlistName.addTextChangedListener { pickerModel.updateChosenName(it?.toString()) }
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
musicModel.playlistDecision.consume()
|
||||
pickerModel.setPlaylistToRename(args.playlistUid)
|
||||
collectImmediately(pickerModel.currentPlaylistToRename, ::updatePlaylistToRename)
|
||||
collectImmediately(pickerModel.chosenName, ::updateChosenName)
|
|
@ -28,10 +28,11 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.fs.useQuery
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.music.metadata.Separators
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
|
@ -75,7 +76,7 @@ interface DeviceLibrary {
|
|||
* Find a [Album] instance corresponding to the given [Music.UID].
|
||||
*
|
||||
* @param uid The [Music.UID] to search for.
|
||||
* @return The corresponding [Song], or null if one was not found.
|
||||
* @return The corresponding [Album], or null if one was not found.
|
||||
*/
|
||||
fun findAlbum(uid: Music.UID): Album?
|
||||
|
||||
|
@ -83,7 +84,7 @@ interface DeviceLibrary {
|
|||
* Find a [Artist] instance corresponding to the given [Music.UID].
|
||||
*
|
||||
* @param uid The [Music.UID] to search for.
|
||||
* @return The corresponding [Song], or null if one was not found.
|
||||
* @return The corresponding [Artist], or null if one was not found.
|
||||
*/
|
||||
fun findArtist(uid: Music.UID): Artist?
|
||||
|
||||
|
@ -91,7 +92,7 @@ interface DeviceLibrary {
|
|||
* Find a [Genre] instance corresponding to the given [Music.UID].
|
||||
*
|
||||
* @param uid The [Music.UID] to search for.
|
||||
* @return The corresponding [Song], or null if one was not found.
|
||||
* @return The corresponding [Genre], or null if one was not found.
|
||||
*/
|
||||
fun findGenre(uid: Music.UID): Genre?
|
||||
|
||||
|
@ -107,16 +108,19 @@ interface DeviceLibrary {
|
|||
*/
|
||||
suspend fun create(
|
||||
rawSongs: Channel<RawSong>,
|
||||
processedSongs: Channel<RawSong>
|
||||
processedSongs: Channel<RawSong>,
|
||||
separators: Separators,
|
||||
nameFactory: Name.Known.Factory
|
||||
): DeviceLibraryImpl
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: MusicSettings) :
|
||||
DeviceLibrary.Factory {
|
||||
class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
|
||||
override suspend fun create(
|
||||
rawSongs: Channel<RawSong>,
|
||||
processedSongs: Channel<RawSong>
|
||||
processedSongs: Channel<RawSong>,
|
||||
separators: Separators,
|
||||
nameFactory: Name.Known.Factory
|
||||
): DeviceLibraryImpl {
|
||||
val songGrouping = mutableMapOf<Music.UID, SongImpl>()
|
||||
val albumGrouping = mutableMapOf<RawAlbum.Key, Grouping<RawAlbum, SongImpl>>()
|
||||
|
@ -127,7 +131,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
|
|||
|
||||
// All music information is grouped as it is indexed by other components.
|
||||
for (rawSong in rawSongs) {
|
||||
val song = SongImpl(rawSong, musicSettings)
|
||||
val song = SongImpl(rawSong, nameFactory, separators)
|
||||
// At times the indexer produces duplicate songs, try to filter these. Comparing by
|
||||
// UID is sufficient for something like this, and also prevents collisions from
|
||||
// causing severe issues elsewhere.
|
||||
|
@ -207,7 +211,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
|
|||
|
||||
// Now that all songs are processed, also process albums and group them into their
|
||||
// respective artists.
|
||||
val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, musicSettings) }
|
||||
val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, nameFactory) }
|
||||
for (album in albums) {
|
||||
for (rawArtist in album.rawArtists) {
|
||||
val key = RawArtist.Key(rawArtist)
|
||||
|
@ -243,8 +247,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
|
|||
}
|
||||
|
||||
// Artists and genres do not need to be grouped and can be processed immediately.
|
||||
val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, musicSettings) }
|
||||
val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, musicSettings) }
|
||||
val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, nameFactory) }
|
||||
val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, nameFactory) }
|
||||
|
||||
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
|
||||
}
|
||||
|
@ -253,10 +257,10 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu
|
|||
// TODO: Avoid redundant data creation
|
||||
|
||||
class DeviceLibraryImpl(
|
||||
override val songs: Set<SongImpl>,
|
||||
override val albums: Set<AlbumImpl>,
|
||||
override val artists: Set<ArtistImpl>,
|
||||
override val genres: Set<GenreImpl>
|
||||
override val songs: Collection<SongImpl>,
|
||||
override val albums: Collection<AlbumImpl>,
|
||||
override val artists: Collection<ArtistImpl>,
|
||||
override val genres: Collection<GenreImpl>
|
||||
) : DeviceLibrary {
|
||||
// Use a mapping to make finding information based on it's UID much faster.
|
||||
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
|
||||
|
@ -266,14 +270,19 @@ class DeviceLibraryImpl(
|
|||
|
||||
// All other music is built from songs, so comparison only needs to check songs.
|
||||
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
|
||||
|
||||
override fun hashCode() = songs.hashCode()
|
||||
|
||||
override fun toString() =
|
||||
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " +
|
||||
"artists=${artists.size}, genres=${genres.size})"
|
||||
|
||||
override fun findSong(uid: Music.UID): Song? = songUidMap[uid]
|
||||
|
||||
override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid]
|
||||
|
||||
override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid]
|
||||
|
||||
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
|
||||
|
||||
override fun findSongForUri(context: Context, uri: Uri) =
|
||||
|
|
|
@ -20,13 +20,12 @@ package org.oxycblt.auxio.music.device
|
|||
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.extractor.CoverUri
|
||||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
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.MusicSettings
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.fs.MimeType
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
|
@ -36,10 +35,10 @@ import org.oxycblt.auxio.music.info.Date
|
|||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.music.info.ReleaseType
|
||||
import org.oxycblt.auxio.music.metadata.Separators
|
||||
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
|
||||
import org.oxycblt.auxio.music.metadata.parseMultiValue
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import org.oxycblt.auxio.util.positiveOrNull
|
||||
import org.oxycblt.auxio.util.toUuidOrNull
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.auxio.util.update
|
||||
|
@ -48,14 +47,19 @@ import org.oxycblt.auxio.util.update
|
|||
* Library-backed implementation of [Song].
|
||||
*
|
||||
* @param rawSong The [RawSong] to derive the member data from.
|
||||
* @param musicSettings [MusicSettings] to for user parsing configuration.
|
||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
||||
* @param separators The [Separators] to parse multi-value tags with.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Song {
|
||||
class SongImpl(
|
||||
private val rawSong: RawSong,
|
||||
private val nameFactory: Name.Known.Factory,
|
||||
private val separators: Separators
|
||||
) : Song {
|
||||
override val uid =
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) }
|
||||
?: Music.UID.auxio(MusicMode.SONGS) {
|
||||
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
|
||||
?: Music.UID.auxio(MusicType.SONGS) {
|
||||
// Song UIDs are based on the raw data without parsing so that they remain
|
||||
// consistent across music setting changes. Parents are not held up to the
|
||||
// same standard since grouping is already inherently linked to settings.
|
||||
|
@ -70,65 +74,47 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
|
|||
update(rawSong.albumArtistNames)
|
||||
}
|
||||
override val name =
|
||||
Name.Known.from(
|
||||
requireNotNull(rawSong.name) { "Invalid raw: No title" },
|
||||
rawSong.sortName,
|
||||
musicSettings)
|
||||
nameFactory.parse(
|
||||
requireNotNull(rawSong.name) { "Invalid raw ${rawSong.fileName}: No title" },
|
||||
rawSong.sortName)
|
||||
|
||||
override val track = rawSong.track
|
||||
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
|
||||
override val date = rawSong.date
|
||||
override val uri = requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()
|
||||
override val uri =
|
||||
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.fileName}: No id" }
|
||||
.toAudioUri()
|
||||
override val path =
|
||||
Path(
|
||||
name = requireNotNull(rawSong.fileName) { "Invalid raw: No display name" },
|
||||
parent = requireNotNull(rawSong.directory) { "Invalid raw: No parent directory" })
|
||||
name =
|
||||
requireNotNull(rawSong.fileName) {
|
||||
"Invalid raw ${rawSong.fileName}: No display name"
|
||||
},
|
||||
parent =
|
||||
requireNotNull(rawSong.directory) {
|
||||
"Invalid raw ${rawSong.fileName}: No parent directory"
|
||||
})
|
||||
override val mimeType =
|
||||
MimeType(
|
||||
fromExtension =
|
||||
requireNotNull(rawSong.extensionMimeType) { "Invalid raw: No mime type" },
|
||||
requireNotNull(rawSong.extensionMimeType) {
|
||||
"Invalid raw ${rawSong.fileName}: No mime type"
|
||||
},
|
||||
fromFormat = null)
|
||||
override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" }
|
||||
override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }
|
||||
override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.fileName}: No size" }
|
||||
override val durationMs =
|
||||
requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.fileName}: No duration" }
|
||||
override val replayGainAdjustment =
|
||||
ReplayGainAdjustment(
|
||||
track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment)
|
||||
|
||||
override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" }
|
||||
override val dateAdded =
|
||||
requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.fileName}: No date added" }
|
||||
|
||||
private var _album: AlbumImpl? = null
|
||||
override val album: Album
|
||||
get() = unlikelyToBeNull(_album)
|
||||
|
||||
private val hashCode = 31 * uid.hashCode() + rawSong.hashCode()
|
||||
|
||||
override fun hashCode() = hashCode
|
||||
override fun equals(other: Any?) =
|
||||
other is SongImpl && uid == other.uid && rawSong == other.rawSong
|
||||
override fun toString() = "Song(uid=$uid, name=$name)"
|
||||
|
||||
private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings)
|
||||
private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings)
|
||||
private val artistSortNames = rawSong.artistSortNames.parseMultiValue(musicSettings)
|
||||
private val rawIndividualArtists =
|
||||
artistNames.mapIndexed { i, name ->
|
||||
RawArtist(
|
||||
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
|
||||
name,
|
||||
artistSortNames.getOrNull(i))
|
||||
}
|
||||
|
||||
private val albumArtistMusicBrainzIds =
|
||||
rawSong.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
|
||||
private val albumArtistNames = rawSong.albumArtistNames.parseMultiValue(musicSettings)
|
||||
private val albumArtistSortNames = rawSong.albumArtistSortNames.parseMultiValue(musicSettings)
|
||||
private val rawAlbumArtists =
|
||||
albumArtistNames.mapIndexed { i, name ->
|
||||
RawArtist(
|
||||
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
|
||||
name,
|
||||
albumArtistSortNames.getOrNull(i))
|
||||
}
|
||||
|
||||
private val _artists = mutableListOf<ArtistImpl>()
|
||||
override val artists: List<Artist>
|
||||
get() = _artists
|
||||
|
@ -141,40 +127,92 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
|
|||
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
|
||||
* [Album].
|
||||
*/
|
||||
val rawAlbum =
|
||||
RawAlbum(
|
||||
mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" },
|
||||
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
|
||||
name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
|
||||
sortName = rawSong.albumSortName,
|
||||
releaseType = ReleaseType.parse(rawSong.releaseTypes.parseMultiValue(musicSettings)),
|
||||
rawArtists =
|
||||
rawAlbumArtists
|
||||
.ifEmpty { rawIndividualArtists }
|
||||
.distinctBy { it.key }
|
||||
.ifEmpty { listOf(RawArtist(null, null)) })
|
||||
val rawAlbum: RawAlbum
|
||||
|
||||
/**
|
||||
* The [RawArtist] instances collated by the [Song]. The artists of the song take priority,
|
||||
* followed by the album artists. If there are no artists, this field will be a single "unknown"
|
||||
* [RawArtist]. This can be used to group up [Song]s into an [Artist].
|
||||
*/
|
||||
val rawArtists =
|
||||
rawIndividualArtists
|
||||
.ifEmpty { rawAlbumArtists }
|
||||
.distinctBy { it.key }
|
||||
.ifEmpty { listOf(RawArtist()) }
|
||||
val rawArtists: List<RawArtist>
|
||||
|
||||
/**
|
||||
* The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a
|
||||
* [Genre]. ID3v2 Genre names are automatically converted to their resolved names.
|
||||
*/
|
||||
val rawGenres =
|
||||
rawSong.genreNames
|
||||
.parseId3GenreNames(musicSettings)
|
||||
.map { RawGenre(it) }
|
||||
.distinctBy { it.key }
|
||||
.ifEmpty { listOf(RawGenre()) }
|
||||
val rawGenres: List<RawGenre>
|
||||
|
||||
private var hashCode: Int = uid.hashCode()
|
||||
|
||||
init {
|
||||
val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds)
|
||||
val artistNames = separators.split(rawSong.artistNames)
|
||||
val artistSortNames = separators.split(rawSong.artistSortNames)
|
||||
val rawIndividualArtists =
|
||||
artistNames
|
||||
.mapIndexed { i, name ->
|
||||
RawArtist(
|
||||
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
|
||||
name,
|
||||
artistSortNames.getOrNull(i))
|
||||
}
|
||||
.distinctBy { it.key }
|
||||
|
||||
val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds)
|
||||
val albumArtistNames = separators.split(rawSong.albumArtistNames)
|
||||
val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames)
|
||||
val rawAlbumArtists =
|
||||
albumArtistNames
|
||||
.mapIndexed { i, name ->
|
||||
RawArtist(
|
||||
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
|
||||
name,
|
||||
albumArtistSortNames.getOrNull(i))
|
||||
}
|
||||
.distinctBy { it.key }
|
||||
|
||||
rawAlbum =
|
||||
RawAlbum(
|
||||
mediaStoreId =
|
||||
requireNotNull(rawSong.albumMediaStoreId) {
|
||||
"Invalid raw ${rawSong.fileName}: No album id"
|
||||
},
|
||||
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
|
||||
name =
|
||||
requireNotNull(rawSong.albumName) {
|
||||
"Invalid raw ${rawSong.fileName}: No album name"
|
||||
},
|
||||
sortName = rawSong.albumSortName,
|
||||
releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)),
|
||||
rawArtists =
|
||||
rawAlbumArtists
|
||||
.ifEmpty { rawIndividualArtists }
|
||||
.ifEmpty { listOf(RawArtist()) })
|
||||
|
||||
rawArtists =
|
||||
rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) }
|
||||
|
||||
val genreNames =
|
||||
(rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames))
|
||||
rawGenres =
|
||||
genreNames.map { RawGenre(it) }.distinctBy { it.key }.ifEmpty { listOf(RawGenre()) }
|
||||
|
||||
hashCode = 31 * hashCode + rawSong.hashCode()
|
||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||
}
|
||||
|
||||
override fun hashCode() = hashCode
|
||||
|
||||
// Since equality on public-facing music models is not identical to the tag equality,
|
||||
// we just compare raw instances and how they are interpreted.
|
||||
override fun equals(other: Any?) =
|
||||
other is SongImpl &&
|
||||
uid == other.uid &&
|
||||
nameFactory == other.nameFactory &&
|
||||
separators == other.separators &&
|
||||
rawSong == other.rawSong
|
||||
|
||||
override fun toString() = "Song(uid=$uid, name=$name)"
|
||||
|
||||
/**
|
||||
* Links this [Song] with a parent [Album].
|
||||
|
@ -209,10 +247,12 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
|
|||
* @return This instance upcasted to [Song].
|
||||
*/
|
||||
fun finalize(): Song {
|
||||
checkNotNull(_album) { "Malformed song: No album" }
|
||||
checkNotNull(_album) { "Malformed song ${path.name}: No album" }
|
||||
|
||||
check(_artists.isNotEmpty()) { "Malformed song: No artists" }
|
||||
check(_artists.size == rawArtists.size) { "Malformed song: Artist grouping mismatch" }
|
||||
check(_artists.isNotEmpty()) { "Malformed song ${path.name}: No artists" }
|
||||
check(_artists.size == rawArtists.size) {
|
||||
"Malformed song ${path.name}: Artist grouping mismatch"
|
||||
}
|
||||
for (i in _artists.indices) {
|
||||
// Non-destructively reorder the linked artists so that they align with
|
||||
// the artist ordering within the song metadata.
|
||||
|
@ -222,8 +262,10 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
|
|||
_artists[i] = other
|
||||
}
|
||||
|
||||
check(_genres.isNotEmpty()) { "Malformed song: No genres" }
|
||||
check(_genres.size == rawGenres.size) { "Malformed song: Genre grouping mismatch" }
|
||||
check(_genres.isNotEmpty()) { "Malformed song ${path.name}: No genres" }
|
||||
check(_genres.size == rawGenres.size) {
|
||||
"Malformed song ${path.name}: Genre grouping mismatch"
|
||||
}
|
||||
for (i in _genres.indices) {
|
||||
// Non-destructively reorder the linked genres so that they align with
|
||||
// the genre ordering within the song metadata.
|
||||
|
@ -240,26 +282,26 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son
|
|||
* Library-backed implementation of [Album].
|
||||
*
|
||||
* @param grouping [Grouping] to derive the member data from.
|
||||
* @param musicSettings [MusicSettings] to for user parsing configuration.
|
||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumImpl(
|
||||
grouping: Grouping<RawAlbum, SongImpl>,
|
||||
musicSettings: MusicSettings,
|
||||
private val nameFactory: Name.Known.Factory
|
||||
) : Album {
|
||||
private val rawAlbum = grouping.raw.inner
|
||||
|
||||
override val uid =
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) }
|
||||
?: Music.UID.auxio(MusicMode.ALBUMS) {
|
||||
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
|
||||
?: Music.UID.auxio(MusicType.ALBUMS) {
|
||||
// Hash based on only names despite the presence of a date to increase stability.
|
||||
// I don't know if there is any situation where an artist will have two albums with
|
||||
// the exact same name, but if there is, I would love to know.
|
||||
update(rawAlbum.name)
|
||||
update(rawAlbum.rawArtists.map { it.name })
|
||||
}
|
||||
override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings)
|
||||
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
|
||||
override val dates: Date.Range?
|
||||
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
|
||||
override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri)
|
||||
|
@ -309,12 +351,21 @@ class AlbumImpl(
|
|||
dateAdded = earliestDateAdded
|
||||
|
||||
hashCode = 31 * hashCode + rawAlbum.hashCode()
|
||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||
hashCode = 31 * hashCode + songs.hashCode()
|
||||
}
|
||||
|
||||
override fun hashCode() = hashCode
|
||||
|
||||
// Since equality on public-facing music models is not identical to the tag equality,
|
||||
// we just compare raw instances and how they are interpreted.
|
||||
override fun equals(other: Any?) =
|
||||
other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs
|
||||
other is AlbumImpl &&
|
||||
uid == other.uid &&
|
||||
rawAlbum == other.rawAlbum &&
|
||||
nameFactory == other.nameFactory &&
|
||||
songs == other.songs
|
||||
|
||||
override fun toString() = "Album(uid=$uid, name=$name)"
|
||||
|
||||
/**
|
||||
|
@ -339,9 +390,11 @@ class AlbumImpl(
|
|||
* @return This instance upcasted to [Album].
|
||||
*/
|
||||
fun finalize(): Album {
|
||||
check(songs.isNotEmpty()) { "Malformed album: Empty" }
|
||||
check(_artists.isNotEmpty()) { "Malformed album: No artists" }
|
||||
check(_artists.size == rawArtists.size) { "Malformed album: Artist grouping mismatch" }
|
||||
check(songs.isNotEmpty()) { "Malformed album $name: Empty" }
|
||||
check(_artists.isNotEmpty()) { "Malformed album $name: No artists" }
|
||||
check(_artists.size == rawArtists.size) {
|
||||
"Malformed album $name: Artist grouping mismatch"
|
||||
}
|
||||
for (i in _artists.indices) {
|
||||
// Non-destructively reorder the linked artists so that they align with
|
||||
// the artist ordering within the song metadata.
|
||||
|
@ -358,22 +411,24 @@ class AlbumImpl(
|
|||
* Library-backed implementation of [Artist].
|
||||
*
|
||||
* @param grouping [Grouping] to derive the member data from.
|
||||
* @param musicSettings [MusicSettings] to for user parsing configuration.
|
||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistImpl(grouping: Grouping<RawArtist, Music>, musicSettings: MusicSettings) : Artist {
|
||||
class ArtistImpl(
|
||||
grouping: Grouping<RawArtist, Music>,
|
||||
private val nameFactory: Name.Known.Factory
|
||||
) : Artist {
|
||||
private val rawArtist = grouping.raw.inner
|
||||
|
||||
override val uid =
|
||||
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) }
|
||||
?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) }
|
||||
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
|
||||
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
|
||||
override val name =
|
||||
rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) }
|
||||
rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
|
||||
?: Name.Unknown(R.string.def_artist)
|
||||
|
||||
override val songs: Set<Song>
|
||||
override val albums: Set<Album>
|
||||
override val explicitAlbums: Set<Album>
|
||||
override val implicitAlbums: Set<Album>
|
||||
override val durationMs: Long?
|
||||
|
@ -399,17 +454,18 @@ class ArtistImpl(grouping: Grouping<RawArtist, Music>, musicSettings: MusicSetti
|
|||
music.link(this)
|
||||
albumMap[music] = true
|
||||
}
|
||||
else -> error("Unexpected input music ${music::class.simpleName}")
|
||||
else -> error("Unexpected input music $music in $name ${music::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
songs = distinctSongs
|
||||
albums = albumMap.keys
|
||||
val albums = albumMap.keys
|
||||
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
|
||||
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
|
||||
durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
|
||||
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
|
||||
|
||||
hashCode = 31 * hashCode + rawArtist.hashCode()
|
||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||
hashCode = 31 * hashCode + songs.hashCode()
|
||||
}
|
||||
|
||||
|
@ -417,10 +473,13 @@ class ArtistImpl(grouping: Grouping<RawArtist, Music>, musicSettings: MusicSetti
|
|||
// the same UID but different songs are not equal.
|
||||
override fun hashCode() = hashCode
|
||||
|
||||
// Since equality on public-facing music models is not identical to the tag equality,
|
||||
// we just compare raw instances and how they are interpreted.
|
||||
override fun equals(other: Any?) =
|
||||
other is ArtistImpl &&
|
||||
uid == other.uid &&
|
||||
rawArtist == other.rawArtist &&
|
||||
nameFactory == other.nameFactory &&
|
||||
songs == other.songs
|
||||
|
||||
override fun toString() = "Artist(uid=$uid, name=$name)"
|
||||
|
@ -443,7 +502,16 @@ class ArtistImpl(grouping: Grouping<RawArtist, Music>, musicSettings: MusicSetti
|
|||
* @return This instance upcasted to [Artist].
|
||||
*/
|
||||
fun finalize(): Artist {
|
||||
check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" }
|
||||
// There are valid artist configurations:
|
||||
// 1. No songs, no implicit albums, some explicit albums
|
||||
// 2. Some songs, no implicit albums, some explicit albums
|
||||
// 3. Some songs, some implicit albums, no implicit albums
|
||||
// 4. Some songs, some implicit albums, some explicit albums
|
||||
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
|
||||
// but I can't be 100% certain.
|
||||
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
|
||||
"Malformed artist $name: Empty"
|
||||
}
|
||||
genres =
|
||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
|
||||
|
@ -455,15 +523,18 @@ class ArtistImpl(grouping: Grouping<RawArtist, Music>, musicSettings: MusicSetti
|
|||
* Library-backed implementation of [Genre].
|
||||
*
|
||||
* @param grouping [Grouping] to derive the member data from.
|
||||
* @param musicSettings [MusicSettings] to for user parsing configuration.
|
||||
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreImpl(grouping: Grouping<RawGenre, SongImpl>, musicSettings: MusicSettings) : Genre {
|
||||
class GenreImpl(
|
||||
grouping: Grouping<RawGenre, SongImpl>,
|
||||
private val nameFactory: Name.Known.Factory
|
||||
) : Genre {
|
||||
private val rawGenre = grouping.raw.inner
|
||||
|
||||
override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) }
|
||||
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
|
||||
override val name =
|
||||
rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) }
|
||||
rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
|
||||
?: Name.Unknown(R.string.def_genre)
|
||||
|
||||
override val songs: Set<Song>
|
||||
|
@ -487,13 +558,18 @@ class GenreImpl(grouping: Grouping<RawGenre, SongImpl>, musicSettings: MusicSett
|
|||
durationMs = totalDuration
|
||||
|
||||
hashCode = 31 * hashCode + rawGenre.hashCode()
|
||||
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||
hashCode = 31 * hashCode + songs.hashCode()
|
||||
}
|
||||
|
||||
override fun hashCode() = hashCode
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs
|
||||
other is GenreImpl &&
|
||||
uid == other.uid &&
|
||||
rawGenre == other.rawGenre &&
|
||||
nameFactory == other.nameFactory &&
|
||||
songs == other.songs
|
||||
|
||||
override fun toString() = "Genre(uid=$uid, name=$name)"
|
||||
|
||||
|
@ -515,7 +591,7 @@ class GenreImpl(grouping: Grouping<RawGenre, SongImpl>, musicSettings: MusicSett
|
|||
* @return This instance upcasted to [Genre].
|
||||
*/
|
||||
fun finalize(): Genre {
|
||||
check(songs.isNotEmpty()) { "Malformed genre: Empty" }
|
||||
check(songs.isNotEmpty()) { "Malformed genre $name: Empty" }
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,12 +24,11 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class FsModule {
|
||||
@Provides
|
||||
fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) =
|
||||
MediaStoreExtractor.from(context, musicSettings)
|
||||
fun mediaStoreExtractor(@ApplicationContext context: Context) =
|
||||
MediaStoreExtractor.from(context)
|
||||
}
|
||||
|
|
|
@ -29,7 +29,6 @@ import androidx.core.database.getStringOrNull
|
|||
import java.io.File
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.cache.Cache
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
import org.oxycblt.auxio.music.info.Date
|
||||
|
@ -50,9 +49,11 @@ interface MediaStoreExtractor {
|
|||
/**
|
||||
* Query the media database.
|
||||
*
|
||||
* @param constraints Configuration parameter to restrict what music should be ignored when
|
||||
* querying.
|
||||
* @return A new [Query] returned from the media database.
|
||||
*/
|
||||
suspend fun query(): Query
|
||||
suspend fun query(constraints: Constraints): Query
|
||||
|
||||
/**
|
||||
* Consume the [Cursor] loaded after [query].
|
||||
|
@ -74,52 +75,54 @@ interface MediaStoreExtractor {
|
|||
/** A black-box interface representing a query from the media database. */
|
||||
interface Query {
|
||||
val projectedTotal: Int
|
||||
|
||||
fun moveToNext(): Boolean
|
||||
|
||||
fun close()
|
||||
|
||||
fun populateFileInfo(rawSong: RawSong)
|
||||
|
||||
fun populateTags(rawSong: RawSong)
|
||||
}
|
||||
|
||||
data class Constraints(val excludeNonMusic: Boolean, val musicDirs: MusicDirectories)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a framework-backed instance.
|
||||
*
|
||||
* @param context [Context] required.
|
||||
* @param musicSettings [MusicSettings] required.
|
||||
* @return A new [MediaStoreExtractor] that will work best on the device's API level.
|
||||
*/
|
||||
fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor =
|
||||
fun from(context: Context): MediaStoreExtractor =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
||||
Api30MediaStoreExtractor(context, musicSettings)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
|
||||
Api29MediaStoreExtractor(context, musicSettings)
|
||||
else -> Api21MediaStoreExtractor(context, musicSettings)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context)
|
||||
else -> Api21MediaStoreExtractor(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class BaseMediaStoreExtractor(
|
||||
protected val context: Context,
|
||||
private val musicSettings: MusicSettings
|
||||
) : MediaStoreExtractor {
|
||||
final override suspend fun query(): MediaStoreExtractor.Query {
|
||||
private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
||||
MediaStoreExtractor {
|
||||
final override suspend fun query(
|
||||
constraints: MediaStoreExtractor.Constraints
|
||||
): MediaStoreExtractor.Query {
|
||||
val start = System.currentTimeMillis()
|
||||
|
||||
val args = mutableListOf<String>()
|
||||
var selector = BASE_SELECTOR
|
||||
|
||||
// Filter out audio that is not music, if enabled.
|
||||
if (musicSettings.excludeNonMusic) {
|
||||
if (constraints.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 = musicSettings.musicDirs
|
||||
if (dirs.dirs.isNotEmpty()) {
|
||||
if (constraints.musicDirs.dirs.isNotEmpty()) {
|
||||
selector += " AND "
|
||||
if (!dirs.shouldInclude) {
|
||||
if (!constraints.musicDirs.shouldInclude) {
|
||||
logD("Excluding directories in selector")
|
||||
// Without a NOT, the query will be restricted to the specified paths, resulting
|
||||
// in the "Include" mode. With a NOT, the specified paths will not be included,
|
||||
|
@ -130,10 +133,10 @@ private abstract class BaseMediaStoreExtractor(
|
|||
|
||||
// Specifying the paths to filter is version-specific, delegate to the concrete
|
||||
// implementations.
|
||||
for (i in dirs.dirs.indices) {
|
||||
if (addDirToSelector(dirs.dirs[i], args)) {
|
||||
for (i in constraints.musicDirs.dirs.indices) {
|
||||
if (addDirToSelector(constraints.musicDirs.dirs[i], args)) {
|
||||
selector +=
|
||||
if (i < dirs.dirs.lastIndex) {
|
||||
if (i < constraints.musicDirs.dirs.lastIndex) {
|
||||
"$dirSelectorTemplate OR "
|
||||
} else {
|
||||
dirSelectorTemplate
|
||||
|
@ -285,7 +288,9 @@ private abstract class BaseMediaStoreExtractor(
|
|||
private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
|
||||
final override val projectedTotal = cursor.count
|
||||
|
||||
final override fun moveToNext() = cursor.moveToNext()
|
||||
|
||||
final override fun close() = cursor.close()
|
||||
|
||||
override fun populateFileInfo(rawSong: RawSong) {
|
||||
|
@ -356,8 +361,7 @@ private abstract class BaseMediaStoreExtractor(
|
|||
// Note: The separation between version-specific backends may not be the cleanest. To preserve
|
||||
// speed, we only want to add redundancy on known issues, not with possible issues.
|
||||
|
||||
private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||
BaseMediaStoreExtractor(context, musicSettings) {
|
||||
private class Api21MediaStoreExtractor(context: Context) : BaseMediaStoreExtractor(context) {
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
|
@ -441,10 +445,8 @@ private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSet
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private abstract class BaseApi29MediaStoreExtractor(
|
||||
context: Context,
|
||||
musicSettings: MusicSettings
|
||||
) : BaseMediaStoreExtractor(context, musicSettings) {
|
||||
private abstract class BaseApi29MediaStoreExtractor(context: Context) :
|
||||
BaseMediaStoreExtractor(context) {
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
|
@ -506,8 +508,7 @@ private abstract class BaseApi29MediaStoreExtractor(
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||
BaseApi29MediaStoreExtractor(context, musicSettings) {
|
||||
private class Api29MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) {
|
||||
|
||||
override val projection: Array<String>
|
||||
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
@ -524,6 +525,7 @@ private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSet
|
|||
storageManager: StorageManager
|
||||
) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) {
|
||||
private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
||||
override fun populateTags(rawSong: RawSong) {
|
||||
super.populateTags(rawSong)
|
||||
// This extractor is volume-aware, but does not support the modern track columns.
|
||||
|
@ -546,8 +548,7 @@ private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSet
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) :
|
||||
BaseApi29MediaStoreExtractor(context, musicSettings) {
|
||||
private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) {
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
|
|
|
@ -35,7 +35,7 @@ import org.oxycblt.auxio.BuildConfig
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
@ -47,7 +47,7 @@ import org.oxycblt.auxio.util.showToast
|
|||
*/
|
||||
@AndroidEntryPoint
|
||||
class MusicDirsDialog :
|
||||
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||
private val dirAdapter = DirectoryAdapter(this)
|
||||
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
|
||||
private var storageManager: StorageManager? = null
|
||||
|
|
|
@ -83,7 +83,7 @@ inline fun <reified R> ContentResolver.useQuery(
|
|||
) = safeQuery(uri, projection, selector, args).use(block)
|
||||
|
||||
/** Album art [MediaStore] database is not a built-in constant, have to define it ourselves. */
|
||||
private val EXTERNAL_COVERS_URI = Uri.parse("content://media/external/audio/albumart")
|
||||
private val externalCoversUri = Uri.parse("content://media/external/audio/albumart")
|
||||
|
||||
/**
|
||||
* Convert a [MediaStore] Song ID into a [Uri] to it's audio file.
|
||||
|
@ -102,21 +102,11 @@ fun Long.toAudioUri() =
|
|||
* @return An external storage image [Uri]. May not exist.
|
||||
* @see ContentUris.withAppendedId
|
||||
*/
|
||||
fun Long.toCoverUri() = ContentUris.withAppendedId(EXTERNAL_COVERS_URI, this)
|
||||
fun Long.toCoverUri() = ContentUris.withAppendedId(externalCoversUri, this)
|
||||
|
||||
// --- STORAGEMANAGER UTILITIES ---
|
||||
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||
|
||||
/**
|
||||
* Provides the analogous method to [StorageManager.getStorageVolumes] method that is usable from
|
||||
* API 21 to API 23, in which the [StorageManager] API was hidden and differed greatly.
|
||||
*
|
||||
* @see StorageManager.getStorageVolumes
|
||||
*/
|
||||
@Suppress("NewApi")
|
||||
private val SM_API21_GET_VOLUME_LIST_METHOD: Method by
|
||||
lazyReflectedMethod(StorageManager::class, "getVolumeList")
|
||||
|
||||
/**
|
||||
* Provides the analogous method to [StorageVolume.getDirectory] method that is usable from API 21
|
||||
* to API 23, in which the [StorageVolume] API was hidden and differed greatly.
|
||||
|
@ -124,7 +114,7 @@ private val SM_API21_GET_VOLUME_LIST_METHOD: Method by
|
|||
* @see StorageVolume.getDirectory
|
||||
*/
|
||||
@Suppress("NewApi")
|
||||
private val SV_API21_GET_PATH_METHOD: Method by lazyReflectedMethod(StorageVolume::class, "getPath")
|
||||
private val svApi21GetPathMethod: Method by lazyReflectedMethod(StorageVolume::class, "getPath")
|
||||
|
||||
/**
|
||||
* The [StorageVolume] considered the "primary" volume by the system, obtained in a
|
||||
|
@ -143,13 +133,7 @@ val StorageManager.primaryStorageVolumeCompat: StorageVolume
|
|||
* @see StorageManager.getStorageVolumes
|
||||
*/
|
||||
val StorageManager.storageVolumesCompat: List<StorageVolume>
|
||||
get() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
storageVolumes.toList()
|
||||
} else {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(SM_API21_GET_VOLUME_LIST_METHOD.invoke(this) as Array<StorageVolume>).toList()
|
||||
}
|
||||
get() = storageVolumes.toList()
|
||||
|
||||
/**
|
||||
* The the absolute path to this [StorageVolume]'s directory within the file-system, in a
|
||||
|
@ -166,8 +150,7 @@ val StorageVolume.directoryCompat: String?
|
|||
// Replicate API: Analogous method if mounted, null if not
|
||||
when (stateCompat) {
|
||||
Environment.MEDIA_MOUNTED,
|
||||
Environment.MEDIA_MOUNTED_READ_ONLY ->
|
||||
SV_API21_GET_PATH_METHOD.invoke(this) as String
|
||||
Environment.MEDIA_MOUNTED_READ_ONLY -> svApi21GetPathMethod.invoke(this) as String
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ import kotlin.math.max
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.inRangeOrNull
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import org.oxycblt.auxio.util.positiveOrNull
|
||||
|
||||
/**
|
||||
* An ISO-8601/RFC 3339 Date.
|
||||
|
@ -74,8 +74,11 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
}
|
||||
|
||||
override fun equals(other: Any?) = other is Date && compareTo(other) == 0
|
||||
|
||||
override fun hashCode() = tokens.hashCode()
|
||||
|
||||
override fun toString() = StringBuilder().appendDate().toString()
|
||||
|
||||
override fun compareTo(other: Date): Int {
|
||||
for (i in 0 until max(tokens.size, other.tokens.size)) {
|
||||
val ai = tokens.getOrNull(i)
|
||||
|
@ -247,7 +250,7 @@ class Date private constructor(private val tokens: List<Int>) : Comparable<Date>
|
|||
* @param dst The destination list to add valid tokens to.
|
||||
*/
|
||||
private fun transformTokens(src: List<Int>, dst: MutableList<Int>) {
|
||||
dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return)
|
||||
dst.add(src.getOrNull(0)?.positiveOrNull() ?: return)
|
||||
dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return)
|
||||
dst.add(src.getOrNull(2)?.inRangeOrNull(1..31) ?: return)
|
||||
dst.add(src.getOrNull(3)?.inRangeOrNull(0..23) ?: return)
|
||||
|
|
|
@ -29,6 +29,8 @@ import org.oxycblt.auxio.list.Item
|
|||
class Disc(val number: Int, val name: String?) : Item, Comparable<Disc> {
|
||||
// We don't want to group discs by differing subtitles, so only compare by the number
|
||||
override fun equals(other: Any?) = other is Disc && number == other.number
|
||||
|
||||
override fun hashCode() = number.hashCode()
|
||||
|
||||
override fun compareTo(other: Disc) = number.compareTo(other.number)
|
||||
}
|
||||
|
|
|
@ -20,14 +20,14 @@ package org.oxycblt.auxio.music.info
|
|||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import java.text.CollationKey
|
||||
import java.text.Collator
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
|
||||
/**
|
||||
* The name of a music item.
|
||||
*
|
||||
* This class automatically implements
|
||||
* This class automatically implements advanced sorting heuristics for music naming,
|
||||
*
|
||||
* @author Alexander Capehart
|
||||
*/
|
||||
|
@ -54,36 +54,7 @@ sealed interface Name : Comparable<Name> {
|
|||
abstract val sort: String?
|
||||
|
||||
/** A tokenized version of the name that will be compared. */
|
||||
protected abstract val sortTokens: List<SortToken>
|
||||
|
||||
/** An individual part of a name string that can be compared intelligently. */
|
||||
protected data class SortToken(val collationKey: CollationKey, val type: Type) :
|
||||
Comparable<SortToken> {
|
||||
override fun compareTo(other: SortToken): Int {
|
||||
// Numeric tokens should always be lower than lexicographic tokens.
|
||||
val modeComp = type.compareTo(other.type)
|
||||
if (modeComp != 0) {
|
||||
return modeComp
|
||||
}
|
||||
|
||||
// Numeric strings must be ordered by magnitude, thus immediately short-circuit
|
||||
// the comparison if the lengths do not match.
|
||||
if (type == Type.NUMERIC &&
|
||||
collationKey.sourceString.length != other.collationKey.sourceString.length) {
|
||||
return collationKey.sourceString.length - other.collationKey.sourceString.length
|
||||
}
|
||||
|
||||
return collationKey.compareTo(other.collationKey)
|
||||
}
|
||||
|
||||
/** Denotes the type of comparison to be performed with this token. */
|
||||
enum class Type {
|
||||
/** Compare as a digit string, like "65". */
|
||||
NUMERIC,
|
||||
/** Compare as a standard alphanumeric string, like "65daysofstatic" */
|
||||
LEXICOGRAPHIC
|
||||
}
|
||||
}
|
||||
@VisibleForTesting(VisibleForTesting.PROTECTED) abstract val sortTokens: List<SortToken>
|
||||
|
||||
final override val thumb: String
|
||||
get() =
|
||||
|
@ -108,20 +79,24 @@ sealed interface Name : Comparable<Name> {
|
|||
is Unknown -> 1
|
||||
}
|
||||
|
||||
companion object {
|
||||
sealed interface Factory {
|
||||
/**
|
||||
* Create a new instance of [Name.Known]
|
||||
*
|
||||
* @param raw The raw name obtained from the music item
|
||||
* @param sort The raw sort name obtained from the music item
|
||||
* @param musicSettings [MusicSettings] required for name configuration.
|
||||
*/
|
||||
fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known =
|
||||
if (musicSettings.intelligentSorting) {
|
||||
IntelligentKnownName(raw, sort)
|
||||
} else {
|
||||
SimpleKnownName(raw, sort)
|
||||
}
|
||||
fun parse(raw: String, sort: String?): Known
|
||||
}
|
||||
|
||||
/** Produces a simple [Known] with basic sorting heuristics that are locale-independent. */
|
||||
data object SimpleFactory : Factory {
|
||||
override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort)
|
||||
}
|
||||
|
||||
/** Produces an intelligent [Known] with advanced, but more fragile heuristics. */
|
||||
data object IntelligentFactory : Factory {
|
||||
override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -132,7 +107,9 @@ sealed interface Name : Comparable<Name> {
|
|||
*/
|
||||
data class Unknown(@StringRes val stringRes: Int) : Name {
|
||||
override val thumb = "?"
|
||||
|
||||
override fun resolve(context: Context) = context.getString(stringRes)
|
||||
|
||||
override fun compareTo(other: Name) =
|
||||
when (other) {
|
||||
// Unknown names do not need any direct comparison right now.
|
||||
|
@ -143,22 +120,23 @@ sealed interface Name : Comparable<Name> {
|
|||
}
|
||||
}
|
||||
|
||||
private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
|
||||
private val PUNCT_REGEX by lazy { Regex("[\\p{Punct}+]") }
|
||||
private val collator: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
|
||||
private val punctRegex by lazy { Regex("[\\p{Punct}+]") }
|
||||
|
||||
// TODO: Consider how you want to handle whitespace and "gaps" in names.
|
||||
|
||||
/**
|
||||
* Plain [Name.Known] implementation that is internationalization-safe.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private data class SimpleKnownName(override val raw: String, override val sort: String?) :
|
||||
Name.Known() {
|
||||
data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() {
|
||||
override val sortTokens = listOf(parseToken(sort ?: raw))
|
||||
|
||||
private fun parseToken(name: String): SortToken {
|
||||
// Remove excess punctuation from the string, as those usually aren't considered in sorting.
|
||||
val stripped = name.replace(PUNCT_REGEX, "").ifEmpty { name }
|
||||
val collationKey = COLLATOR.getCollationKey(stripped)
|
||||
val stripped = name.replace(punctRegex, "").trim().ifEmpty { name }
|
||||
val collationKey = collator.getCollationKey(stripped)
|
||||
// Always use lexicographic mode since we aren't parsing any numeric components
|
||||
return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC)
|
||||
}
|
||||
|
@ -169,7 +147,7 @@ private data class SimpleKnownName(override val raw: String, override val sort:
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private data class IntelligentKnownName(override val raw: String, override val sort: String?) :
|
||||
data class IntelligentKnownName(override val raw: String, override val sort: String?) :
|
||||
Name.Known() {
|
||||
override val sortTokens = parseTokens(sort ?: raw)
|
||||
|
||||
|
@ -178,8 +156,9 @@ private data class IntelligentKnownName(override val raw: String, override val s
|
|||
// optimize it
|
||||
val stripped =
|
||||
name
|
||||
// Remove excess punctuation from the string, as those u
|
||||
.replace(PUNCT_REGEX, "")
|
||||
// Remove excess punctuation from the string, as those usually aren't
|
||||
// considered in sorting.
|
||||
.replace(punctRegex, "")
|
||||
.ifEmpty { name }
|
||||
.run {
|
||||
// Strip any english articles like "the" or "an" from the start, as music
|
||||
|
@ -206,10 +185,10 @@ private data class IntelligentKnownName(override val raw: String, override val s
|
|||
val digits =
|
||||
token.trimStart { Character.getNumericValue(it) == 0 }.ifEmpty { token }
|
||||
// Other languages have other types of digit strings, still use collation keys
|
||||
collationKey = COLLATOR.getCollationKey(digits)
|
||||
collationKey = collator.getCollationKey(digits)
|
||||
type = SortToken.Type.NUMERIC
|
||||
} else {
|
||||
collationKey = COLLATOR.getCollationKey(token)
|
||||
collationKey = collator.getCollationKey(token)
|
||||
type = SortToken.Type.LEXICOGRAPHIC
|
||||
}
|
||||
SortToken(collationKey, type)
|
||||
|
@ -220,3 +199,32 @@ private data class IntelligentKnownName(override val raw: String, override val s
|
|||
private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") }
|
||||
}
|
||||
}
|
||||
|
||||
/** An individual part of a name string that can be compared intelligently. */
|
||||
@VisibleForTesting(VisibleForTesting.PROTECTED)
|
||||
data class SortToken(val collationKey: CollationKey, val type: Type) : Comparable<SortToken> {
|
||||
override fun compareTo(other: SortToken): Int {
|
||||
// Numeric tokens should always be lower than lexicographic tokens.
|
||||
val modeComp = type.compareTo(other.type)
|
||||
if (modeComp != 0) {
|
||||
return modeComp
|
||||
}
|
||||
|
||||
// Numeric strings must be ordered by magnitude, thus immediately short-circuit
|
||||
// the comparison if the lengths do not match.
|
||||
if (type == Type.NUMERIC &&
|
||||
collationKey.sourceString.length != other.collationKey.sourceString.length) {
|
||||
return collationKey.sourceString.length - other.collationKey.sourceString.length
|
||||
}
|
||||
|
||||
return collationKey.compareTo(other.collationKey)
|
||||
}
|
||||
|
||||
/** Denotes the type of comparison to be performed with this token. */
|
||||
enum class Type {
|
||||
/** Compare as a digit string, like "65". */
|
||||
NUMERIC,
|
||||
/** Compare as a standard alphanumeric string, like "65daysofstatic" */
|
||||
LEXICOGRAPHIC
|
||||
}
|
||||
}
|
||||
|
|
|
@ -111,7 +111,7 @@ sealed interface ReleaseType {
|
|||
* A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually
|
||||
* visual) media.
|
||||
*/
|
||||
object Soundtrack : ReleaseType {
|
||||
data object Soundtrack : ReleaseType {
|
||||
override val refinement: Refinement?
|
||||
get() = null
|
||||
|
||||
|
@ -123,7 +123,7 @@ sealed interface ReleaseType {
|
|||
* 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 {
|
||||
data object Mix : ReleaseType {
|
||||
override val refinement: Refinement?
|
||||
get() = null
|
||||
|
||||
|
@ -135,7 +135,7 @@ sealed interface ReleaseType {
|
|||
* A Mix-tape. These are usually [EP]-sized releases of music made to promote an Artist or a
|
||||
* future release.
|
||||
*/
|
||||
object Mixtape : ReleaseType {
|
||||
data object Mixtape : ReleaseType {
|
||||
override val refinement: Refinement?
|
||||
get() = null
|
||||
|
||||
|
@ -143,6 +143,18 @@ sealed interface ReleaseType {
|
|||
get() = R.string.lbl_mixtape
|
||||
}
|
||||
|
||||
/**
|
||||
* A demo. These are usually [EP]-sized releases of music made to promote an Artist or a future
|
||||
* release.
|
||||
*/
|
||||
data object Demo : ReleaseType {
|
||||
override val refinement: Refinement?
|
||||
get() = null
|
||||
|
||||
override val stringRes: Int
|
||||
get() = R.string.lbl_demo
|
||||
}
|
||||
|
||||
/** A specification of what kind of performance a particular release is. */
|
||||
enum class Refinement {
|
||||
/** A release consisting of a live performance */
|
||||
|
@ -220,6 +232,7 @@ sealed interface ReleaseType {
|
|||
type.equals("dj-mix", true) -> Mix
|
||||
type.equals("live", true) -> convertRefinement(Refinement.LIVE)
|
||||
type.equals("remix", true) -> convertRefinement(Refinement.REMIX)
|
||||
type.equals("demo", true) -> Demo
|
||||
else -> convertRefinement(null)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,8 @@ import dagger.hilt.components.SingletonComponent
|
|||
@InstallIn(SingletonComponent::class)
|
||||
interface MetadataModule {
|
||||
@Binds fun tagExtractor(extractor: TagExtractorImpl): TagExtractor
|
||||
|
||||
@Binds fun tagWorkerFactory(factory: TagWorkerFactoryImpl): TagWorker.Factory
|
||||
|
||||
@Binds fun audioPropertiesFactory(factory: AudioPropertiesFactoryImpl): AudioProperties.Factory
|
||||
}
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Separators.kt is part of Auxio.
|
||||
*
|
||||
* 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.metadata
|
||||
|
||||
/**
|
||||
* Defines the user-specified parsing of multi-value tags. This should be used to parse any tags
|
||||
* that may be delimited with a separator character.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface Separators {
|
||||
/**
|
||||
* Parse a separated value from one or more strings. If the value is already composed of more
|
||||
* than one value, nothing is done. Otherwise, it will attempt to split it based on the user's
|
||||
* separator preferences.
|
||||
*
|
||||
* @return A new list of one or more [String]s parsed by the separator configuration
|
||||
*/
|
||||
fun split(strings: List<String>): List<String>
|
||||
|
||||
companion object {
|
||||
const val COMMA = ','
|
||||
const val SEMICOLON = ';'
|
||||
const val SLASH = '/'
|
||||
const val PLUS = '+'
|
||||
const val AND = '&'
|
||||
|
||||
/**
|
||||
* Creates a new instance from a string of separator characters to use.
|
||||
*
|
||||
* @param chars The separator characters to use. Each character in the string will be
|
||||
* checked for when splitting a string list.
|
||||
* @return A new [Separators] instance reflecting the separators.
|
||||
*/
|
||||
fun from(chars: String) =
|
||||
if (chars.isNotEmpty()) {
|
||||
CharSeparators(chars.toSet())
|
||||
} else {
|
||||
NoSeparators
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CharSeparators(private val chars: Set<Char>) : Separators {
|
||||
override fun split(strings: List<String>) =
|
||||
if (strings.size == 1) splitImpl(strings.first()) else strings
|
||||
|
||||
private fun splitImpl(string: String) =
|
||||
string.splitEscaped { chars.contains(it) }.correctWhitespace()
|
||||
}
|
||||
|
||||
private object NoSeparators : Separators {
|
||||
override fun split(strings: List<String>) = strings
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue