Compare commits
No commits in common. "dev" and "hotfixes" have entirely different histories.
542 changed files with 14589 additions and 23097 deletions
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -34,7 +34,6 @@ body:
|
|||
attributes:
|
||||
label: What android version do you use?
|
||||
options:
|
||||
- Android 15
|
||||
- Android 14
|
||||
- Android 13
|
||||
- Android 12L
|
||||
|
|
12
.github/workflows/android.yml
vendored
12
.github/workflows/android.yml
vendored
|
@ -14,25 +14,23 @@ jobs:
|
|||
- name: Install ninja-build
|
||||
run: sudo apt-get install -y ninja-build
|
||||
- name: Clone repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive --remote
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Check formatting with spotless
|
||||
run: ./gradlew spotlessCheck
|
||||
- name: Test musikr with Gradle
|
||||
run: ./gradlew musikr: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
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3.1.1
|
||||
with:
|
||||
name: Auxio_Canary
|
||||
path: ./app/build/outputs/apk/debug/app-debug.apk
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -13,6 +13,3 @@ captures/
|
|||
.externalNativeBuild
|
||||
*.iml
|
||||
.cxx
|
||||
.kotlin
|
||||
.aider*
|
||||
.env
|
||||
|
|
5
.gitmodules
vendored
5
.gitmodules
vendored
|
@ -1,8 +1,3 @@
|
|||
[submodule "media"]
|
||||
path = media
|
||||
url = https://github.com/OxygenCobalt/media.git
|
||||
|
||||
[submodule "musikr/src/main/cpp/taglib"]
|
||||
path = musikr/src/main/cpp/taglib
|
||||
url = https://github.com/taglib/taglib.git
|
||||
tag = ee1931b
|
||||
|
|
96
CHANGELOG.md
96
CHANGELOG.md
|
@ -1,101 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## 4.0.3
|
||||
|
||||
#### What's Improved
|
||||
- Improved music loader pipeline efficiency
|
||||
- Made cover.png support more flexible
|
||||
- Albums with the same name but different album artists are now split
|
||||
if fully tagged with album artists
|
||||
|
||||
#### What's Fixed
|
||||
- Possibly fixed cache failures on large libraries
|
||||
- Possibly fixed playback state saving failing on some devices
|
||||
- Fixed issue where artists w/o songs would not have a cover
|
||||
- Fixed music not being reloaded when music locations changed
|
||||
- Fixed tasker media control not working
|
||||
- Fixed tasker playback start command never finishing
|
||||
|
||||
#### Dev/Meta
|
||||
- Removed useless storage permissions
|
||||
- Internal cleanup/simplification of musikr API
|
||||
- Removed unused resources
|
||||
|
||||
#### What's Fixed
|
||||
|
||||
## 4.0.2
|
||||
|
||||
#### What's New
|
||||
- Added back in support for cover art from cover.png/cover.jpg
|
||||
- Added "As is" cover art setting
|
||||
- Option to include hidden files or not (off by default)
|
||||
|
||||
#### What's Improved
|
||||
- Reduced elevation contrast in black theme
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed incorrect extension stripping on some files
|
||||
- Fixed various errors in new branding
|
||||
- Fixed MTE segfault from improper string handling
|
||||
|
||||
#### What's Changed
|
||||
- Hidden files no longer loaded by default
|
||||
|
||||
## 4.0.1
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed music loading hanging on files without tags
|
||||
- Fixed playlists being destroyed in poorly tagged libraries
|
||||
|
||||
## 4.0.0
|
||||
|
||||
#### What's New
|
||||
- A total user interface refresh based on the latest Material Design specs
|
||||
- New theme palettes
|
||||
- Improved designs for playback and detail views
|
||||
- New app branding and icon
|
||||
- Refreshed round mode
|
||||
- Less intrusive music loading indicators
|
||||
- **Musikr**, a brand new music loading system
|
||||
- Directly accesses user files rather than unreliable media database
|
||||
- Uses faster and more capable native tag parsing
|
||||
- Stores cover data on-device for fast and high-quality access
|
||||
- New interpretation system with many quality-of-life improvements
|
||||
- Android 15 support
|
||||
|
||||
#### What's Improved
|
||||
- Initial music loading is signifigantly faster and less resource intensive
|
||||
- Album grouping no longer done with artist
|
||||
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries
|
||||
- M3U playlist file name is now proposed if one cannot be found within the file
|
||||
- Duration is now parsed from certain files that previously could not be parsed
|
||||
- ID3v2 tags are now parsed from WAV files
|
||||
- NN/TT tracks/discs are now handled in Vorbis
|
||||
- Music library will is less likely to fail to respond to updates
|
||||
- Hidden audio files can now be loaded
|
||||
- Sorting songs by date now uses songs date first, before the earliest album date
|
||||
- Added working layouts for small split-screen form factors
|
||||
- Added fast scrolling in detail views
|
||||
- Added ability to make issues and make feedback e-mails in-app
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed playback sheet flickering on warm start
|
||||
- No longer possible to save a sort with no direction specified
|
||||
- Fixed inconsistent corner radii in widget
|
||||
- Possibly fixed foreground start music loading failures
|
||||
- Fixed playlist view not exiting on deletion
|
||||
|
||||
#### What's Changed
|
||||
- Date added is now local to when the app discovers the file and will not
|
||||
persist long-term
|
||||
- Songs with no album are now "Unknown album" rather than folder name
|
||||
- Tab layout no longer changes depending on device configuration
|
||||
- Round mode is now on by default
|
||||
|
||||
#### Dev/Meta
|
||||
- No longer using custom logging setup
|
||||
- Music loading split off into separate musikr module
|
||||
|
||||
## 3.6.3
|
||||
|
||||
#### What's Fixed
|
||||
|
|
39
README.md
39
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/v4.0.4">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.6.3">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.6.3&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">
|
||||
|
@ -15,12 +15,7 @@
|
|||
</p>
|
||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
|
||||
<p align="center">
|
||||
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a>
|
||||
<a href="https://accrescent.app/app/org.oxycblt.auxio">
|
||||
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
|
||||
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
|
||||
</p>
|
||||
|
||||
|
@ -33,12 +28,14 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
|
|||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png" width=200>
|
||||
</p>
|
||||
|
||||
|
||||
|
@ -64,13 +61,13 @@ 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 (if you want them)
|
||||
- No rounded album covers (by default)
|
||||
|
||||
## Permissions
|
||||
|
||||
- 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
|
||||
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
||||
- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
||||
|
||||
## Donate
|
||||
|
||||
|
@ -79,9 +76,7 @@ You can support Auxio's development through [my Github Sponsors page](https://gi
|
|||
<p align="center"><b>$16/month supporters:</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></a>
|
||||
<br/>
|
||||
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
|
||||
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=100 /><p align="center"><b><a href="https://github.com/yrliet">yrliet</a></b></p></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><b>$8/month supporters:</b></p>
|
||||
|
@ -89,14 +84,12 @@ You can support Auxio's development through [my Github Sponsors page](https://gi
|
|||
<p align="center">
|
||||
<a href="https://github.com/alanorth"><img src="https://avatars.githubusercontent.com/u/191754?v=4" width=50 /></a>
|
||||
<a href="https://github.com/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
|
||||
<a href="https://github.com/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a>
|
||||
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
|
||||
<a href="https://github.com/gtsiam"><img src="https://avatars.githubusercontent.com/u/7459196?v=4" width=50 /></a>
|
||||
</p>
|
||||
|
||||
## Building
|
||||
|
||||
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata
|
||||
parsing. 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.
|
||||
|
|
|
@ -2,6 +2,7 @@ plugins {
|
|||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "androidx.navigation.safeargs.kotlin"
|
||||
id "com.diffplug.spotless"
|
||||
id "kotlin-parcelize"
|
||||
id "dagger.hilt.android.plugin"
|
||||
id "kotlin-kapt"
|
||||
|
@ -10,19 +11,21 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
compileSdk 35
|
||||
// Auxio implicitly depends on the native modules, explicitly specify it
|
||||
// here so the libraries are still stripped.
|
||||
ndkVersion ndk_version
|
||||
compileSdk 34
|
||||
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
|
||||
// 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 "26.3.11579264"
|
||||
namespace "org.oxycblt.auxio"
|
||||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "4.0.4"
|
||||
versionCode 63
|
||||
versionName "3.6.3"
|
||||
versionCode 53
|
||||
|
||||
minSdk min_sdk
|
||||
targetSdk target_sdk
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
@ -67,7 +70,6 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,16 +79,16 @@ dependencies {
|
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
|
||||
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:$core_version"
|
||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||
implementation "androidx.activity:activity-ktx:1.9.3"
|
||||
// noinspection GradleDependency
|
||||
implementation "androidx.core:core-ktx:1.12.0"
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation "androidx.activity:activity-ktx:1.8.2"
|
||||
implementation "androidx.fragment:fragment-ktx:1.6.2"
|
||||
|
||||
// Components
|
||||
|
@ -95,13 +97,11 @@ dependencies {
|
|||
// TODO: Report this issue and hope for a timely fix
|
||||
// noinspection GradleDependency
|
||||
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.2.0"
|
||||
// 1.1.0 upgrades recyclerview to 1.3.0, keep it on 1.0.0
|
||||
//noinspection GradleDependency
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||
implementation "androidx.viewpager2:viewpager2:1.0.0"
|
||||
|
||||
// Lifecycle
|
||||
def lifecycle_version = "2.8.7"
|
||||
def lifecycle_version = "2.7.0"
|
||||
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
|
@ -121,31 +121,25 @@ dependencies {
|
|||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
// Database
|
||||
def room_version = '2.6.1'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
ksp "androidx.room:room-compiler:$room_version"
|
||||
implementation "androidx.room:room-ktx:$room_version"
|
||||
|
||||
// Build
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugaring_version"
|
||||
|
||||
// --- SECOND PARTY ---
|
||||
|
||||
// Musikr
|
||||
implementation project(":musikr")
|
||||
|
||||
// --- THIRD PARTY ---
|
||||
|
||||
// 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.coil3:coil-core:3.0.2'
|
||||
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||
|
||||
// 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.13.0-alpha07"
|
||||
implementation "com.google.android.material:material:1.10.0"
|
||||
|
||||
// Dependency Injection
|
||||
implementation "com.google.dagger:dagger:$hilt_version"
|
||||
|
@ -164,4 +158,25 @@ dependencies {
|
|||
|
||||
// Fuzzy search
|
||||
implementation 'org.apache.commons:commons-text:1.9'
|
||||
|
||||
// Testing
|
||||
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.11"
|
||||
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'
|
||||
}
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
target "src/**/*.kt"
|
||||
ktfmt().dropboxStyle()
|
||||
licenseHeaderFile("NOTICE")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
preDebugBuild.dependsOn spotlessApply
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="info_app_name" translatable="false">Auxio Debug</string>
|
||||
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
|
||||
</resources>
|
|
@ -2,6 +2,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Android 13 uses READ_MEDIA_AUDIO instead of READ_EXTERNAL_STORAGE -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
@ -45,7 +48,6 @@
|
|||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:launchMode="singleTask"
|
||||
android:allowCrossUidActivitySwitchFromBelow="false"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:windowSoftInputMode="adjustPan">
|
||||
|
||||
|
@ -90,22 +92,12 @@
|
|||
android:foregroundServiceType="mediaPlayback"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:exported="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
tools:ignore="ExportedService">
|
||||
android:roundIcon="@mipmap/ic_launcher">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Expose Auxio's cover data to the android system
|
||||
-->
|
||||
<provider
|
||||
android:name=".image.CoverProvider"
|
||||
android:authorities="@string/pkg_authority_cover"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
|
||||
<!--
|
||||
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
|
||||
See the class for more info.
|
||||
|
|
|
@ -1309,6 +1309,7 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
+ " should not be set externally.");
|
||||
}
|
||||
if (!hideable && state == STATE_HIDDEN) {
|
||||
Log.w(TAG, "Cannot set state: " + state);
|
||||
return;
|
||||
}
|
||||
final int finalState;
|
||||
|
@ -1389,10 +1390,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
return shouldRemoveExpandedCorners;
|
||||
}
|
||||
|
||||
public void killCorners() {
|
||||
materialShapeDrawable.setCornerSize(0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current state of the bottom sheet.
|
||||
*
|
||||
|
@ -1632,13 +1629,12 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
return;
|
||||
}
|
||||
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
|
||||
boolean canActuallyHide = hideable && isHideableWhenDragging();
|
||||
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet.
|
||||
setState(canActuallyHide ? STATE_HIDDEN : STATE_COLLAPSED);
|
||||
setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED);
|
||||
return;
|
||||
}
|
||||
if (canActuallyHide) {
|
||||
if (hideable) {
|
||||
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
||||
backEvent,
|
||||
new AnimatorListenerAdapter() {
|
||||
|
|
|
@ -29,7 +29,6 @@ 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 org.oxycblt.auxio.util.CopyleftNoticeTree
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
|
@ -46,11 +45,7 @@ class Auxio : Application() {
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@Suppress("KotlinConstantConditions")
|
||||
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
|
||||
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
|
||||
Timber.plant(CopyleftNoticeTree())
|
||||
} else if (BuildConfig.DEBUG) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||
import timber.log.Timber
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService :
|
||||
|
@ -54,30 +54,24 @@ class AuxioService :
|
|||
musicFragment = musicFragmentFactory.create(this, this, this)
|
||||
sessionToken = playbackFragment.attach()
|
||||
musicFragment.attach()
|
||||
Timber.d("Service Created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// TODO: Start command occurring from a foreign service basically implies a detached
|
||||
// service, we might need more handling here.
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
onHandleForeground(intent)
|
||||
// If we die we want to not restart, we will immediately try to foreground in and just
|
||||
// fail to start again since the activity will be dead too. This is not the semantically
|
||||
// "correct" flag (normally you want START_STICKY for playback) but we need this to avoid
|
||||
// weird foreground errors.
|
||||
return START_NOT_STICKY
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
val binder = super.onBind(intent)
|
||||
onHandleForeground(intent)
|
||||
return binder
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
private fun onHandleForeground(intent: Intent?) {
|
||||
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
|
||||
musicFragment.start()
|
||||
playbackFragment.start(intent)
|
||||
playbackFragment.start(startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
|
@ -123,7 +117,8 @@ class AuxioService :
|
|||
|
||||
private fun getRootChildrenLimit(): Int {
|
||||
return browserRootHints?.getInt(
|
||||
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4
|
||||
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4)
|
||||
?: 4
|
||||
}
|
||||
|
||||
private fun Bundle.getPage(): MusicServiceFragment.Page? {
|
||||
|
@ -141,7 +136,6 @@ class AuxioService :
|
|||
}
|
||||
// Nothing changed, but don't show anything music related since we can always
|
||||
// index during playback.
|
||||
isForeground = true
|
||||
} else {
|
||||
musicFragment.createNotification {
|
||||
if (it != null) {
|
||||
|
@ -156,12 +150,11 @@ class AuxioService :
|
|||
}
|
||||
|
||||
override fun invalidateMusic(mediaId: String) {
|
||||
logD(mediaId)
|
||||
notifyChildrenChanged(mediaId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = BuildConfig.APPLICATION_ID + ".service.START"
|
||||
|
||||
var isForeground = false
|
||||
private set
|
||||
|
||||
|
|
|
@ -49,10 +49,8 @@ object IntegerTable {
|
|||
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
|
||||
/** DiscHeaderViewHolder */
|
||||
const val VIEW_TYPE_DISC_HEADER = 0xA00B
|
||||
/** DiscHeaderViewHolder */
|
||||
const val VIEW_TYPE_DISC_DIVIDER = 0xA00C
|
||||
/** EditHeaderViewHolder */
|
||||
const val VIEW_TYPE_EDIT_HEADER = 0xA00D
|
||||
const val VIEW_TYPE_EDIT_HEADER = 0xA00C
|
||||
/** PlaylistSongViewHolder */
|
||||
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
|
||||
/** "Music playback" notification code */
|
||||
|
@ -65,8 +63,6 @@ object IntegerTable {
|
|||
const val START_ID_ACTIVITY = 0xA050
|
||||
/** Tasker AuxioService Start ID */
|
||||
const val START_ID_TASKER = 0xA051
|
||||
/** MediaButtonReceiver AuxioService Start ID */
|
||||
const val START_ID_MEDIA_BUTTON = 0xA052
|
||||
/** RepeatMode.NONE */
|
||||
const val REPEAT_MODE_NONE = 0xA100
|
||||
/** RepeatMode.ALL */
|
||||
|
@ -125,10 +121,10 @@ object IntegerTable {
|
|||
const val ACTION_MODE_SHUFFLE = 0xA11B
|
||||
/** CoverMode.Off */
|
||||
const val COVER_MODE_OFF = 0xA11C
|
||||
/** CoverMode.Balanced */
|
||||
const val COVER_MODE_BALANCED = 0xA11D
|
||||
/** CoverMode.MediaStore */
|
||||
const val COVER_MODE_MEDIA_STORE = 0xA11D
|
||||
/** CoverMode.Quality */
|
||||
const val COVER_MODE_HIGH_QUALITY = 0xA11E
|
||||
const val COVER_MODE_QUALITY = 0xA11E
|
||||
/** PlaySong.FromAll */
|
||||
const val PLAY_SONG_FROM_ALL = 0xA11F
|
||||
/** PlaySong.FromAlbum */
|
||||
|
@ -141,8 +137,4 @@ object IntegerTable {
|
|||
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
|
||||
/** PlaySong.ByItself */
|
||||
const val PLAY_SONG_BY_ITSELF = 0xA124
|
||||
/** CoverMode.SaveSpace */
|
||||
const val COVER_MODE_SAVE_SPACE = 0xA125
|
||||
/** CoverMode.AsIs */
|
||||
const val COVER_MODE_AS_IS = 0xA126
|
||||
}
|
||||
|
|
|
@ -33,8 +33,9 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
|
|||
import org.oxycblt.auxio.playback.state.DeferredPlayback
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.isNight
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* Auxio's single [AppCompatActivity].
|
||||
|
@ -62,7 +63,7 @@ class MainActivity : AppCompatActivity() {
|
|||
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupEdgeToEdge(binding.root)
|
||||
L.d("Activity created")
|
||||
logD("Activity created")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -70,7 +71,6 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
startService(
|
||||
Intent(this, AuxioService::class.java)
|
||||
.setAction(AuxioService.ACTION_START)
|
||||
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
|
||||
|
||||
if (!startIntentAction(intent)) {
|
||||
|
@ -90,10 +90,10 @@ class MainActivity : AppCompatActivity() {
|
|||
// Apply the color scheme. The black theme requires it's own set of themes since
|
||||
// it's not possible to modify the themes at run-time.
|
||||
if (isNight && uiSettings.useBlackTheme) {
|
||||
L.d("Applying black theme [accent ${uiSettings.accent}]")
|
||||
logD("Applying black theme [accent ${uiSettings.accent}]")
|
||||
setTheme(uiSettings.accent.blackTheme)
|
||||
} else {
|
||||
L.d("Applying normal theme [accent ${uiSettings.accent}]")
|
||||
logD("Applying normal theme [accent ${uiSettings.accent}]")
|
||||
setTheme(uiSettings.accent.theme)
|
||||
}
|
||||
}
|
||||
|
@ -120,7 +120,7 @@ class MainActivity : AppCompatActivity() {
|
|||
private fun startIntentAction(intent: Intent?): Boolean {
|
||||
if (intent == null) {
|
||||
// Nothing to do.
|
||||
L.d("No intent to handle")
|
||||
logD("No intent to handle")
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -129,7 +129,7 @@ class MainActivity : AppCompatActivity() {
|
|||
// This is because onStart can run multiple times, and thus we really don't
|
||||
// want to return false and override the original delayed action with a
|
||||
// RestoreState action.
|
||||
L.d("Already used this intent")
|
||||
logD("Already used this intent")
|
||||
return true
|
||||
}
|
||||
intent.putExtra(KEY_INTENT_USED, true)
|
||||
|
@ -139,11 +139,11 @@ class MainActivity : AppCompatActivity() {
|
|||
Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
|
||||
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
|
||||
else -> {
|
||||
L.w("Unexpected intent ${intent.action}")
|
||||
logW("Unexpected intent ${intent.action}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
L.d("Translated intent to $action")
|
||||
logD("Translated intent to $action")
|
||||
playbackModel.playDeferred(action)
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -22,25 +22,21 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowInsets
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.activityViewModels
|
||||
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.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import com.google.android.material.transition.MaterialFadeThrough
|
||||
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||
import com.leinardi.android.speeddial.SpeedDialView
|
||||
import com.leinardi.android.speeddial.SpeedDialOverlayLayout
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Method
|
||||
import javax.inject.Inject
|
||||
import java.lang.reflect.Field
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
|
@ -49,15 +45,13 @@ 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.IndexingState
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
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.UISettings
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
|
@ -65,12 +59,11 @@ 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.lazyReflectedMethod
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A wrapper around the home fragment that shows the playback fragment and high-level navigation.
|
||||
|
@ -79,10 +72,7 @@ import timber.log.Timber as L
|
|||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainFragment :
|
||||
ViewBindingFragment<FragmentMainBinding>(),
|
||||
ViewTreeObserver.OnPreDrawListener,
|
||||
SpeedDialView.OnActionSelectedListener {
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val listModel: ListViewModel by activityViewModels()
|
||||
|
@ -91,13 +81,9 @@ class MainFragment :
|
|||
private var detailBackCallback: DetailBackPressedCallback? = null
|
||||
private var selectionBackCallback: SelectionBackPressedCallback? = null
|
||||
private var speedDialBackCallback: SpeedDialBackPressedCallback? = null
|
||||
private var navigationListener: DialogAwareNavigationListener? = null
|
||||
private var selectionNavigationListener: DialogAwareNavigationListener? = null
|
||||
private var lastInsets: WindowInsets? = null
|
||||
private var elevationNormal = 0f
|
||||
private var normalCornerSize = 0f
|
||||
private var maxScaleXDistance = 0f
|
||||
private var sheetRising: Boolean? = null
|
||||
@Inject lateinit var uiSettings: UISettings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
@ -112,13 +98,10 @@ class MainFragment :
|
|||
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
playbackSheetBehavior.uiSettings = uiSettings
|
||||
playbackSheetBehavior.makeBackgroundDrawable(requireContext())
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
queueSheetBehavior?.uiSettings = uiSettings
|
||||
|
||||
elevationNormal = binding.context.getDimen(MR.dimen.m3_sys_elevation_level1)
|
||||
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
|
||||
|
||||
// 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
|
||||
|
@ -131,9 +114,10 @@ class MainFragment :
|
|||
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
|
||||
val selectionBackCallback =
|
||||
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
|
||||
speedDialBackCallback = SpeedDialBackPressedCallback()
|
||||
val speedDialBackCallback =
|
||||
SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it }
|
||||
|
||||
navigationListener = DialogAwareNavigationListener(::onExploreNavigate)
|
||||
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
|
||||
|
||||
// --- UI SETUP ---
|
||||
val context = requireActivity()
|
||||
|
@ -151,50 +135,30 @@ class MainFragment :
|
|||
|
||||
if (queueSheetBehavior != null) {
|
||||
// In portrait mode, set up click listeners on the stacked sheets.
|
||||
L.d("Configuring stacked bottom sheets")
|
||||
logD("Configuring stacked bottom sheets")
|
||||
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
|
||||
playbackModel.openQueue()
|
||||
}
|
||||
} else {
|
||||
// Dual-pane mode, manually style the static queue sheet.
|
||||
L.d("Configuring dual-pane bottom sheet")
|
||||
logD("Configuring dual-pane bottom sheet")
|
||||
binding.queueSheet.apply {
|
||||
// Emulate the elevated bottom sheet style.
|
||||
background =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
shapeAppearanceModel =
|
||||
ShapeAppearanceModel.builder(
|
||||
context,
|
||||
MR.style.ShapeAppearance_Material3_Corner_ExtraLarge,
|
||||
MR.style.ShapeAppearanceOverlay_Material3_Corner_Top)
|
||||
.build()
|
||||
fillColor = context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
|
||||
fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
|
||||
elevation = context.getDimen(R.dimen.elevation_normal)
|
||||
}
|
||||
// Apply bar insets for the queue's RecyclerView to use.
|
||||
setOnApplyWindowInsetsListener { v, insets ->
|
||||
v.updatePadding(top = insets.systemBarInsetsCompat.top)
|
||||
insets
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
normalCornerSize = playbackSheetBehavior.sheetBackgroundDrawable.topLeftCornerResolvedSize
|
||||
maxScaleXDistance =
|
||||
context.getDimen(MR.dimen.m3_back_progress_bottom_container_max_scale_x_distance)
|
||||
|
||||
binding.playbackSheet.elevation = 0f
|
||||
|
||||
binding.mainScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
|
||||
binding.sheetScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
|
||||
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
|
||||
binding.homeNewPlaylistFab.apply {
|
||||
inflate(R.menu.new_playlist_actions)
|
||||
setOnActionSelectedListener(this@MainFragment)
|
||||
setChangeListener(::updateSpeedDial)
|
||||
}
|
||||
|
||||
forceHideAllFabs()
|
||||
updateSpeedDial(false)
|
||||
updateFabVisibility(
|
||||
binding,
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value,
|
||||
homeModel.currentTabType.value)
|
||||
binding.mainScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
|
||||
binding.sheetScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
// This has to be done here instead of the playback panel to make sure that it's prioritized
|
||||
|
@ -204,9 +168,7 @@ class MainFragment :
|
|||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
|
||||
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
|
||||
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
|
||||
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||
collectImmediately(homeModel.speedDialOpen, ::handleSpeedDialState)
|
||||
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
|
||||
|
@ -217,7 +179,7 @@ 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.
|
||||
requireNotNull(navigationListener) { "NavigationListener was not available" }
|
||||
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.
|
||||
|
@ -240,7 +202,7 @@ class MainFragment :
|
|||
override fun onStop() {
|
||||
super.onStop()
|
||||
val binding = requireBinding()
|
||||
requireNotNull(navigationListener) { "NavigationListener was not available" }
|
||||
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
|
||||
.release(binding.exploreNavHost.findNavController())
|
||||
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
}
|
||||
|
@ -251,15 +213,13 @@ class MainFragment :
|
|||
sheetBackCallback = null
|
||||
detailBackCallback = null
|
||||
selectionBackCallback = null
|
||||
navigationListener = null
|
||||
binding.homeNewPlaylistFab.setChangeListener(null)
|
||||
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
|
||||
selectionNavigationListener = null
|
||||
}
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
// This is where I shove literally all the UI logic that won't behave any callback
|
||||
// or "normal" method I've tried. Surely running this on every frame will actually cause
|
||||
// it to work properly!
|
||||
// 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
|
||||
|
@ -271,55 +231,28 @@ class MainFragment :
|
|||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
|
||||
if (playbackRatio > 0f && homeModel.speedDialOpen.value) {
|
||||
// Stupid hack to prevent you from sliding the sheet up without closing the speed
|
||||
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the
|
||||
// speed dial, which is super finicky behavior.
|
||||
val rising = playbackRatio > 0f
|
||||
if (rising != sheetRising) {
|
||||
sheetRising = rising
|
||||
updateFabVisibility(
|
||||
binding,
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value,
|
||||
homeModel.currentTabType.value)
|
||||
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the speed
|
||||
// dial, which is super finicky behavior.
|
||||
homeModel.setSpeedDialOpen(false)
|
||||
}
|
||||
|
||||
val playbackOutRatio = 1 - min(playbackRatio * 2, 1f)
|
||||
val playbackInRatio = max(playbackRatio - 0.5f, 0f) * 2
|
||||
|
||||
val playbackMaxXScaleDelta = maxScaleXDistance / binding.playbackSheet.width
|
||||
val playbackEdgeRatio = max(playbackRatio - 0.9f, 0f) / 0.1f
|
||||
val playbackBackRatio =
|
||||
max(1 - ((1 - binding.playbackSheet.scaleX) / playbackMaxXScaleDelta), 0f)
|
||||
val playbackLastStretchRatio = min(playbackEdgeRatio * playbackBackRatio, 1f)
|
||||
binding.mainSheetScrim.alpha = playbackLastStretchRatio
|
||||
|
||||
playbackSheetBehavior.sheetBackgroundDrawable.setCornerSize(
|
||||
normalCornerSize * (1 - playbackLastStretchRatio))
|
||||
binding.exploreNavHost.isInvisible = playbackLastStretchRatio == 1f
|
||||
binding.playbackSheet.translationZ = (1 - playbackLastStretchRatio) * elevationNormal
|
||||
val outPlaybackRatio = 1 - playbackRatio
|
||||
val halfOutRatio = min(playbackRatio * 2, 1f)
|
||||
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
|
||||
|
||||
if (queueSheetBehavior != null) {
|
||||
// Queue sheet available, the normal transition applies, but it now much be combined
|
||||
// with another transition where the playback panel disappears and the playback bar
|
||||
// appears as the queue sheet expands.
|
||||
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
|
||||
val queueInRatio = max(queueRatio - 0.5f, 0f) * 2
|
||||
val halfOutQueueRatio = min(queueRatio * 2, 1f)
|
||||
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
|
||||
|
||||
val queueMaxXScaleDelta = maxScaleXDistance / binding.queueSheet.width
|
||||
val queueBackRatio =
|
||||
max(1 - ((1 - binding.queueSheet.scaleX) / queueMaxXScaleDelta), 0f)
|
||||
|
||||
val queueEdgeRatio = max(queueRatio - 0.9f, 0f) / 0.1f
|
||||
|
||||
val queueBarEdgeRatio = max(queueEdgeRatio - 0.5f, 0f) * 2
|
||||
val queueBarBackRatio = max(queueBackRatio - 0.5f, 0f) * 2
|
||||
val queueBarRatio = min(queueBarEdgeRatio * queueBarBackRatio, 1f)
|
||||
|
||||
val queuePanelEdgeRatio = min(queueEdgeRatio * 2, 1f)
|
||||
val queuePanelBackRatio = min(queueBackRatio * 2, 1f)
|
||||
val queuePanelRatio = 1 - min(queuePanelEdgeRatio * queuePanelBackRatio, 1f)
|
||||
|
||||
binding.playbackBarFragment.alpha = max(playbackOutRatio, queueBarRatio)
|
||||
binding.playbackPanelFragment.alpha = min(playbackInRatio, queuePanelRatio)
|
||||
binding.queueFragment.alpha = queueInRatio
|
||||
binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio)
|
||||
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
|
||||
binding.queueFragment.alpha = queueRatio
|
||||
|
||||
if (playbackModel.song.value != null) {
|
||||
// Playback sheet intercepts queue sheet touch events, prevent that from
|
||||
|
@ -329,18 +262,33 @@ class MainFragment :
|
|||
}
|
||||
} else {
|
||||
// No queue sheet, fade normally based on the playback sheet
|
||||
binding.playbackBarFragment.alpha = playbackOutRatio
|
||||
binding.playbackPanelFragment.alpha = playbackInRatio
|
||||
(binding.queueSheet.background as MaterialShapeDrawable).shapeAppearanceModel =
|
||||
ShapeAppearanceModel.builder()
|
||||
.setTopLeftCornerSize(normalCornerSize)
|
||||
.setTopRightCornerSize(normalCornerSize * (1 - playbackLastStretchRatio))
|
||||
.build()
|
||||
binding.playbackBarFragment.alpha = 1 - halfOutRatio
|
||||
binding.playbackPanelFragment.alpha = halfInPlaybackRatio
|
||||
}
|
||||
|
||||
// Fade out the content as the playback panel expands.
|
||||
// TODO: Replace with shadow?
|
||||
binding.exploreNavHost.apply {
|
||||
alpha = outPlaybackRatio
|
||||
// Prevent interactions when the content fully fades out.
|
||||
isInvisible = alpha == 0f
|
||||
}
|
||||
|
||||
// Reduce playback sheet elevation as it expands. This involves both updating the
|
||||
// shadow elevation for older versions, and fading out the background drawable
|
||||
// containing the elevation overlay.
|
||||
binding.playbackSheet.translationZ = elevationNormal * outPlaybackRatio
|
||||
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt()
|
||||
|
||||
// Fade out the playback bar as the panel expands.
|
||||
binding.playbackBarFragment.apply {
|
||||
// Prevent interactions when the playback bar fully fades out.
|
||||
isInvisible = alpha == 0f
|
||||
// As the playback bar expands, we also want to subtly translate the bar to
|
||||
// align with the top inset. This results in both a smooth transition from the bar
|
||||
// to the playback panel's toolbar, but also a correctly positioned playback bar
|
||||
// for when the queue sheet expands.
|
||||
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
|
||||
}
|
||||
|
||||
// Prevent interactions when the playback panel fully fades out.
|
||||
|
@ -348,7 +296,7 @@ class MainFragment :
|
|||
|
||||
binding.queueSheet.apply {
|
||||
// Queue sheet (not queue content) should fade out with the playback panel.
|
||||
alpha = playbackInRatio
|
||||
alpha = halfInPlaybackRatio
|
||||
// Prevent interactions when the queue sheet fully fades out.
|
||||
binding.queueSheet.isInvisible = alpha == 0f
|
||||
}
|
||||
|
@ -367,160 +315,9 @@ class MainFragment :
|
|||
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
|
||||
.invalidateEnabled()
|
||||
|
||||
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
|
||||
binding.mainFabContainer.isVisible =
|
||||
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
|
||||
when (actionItem.id) {
|
||||
R.id.action_new_playlist -> {
|
||||
L.d("Creating playlist")
|
||||
musicModel.createPlaylist()
|
||||
}
|
||||
R.id.action_import_playlist -> {
|
||||
L.d("Importing playlist")
|
||||
musicModel.importPlaylist()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
// Returning false to close the speed dial results in no animation, manually close instead.
|
||||
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||
requireBinding().homeNewPlaylistFab.close()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onExploreNavigate() {
|
||||
listModel.dropSelection()
|
||||
updateFabVisibility(
|
||||
requireBinding(),
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value,
|
||||
homeModel.currentTabType.value)
|
||||
}
|
||||
|
||||
private fun updateCurrentTab(tabType: MusicType) {
|
||||
val binding = requireBinding()
|
||||
updateFabVisibility(
|
||||
binding, homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
|
||||
}
|
||||
|
||||
private fun updateIndexerState(state: IndexingState?) {
|
||||
if (state is IndexingState.Completed && state.error == null) {
|
||||
L.d("Received ok response")
|
||||
val binding = requireBinding()
|
||||
updateFabVisibility(
|
||||
binding,
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value,
|
||||
homeModel.currentTabType.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||
val binding = requireBinding()
|
||||
updateFabVisibility(binding, songs, isFastScrolling, homeModel.currentTabType.value)
|
||||
}
|
||||
|
||||
private fun updateFabVisibility(
|
||||
binding: FragmentMainBinding,
|
||||
songs: List<Song>,
|
||||
isFastScrolling: Boolean,
|
||||
tabType: MusicType
|
||||
) {
|
||||
// If there are no songs, it's likely that the library has not been loaded, so
|
||||
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
||||
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
||||
if (shouldHideAllFabs(binding, songs, isFastScrolling)) {
|
||||
L.d("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
|
||||
forceHideAllFabs()
|
||||
} else {
|
||||
if (tabType != MusicType.PLAYLISTS) {
|
||||
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||
return
|
||||
}
|
||||
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
L.d("Animating transition")
|
||||
binding.homeNewPlaylistFab.hide(
|
||||
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
super.onHidden(fab)
|
||||
if (shouldHideAllFabs(
|
||||
binding,
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value)) {
|
||||
return
|
||||
}
|
||||
binding.homeShuffleFab.show()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
L.d("Showing immediately")
|
||||
binding.homeShuffleFab.show()
|
||||
}
|
||||
} else {
|
||||
L.d("Showing playlist button")
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
return
|
||||
}
|
||||
|
||||
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||
L.d("Animating transition")
|
||||
binding.homeShuffleFab.hide(
|
||||
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
super.onHidden(fab)
|
||||
if (shouldHideAllFabs(
|
||||
binding,
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value)) {
|
||||
return
|
||||
}
|
||||
binding.homeNewPlaylistFab.show()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
L.d("Showing immediately")
|
||||
binding.homeNewPlaylistFab.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldHideAllFabs(
|
||||
binding: FragmentMainBinding,
|
||||
songs: List<Song>,
|
||||
isFastScrolling: Boolean
|
||||
) =
|
||||
binding.exploreNavHost.findNavController().currentDestination?.id != R.id.home_fragment ||
|
||||
sheetRising == true ||
|
||||
songs.isEmpty() ||
|
||||
isFastScrolling
|
||||
|
||||
private fun forceHideAllFabs() {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
|
||||
}
|
||||
if (binding.homeNewPlaylistFab.isOpen) {
|
||||
binding.homeNewPlaylistFab.close()
|
||||
}
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSpeedDial(open: Boolean) {
|
||||
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
||||
.invalidateEnabled(open)
|
||||
val binding = requireBinding()
|
||||
binding.mainScrim.isInvisible = !open
|
||||
binding.sheetScrim.isInvisible = !open
|
||||
}
|
||||
|
||||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongAlbumDetails,
|
||||
|
@ -546,6 +343,13 @@ class MainFragment :
|
|||
homeModel.showOuter.consume()
|
||||
}
|
||||
|
||||
private fun handleSpeedDialState(open: Boolean) {
|
||||
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
||||
.invalidateEnabled(open)
|
||||
requireBinding().mainScrim.isVisible = open
|
||||
requireBinding().sheetScrim.isVisible = open
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
if (song != null) {
|
||||
tryShowSheets()
|
||||
|
@ -556,7 +360,7 @@ class MainFragment :
|
|||
|
||||
private fun handlePanel(panel: OpenPanel?) {
|
||||
if (panel == null) return
|
||||
L.d("Trying to update panel to $panel")
|
||||
logD("Trying to update panel to $panel")
|
||||
when (panel) {
|
||||
OpenPanel.MAIN -> tryClosePlaybackPanel()
|
||||
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
|
||||
|
@ -572,7 +376,7 @@ class MainFragment :
|
|||
|
||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
|
||||
// Playback sheet is not expanded and not hidden, we can expand it.
|
||||
L.d("Expanding playback sheet")
|
||||
logD("Expanding playback sheet")
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
|
||||
return
|
||||
}
|
||||
|
@ -583,7 +387,7 @@ class MainFragment :
|
|||
queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Queue sheet and playback sheet is expanded, close the queue sheet so the
|
||||
// playback panel can shown.
|
||||
L.d("Collapsing queue sheet")
|
||||
logD("Collapsing queue sheet")
|
||||
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
@ -594,7 +398,7 @@ class MainFragment :
|
|||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Playback sheet (and possibly queue) needs to be collapsed.
|
||||
L.d("Collapsing playback and queue sheets")
|
||||
logD("Collapsing playback and queue sheets")
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
|
@ -620,7 +424,7 @@ class MainFragment :
|
|||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
|
||||
L.d("Unhiding and enabling playback sheet")
|
||||
logD("Unhiding and enabling playback sheet")
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
||||
|
@ -641,7 +445,7 @@ class MainFragment :
|
|||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
L.d("Hiding and disabling playback and queue sheets")
|
||||
logD("Hiding and disabling playback and queue sheets")
|
||||
|
||||
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
||||
queueSheetBehavior?.apply {
|
||||
|
@ -660,49 +464,19 @@ class MainFragment :
|
|||
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
|
||||
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
|
||||
) : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackStarted(backEvent: BackEventCompat) {
|
||||
if (queueSheetShown()) {
|
||||
unlikelyToBeNull(queueSheetBehavior).startBackProgress(backEvent)
|
||||
}
|
||||
|
||||
if (playbackSheetShown()) {
|
||||
playbackSheetBehavior.startBackProgress(backEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
|
||||
if (queueSheetShown()) {
|
||||
unlikelyToBeNull(queueSheetBehavior).updateBackProgress(backEvent)
|
||||
return
|
||||
}
|
||||
|
||||
if (playbackSheetShown()) {
|
||||
playbackSheetBehavior.updateBackProgress(backEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
// If expanded, collapse the queue sheet first.
|
||||
if (queueSheetShown()) {
|
||||
unlikelyToBeNull(queueSheetBehavior).handleBackInvoked()
|
||||
unlikelyToBeNull(queueSheetBehavior).state =
|
||||
BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
logD("Collapsed queue sheet")
|
||||
return
|
||||
}
|
||||
|
||||
// If expanded, collapse the playback sheet next.
|
||||
if (playbackSheetShown()) {
|
||||
playbackSheetBehavior.handleBackInvoked()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleOnBackCancelled() {
|
||||
if (queueSheetShown()) {
|
||||
unlikelyToBeNull(queueSheetBehavior).cancelBackProgress()
|
||||
return
|
||||
}
|
||||
|
||||
if (playbackSheetShown()) {
|
||||
playbackSheetBehavior.cancelBackProgress()
|
||||
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
|
||||
logD("Collapsed playback sheet")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -725,7 +499,7 @@ class MainFragment :
|
|||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (detailModel.dropPlaylistEdit()) {
|
||||
L.d("Dropped playlist edits")
|
||||
logD("Dropped playlist edits")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -738,7 +512,7 @@ class MainFragment :
|
|||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (listModel.dropSelection()) {
|
||||
L.d("Dropped selection")
|
||||
logD("Dropped selection")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -747,11 +521,11 @@ class MainFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private inner class SpeedDialBackPressedCallback : OnBackPressedCallback(false) {
|
||||
private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) :
|
||||
OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeNewPlaylistFab.isOpen) {
|
||||
binding.homeNewPlaylistFab.close()
|
||||
if (homeModel.speedDialOpen.value) {
|
||||
homeModel.setSpeedDialOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -761,11 +535,7 @@ class MainFragment :
|
|||
}
|
||||
|
||||
private companion object {
|
||||
val FAB_HIDE_FROM_USER_FIELD: Method by
|
||||
lazyReflectedMethod(
|
||||
FloatingActionButton::class,
|
||||
"hide",
|
||||
FloatingActionButton.OnVisibilityChangedListener::class,
|
||||
Boolean::class)
|
||||
val SPEED_DIAL_OVERLAY_ANIMATION_DURATION_FIELD: Field by
|
||||
lazyReflectedField(SpeedDialOverlayLayout::class, "mAnimationDuration")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,34 +19,45 @@
|
|||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
|
||||
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
||||
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.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.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
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.getPlural
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information about an [Album].
|
||||
|
@ -54,17 +65,60 @@ import timber.log.Timber as L
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||
class AlbumDetailFragment :
|
||||
ListFragment<Song, FragmentDetailBinding>(),
|
||||
AlbumDetailHeaderAdapter.Listener,
|
||||
DetailListAdapter.Listener<Song> {
|
||||
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()
|
||||
private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this)
|
||||
private val albumListAdapter = AlbumDetailListAdapter(this)
|
||||
|
||||
override fun getDetailListAdapter() = albumListAdapter
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Detail transitions are always on the X axis. Shared element transitions are more
|
||||
// semantically correct, but are also too buggy to be sensible.
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP --
|
||||
binding.detailNormalToolbar.apply {
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
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.albumSongList.value[it - 1]
|
||||
item is Divider || item is Header || item is Disc
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- VIEWMODEL SETUP ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setAlbum(args.albumUid)
|
||||
|
@ -82,6 +136,8 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||
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.albumSongInstructions.consume()
|
||||
|
@ -91,68 +147,34 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
playbackModel.play(item, detailModel.playInAlbumWith)
|
||||
}
|
||||
|
||||
override fun onOpenParentMenu() {
|
||||
listModel.openMenu(R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Song) {
|
||||
listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith)
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
override fun onShuffle() {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(AlbumDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
override fun onNavigateToParentArtist() {
|
||||
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
private fun updateAlbum(album: Album?) {
|
||||
if (album == null) {
|
||||
L.d("No album to show, navigating away")
|
||||
logD("No album to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
||||
val binding = requireBinding()
|
||||
val context = requireContext()
|
||||
val name = album.name.resolve(context)
|
||||
|
||||
binding.detailToolbarTitle.text = name
|
||||
binding.detailCover.bind(album)
|
||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||
binding.detailType.text = album.releaseType.resolve(context)
|
||||
binding.detailName.text = name
|
||||
// Artist name maps to the subhead text
|
||||
binding.detailSubhead.apply {
|
||||
text = album.artists.resolveNames(context)
|
||||
|
||||
// Add a QoL behavior where navigation to the artist will occur if the artist
|
||||
// name is pressed.
|
||||
setOnClickListener {
|
||||
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
}
|
||||
|
||||
// Date, song count, and duration map to the info text
|
||||
binding.detailInfo.apply {
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
|
||||
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||
val duration = album.durationMs.formatDurationMs(true)
|
||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||
}
|
||||
|
||||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
|
||||
albumHeaderAdapter.setParent(album)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -163,7 +185,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
val binding = requireBinding()
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
L.d("Navigating to ${show.song}")
|
||||
logD("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
@ -172,11 +194,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
// fragment should be launched otherwise.
|
||||
is Show.SongAlbumDetails -> {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) {
|
||||
L.d("Navigating to a ${show.song} in this album")
|
||||
logD("Navigating to a ${show.song} in this album")
|
||||
scrollToAlbumSong(show.song)
|
||||
detailModel.toShow.consume()
|
||||
} else {
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
@ -186,27 +208,27 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
// detail fragment.
|
||||
is Show.AlbumDetails -> {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) {
|
||||
L.d("Navigating to the top of this album")
|
||||
logD("Navigating to the top of this album")
|
||||
binding.detailRecycler.scrollToPosition(0)
|
||||
detailModel.toShow.consume()
|
||||
} else {
|
||||
L.d("Navigating to ${show.album}")
|
||||
logD("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
}
|
||||
is Show.ArtistDetails -> {
|
||||
L.d("Navigating to ${show.artist}")
|
||||
logD("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
|
@ -249,7 +271,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
AlbumDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -278,11 +300,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
AlbumDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
AlbumDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
|
@ -296,14 +318,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
if (pos != -1) {
|
||||
// Only scroll if the song is within this album.
|
||||
val binding = requireBinding()
|
||||
// RecyclerView will scroll assuming it has the total height of the screen (i.e a
|
||||
// collapsed appbar), so we need to collapse the appbar if that's the case.
|
||||
binding.detailAppbar.setExpanded(false)
|
||||
if (!binding.detailRecycler.canScroll()) {
|
||||
// Don't scroll if the RecyclerView goes off screen. If we go anyway, overscroll
|
||||
// kicks in and creates a weird bounce effect.
|
||||
return
|
||||
}
|
||||
binding.detailRecycler.post {
|
||||
// Use a custom smooth scroller that will settle the item in the middle of
|
||||
// the screen rather than the end.
|
||||
|
@ -326,9 +340,12 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
|
||||
// Make sure to increment the position to make up for the detail header
|
||||
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
|
||||
// If the recyclerview can scroll, its certain that it will have to scroll to
|
||||
// correctly center the playing item, so make sure that the Toolbar is lifted in
|
||||
// that case.
|
||||
binding.detailAppbar.isLifted = binding.detailRecycler.canScroll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,33 +19,44 @@
|
|||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.isVisible
|
||||
import android.view.LayoutInflater
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
|
||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
||||
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
||||
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.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
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
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.getPlural
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information about an [Artist].
|
||||
|
@ -53,17 +64,63 @@ import timber.log.Timber as L
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
||||
class ArtistDetailFragment :
|
||||
ListFragment<Music, FragmentDetailBinding>(),
|
||||
DetailHeaderAdapter.Listener,
|
||||
DetailListAdapter.Listener<Music> {
|
||||
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
|
||||
// as a UID, as that is the only safe way to parcel an artist.
|
||||
private val args: ArtistDetailFragmentArgs by navArgs()
|
||||
private val artistHeaderAdapter = ArtistDetailHeaderAdapter(this)
|
||||
private val artistListAdapter = ArtistDetailListAdapter(this)
|
||||
|
||||
override fun getDetailListAdapter() = artistListAdapter
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Detail transitions are always on the X axis. Shared element transitions are more
|
||||
// semantically correct, but are also too buggy to be sensible.
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailNormalToolbar.apply {
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@ArtistDetailFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
}
|
||||
|
||||
binding.detailRecycler.apply {
|
||||
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
|
||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item =
|
||||
detailModel.artistSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is Divider || item is Header
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setArtist(args.artistUid)
|
||||
|
@ -81,6 +138,8 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||
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.artistSongInstructions.consume()
|
||||
|
@ -94,10 +153,6 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onOpenParentMenu() {
|
||||
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Music) {
|
||||
when (item) {
|
||||
is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith)
|
||||
|
@ -106,75 +161,26 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
|
||||
override fun onShuffle() {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(ArtistDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
private fun updateArtist(artist: Artist?) {
|
||||
if (artist == null) {
|
||||
L.d("No artist to show, navigating away")
|
||||
logD("No artist to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
val binding = requireBinding()
|
||||
val context = requireContext()
|
||||
val name = artist.name.resolve(context)
|
||||
binding.detailToolbarTitle.text = name
|
||||
|
||||
binding.detailCover.bind(artist)
|
||||
binding.detailType.text = context.getString(R.string.lbl_artist)
|
||||
binding.detailName.text = name
|
||||
|
||||
// Song and album counts map to the info
|
||||
binding.detailInfo.text =
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
if (artist.explicitAlbums.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
|
||||
} else {
|
||||
context.getString(R.string.def_album_count)
|
||||
},
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
|
||||
} else {
|
||||
context.getString(R.string.def_song_count)
|
||||
})
|
||||
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
// Information about the artist's genre(s) map to the sub-head text
|
||||
binding.detailSubhead.apply {
|
||||
isVisible = true
|
||||
text = artist.genres.resolveNames(context)
|
||||
}
|
||||
|
||||
// In the case that this header used to he configured to have no songs,
|
||||
// we want to reset the visibility of all information that was hidden.
|
||||
binding.detailPlayButton?.isVisible = true
|
||||
binding.detailShuffleButton?.isVisible = true
|
||||
} else {
|
||||
// The artist does not have any songs, so hide functionality that makes no sense.
|
||||
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
||||
// Artists are always guaranteed to have albums however, so continue to show those.
|
||||
L.d("Artist is empty, disabling genres and playback")
|
||||
binding.detailSubhead.isVisible = false
|
||||
binding.detailPlayButton?.isEnabled = false
|
||||
binding.detailShuffleButton?.isEnabled = false
|
||||
}
|
||||
|
||||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
|
||||
artistHeaderAdapter.setParent(artist)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -185,14 +191,14 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
val binding = requireBinding()
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
L.d("Navigating to ${show.song}")
|
||||
logD("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
||||
// Songs should be shown in their album, not in their artist.
|
||||
is Show.SongAlbumDetails -> {
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
@ -200,7 +206,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
// Launch a new detail view for an album, even if it is part of
|
||||
// this artist.
|
||||
is Show.AlbumDetails -> {
|
||||
L.d("Navigating to ${show.album}")
|
||||
logD("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
|
@ -209,22 +215,22 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
// scroll back to the top. Otherwise launch a new detail view.
|
||||
is Show.ArtistDetails -> {
|
||||
if (show.artist == detailModel.currentArtist.value) {
|
||||
L.d("Navigating to the top of this artist")
|
||||
logD("Navigating to the top of this artist")
|
||||
binding.detailRecycler.scrollToPosition(0)
|
||||
detailModel.toShow.consume()
|
||||
} else {
|
||||
L.d("Navigating to ${show.artist}")
|
||||
logD("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
|
@ -268,7 +274,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
ArtistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -309,7 +315,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
is PlaybackDecision.PlayFromArtist ->
|
||||
error("Unexpected playback decision $decision")
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
ArtistDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,116 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* ContinuousAppBarLayoutBehavior.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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.VelocityTracker
|
||||
import android.view.ViewGroup
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
|
||||
class ContinuousAppBarLayoutBehavior
|
||||
@JvmOverloads
|
||||
constructor(context: Context? = null, attrs: AttributeSet? = null) :
|
||||
AppBarLayout.Behavior(context, attrs) {
|
||||
private var recycler: RecyclerView? = null
|
||||
private var pointerId = -1
|
||||
private var velocityTracker: VelocityTracker? = null
|
||||
|
||||
override fun onInterceptTouchEvent(
|
||||
parent: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
ev: MotionEvent
|
||||
): Boolean {
|
||||
val consumed = super.onInterceptTouchEvent(parent, child, ev)
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
ensureVelocityTracker()
|
||||
findRecyclerView(child).stopScroll()
|
||||
pointerId = ev.getPointerId(0)
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
velocityTracker?.recycle()
|
||||
velocityTracker = null
|
||||
pointerId = -1
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
return consumed
|
||||
}
|
||||
|
||||
override fun onTouchEvent(
|
||||
parent: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
ev: MotionEvent
|
||||
): Boolean {
|
||||
val consumed = super.onTouchEvent(parent, child, ev)
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
ensureVelocityTracker()
|
||||
pointerId = ev.getPointerId(0)
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
findRecyclerView(child).fling(0, getYVelocity(ev))
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
velocityTracker?.recycle()
|
||||
velocityTracker = null
|
||||
pointerId = -1
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
velocityTracker?.addMovement(ev)
|
||||
return consumed
|
||||
}
|
||||
|
||||
private fun ensureVelocityTracker() {
|
||||
if (velocityTracker == null) {
|
||||
velocityTracker = VelocityTracker.obtain()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getYVelocity(event: MotionEvent): Int {
|
||||
velocityTracker?.let {
|
||||
it.addMovement(event)
|
||||
it.computeCurrentVelocity(FLING_UNITS)
|
||||
return -it.getYVelocity(pointerId).toInt()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun findRecyclerView(child: AppBarLayout): RecyclerView {
|
||||
val recycler = recycler
|
||||
if (recycler != null) {
|
||||
return recycler
|
||||
}
|
||||
|
||||
// Use the scrolling view in order to find a RecyclerView to use.
|
||||
val newRecycler =
|
||||
(child.parent as ViewGroup).findViewById<RecyclerView>(child.liftOnScrollTargetViewId)
|
||||
this.recycler = newRecycler
|
||||
return newRecycler
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FLING_UNITS = 1000 // copied from base class
|
||||
}
|
||||
}
|
169
app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
Normal file
169
app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt
Normal file
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* DetailAppBarLayout.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
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import java.lang.reflect.Field
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||
* view goes beyond it's first item.
|
||||
*
|
||||
* This is intended for the detail views, in which the first item is the album/artist/genre header,
|
||||
* and thus scrolling past them should make the toolbar show the name in order to give context on
|
||||
* where the user currently is.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DetailAppBarLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
|
||||
private var titleView: TextView? = null
|
||||
private var recycler: RecyclerView? = null
|
||||
|
||||
private var titleShown: Boolean? = null
|
||||
private var titleAnimator: ValueAnimator? = null
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (!isInEditMode) {
|
||||
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findTitleView(): TextView {
|
||||
val titleView = titleView
|
||||
if (titleView != null) {
|
||||
return titleView
|
||||
}
|
||||
|
||||
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
|
||||
// used within the detail layouts.
|
||||
val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
|
||||
|
||||
// The Toolbar's title view is actually hidden. To avoid having to create our own
|
||||
// title view, we just reflect into Toolbar and grab the hidden field.
|
||||
val newTitleView =
|
||||
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
|
||||
// We can never properly initialize the title view's state before draw time,
|
||||
// so we just set it's alpha to 0f to produce a less jarring initialization
|
||||
// animation.
|
||||
alpha = 0f
|
||||
}
|
||||
|
||||
this.titleView = newTitleView
|
||||
return newTitleView
|
||||
}
|
||||
|
||||
private fun findRecyclerView(): RecyclerView {
|
||||
val recycler = recycler
|
||||
if (recycler != null) {
|
||||
return recycler
|
||||
}
|
||||
|
||||
// Use the scrolling view in order to find a RecyclerView to use.
|
||||
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
|
||||
this.recycler = newRecycler
|
||||
return newRecycler
|
||||
}
|
||||
|
||||
private fun setTitleVisibility(visible: Boolean) {
|
||||
if (titleShown == visible) return
|
||||
titleShown = visible
|
||||
|
||||
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
|
||||
// the title view's alpha instead of the AppBarLayout's elevation.
|
||||
val titleView = findTitleView()
|
||||
val from: Float
|
||||
val to: Float
|
||||
|
||||
if (visible) {
|
||||
from = 0f
|
||||
to = 1f
|
||||
} else {
|
||||
from = 1f
|
||||
to = 0f
|
||||
}
|
||||
|
||||
if (titleView.alpha == to) {
|
||||
// Nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
logD("Changing title visibility [from: $from to: $to]")
|
||||
titleAnimator?.cancel()
|
||||
titleAnimator =
|
||||
ValueAnimator.ofFloat(from, to).apply {
|
||||
addUpdateListener { titleView.alpha = it.animatedValue as Float }
|
||||
duration =
|
||||
if (titleShown == true) {
|
||||
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
class Behavior
|
||||
@JvmOverloads
|
||||
constructor(context: Context? = null, attrs: AttributeSet? = null) :
|
||||
AppBarLayout.Behavior(context, attrs) {
|
||||
override fun onNestedPreScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: AppBarLayout,
|
||||
target: View,
|
||||
dx: Int,
|
||||
dy: Int,
|
||||
consumed: IntArray,
|
||||
type: Int
|
||||
) {
|
||||
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
|
||||
|
||||
val appBarLayout = child as DetailAppBarLayout
|
||||
val recycler = appBarLayout.findRecyclerView()
|
||||
|
||||
// Title should be visible if we are no longer showing the top item
|
||||
// (i.e the header)
|
||||
appBarLayout.setTitleVisibility(
|
||||
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
|
||||
}
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DetailFragment.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
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
|
||||
abstract class DetailFragment<P : MusicParent, C : Music> :
|
||||
ListFragment<C, FragmentDetailBinding>(),
|
||||
DetailListAdapter.Listener<C>,
|
||||
AppBarLayout.OnOffsetChangedListener {
|
||||
protected val detailModel: DetailViewModel by activityViewModels()
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
||||
private var spacingSmall = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Detail transitions are always on the X axis. Shared element transitions are more
|
||||
// semantically correct, but are also too buggy to be sensible.
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
abstract fun getDetailListAdapter(): DetailListAdapter
|
||||
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailAppbar.addOnOffsetChangedListener(this)
|
||||
|
||||
binding.detailNormalToolbar.apply {
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@DetailFragment)
|
||||
overrideOnOverflowMenuClick { onOpenParentMenu() }
|
||||
}
|
||||
|
||||
binding.detailRecycler.apply {
|
||||
adapter = getDetailListAdapter()
|
||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item =
|
||||
detailModel.artistSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is PlainDivider || item is PlainHeader
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spacingSmall = requireContext().getDimenPixels(R.dimen.spacing_small)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.detailAppbar.removeOnOffsetChangedListener(this)
|
||||
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||
binding.detailRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
val binding = requireBinding()
|
||||
val range = appBarLayout.totalScrollRange
|
||||
val ratio = abs(verticalOffset.toFloat()) / range.toFloat()
|
||||
|
||||
val outRatio = min(ratio * 2, 1f)
|
||||
val detailHeader = binding.detailHeader
|
||||
detailHeader.scaleX = 1 - 0.2f * outRatio / (5f / 3f)
|
||||
detailHeader.scaleY = 1 - 0.2f * outRatio / (5f / 3f)
|
||||
detailHeader.alpha = 1 - outRatio
|
||||
|
||||
val inRatio = max(ratio - 0.5f, 0f) * 2
|
||||
val detailContent = binding.detailToolbarContent
|
||||
detailContent.alpha = inRatio
|
||||
detailContent.translationY = spacingSmall * (1 - inRatio)
|
||||
|
||||
// Enable fast scrolling once fully collapsed
|
||||
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
|
||||
}
|
||||
|
||||
abstract fun onOpenParentMenu()
|
||||
}
|
|
@ -23,18 +23,18 @@ import javax.inject.Inject
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.tag.Disc
|
||||
import org.oxycblt.musikr.tag.ReleaseType
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.info.ReleaseType
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
interface DetailGenerator {
|
||||
fun any(uid: Music.UID): Detail<out MusicParent>?
|
||||
|
@ -121,7 +121,7 @@ private class DetailGeneratorImpl(
|
|||
}
|
||||
|
||||
override fun album(uid: Music.UID): Detail<Album>? {
|
||||
val album = musicRepository.library?.findAlbum(uid) ?: return null
|
||||
val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null
|
||||
val songs = listSettings.albumSongSort.songs(album.songs)
|
||||
val discs = songs.groupBy { it.disc }
|
||||
val section =
|
||||
|
@ -134,7 +134,7 @@ private class DetailGeneratorImpl(
|
|||
}
|
||||
|
||||
override fun artist(uid: Music.UID): Detail<Artist>? {
|
||||
val artist = musicRepository.library?.findArtist(uid) ?: return null
|
||||
val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null
|
||||
val grouping =
|
||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
||||
// Remap the complicated ReleaseType data structure into detail sections
|
||||
|
@ -156,7 +156,7 @@ private class DetailGeneratorImpl(
|
|||
}
|
||||
|
||||
if (artist.implicitAlbums.isNotEmpty()) {
|
||||
L.d("Implicit albums present, adding to list")
|
||||
logD("Implicit albums present, adding to list")
|
||||
grouping[DetailSection.Albums.Category.APPEARANCES] =
|
||||
artist.implicitAlbums.toMutableList()
|
||||
}
|
||||
|
@ -173,14 +173,14 @@ private class DetailGeneratorImpl(
|
|||
}
|
||||
|
||||
override fun genre(uid: Music.UID): Detail<Genre>? {
|
||||
val genre = musicRepository.library?.findGenre(uid) ?: return null
|
||||
val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null
|
||||
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
|
||||
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
|
||||
return Detail(genre, listOf(artists, songs))
|
||||
}
|
||||
|
||||
override fun playlist(uid: Music.UID): Detail<Playlist>? {
|
||||
val playlist = musicRepository.library?.findPlaylist(uid) ?: return null
|
||||
val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null
|
||||
if (playlist.songs.isNotEmpty()) {
|
||||
val songs = DetailSection.Songs(playlist.songs)
|
||||
return Detail(playlist, listOf(songs))
|
||||
|
|
|
@ -22,37 +22,40 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.detail.list.DiscDivider
|
||||
import org.oxycblt.auxio.detail.list.DiscHeader
|
||||
import org.oxycblt.auxio.detail.list.EditHeader
|
||||
import org.oxycblt.auxio.detail.list.SongProperty
|
||||
import org.oxycblt.auxio.detail.list.SortHeader
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
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.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
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
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
|
||||
|
@ -66,11 +69,11 @@ class DetailViewModel
|
|||
constructor(
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val audioPropertiesFactory: AudioProperties.Factory,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
detailGeneratorFactory: DetailGenerator.Factory
|
||||
) : ViewModel(), DetailGenerator.Invalidator {
|
||||
private val _toShow = MutableEvent<Show>()
|
||||
|
||||
/**
|
||||
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||
*/
|
||||
|
@ -79,34 +82,30 @@ constructor(
|
|||
|
||||
// --- SONG ---
|
||||
|
||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||
private var currentSongJob: Job? = null
|
||||
|
||||
private val _currentSong = MutableStateFlow<Song?>(null)
|
||||
/** The current [Song] to display. Null if there is nothing to show. */
|
||||
val currentSong: StateFlow<Song?>
|
||||
get() = _currentSong
|
||||
|
||||
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
|
||||
|
||||
/** The current properties of [currentSong]. Empty if nothing to show. */
|
||||
val currentSongProperties: StateFlow<List<SongProperty>>
|
||||
get() = _currentSongProperties
|
||||
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
|
||||
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
|
||||
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
|
||||
|
||||
// --- ALBUM ---
|
||||
|
||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||
|
||||
/** The current [Album] to display. Null if there is nothing to show. */
|
||||
val currentAlbum: StateFlow<Album?>
|
||||
get() = _currentAlbum
|
||||
|
||||
private val _albumSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentAlbum]. */
|
||||
val albumSongList: StateFlow<List<Item>>
|
||||
get() = _albumSongList
|
||||
|
||||
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [albumSongList] in the UI. */
|
||||
val albumSongInstructions: Event<UpdateInstructions>
|
||||
get() = _albumSongInstructions
|
||||
|
@ -122,18 +121,15 @@ constructor(
|
|||
// --- ARTIST ---
|
||||
|
||||
private val _currentArtist = MutableStateFlow<Artist?>(null)
|
||||
|
||||
/** The current [Artist] to display. Null if there is nothing to show. */
|
||||
val currentArtist: StateFlow<Artist?>
|
||||
get() = _currentArtist
|
||||
|
||||
private val _artistSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list derived from [currentArtist]. */
|
||||
val artistSongList: StateFlow<List<Item>> = _artistSongList
|
||||
|
||||
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val artistSongInstructions: Event<UpdateInstructions>
|
||||
get() = _artistSongInstructions
|
||||
|
@ -149,18 +145,15 @@ constructor(
|
|||
// --- GENRE ---
|
||||
|
||||
private val _currentGenre = MutableStateFlow<Genre?>(null)
|
||||
|
||||
/** The current [Genre] to display. Null if there is nothing to show. */
|
||||
val currentGenre: StateFlow<Genre?>
|
||||
get() = _currentGenre
|
||||
|
||||
private val _genreSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentGenre]. */
|
||||
val genreSongList: StateFlow<List<Item>> = _genreSongList
|
||||
|
||||
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val genreSongInstructions: Event<UpdateInstructions>
|
||||
get() = _genreSongInstructions
|
||||
|
@ -176,24 +169,20 @@ constructor(
|
|||
// --- PLAYLIST ---
|
||||
|
||||
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
||||
|
||||
/** The current [Playlist] to display. Null if there is nothing to do. */
|
||||
val currentPlaylist: StateFlow<Playlist?>
|
||||
get() = _currentPlaylist
|
||||
|
||||
private val _playlistSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentPlaylist] */
|
||||
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)
|
||||
|
||||
/**
|
||||
* The new playlist songs created during the current editing session. Null if no editing session
|
||||
* is occurring.
|
||||
|
@ -312,23 +301,23 @@ constructor(
|
|||
private fun showImpl(show: Show) {
|
||||
val existing = toShow.flow.value
|
||||
if (existing != null) {
|
||||
L.d("Already have pending show command $existing, ignoring $show")
|
||||
logD("Already have pending show command $existing, ignoring $show")
|
||||
return
|
||||
}
|
||||
_toShow.put(show)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
|
||||
* the new [Song].
|
||||
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
|
||||
* be updated to align with the new [Song].
|
||||
*
|
||||
* @param uid The UID of the [Song] to load. Must be valid.
|
||||
*/
|
||||
fun setSong(uid: Music.UID) {
|
||||
L.d("Opening song $uid")
|
||||
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo)
|
||||
logD("Opening song $uid")
|
||||
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
|
||||
if (_currentSong.value == null) {
|
||||
L.w("Given song UID was invalid")
|
||||
logW("Given song UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -339,14 +328,14 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||
*/
|
||||
fun setAlbum(uid: Music.UID) {
|
||||
L.d("Opening album $uid")
|
||||
logD("Opening album $uid")
|
||||
if (uid === _currentAlbum.value?.uid) {
|
||||
return
|
||||
}
|
||||
val album = detailGenerator.album(uid)
|
||||
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
|
||||
if (_currentAlbum.value == null) {
|
||||
L.w("Given album UID was invalid")
|
||||
logW("Given album UID was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -366,7 +355,7 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||
*/
|
||||
fun setArtist(uid: Music.UID) {
|
||||
L.d("Opening artist $uid")
|
||||
logD("Opening artist $uid")
|
||||
if (uid === _currentArtist.value?.uid) {
|
||||
return
|
||||
}
|
||||
|
@ -390,7 +379,7 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||
*/
|
||||
fun setGenre(uid: Music.UID) {
|
||||
L.d("Opening genre $uid")
|
||||
logD("Opening genre $uid")
|
||||
if (uid === _currentGenre.value?.uid) {
|
||||
return
|
||||
}
|
||||
|
@ -414,7 +403,7 @@ constructor(
|
|||
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
|
||||
*/
|
||||
fun setPlaylist(uid: Music.UID) {
|
||||
L.d("Opening playlist $uid")
|
||||
logD("Opening playlist $uid")
|
||||
if (uid === _currentPlaylist.value?.uid) {
|
||||
return
|
||||
}
|
||||
|
@ -424,7 +413,7 @@ constructor(
|
|||
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
|
||||
fun startPlaylistEdit() {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
L.d("Starting playlist edit")
|
||||
logD("Starting playlist edit")
|
||||
_editedPlaylist.value = playlist.songs
|
||||
refreshPlaylist(playlist.uid)
|
||||
}
|
||||
|
@ -436,7 +425,7 @@ constructor(
|
|||
fun savePlaylistEdit() {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
val editedPlaylist = _editedPlaylist.value ?: return
|
||||
L.d("Committing playlist edits")
|
||||
logD("Committing playlist edits")
|
||||
viewModelScope.launch {
|
||||
musicRepository.rewritePlaylist(playlist, editedPlaylist)
|
||||
// TODO: The user could probably press some kind of button if they were fast enough.
|
||||
|
@ -484,12 +473,12 @@ constructor(
|
|||
fun movePlaylistSongs(from: Int, to: Int): Boolean {
|
||||
val playlist = _currentPlaylist.value ?: return false
|
||||
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
|
||||
val realFrom = from - 1
|
||||
val realTo = to - 1
|
||||
val realFrom = from - 2
|
||||
val realTo = to - 2
|
||||
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
|
||||
return false
|
||||
}
|
||||
L.d("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
|
||||
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
|
||||
|
@ -504,11 +493,11 @@ constructor(
|
|||
fun removePlaylistSong(at: Int) {
|
||||
val playlist = _currentPlaylist.value ?: return
|
||||
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
|
||||
val realAt = at - 1
|
||||
val realAt = at - 2
|
||||
if (realAt !in editedPlaylist.indices) {
|
||||
return
|
||||
}
|
||||
L.d("Removing playlist song at $realAt [$at]")
|
||||
logD("Removing playlist song at $realAt [$at]")
|
||||
editedPlaylist.removeAt(realAt)
|
||||
_editedPlaylist.value = editedPlaylist
|
||||
refreshPlaylist(
|
||||
|
@ -516,38 +505,22 @@ constructor(
|
|||
if (editedPlaylist.isNotEmpty()) {
|
||||
UpdateInstructions.Remove(at, 1)
|
||||
} else {
|
||||
L.d("Playlist will be empty after removal, removing header")
|
||||
UpdateInstructions.Remove(at - 1, 3)
|
||||
logD("Playlist will be empty after removal, removing header")
|
||||
UpdateInstructions.Remove(at - 2, 3)
|
||||
})
|
||||
}
|
||||
|
||||
private fun refreshAudioInfo(song: Song) {
|
||||
_currentSongProperties.value = buildList {
|
||||
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
|
||||
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
|
||||
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
|
||||
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
|
||||
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
|
||||
song.track?.let {
|
||||
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
|
||||
}
|
||||
song.disc?.let {
|
||||
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
|
||||
}
|
||||
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
|
||||
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
|
||||
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
|
||||
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
|
||||
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
|
||||
add(
|
||||
SongProperty(
|
||||
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
|
||||
song.replayGainAdjustment.track?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_track, SongProperty.Value.Decibels(it)))
|
||||
}
|
||||
song.replayGainAdjustment.album?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_album, SongProperty.Value.Decibels(it)))
|
||||
}
|
||||
logD("Refreshing audio info")
|
||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||
currentSongJob?.cancel()
|
||||
_songAudioProperties.value = null
|
||||
currentSongJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val info = audioPropertiesFactory.extract(song)
|
||||
yield()
|
||||
logD("Updating audio info to $info")
|
||||
_songAudioProperties.value = info
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -557,7 +530,7 @@ constructor(
|
|||
list: MutableStateFlow<List<Item>>,
|
||||
instructions: MutableEvent<UpdateInstructions>,
|
||||
replace: Int?,
|
||||
songHeader: (Int) -> PlainHeader = { SortHeader(it) }
|
||||
songHeader: (Int) -> Header = { SortHeader(it) }
|
||||
) {
|
||||
if (detail == null) {
|
||||
parent.value = null
|
||||
|
@ -572,28 +545,15 @@ constructor(
|
|||
val header =
|
||||
if (section is DetailSection.Songs) songHeader(section.stringRes)
|
||||
else BasicHeader(section.stringRes)
|
||||
if (newList.isNotEmpty()) {
|
||||
newList.add(PlainDivider(header))
|
||||
}
|
||||
newList.add(Divider(header))
|
||||
newList.add(header)
|
||||
section.items
|
||||
}
|
||||
is DetailSection.Discs -> {
|
||||
val header = SortHeader(section.stringRes)
|
||||
if (newList.isNotEmpty()) {
|
||||
newList.add(PlainDivider(header))
|
||||
}
|
||||
newList.add(Divider(header))
|
||||
newList.add(header)
|
||||
buildList<Item> {
|
||||
for (entry in section.discs) {
|
||||
val discHeader = DiscHeader(inner = entry.key)
|
||||
if (isNotEmpty()) {
|
||||
add(DiscDivider(discHeader))
|
||||
}
|
||||
add(discHeader)
|
||||
addAll(entry.value)
|
||||
}
|
||||
}
|
||||
section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value }
|
||||
}
|
||||
}
|
||||
// Currently only the final section (songs, which can be sorted) are invalidatable
|
||||
|
@ -613,7 +573,7 @@ constructor(
|
|||
uid: Music.UID,
|
||||
instructions: UpdateInstructions = UpdateInstructions.Diff
|
||||
) {
|
||||
L.d("Refreshing playlist list")
|
||||
logD("Refreshing playlist list")
|
||||
val edited = editedPlaylist.value
|
||||
if (edited == null) {
|
||||
val playlist = detailGenerator.playlist(uid)
|
||||
|
@ -626,6 +586,7 @@ constructor(
|
|||
val list = mutableListOf<Item>()
|
||||
if (edited.isNotEmpty()) {
|
||||
val header = EditHeader(R.string.lbl_songs)
|
||||
list.add(Divider(header))
|
||||
list.add(header)
|
||||
list.addAll(edited)
|
||||
}
|
||||
|
|
|
@ -19,32 +19,44 @@
|
|||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.view.isVisible
|
||||
import android.view.LayoutInflater
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
||||
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
|
||||
import org.oxycblt.auxio.detail.list.DetailListAdapter
|
||||
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
||||
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.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
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
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.getPlural
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information for a particular [Genre].
|
||||
|
@ -52,21 +64,65 @@ import timber.log.Timber as L
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
||||
class GenreDetailFragment :
|
||||
ListFragment<Music, FragmentDetailBinding>(),
|
||||
DetailHeaderAdapter.Listener,
|
||||
DetailListAdapter.Listener<Music> {
|
||||
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
|
||||
// as a UID, as that is the only safe way to parcel an genre.
|
||||
private val args: GenreDetailFragmentArgs by navArgs()
|
||||
private val genreHeaderAdapter = GenreDetailHeaderAdapter(this)
|
||||
private val genreListAdapter = GenreDetailListAdapter(this)
|
||||
|
||||
override fun getDetailListAdapter() = genreListAdapter
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailNormalToolbar.apply {
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@GenreDetailFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
}
|
||||
|
||||
binding.detailRecycler.apply {
|
||||
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
|
||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item =
|
||||
detailModel.genreSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is Divider || item is Header
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setGenre(args.genreUid)
|
||||
collectImmediately(detailModel.currentGenre, ::updateGenre)
|
||||
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
|
||||
collectImmediately(detailModel.genreSongList, ::updateList)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
|
@ -80,6 +136,8 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
|
||||
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.genreSongInstructions.consume()
|
||||
|
@ -93,10 +151,6 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onOpenParentMenu() {
|
||||
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Music) {
|
||||
when (item) {
|
||||
is Artist -> listModel.openMenu(R.menu.parent, item)
|
||||
|
@ -105,45 +159,26 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
|
||||
override fun onShuffle() {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(GenreDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
private fun updateGenre(genre: Genre?) {
|
||||
private fun updatePlaylist(genre: Genre?) {
|
||||
if (genre == null) {
|
||||
L.d("No genre to show, navigating away")
|
||||
logD("No genre to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
val binding = requireBinding()
|
||||
val context = requireContext()
|
||||
val name = genre.name.resolve(context)
|
||||
binding.detailToolbarTitle.text = name
|
||||
binding.detailCover.bind(genre)
|
||||
binding.detailType.text = context.getString(R.string.lbl_genre)
|
||||
binding.detailName.text = genre.name.resolve(context)
|
||||
// Nothing about a genre is applicable to the sub-head text.
|
||||
binding.detailSubhead.isVisible = false
|
||||
// The song and artist count of the genre maps to the info text.
|
||||
binding.detailInfo.text =
|
||||
context.getString(
|
||||
R.string.fmt_two,
|
||||
context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
|
||||
context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
|
||||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
|
||||
genreHeaderAdapter.setParent(genre)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -153,7 +188,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
L.d("Navigating to ${show.song}")
|
||||
logD("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
|
@ -161,7 +196,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
// Songs should be scrolled to if the album matches, or a new detail
|
||||
// fragment should be launched otherwise.
|
||||
is Show.SongAlbumDetails -> {
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
|
@ -169,29 +204,29 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
// If the album matches, no need to do anything. Otherwise launch a new
|
||||
// detail fragment.
|
||||
is Show.AlbumDetails -> {
|
||||
L.d("Navigating to ${show.album}")
|
||||
logD("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
|
||||
// Always launch a new ArtistDetailFragment.
|
||||
is Show.ArtistDetails -> {
|
||||
L.d("Navigating to ${show.artist}")
|
||||
logD("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
L.d("Navigated to this genre")
|
||||
logD("Navigated to this genre")
|
||||
detailModel.toShow.consume()
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
|
@ -232,7 +267,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Add -> {
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
GenreDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -271,7 +306,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
GenreDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")
|
||||
|
|
|
@ -19,41 +19,51 @@
|
|||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.ConcatAdapter
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
|
||||
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
|
||||
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
|
||||
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
||||
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.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.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.external.M3U
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
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.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
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.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.playlist.m3u.M3U
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows information for a particular [Playlist].
|
||||
|
@ -62,17 +72,35 @@ import timber.log.Timber as L
|
|||
*/
|
||||
@AndroidEntryPoint
|
||||
class PlaylistDetailFragment :
|
||||
DetailFragment<Playlist, Song>(), PlaylistDetailListAdapter.Listener {
|
||||
ListFragment<Song, FragmentDetailBinding>(),
|
||||
DetailHeaderAdapter.Listener,
|
||||
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
|
||||
// as a UID, as that is the only safe way to parcel an playlist.
|
||||
private val args: PlaylistDetailFragmentArgs by navArgs()
|
||||
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
|
||||
private val playlistListAdapter = PlaylistDetailListAdapter(this)
|
||||
private var touchHelper: ItemTouchHelper? = null
|
||||
private var editNavigationListener: DialogAwareNavigationListener? = null
|
||||
private var getContentLauncher: ActivityResultLauncher<String>? = null
|
||||
private var pendingImportTarget: Playlist? = null
|
||||
|
||||
override fun getDetailListAdapter() = playlistListAdapter
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
@ -82,31 +110,52 @@ class PlaylistDetailFragment :
|
|||
getContentLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
L.w("No URI returned from file picker")
|
||||
logW("No URI returned from file picker")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
L.d("Received playlist URI $uri")
|
||||
logD("Received playlist URI $uri")
|
||||
musicModel.importPlaylist(uri, pendingImportTarget)
|
||||
}
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailNormalToolbar.apply {
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
||||
overrideOnOverflowMenuClick {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
|
||||
binding.detailEditToolbar.apply {
|
||||
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
|
||||
setOnMenuItemClickListener(this@PlaylistDetailFragment)
|
||||
}
|
||||
|
||||
binding.detailRecycler.apply {
|
||||
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
|
||||
touchHelper =
|
||||
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
|
||||
it.attachToRecyclerView(binding.detailRecycler)
|
||||
it.attachToRecyclerView(this)
|
||||
}
|
||||
(layoutManager as GridLayoutManager).setFullWidthLookup {
|
||||
if (it != 0) {
|
||||
val item =
|
||||
detailModel.playlistSongList.value.getOrElse(it - 1) {
|
||||
return@setFullWidthLookup false
|
||||
}
|
||||
item is Divider || item is Header
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setPlaylist(args.playlistUid)
|
||||
collectImmediately(
|
||||
detailModel.currentPlaylist, detailModel.editedPlaylist, ::updatePlaylist)
|
||||
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
|
||||
collectImmediately(detailModel.playlistSongList, ::updateList)
|
||||
collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
|
@ -161,97 +210,41 @@ class PlaylistDetailFragment :
|
|||
playbackModel.play(item, detailModel.playInPlaylistWith)
|
||||
}
|
||||
|
||||
override fun onStartEdit() {
|
||||
detailModel.startPlaylistEdit()
|
||||
}
|
||||
|
||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
|
||||
}
|
||||
|
||||
override fun onOpenParentMenu() {
|
||||
listModel.openMenu(
|
||||
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Song) {
|
||||
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
|
||||
}
|
||||
|
||||
override fun onPlay() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
|
||||
override fun onShuffle() {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
|
||||
override fun onStartEdit() {
|
||||
detailModel.startPlaylistEdit()
|
||||
}
|
||||
|
||||
override fun onOpenSortMenu() {
|
||||
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
|
||||
}
|
||||
|
||||
private fun updatePlaylist(playlist: Playlist?, editedPlaylist: List<Song>?) {
|
||||
private fun updatePlaylist(playlist: Playlist?) {
|
||||
if (playlist == null) {
|
||||
// Playlist we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
val binding = requireBinding()
|
||||
binding.detailToolbarTitle.text = playlist.name.resolve(requireContext())
|
||||
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
|
||||
binding.detailEditToolbar.title =
|
||||
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
|
||||
|
||||
if (editedPlaylist != null) {
|
||||
L.d("Binding edited playlist image")
|
||||
binding.detailCover.bind(
|
||||
editedPlaylist,
|
||||
binding.context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
R.drawable.ic_playlist_24)
|
||||
} else {
|
||||
binding.detailCover.bind(playlist)
|
||||
}
|
||||
|
||||
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
|
||||
binding.detailName.text = playlist.name.resolve(binding.context)
|
||||
// Nothing about a playlist is applicable to the sub-head text.
|
||||
binding.detailSubhead.isVisible = false
|
||||
|
||||
val songs = editedPlaylist ?: playlist.songs
|
||||
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
|
||||
// The song count of the playlist maps to the info text.
|
||||
binding.detailInfo.text =
|
||||
if (songs.isNotEmpty()) {
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
|
||||
durationMs.formatDurationMs(true))
|
||||
} else {
|
||||
binding.context.getString(R.string.def_song_count)
|
||||
}
|
||||
|
||||
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||
if (!playable) {
|
||||
L.d("Playlist is being edited or is empty, disabling playback options")
|
||||
}
|
||||
|
||||
binding.detailPlayButton?.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailToolbarPlay.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailShuffleButton?.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailToolbarShuffle.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
playlistHeaderAdapter.setParent(playlist)
|
||||
}
|
||||
|
||||
private fun updateList(list: List<Item>) {
|
||||
|
@ -260,10 +253,11 @@ class PlaylistDetailFragment :
|
|||
|
||||
private fun updateEditedList(editedPlaylist: List<Song>?) {
|
||||
playlistListAdapter.setEditing(editedPlaylist != null)
|
||||
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
|
||||
listModel.dropSelection()
|
||||
|
||||
if (editedPlaylist != null) {
|
||||
L.d("Updating save button state")
|
||||
logD("Updating save button state")
|
||||
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
|
||||
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
|
||||
}
|
||||
|
@ -275,38 +269,38 @@ class PlaylistDetailFragment :
|
|||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
L.d("Navigating to ${show.song}")
|
||||
logD("Navigating to ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
is Show.SongAlbumDetails -> {
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
is Show.AlbumDetails -> {
|
||||
L.d("Navigating to ${show.album}")
|
||||
logD("Navigating to ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
is Show.ArtistDetails -> {
|
||||
L.d("Navigating to ${show.artist}")
|
||||
logD("Navigating to ${show.artist}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(
|
||||
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
L.d("Navigated to this playlist")
|
||||
logD("Navigated to this playlist")
|
||||
detailModel.toShow.consume()
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
|
@ -347,7 +341,7 @@ class PlaylistDetailFragment :
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.Import -> {
|
||||
L.d("Importing playlist")
|
||||
logD("Importing playlist")
|
||||
pendingImportTarget = decision.target
|
||||
requireNotNull(getContentLauncher) {
|
||||
"Content picker launcher was not available"
|
||||
|
@ -357,7 +351,7 @@ class PlaylistDetailFragment :
|
|||
return
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
L.d("Renaming ${decision.playlist}")
|
||||
logD("Renaming ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.renamePlaylist(
|
||||
decision.playlist.uid,
|
||||
decision.template,
|
||||
|
@ -365,15 +359,15 @@ class PlaylistDetailFragment :
|
|||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Export -> {
|
||||
L.d("Exporting ${decision.playlist}")
|
||||
logD("Exporting ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
L.d("Deleting ${decision.playlist}")
|
||||
logD("Deleting ${decision.playlist}")
|
||||
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Add -> {
|
||||
L.d("Adding ${decision.songs.size} songs to a playlist")
|
||||
logD("Adding ${decision.songs.size} songs to a playlist")
|
||||
PlaylistDetailFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -399,11 +393,11 @@ class PlaylistDetailFragment :
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaybackDecision.PlayFromArtist -> {
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid)
|
||||
}
|
||||
is PlaybackDecision.PlayFromGenre -> {
|
||||
L.d("Launching play from artist dialog for $decision")
|
||||
logD("Launching play from artist dialog for $decision")
|
||||
PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid)
|
||||
}
|
||||
}
|
||||
|
@ -414,15 +408,15 @@ class PlaylistDetailFragment :
|
|||
val id =
|
||||
when {
|
||||
detailModel.editedPlaylist.value != null -> {
|
||||
L.d("Currently editing playlist, showing edit toolbar")
|
||||
logD("Currently editing playlist, showing edit toolbar")
|
||||
R.id.detail_edit_toolbar
|
||||
}
|
||||
listModel.selected.value.isNotEmpty() -> {
|
||||
L.d("Currently selecting, showing selection toolbar")
|
||||
logD("Currently selecting, showing selection toolbar")
|
||||
R.id.detail_selection_toolbar
|
||||
}
|
||||
else -> {
|
||||
L.d("Using normal toolbar")
|
||||
logD("Using normal toolbar")
|
||||
R.id.detail_normal_toolbar
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,9 @@
|
|||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.activityViewModels
|
||||
|
@ -30,10 +32,17 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
|||
import org.oxycblt.auxio.detail.list.SongProperty
|
||||
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
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.playback.replaygain.formatDb
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.concatLocalized
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingMaterialDialogFragment] that shows information about a Song.
|
||||
|
@ -62,19 +71,74 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
|
|||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setSong(args.songUid)
|
||||
detailModel.toShow.consume()
|
||||
collectImmediately(detailModel.currentSong, ::updateSong)
|
||||
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
|
||||
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
L.d("No song to show, navigating away")
|
||||
private fun updateSong(song: Song?, info: AudioProperties?) {
|
||||
if (song == null) {
|
||||
logD("No song to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
||||
if (info != null) {
|
||||
val context = requireContext()
|
||||
detailAdapter.update(
|
||||
buildList {
|
||||
add(SongProperty(R.string.lbl_name, song.zipName(context)))
|
||||
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
|
||||
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
|
||||
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
|
||||
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
|
||||
song.track?.let {
|
||||
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
|
||||
}
|
||||
song.disc?.let {
|
||||
val formattedNumber = getString(R.string.fmt_number, it.number)
|
||||
val zipped =
|
||||
if (it.name != null) {
|
||||
getString(R.string.fmt_zipped_names, formattedNumber, it.name)
|
||||
} else {
|
||||
formattedNumber
|
||||
}
|
||||
add(SongProperty(R.string.lbl_disc, zipped))
|
||||
}
|
||||
add(SongProperty(R.string.lbl_path, song.path.resolve(context)))
|
||||
info.resolvedMimeType.resolveName(context)?.let {
|
||||
add(SongProperty(R.string.lbl_format, it))
|
||||
}
|
||||
add(
|
||||
SongProperty(
|
||||
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
|
||||
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
|
||||
info.bitrateKbps?.let {
|
||||
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
|
||||
}
|
||||
info.sampleRateHz?.let {
|
||||
add(
|
||||
SongProperty(
|
||||
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
|
||||
}
|
||||
song.replayGainAdjustment.track?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_track, it.formatDb(context)))
|
||||
}
|
||||
song.replayGainAdjustment.album?.let {
|
||||
add(SongProperty(R.string.lbl_replaygain_album, it.formatDb(context)))
|
||||
}
|
||||
},
|
||||
UpdateInstructions.Replace(0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSongProperties(songProperties: List<SongProperty>) {
|
||||
detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
|
||||
private fun <T : Music> T.zipName(context: Context): String {
|
||||
val name = name
|
||||
return if (name is Name.Known && name.sort != null) {
|
||||
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
|
||||
} else {
|
||||
name.resolve(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Music> List<T>.zipNames(context: Context) =
|
||||
concatLocalized(context) { it.zipName(context) }
|
||||
}
|
||||
|
|
|
@ -25,10 +25,9 @@ import org.oxycblt.auxio.list.ClickableListListener
|
|||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Artist
|
||||
|
||||
/**
|
||||
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with
|
||||
|
|
|
@ -23,13 +23,14 @@ 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.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Library
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [ViewModel] that stores choice information for [ShowArtistDialog], and possibly others in the
|
||||
|
@ -56,10 +57,10 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
|
|||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.deviceLibrary) return
|
||||
val library = musicRepository.library ?: return
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
// Need to sanitize different items depending on the current set of choices.
|
||||
_artistChoices.value = _artistChoices.value?.sanitize(library)
|
||||
L.d("Updated artist choices: ${_artistChoices.value}")
|
||||
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
|
||||
logD("Updated artist choices: ${_artistChoices.value}")
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -68,20 +69,20 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
|
|||
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
|
||||
*/
|
||||
fun setArtistChoiceUid(itemUid: Music.UID) {
|
||||
L.d("Opening navigation choices for $itemUid")
|
||||
logD("Opening navigation choices for $itemUid")
|
||||
// Support Songs and Albums, which have parent artists.
|
||||
_artistChoices.value =
|
||||
when (val music = musicRepository.find(itemUid)) {
|
||||
is Song -> {
|
||||
L.d("Creating navigation choices for song")
|
||||
logD("Creating navigation choices for song")
|
||||
ArtistShowChoices.FromSong(music)
|
||||
}
|
||||
is Album -> {
|
||||
L.d("Creating navigation choices for album")
|
||||
logD("Creating navigation choices for album")
|
||||
ArtistShowChoices.FromAlbum(music)
|
||||
}
|
||||
else -> {
|
||||
L.w("Given song/album UID was invalid")
|
||||
logW("Given song/album UID was invalid")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
@ -98,15 +99,16 @@ sealed interface ArtistShowChoices {
|
|||
val uid: Music.UID
|
||||
/** The current [Artist] choices. */
|
||||
val choices: List<Artist>
|
||||
/** Sanitize this instance with a [Library]. */
|
||||
fun sanitize(newLibrary: Library): ArtistShowChoices?
|
||||
/** Sanitize this instance with a [DeviceLibrary]. */
|
||||
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
|
||||
|
||||
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
|
||||
class FromSong(val song: Song) : ArtistShowChoices {
|
||||
override val uid = song.uid
|
||||
override val choices = song.artists
|
||||
|
||||
override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) }
|
||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
||||
newLibrary.findSong(uid)?.let { FromSong(it) }
|
||||
}
|
||||
|
||||
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
|
||||
|
@ -114,7 +116,7 @@ sealed interface ArtistShowChoices {
|
|||
override val uid = album.uid
|
||||
override val choices = album.artists
|
||||
|
||||
override fun sanitize(newLibrary: Library) =
|
||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
||||
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,10 +32,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
|
|||
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.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Artist
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
|
||||
|
@ -85,7 +85,7 @@ class ShowArtistDialog :
|
|||
|
||||
private fun updateChoices(choices: ArtistShowChoices?) {
|
||||
if (choices == null) {
|
||||
L.d("No choices to show, navigating away")
|
||||
logD("No choices to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* AlbumDetailHeaderAdapter.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.header
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* A [DetailHeaderAdapter] that shows [Album] information.
|
||||
*
|
||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumDetailHeaderAdapter(private val listener: Listener) :
|
||||
DetailHeaderAdapter<Album, AlbumDetailHeaderViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
AlbumDetailHeaderViewHolder.from(parent)
|
||||
|
||||
override fun onBindHeader(holder: AlbumDetailHeaderViewHolder, parent: Album) =
|
||||
holder.bind(parent, listener)
|
||||
|
||||
/** An extended listener for [DetailHeaderAdapter] implementations. */
|
||||
interface Listener : DetailHeaderAdapter.Listener {
|
||||
|
||||
/**
|
||||
* Called when the artist name in the [Album] header was clicked, requesting navigation to
|
||||
* it's parent artist.
|
||||
*/
|
||||
fun onNavigateToParentArtist()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
|
||||
* create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumDetailHeaderViewHolder
|
||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
*
|
||||
* @param album The new [Album] to bind.
|
||||
* @param listener A [AlbumDetailHeaderAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(album: Album, listener: AlbumDetailHeaderAdapter.Listener) {
|
||||
binding.detailCover.bind(album)
|
||||
|
||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
|
||||
|
||||
binding.detailName.text = album.name.resolve(binding.context)
|
||||
|
||||
// Artist name maps to the subhead text
|
||||
binding.detailSubhead.apply {
|
||||
text = album.artists.resolveNames(context)
|
||||
|
||||
// Add a QoL behavior where navigation to the artist will occur if the artist
|
||||
// name is pressed.
|
||||
setOnClickListener { listener.onNavigateToParentArtist() }
|
||||
}
|
||||
|
||||
// Date, song count, and duration map to the info text
|
||||
binding.detailInfo.apply {
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
|
||||
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||
val duration = album.durationMs.formatDurationMs(true)
|
||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||
}
|
||||
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: View) =
|
||||
AlbumDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* ArtistDetailHeaderAdapter.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.header
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [DetailHeaderAdapter] that shows [Artist] information.
|
||||
*
|
||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
|
||||
* create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistDetailHeaderViewHolder
|
||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
*
|
||||
* @param artist The new [Artist] to bind.
|
||||
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
|
||||
binding.detailCover.bind(artist)
|
||||
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
|
||||
binding.detailName.text = artist.name.resolve(binding.context)
|
||||
|
||||
// Song and album counts map to the info
|
||||
binding.detailInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
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 {
|
||||
binding.context.getString(R.string.def_song_count)
|
||||
})
|
||||
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
// Information about the artist's genre(s) map to the sub-head text
|
||||
binding.detailSubhead.apply {
|
||||
isVisible = true
|
||||
text = artist.genres.resolveNames(context)
|
||||
}
|
||||
|
||||
// In the case that this header used to he configured to have no songs,
|
||||
// we want to reset the visibility of all information that was hidden.
|
||||
binding.detailPlayButton.isVisible = true
|
||||
binding.detailShuffleButton.isVisible = true
|
||||
} else {
|
||||
// The artist does not have any songs, so hide functionality that makes no sense.
|
||||
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
||||
// Artists are always guaranteed to have albums however, so continue to show those.
|
||||
logD("Artist is empty, disabling genres and playback")
|
||||
binding.detailSubhead.isVisible = false
|
||||
binding.detailPlayButton.isEnabled = false
|
||||
binding.detailShuffleButton.isEnabled = false
|
||||
}
|
||||
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: View) =
|
||||
ArtistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* DetailHeaderAdapter.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.header
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
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))
|
||||
|
||||
/**
|
||||
* Bind the created header [RecyclerView.ViewHolder] with the current [parent].
|
||||
*
|
||||
* @param holder The [RecyclerView.ViewHolder] to bind.
|
||||
* @param parent The current [MusicParent] to bind.
|
||||
*/
|
||||
abstract fun onBindHeader(holder: VH, parent: T)
|
||||
|
||||
/**
|
||||
* Update the [MusicParent] shown in the header.
|
||||
*
|
||||
* @param parent The new [MusicParent] to show.
|
||||
*/
|
||||
fun setParent(parent: T) {
|
||||
logD("Updating parent [old: $currentParent new: $parent]")
|
||||
currentParent = parent
|
||||
rebindParent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
|
||||
*/
|
||||
protected fun rebindParent() {
|
||||
logD("Rebinding parent")
|
||||
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
|
||||
}
|
||||
|
||||
/** A listener for [DetailHeaderAdapter] implementations. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the play button in a detail header is pressed, requesting that the current
|
||||
* item should be played.
|
||||
*/
|
||||
fun onPlay()
|
||||
|
||||
/**
|
||||
* Called when the shuffle button in a detail header is pressed, requesting that the current
|
||||
* item should be shuffled
|
||||
*/
|
||||
fun onShuffle()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PAYLOAD_UPDATE_HEADER = Any()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* GenreDetailHeaderAdapter.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.header
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* A [DetailHeaderAdapter] that shows [Genre] information.
|
||||
*
|
||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreDetailHeaderAdapter(private val listener: Listener) :
|
||||
DetailHeaderAdapter<Genre, GenreDetailHeaderViewHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
GenreDetailHeaderViewHolder.from(parent)
|
||||
|
||||
override fun onBindHeader(holder: GenreDetailHeaderViewHolder, parent: Genre) =
|
||||
holder.bind(parent, listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
|
||||
* create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreDetailHeaderViewHolder
|
||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
*
|
||||
* @param genre The new [Genre] to bind.
|
||||
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
|
||||
binding.detailCover.bind(genre)
|
||||
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
|
||||
binding.detailName.text = genre.name.resolve(binding.context)
|
||||
// Nothing about a genre is applicable to the sub-head text.
|
||||
binding.detailSubhead.isVisible = false
|
||||
// The song and artist count of the genre maps to the info text.
|
||||
binding.detailInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: View) =
|
||||
GenreDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* PlaylistDetailHeaderAdapter.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.header
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [DetailHeaderAdapter] that shows [Playlist] information.
|
||||
*
|
||||
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
|
||||
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
|
||||
private var editedPlaylist: List<Song>? = null
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
PlaylistDetailHeaderViewHolder.from(parent)
|
||||
|
||||
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
|
||||
holder.bind(parent, editedPlaylist, listener)
|
||||
|
||||
/**
|
||||
* Indicate to this adapter that editing is ongoing with the current state of the editing
|
||||
* process. This will make the header immediately update to reflect information about the edited
|
||||
* playlist.
|
||||
*/
|
||||
fun setEditedPlaylist(songs: List<Song>?) {
|
||||
if (editedPlaylist == songs) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
|
||||
editedPlaylist = songs
|
||||
rebindParent()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to
|
||||
* create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaylistDetailHeaderViewHolder
|
||||
private constructor(private val binding: ItemDetailHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
*
|
||||
* @param playlist The new [Playlist] to bind.
|
||||
* @param editedPlaylist The current edited state of the playlist, if it exists.
|
||||
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(
|
||||
playlist: Playlist,
|
||||
editedPlaylist: List<Song>?,
|
||||
listener: DetailHeaderAdapter.Listener
|
||||
) {
|
||||
if (editedPlaylist != null) {
|
||||
logD("Binding edited playlist image")
|
||||
binding.detailCover.bind(
|
||||
editedPlaylist,
|
||||
binding.context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
R.drawable.ic_playlist_24)
|
||||
} else {
|
||||
binding.detailCover.bind(playlist)
|
||||
}
|
||||
|
||||
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
|
||||
binding.detailName.text = playlist.name.resolve(binding.context)
|
||||
// Nothing about a playlist is applicable to the sub-head text.
|
||||
binding.detailSubhead.isVisible = false
|
||||
|
||||
val songs = editedPlaylist ?: playlist.songs
|
||||
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
|
||||
// The song count of the playlist maps to the info text.
|
||||
binding.detailInfo.text =
|
||||
if (songs.isNotEmpty()) {
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
|
||||
durationMs.formatDurationMs(true))
|
||||
} else {
|
||||
binding.context.getString(R.string.def_song_count)
|
||||
}
|
||||
|
||||
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
|
||||
if (!playable) {
|
||||
logD("Playlist is being edited or is empty, disabling playback options")
|
||||
}
|
||||
|
||||
binding.detailPlayButton.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener { listener.onPlay() }
|
||||
}
|
||||
binding.detailShuffleButton.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: View) =
|
||||
PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
|
@ -24,25 +24,21 @@ import androidx.core.view.isGone
|
|||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDivider
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
|
||||
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.info.resolveNumber
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.tag.Disc
|
||||
|
||||
/**
|
||||
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
|
||||
|
@ -56,7 +52,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
when (getItem(position)) {
|
||||
// Support sub-headers for each disc, and special album songs.
|
||||
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
|
||||
is DiscDivider -> DiscDividerViewHolder.VIEW_TYPE
|
||||
is Song -> AlbumSongViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
}
|
||||
|
@ -64,7 +59,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
|
||||
DiscDividerViewHolder.VIEW_TYPE -> DiscDividerViewHolder.from(parent)
|
||||
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
|
||||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
@ -85,8 +79,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
when {
|
||||
oldItem is Disc && newItem is Disc ->
|
||||
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is DiscDivider && newItem is DiscDivider ->
|
||||
DiscDividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Song && newItem is Song ->
|
||||
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
|
||||
|
@ -102,9 +94,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class DiscHeader(val inner: Disc?) : Header
|
||||
|
||||
data class DiscDivider(override val anchor: DiscHeader?) : Divider<DiscHeader>
|
||||
data class DiscHeader(val inner: Disc?) : Item
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
|
||||
|
@ -121,7 +111,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
*/
|
||||
fun bind(discHeader: DiscHeader) {
|
||||
val disc = discHeader.inner
|
||||
binding.discNumber.text = disc.resolve(binding.context)
|
||||
binding.discNumber.text = disc.resolveNumber(binding.context)
|
||||
binding.discName.apply {
|
||||
text = disc?.name
|
||||
isGone = disc?.name == null
|
||||
|
@ -150,42 +140,6 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [DiscHeader]. Use [from] to create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DiscDividerViewHolder private constructor(divider: MaterialDivider) :
|
||||
RecyclerView.ViewHolder(divider) {
|
||||
|
||||
init {
|
||||
divider.dividerColor =
|
||||
divider.context
|
||||
.getAttrColorCompat(com.google.android.material.R.attr.colorOutlineVariant)
|
||||
.defaultColor
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DISC_DIVIDER
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun from(parent: View) = DiscDividerViewHolder(MaterialDivider(parent.context))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<DiscDivider>() {
|
||||
override fun areContentsTheSame(oldItem: DiscDivider, newItem: DiscDivider) =
|
||||
oldItem.anchor == newItem.anchor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
|
||||
* create an instance.
|
||||
|
|
|
@ -29,13 +29,12 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
|
||||
|
@ -105,7 +104,8 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
|||
binding.parentName.text = album.name.resolve(binding.context)
|
||||
binding.parentInfo.text =
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date)
|
||||
album.dates?.resolveDate(binding.context)
|
||||
?: binding.context.getString(R.string.def_date)
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
|
|
|
@ -27,17 +27,17 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.DividerViewHolder
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Music
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the
|
||||
|
@ -55,7 +55,7 @@ abstract class DetailListAdapter(
|
|||
override fun getItemViewType(position: Int) =
|
||||
when (getItem(position)) {
|
||||
// Implement support for headers and sort headers
|
||||
is PlainDivider -> DividerViewHolder.VIEW_TYPE
|
||||
is Divider -> DividerViewHolder.VIEW_TYPE
|
||||
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
|
||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
|
@ -91,7 +91,7 @@ abstract class DetailListAdapter(
|
|||
object : SimpleDiffCallback<Item>() {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is PlainDivider && newItem is PlainDivider ->
|
||||
oldItem is Divider && newItem is Divider ->
|
||||
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is BasicHeader && newItem is BasicHeader ->
|
||||
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
|
@ -110,7 +110,7 @@ abstract class DetailListAdapter(
|
|||
* @param titleRes The string resource to use as the header title
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SortHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||
data class SortHeader(@StringRes override val titleRes: Int) : Header
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create
|
||||
|
|
|
@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
|
||||
|
|
|
@ -30,24 +30,25 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
|
||||
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
|
||||
import org.oxycblt.auxio.list.EditableListListener
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.PlainHeader
|
||||
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
|
||||
|
@ -98,9 +99,9 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
L.d("Updating editing state [old: $isEditing new: $editing]")
|
||||
logD("Updating editing state [old: $isEditing new: $editing]")
|
||||
this.isEditing = editing
|
||||
notifyItemRangeChanged(0, currentList.size, PAYLOAD_EDITING_CHANGED)
|
||||
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
|
||||
}
|
||||
|
||||
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
|
||||
|
@ -141,12 +142,12 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
|
|||
}
|
||||
|
||||
/**
|
||||
* A [PlainHeader] variant that displays an edit button.
|
||||
* A [Header] variant that displays an edit button.
|
||||
*
|
||||
* @param titleRes The string resource to use as the header title
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class EditHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||
data class EditHeader(@StringRes override val titleRes: Int) : Header
|
||||
|
||||
/**
|
||||
* Displays an [EditHeader] and it's actions. Use [from] to create an instance.
|
||||
|
@ -231,7 +232,8 @@ private constructor(private val binding: ItemEditableSongBinding) :
|
|||
override val delete = binding.background
|
||||
override val background =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
|
||||
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
|
||||
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
|
||||
elevation = binding.context.getDimen(R.dimen.elevation_normal)
|
||||
alpha = 0
|
||||
}
|
||||
|
||||
|
|
|
@ -18,26 +18,17 @@
|
|||
|
||||
package org.oxycblt.auxio.detail.list
|
||||
|
||||
import android.text.format.Formatter
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.replaygain.formatDb
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.fs.Format
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
|
||||
/**
|
||||
* An adapter for [SongProperty] instances.
|
||||
|
@ -62,31 +53,7 @@ class SongPropertyAdapter :
|
|||
* @param value The value of the property.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SongProperty(@StringRes val name: Int, val value: Value) {
|
||||
sealed interface Value {
|
||||
data class MusicName(val music: Music) : Value
|
||||
|
||||
data class MusicNames(val name: List<Music>) : Value
|
||||
|
||||
data class Number(val value: Int, val subtitle: String?) : Value
|
||||
|
||||
data class ItemDate(val date: Date) : Value
|
||||
|
||||
data class ItemPath(val path: Path) : Value
|
||||
|
||||
data class Size(val sizeBytes: Long) : Value
|
||||
|
||||
data class Duration(val durationMs: Long) : Value
|
||||
|
||||
data class ItemFormat(val format: Format) : Value
|
||||
|
||||
data class Bitrate(val kbps: Int) : Value
|
||||
|
||||
data class SampleRate(val hz: Int) : Value
|
||||
|
||||
data class Decibels(val value: Float) : Value
|
||||
}
|
||||
}
|
||||
data class SongProperty(@StringRes val name: Int, val value: String) : Item
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
|
||||
|
@ -98,58 +65,7 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
|
|||
fun bind(property: SongProperty) {
|
||||
val context = binding.context
|
||||
binding.propertyName.hint = context.getString(property.name)
|
||||
when (property.value) {
|
||||
is SongProperty.Value.MusicName -> {
|
||||
val music = property.value.music
|
||||
binding.propertyValue.setText(music.name.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.MusicNames -> {
|
||||
val names = property.value.name.resolveNames(context)
|
||||
binding.propertyValue.setText(names)
|
||||
}
|
||||
is SongProperty.Value.Number -> {
|
||||
val value = context.getString(R.string.fmt_number, property.value.value)
|
||||
val subtitle = property.value.subtitle
|
||||
binding.propertyValue.setText(
|
||||
if (subtitle != null) {
|
||||
context.getString(R.string.fmt_zipped_names, value, subtitle)
|
||||
} else {
|
||||
value
|
||||
})
|
||||
}
|
||||
is SongProperty.Value.ItemDate -> {
|
||||
val date = property.value.date
|
||||
binding.propertyValue.setText(date.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.ItemPath -> {
|
||||
val path = property.value.path
|
||||
binding.propertyValue.setText(path.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.Size -> {
|
||||
val size = property.value.sizeBytes
|
||||
binding.propertyValue.setText(Formatter.formatFileSize(context, size))
|
||||
}
|
||||
is SongProperty.Value.Duration -> {
|
||||
val duration = property.value.durationMs
|
||||
binding.propertyValue.setText(duration.formatDurationMs(true))
|
||||
}
|
||||
is SongProperty.Value.ItemFormat -> {
|
||||
val format = property.value.format
|
||||
binding.propertyValue.setText(format.resolve(context))
|
||||
}
|
||||
is SongProperty.Value.Bitrate -> {
|
||||
val kbps = property.value.kbps
|
||||
binding.propertyValue.setText(context.getString(R.string.fmt_bitrate, kbps))
|
||||
}
|
||||
is SongProperty.Value.SampleRate -> {
|
||||
val hz = property.value.hz
|
||||
binding.propertyValue.setText(context.getString(R.string.fmt_sample_rate, hz))
|
||||
}
|
||||
is SongProperty.Value.Decibels -> {
|
||||
val value = property.value.value
|
||||
binding.propertyValue.setText(value.formatDb(context))
|
||||
}
|
||||
}
|
||||
binding.propertyValue.setText(property.value)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -26,9 +26,9 @@ 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.musikr.Album
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort].
|
||||
|
@ -56,7 +56,7 @@ class AlbumSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updateAlbum(album: Album?) {
|
||||
if (album == null) {
|
||||
L.d("No album to sort, navigating away")
|
||||
logD("No album to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ 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.musikr.Artist
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort].
|
||||
|
@ -57,7 +57,7 @@ class ArtistSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updateArtist(artist: Artist?) {
|
||||
if (artist == null) {
|
||||
L.d("No artist to sort, navigating away")
|
||||
logD("No artist to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ 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.musikr.Genre
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||
|
@ -62,7 +62,7 @@ class GenreSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updateGenre(genre: Genre?) {
|
||||
if (genre == null) {
|
||||
L.d("No genre to sort, navigating away")
|
||||
logD("No genre to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,9 +26,9 @@ 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.musikr.Playlist
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
|
||||
|
@ -62,7 +62,7 @@ class PlaylistSongSortDialog : SortDialog() {
|
|||
|
||||
private fun updatePlaylist(genre: Playlist?) {
|
||||
if (genre == null) {
|
||||
L.d("No genre to sort, navigating away")
|
||||
logD("No genre to sort, navigating away")
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui
|
||||
package org.oxycblt.auxio.home
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
|
@ -40,6 +40,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
// Prevent excessive layouts by using translation instead of padding.
|
||||
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
|
||||
return insets
|
||||
}
|
|
@ -24,11 +24,9 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.openInBrowser
|
||||
|
@ -44,12 +42,10 @@ import org.oxycblt.auxio.util.showToast
|
|||
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
|
||||
private val args: ErrorDetailsDialogArgs by navArgs()
|
||||
private var clipboardManager: ClipboardManager? = null
|
||||
private val musicModel: MusicViewModel by viewModels()
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
builder
|
||||
.setTitle(R.string.lbl_error_info)
|
||||
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
|
||||
.setPositiveButton(R.string.lbl_report) { _, _ ->
|
||||
requireContext().openInBrowser(LINK_ISSUES)
|
||||
}
|
||||
|
|
|
@ -22,10 +22,11 @@ import android.annotation.SuppressLint
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.MenuCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
|
@ -37,11 +38,16 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.google.android.material.transition.MaterialSharedAxis
|
||||
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||
import com.leinardi.android.speeddial.SpeedDialView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
|
@ -51,28 +57,36 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
|
|||
import org.oxycblt.auxio.home.list.GenreListFragment
|
||||
import org.oxycblt.auxio.home.list.PlaylistListFragment
|
||||
import org.oxycblt.auxio.home.list.SongListFragment
|
||||
import org.oxycblt.auxio.home.tabs.NamedTabStrategy
|
||||
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
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.MusicType
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.NoAudioPermissionException
|
||||
import org.oxycblt.auxio.music.NoMusicException
|
||||
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.external.M3U
|
||||
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.getColorCompat
|
||||
import org.oxycblt.auxio.util.isUnder
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.musikr.IndexingProgress
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.playlist.m3u.M3U
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
||||
|
@ -82,7 +96,9 @@ import timber.log.Timber as L
|
|||
*/
|
||||
@AndroidEntryPoint
|
||||
class HomeFragment :
|
||||
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
|
||||
SelectionFragment<FragmentHomeBinding>(),
|
||||
AppBarLayout.OnOffsetChangedListener,
|
||||
SpeedDialView.OnActionSelectedListener {
|
||||
override val listModel: ListViewModel by activityViewModels()
|
||||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
|
@ -95,10 +111,15 @@ class HomeFragment :
|
|||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
|
||||
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
|
||||
if (savedInstanceState != null) {
|
||||
// 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_ID, -1)
|
||||
if (axis > -1) {
|
||||
applyAxisTransition(axis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
|
||||
|
@ -118,11 +139,11 @@ class HomeFragment :
|
|||
getContentLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
L.w("No URI returned from file picker")
|
||||
logW("No URI returned from file picker")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
L.d("Received playlist URI $uri")
|
||||
logD("Received playlist URI $uri")
|
||||
musicModel.importPlaylist(uri, pendingImportTarget)
|
||||
}
|
||||
|
||||
|
@ -134,6 +155,11 @@ class HomeFragment :
|
|||
MenuCompat.setGroupDividerEnabled(menu, true)
|
||||
}
|
||||
|
||||
// Load the track color in manually as it's unclear whether the track actually supports
|
||||
// using a ColorStateList in the resources
|
||||
binding.homeIndexingProgress.trackColor =
|
||||
requireContext().getColorCompat(R.color.sel_track).defaultColor
|
||||
|
||||
binding.homePager.apply {
|
||||
// Update HomeViewModel whenever the user swipes through the ViewPager.
|
||||
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
|
||||
|
@ -170,10 +196,25 @@ class HomeFragment :
|
|||
// re-creating the ViewPager.
|
||||
setupPager(binding)
|
||||
|
||||
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
|
||||
|
||||
binding.homeNewPlaylistFab.apply {
|
||||
inflate(R.menu.new_playlist_actions)
|
||||
setOnActionSelectedListener(this@HomeFragment)
|
||||
setChangeListener(homeModel::setSpeedDialOpen)
|
||||
}
|
||||
|
||||
hideAllFabs()
|
||||
updateFabVisibility(
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value,
|
||||
homeModel.currentTabType.value)
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
|
||||
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
|
||||
collect(homeModel.speedDialOpen, ::updateSpeedDial)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
|
@ -183,11 +224,37 @@ class HomeFragment :
|
|||
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
|
||||
// it ourselves.
|
||||
requireBinding().root.rootView.apply {
|
||||
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
|
||||
handleSpeedDialBoundaryTouch(event)
|
||||
}
|
||||
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
|
||||
handleSpeedDialBoundaryTouch(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
val transition = enterTransition
|
||||
if (transition is MaterialSharedAxis) {
|
||||
outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis)
|
||||
}
|
||||
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentHomeBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
storagePermissionLauncher = null
|
||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
|
||||
binding.homeNewPlaylistFab.setChangeListener(null)
|
||||
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
|
||||
}
|
||||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
|
@ -209,17 +276,18 @@ class HomeFragment :
|
|||
return when (item.itemId) {
|
||||
// Handle main actions (Search, Settings, About)
|
||||
R.id.action_search -> {
|
||||
L.d("Navigating to search")
|
||||
logD("Navigating to search")
|
||||
applyAxisTransition(MaterialSharedAxis.Z)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.search())
|
||||
true
|
||||
}
|
||||
R.id.action_settings -> {
|
||||
L.d("Navigating to preferences")
|
||||
logD("Navigating to preferences")
|
||||
homeModel.showSettings()
|
||||
true
|
||||
}
|
||||
R.id.action_about -> {
|
||||
L.d("Navigating to about")
|
||||
logD("Navigating to about")
|
||||
homeModel.showAbout()
|
||||
true
|
||||
}
|
||||
|
@ -239,12 +307,30 @@ class HomeFragment :
|
|||
true
|
||||
}
|
||||
else -> {
|
||||
L.w("Unexpected menu item selected")
|
||||
logW("Unexpected menu item selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
|
||||
when (actionItem.id) {
|
||||
R.id.action_new_playlist -> {
|
||||
logD("Creating playlist")
|
||||
musicModel.createPlaylist()
|
||||
}
|
||||
R.id.action_import_playlist -> {
|
||||
logD("Importing playlist")
|
||||
musicModel.importPlaylist()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
// Returning false to close th speed dial results in no animation, manually close instead.
|
||||
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||
requireBinding().homeNewPlaylistFab.close()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupPager(binding: FragmentHomeBinding) {
|
||||
binding.homePager.adapter =
|
||||
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
|
||||
|
@ -253,7 +339,7 @@ class HomeFragment :
|
|||
if (homeModel.currentTabTypes.size == 1) {
|
||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||
// behavior.
|
||||
L.d("Single tab shown, disabling TabLayout")
|
||||
logD("Single tab shown, disabling TabLayout")
|
||||
binding.homeTabs.isVisible = false
|
||||
binding.homeAppbar.setExpanded(true, false)
|
||||
toolbarParams.scrollFlags = 0
|
||||
|
@ -266,7 +352,9 @@ class HomeFragment :
|
|||
|
||||
// Set up the mapping between the ViewPager and TabLayout.
|
||||
TabLayoutMediator(
|
||||
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
|
||||
binding.homeTabs,
|
||||
binding.homePager,
|
||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
|
||||
.attach()
|
||||
}
|
||||
|
||||
|
@ -284,12 +372,14 @@ class HomeFragment :
|
|||
MusicType.GENRES -> R.id.home_genre_recycler
|
||||
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
|
||||
}
|
||||
|
||||
updateFabVisibility(homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
|
||||
}
|
||||
|
||||
private fun handleRecreate(recreate: Unit?) {
|
||||
if (recreate == null) return
|
||||
val binding = requireBinding()
|
||||
L.d("Recreating ViewPager")
|
||||
logD("Recreating ViewPager")
|
||||
// Move back to position zero, as there must be a tab there.
|
||||
binding.homePager.currentItem = 0
|
||||
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
||||
|
@ -297,49 +387,104 @@ class HomeFragment :
|
|||
homeModel.recreateTabs.consume()
|
||||
}
|
||||
|
||||
private fun handleChooseFolders(unit: Unit?) {
|
||||
if (unit == null) {
|
||||
return
|
||||
}
|
||||
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
|
||||
homeModel.chooseMusicLocations.consume()
|
||||
}
|
||||
|
||||
private fun updateIndexerState(state: IndexingState?) {
|
||||
// TODO: Make music loading experience a bit more pleasant
|
||||
// 1. Loading placeholder for item lists
|
||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
||||
val binding = requireBinding()
|
||||
when (state) {
|
||||
is IndexingState.Completed -> {
|
||||
binding.homeIndexingContainer.isInvisible = state.error == null
|
||||
binding.homeIndexingProgress.isInvisible = state.error != null
|
||||
binding.homeIndexingError.isInvisible = state.error == null
|
||||
if (state.error != null) {
|
||||
binding.homeIndexingContainer.setOnClickListener {
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.reportError(state.error))
|
||||
}
|
||||
} else {
|
||||
binding.homeIndexingContainer.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
is IndexingState.Indexing -> {
|
||||
binding.homeIndexingContainer.isInvisible = false
|
||||
binding.homeIndexingProgress.apply {
|
||||
isInvisible = false
|
||||
when (state.progress) {
|
||||
is IndexingProgress.Songs -> {
|
||||
isIndeterminate = false
|
||||
progress = state.progress.loaded
|
||||
max = state.progress.explored
|
||||
}
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
isIndeterminate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.homeIndexingError.isInvisible = true
|
||||
}
|
||||
is IndexingState.Completed -> setupCompleteState(binding, state.error)
|
||||
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
|
||||
null -> {
|
||||
binding.homeIndexingContainer.isInvisible = true
|
||||
logD("Indexer is in indeterminate state")
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
|
||||
if (error == null) {
|
||||
logD("Received ok response")
|
||||
updateFabVisibility(
|
||||
homeModel.songList.value,
|
||||
homeModel.isFastScrolling.value,
|
||||
homeModel.currentTabType.value)
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
logD("Received non-ok response")
|
||||
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.homeIndexingTry.apply {
|
||||
text = context.getString(R.string.lbl_grant)
|
||||
setOnClickListener {
|
||||
requireNotNull(storagePermissionLauncher) {
|
||||
"Permission launcher was not available"
|
||||
}
|
||||
.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.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.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
|
||||
// Remove all content except for the progress indicator.
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
||||
binding.homeIndexingActions.visibility = View.INVISIBLE
|
||||
|
||||
when (progress) {
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
// In a query/initialization state, show a generic loading status.
|
||||
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
||||
binding.homeIndexingProgress.isIndeterminate = true
|
||||
}
|
||||
is IndexingProgress.Songs -> {
|
||||
// Actively loading songs, show the current progress.
|
||||
binding.homeIndexingStatus.text =
|
||||
getString(R.string.fmt_indexing, progress.current, progress.total)
|
||||
binding.homeIndexingProgress.apply {
|
||||
isIndeterminate = false
|
||||
max = progress.total
|
||||
this.progress = progress.current
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -349,14 +494,14 @@ class HomeFragment :
|
|||
val directions =
|
||||
when (decision) {
|
||||
is PlaylistDecision.New -> {
|
||||
L.d("Creating new playlist")
|
||||
logD("Creating new playlist")
|
||||
HomeFragmentDirections.newPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray(),
|
||||
decision.template,
|
||||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Import -> {
|
||||
L.d("Importing playlist")
|
||||
logD("Importing playlist")
|
||||
pendingImportTarget = decision.target
|
||||
requireNotNull(getContentLauncher) {
|
||||
"Content picker launcher was not available"
|
||||
|
@ -366,7 +511,7 @@ class HomeFragment :
|
|||
return
|
||||
}
|
||||
is PlaylistDecision.Rename -> {
|
||||
L.d("Renaming ${decision.playlist}")
|
||||
logD("Renaming ${decision.playlist}")
|
||||
HomeFragmentDirections.renamePlaylist(
|
||||
decision.playlist.uid,
|
||||
decision.template,
|
||||
|
@ -374,15 +519,15 @@ class HomeFragment :
|
|||
decision.reason)
|
||||
}
|
||||
is PlaylistDecision.Export -> {
|
||||
L.d("Exporting ${decision.playlist}")
|
||||
logD("Exporting ${decision.playlist}")
|
||||
HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Delete -> {
|
||||
L.d("Deleting ${decision.playlist}")
|
||||
logD("Deleting ${decision.playlist}")
|
||||
HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
|
||||
}
|
||||
is PlaylistDecision.Add -> {
|
||||
L.d("Adding ${decision.songs.size} to a playlist")
|
||||
logD("Adding ${decision.songs.size} to a playlist")
|
||||
HomeFragmentDirections.addToPlaylist(
|
||||
decision.songs.map { it.uid }.toTypedArray())
|
||||
}
|
||||
|
@ -410,41 +555,157 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||
updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value)
|
||||
}
|
||||
|
||||
private fun updateFabVisibility(
|
||||
songs: List<Song>,
|
||||
isFastScrolling: Boolean,
|
||||
tabType: MusicType
|
||||
) {
|
||||
val binding = requireBinding()
|
||||
// If there are no songs, it's likely that the library has not been loaded, so
|
||||
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
||||
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
||||
if (songs.isEmpty() || isFastScrolling) {
|
||||
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
|
||||
hideAllFabs()
|
||||
} else {
|
||||
if (tabType != MusicType.PLAYLISTS) {
|
||||
logD("Showing shuffle button")
|
||||
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||
logD("Nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
logD("Animating transition")
|
||||
binding.homeNewPlaylistFab.hide(
|
||||
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
super.onHidden(fab)
|
||||
binding.homeShuffleFab.show()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logD("Showing immediately")
|
||||
binding.homeShuffleFab.show()
|
||||
}
|
||||
} else {
|
||||
logD("Showing playlist button")
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
logD("Nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||
logD("Animating transition")
|
||||
binding.homeShuffleFab.hide(
|
||||
object : FloatingActionButton.OnVisibilityChangedListener() {
|
||||
override fun onHidden(fab: FloatingActionButton) {
|
||||
super.onHidden(fab)
|
||||
binding.homeNewPlaylistFab.show()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logD("Showing immediately")
|
||||
binding.homeNewPlaylistFab.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideAllFabs() {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeShuffleFab.isOrWillBeShown) {
|
||||
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
|
||||
}
|
||||
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
|
||||
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSpeedDial(open: Boolean) {
|
||||
val binding = requireBinding()
|
||||
|
||||
if (open) {
|
||||
binding.homeNewPlaylistFab.open(true)
|
||||
} else {
|
||||
binding.homeNewPlaylistFab.close(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean {
|
||||
val binding = binding ?: return false
|
||||
|
||||
if (homeModel.speedDialOpen.value && binding.homeNewPlaylistFab.isUnder(event.x, event.y)) {
|
||||
// Convert absolute coordinates to relative coordinates
|
||||
val offsetX = event.x - binding.homeNewPlaylistFab.x
|
||||
val offsetY = event.y - binding.homeNewPlaylistFab.y
|
||||
|
||||
// Create a new MotionEvent with relative coordinates
|
||||
val relativeEvent =
|
||||
MotionEvent.obtain(
|
||||
event.downTime,
|
||||
event.eventTime,
|
||||
event.action,
|
||||
offsetX,
|
||||
offsetY,
|
||||
event.metaState)
|
||||
|
||||
// Dispatch the relative MotionEvent to the target child view
|
||||
val handled = binding.homeNewPlaylistFab.dispatchTouchEvent(relativeEvent)
|
||||
|
||||
// Recycle the relative MotionEvent
|
||||
relativeEvent.recycle()
|
||||
|
||||
return handled
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun handleShow(show: Show?) {
|
||||
when (show) {
|
||||
is Show.SongDetails -> {
|
||||
L.d("Navigating to ${show.song}")
|
||||
logD("Navigating to ${show.song}")
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
|
||||
}
|
||||
is Show.SongAlbumDetails -> {
|
||||
L.d("Navigating to the album of ${show.song}")
|
||||
logD("Navigating to the album of ${show.song}")
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
|
||||
}
|
||||
is Show.AlbumDetails -> {
|
||||
L.d("Navigating to ${show.album}")
|
||||
logD("Navigating to ${show.album}")
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
|
||||
}
|
||||
is Show.ArtistDetails -> {
|
||||
L.d("Navigating to ${show.artist}")
|
||||
logD("Navigating to ${show.artist}")
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
|
||||
}
|
||||
is Show.SongArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.song}")
|
||||
logD("Navigating to artist choices for ${show.song}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
|
||||
}
|
||||
is Show.AlbumArtistDecision -> {
|
||||
L.d("Navigating to artist choices for ${show.album}")
|
||||
logD("Navigating to artist choices for ${show.album}")
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
|
||||
}
|
||||
is Show.GenreDetails -> {
|
||||
L.d("Navigating to ${show.genre}")
|
||||
logD("Navigating to ${show.genre}")
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
|
||||
}
|
||||
is Show.PlaylistDetails -> {
|
||||
L.d("Navigating to ${show.playlist}")
|
||||
logD("Navigating to ${show.playlist}")
|
||||
applyAxisTransition(MaterialSharedAxis.X)
|
||||
findNavController()
|
||||
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
|
||||
}
|
||||
|
@ -472,7 +733,7 @@ class HomeFragment :
|
|||
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
|
||||
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
|
||||
// New selection started, show the AppBarLayout to indicate the new state.
|
||||
L.d("Significant selection occurred, expanding AppBar")
|
||||
logD("Significant selection occurred, expanding AppBar")
|
||||
binding.homeAppbar.expandWithScrollingRecycler()
|
||||
}
|
||||
} else {
|
||||
|
@ -480,6 +741,18 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
enterTransition = MaterialSharedAxis(axis, true)
|
||||
returnTransition = MaterialSharedAxis(axis, false)
|
||||
exitTransition = MaterialSharedAxis(axis, true)
|
||||
reenterTransition = MaterialSharedAxis(axis, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
|
||||
*
|
||||
|
@ -508,5 +781,12 @@ class HomeFragment :
|
|||
private companion object {
|
||||
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||
val FAB_HIDE_FROM_USER_FIELD: Method by
|
||||
lazyReflectedMethod(
|
||||
FloatingActionButton::class,
|
||||
"hide",
|
||||
FloatingActionButton.OnVisibilityChangedListener::class,
|
||||
Boolean::class)
|
||||
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,22 +22,20 @@ import javax.inject.Inject
|
|||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
interface HomeGenerator {
|
||||
fun attach()
|
||||
|
||||
fun release()
|
||||
|
||||
fun empty(): Boolean
|
||||
|
||||
fun songs(): List<Song>
|
||||
|
||||
fun albums(): List<Album>
|
||||
|
@ -51,8 +49,6 @@ interface HomeGenerator {
|
|||
fun tabs(): List<MusicType>
|
||||
|
||||
interface Invalidator {
|
||||
fun invalidateEmpty() {}
|
||||
|
||||
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
|
||||
|
||||
fun invalidateTabs()
|
||||
|
@ -91,9 +87,6 @@ private class HomeGeneratorImpl(
|
|||
}
|
||||
|
||||
override fun onHideCollaboratorsChanged() {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
L.d("Collaborator setting changed, forwarding update")
|
||||
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
|
||||
}
|
||||
|
||||
|
@ -123,11 +116,9 @@ private class HomeGeneratorImpl(
|
|||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
invalidator.invalidateEmpty()
|
||||
|
||||
val library = musicRepository.library
|
||||
if (changes.deviceLibrary && library != null) {
|
||||
L.d("Refreshing library")
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
logD("Refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff)
|
||||
|
@ -136,8 +127,9 @@ private class HomeGeneratorImpl(
|
|||
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
|
||||
}
|
||||
|
||||
if (changes.userLibrary && library != null) {
|
||||
L.d("Refreshing playlists")
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
logD("Refreshing playlists")
|
||||
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
|
||||
}
|
||||
}
|
||||
|
@ -148,29 +140,30 @@ private class HomeGeneratorImpl(
|
|||
homeSettings.unregisterListener(this)
|
||||
}
|
||||
|
||||
override fun empty() = musicRepository.library?.empty() ?: true
|
||||
|
||||
override fun songs() =
|
||||
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
|
||||
musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
|
||||
|
||||
override fun albums() =
|
||||
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
|
||||
musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) }
|
||||
?: emptyList()
|
||||
|
||||
override fun artists() =
|
||||
musicRepository.library?.let { deviceLibrary ->
|
||||
musicRepository.deviceLibrary?.let { deviceLibrary ->
|
||||
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
sorted.filter { it.explicitAlbums.isNotEmpty() }
|
||||
} else {
|
||||
sorted
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
?: emptyList()
|
||||
|
||||
override fun genres() =
|
||||
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
|
||||
musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) }
|
||||
?: emptyList()
|
||||
|
||||
override fun playlists() =
|
||||
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
|
||||
musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) }
|
||||
?: emptyList()
|
||||
|
||||
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
||||
|
|
|
@ -26,8 +26,8 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
* User configuration specific to the home UI.
|
||||
|
@ -68,17 +68,17 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
|||
|
||||
override fun migrate() {
|
||||
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
|
||||
L.d("Migrating tab setting")
|
||||
logD("Migrating tab setting")
|
||||
val oldTabs =
|
||||
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
|
||||
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
|
||||
L.d("Old tabs: $oldTabs")
|
||||
logD("Old tabs: $oldTabs")
|
||||
|
||||
// The playlist tab is now parsed, but it needs to be made visible.
|
||||
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
|
||||
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
|
||||
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
|
||||
L.d("New tabs: $oldTabs")
|
||||
logD("New tabs: $oldTabs")
|
||||
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
|
||||
|
@ -90,11 +90,11 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
|
|||
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
|
||||
when (key) {
|
||||
getString(R.string.set_key_home_tabs) -> {
|
||||
L.d("Dispatching tab setting change")
|
||||
logD("Dispatching tab setting change")
|
||||
listener.onTabsChanged()
|
||||
}
|
||||
getString(R.string.set_key_hide_collaborators) -> {
|
||||
L.d("Dispatching collaborator setting change")
|
||||
logD("Dispatching collaborator setting change")
|
||||
listener.onHideCollaboratorsChanged()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,17 +27,17 @@ import org.oxycblt.auxio.home.tabs.Tab
|
|||
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.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
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* The ViewModel for managing the tab data and lists of the home view.
|
||||
|
@ -120,10 +120,6 @@ constructor(
|
|||
val playlistList: StateFlow<List<Playlist>>
|
||||
get() = _playlistList
|
||||
|
||||
private val _empty = MutableStateFlow(false)
|
||||
val empty: StateFlow<Boolean>
|
||||
get() = _empty
|
||||
|
||||
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [genreList] in the UI. */
|
||||
val playlistInstructions: Event<UpdateInstructions>
|
||||
|
@ -159,14 +155,14 @@ constructor(
|
|||
/** A marker for whether the user is fast-scrolling in the home view or not. */
|
||||
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||
|
||||
private val _speedDialOpen = MutableStateFlow(false)
|
||||
/** A marker for whether the speed dial is open or not. */
|
||||
val speedDialOpen: StateFlow<Boolean> = _speedDialOpen
|
||||
|
||||
private val _showOuter = MutableEvent<Outer>()
|
||||
val showOuter: Event<Outer>
|
||||
get() = _showOuter
|
||||
|
||||
private val _chooseMusicLocations = MutableEvent<Unit>()
|
||||
val chooseMusicLocations: Event<Unit>
|
||||
get() = _chooseMusicLocations
|
||||
|
||||
init {
|
||||
homeGenerator.attach()
|
||||
}
|
||||
|
@ -176,10 +172,6 @@ constructor(
|
|||
homeGenerator.release()
|
||||
}
|
||||
|
||||
override fun invalidateEmpty() {
|
||||
_empty.value = homeGenerator.empty()
|
||||
}
|
||||
|
||||
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||
when (type) {
|
||||
MusicType.SONGS -> {
|
||||
|
@ -261,7 +253,7 @@ constructor(
|
|||
* @param pagerPos The new position of the ViewPager2 instance.
|
||||
*/
|
||||
fun synchronizeTabPosition(pagerPos: Int) {
|
||||
L.d("Updating current tab to ${currentTabTypes[pagerPos]}")
|
||||
logD("Updating current tab to ${currentTabTypes[pagerPos]}")
|
||||
_currentTabType.value = currentTabTypes[pagerPos]
|
||||
}
|
||||
|
||||
|
@ -271,12 +263,18 @@ constructor(
|
|||
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
||||
*/
|
||||
fun setFastScrolling(isFastScrolling: Boolean) {
|
||||
L.d("Updating fast scrolling state: $isFastScrolling")
|
||||
logD("Updating fast scrolling state: $isFastScrolling")
|
||||
_isFastScrolling.value = isFastScrolling
|
||||
}
|
||||
|
||||
fun startChooseMusicLocations() {
|
||||
_chooseMusicLocations.put(Unit)
|
||||
/**
|
||||
* Update whether the speed dial is open or not.
|
||||
*
|
||||
* @param speedDialOpen true if the speed dial is open, false otherwise.
|
||||
*/
|
||||
fun setSpeedDialOpen(speedDialOpen: Boolean) {
|
||||
logD("Updating speed dial state: $speedDialOpen")
|
||||
_speedDialOpen.value = speedDialOpen
|
||||
}
|
||||
|
||||
fun showSettings() {
|
||||
|
|
|
@ -39,6 +39,7 @@ import androidx.core.os.BundleCompat
|
|||
import androidx.core.view.setMargins
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.leinardi.android.speeddial.FabWithLabelView
|
||||
import com.leinardi.android.speeddial.SpeedDialActionItem
|
||||
|
@ -46,7 +47,6 @@ import com.leinardi.android.speeddial.SpeedDialView
|
|||
import kotlin.math.roundToInt
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.AnimConfig
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
|
@ -78,8 +78,6 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
@AttrRes defStyleAttr: Int
|
||||
) : super(context, attrs, defStyleAttr)
|
||||
|
||||
private val stationaryConfig = AnimConfig.of(context, AnimConfig.STANDARD, AnimConfig.MEDIUM2)
|
||||
|
||||
init {
|
||||
// Work around ripple bug on Android 12 when useCompatPadding = true.
|
||||
// @see https://github.com/material-components/material-components-android/issues/2617
|
||||
|
@ -109,7 +107,7 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
val mainFabDrawable =
|
||||
RotateDrawable().apply {
|
||||
drawable = mainFab.drawable
|
||||
toDegrees = 45f + 90f
|
||||
toDegrees = mainFabAnimationRotateAngle
|
||||
}
|
||||
mainFabAnimationRotateAngle = 0f
|
||||
setMainFabClosedDrawable(mainFabDrawable)
|
||||
|
@ -118,13 +116,6 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
override fun onMainActionSelected(): Boolean = false
|
||||
|
||||
override fun onToggleChanged(isOpen: Boolean) {
|
||||
mainFab.backgroundTintList =
|
||||
ColorStateList.valueOf(
|
||||
if (isOpen) mainFabClosedBackgroundColor
|
||||
else mainFabOpenedBackgroundColor)
|
||||
mainFab.imageTintList =
|
||||
ColorStateList.valueOf(
|
||||
if (isOpen) mainFabClosedIconColor else mainFabOpenedIconColor)
|
||||
mainFabAnimator?.cancel()
|
||||
mainFabAnimator =
|
||||
createMainFabAnimator(isOpen).apply {
|
||||
|
@ -141,43 +132,21 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
})
|
||||
}
|
||||
|
||||
private fun createMainFabAnimator(isOpen: Boolean): Animator {
|
||||
val totalDuration = stationaryConfig.duration
|
||||
val partialDuration = totalDuration / 2 // This is half of the total duration
|
||||
val delay = totalDuration / 4 // This is one fourth of the total duration
|
||||
|
||||
val backgroundTintAnimator =
|
||||
private fun createMainFabAnimator(isOpen: Boolean): Animator =
|
||||
AnimatorSet().apply {
|
||||
playTogether(
|
||||
ObjectAnimator.ofArgb(
|
||||
mainFab,
|
||||
VIEW_PROPERTY_BACKGROUND_TINT,
|
||||
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor)
|
||||
.apply {
|
||||
startDelay = delay
|
||||
duration = partialDuration
|
||||
}
|
||||
|
||||
val imageTintAnimator =
|
||||
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor),
|
||||
ObjectAnimator.ofArgb(
|
||||
mainFab,
|
||||
IMAGE_VIEW_PROPERTY_IMAGE_TINT,
|
||||
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor)
|
||||
.apply {
|
||||
startDelay = delay
|
||||
duration = partialDuration
|
||||
}
|
||||
|
||||
val levelAnimator =
|
||||
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor),
|
||||
ObjectAnimator.ofInt(
|
||||
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0)
|
||||
.apply { duration = totalDuration }
|
||||
|
||||
val animatorSet =
|
||||
AnimatorSet().apply {
|
||||
playTogether(backgroundTintAnimator, imageTintAnimator, levelAnimator)
|
||||
interpolator = stationaryConfig.interpolator
|
||||
}
|
||||
animatorSet.start()
|
||||
return animatorSet
|
||||
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0))
|
||||
duration = 200
|
||||
interpolator = FastOutSlowInInterpolator()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
|
@ -190,8 +159,6 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
|
||||
overlayLayout.setBackgroundColor(overlayColor)
|
||||
}
|
||||
// Fix default margins added by library
|
||||
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
private fun Int.withModulatedAlpha(
|
||||
|
@ -232,24 +199,13 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
return super.addActionItem(actionItem, position, animate)?.apply {
|
||||
fab.apply {
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
|
||||
if (position == actionItems.lastIndex) {
|
||||
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
|
||||
setMargins(0, 0, rightMargin, bottomMargin)
|
||||
} else {
|
||||
setMargins(0, 0, rightMargin, 0)
|
||||
}
|
||||
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
|
||||
setMargins(horizontalMargin, 0, horizontalMargin, 0)
|
||||
}
|
||||
useCompatPadding = false
|
||||
}
|
||||
|
||||
labelBackground.apply {
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
if (position == actionItems.lastIndex) {
|
||||
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
|
||||
setMargins(0, 0, rightMargin, bottomMargin)
|
||||
}
|
||||
}
|
||||
useCompatPadding = false
|
||||
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
|
||||
background =
|
||||
|
@ -306,7 +262,7 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
|
||||
private val DRAWABLE_PROPERTY_LEVEL =
|
||||
object : Property<Drawable, Int>(Int::class.java, "level") {
|
||||
override fun get(drawable: Drawable): Int = drawable.level
|
||||
override fun get(drawable: Drawable): Int? = drawable.level
|
||||
|
||||
override fun set(drawable: Drawable, value: Int?) {
|
||||
drawable.level = value!!
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* FastScrollPopupView.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.fastscroll
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Outline
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.isRtl
|
||||
|
||||
/**
|
||||
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt), Hai Zhang
|
||||
*/
|
||||
class FastScrollPopupView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
|
||||
MaterialTextView(context, attrs, defStyleRes) {
|
||||
init {
|
||||
minimumWidth = context.getDimenPixels(R.dimen.fast_scroll_popup_min_width)
|
||||
minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
|
||||
|
||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
|
||||
setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary))
|
||||
ellipsize = TextUtils.TruncateAt.MIDDLE
|
||||
gravity = Gravity.CENTER
|
||||
includeFontPadding = false
|
||||
|
||||
alpha = 0f
|
||||
elevation = context.getDimenPixels(R.dimen.elevation_normal).toFloat()
|
||||
background = FastScrollPopupDrawable(context)
|
||||
}
|
||||
|
||||
private class FastScrollPopupDrawable(context: Context) : Drawable() {
|
||||
private val paint: Paint =
|
||||
Paint().apply {
|
||||
isAntiAlias = true
|
||||
color =
|
||||
context
|
||||
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondary)
|
||||
.defaultColor
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val path = Path()
|
||||
private val matrix = Matrix()
|
||||
|
||||
private val paddingStart = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_start)
|
||||
private val paddingEnd = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_end)
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.drawPath(path, paint)
|
||||
}
|
||||
|
||||
override fun onBoundsChange(bounds: Rect) {
|
||||
updatePath()
|
||||
}
|
||||
|
||||
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
|
||||
updatePath()
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun getOutline(outline: Outline) {
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
|
||||
|
||||
// Paths don't need to be convex on android Q, but the API was mislabeled and so
|
||||
// we still have to use this method.
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
|
||||
else ->
|
||||
if (!path.isConvex) {
|
||||
// The outline path must be convex before Q, but we may run into floating
|
||||
// point errors caused by calculations involving sqrt(2) or OEM differences,
|
||||
// so in this case we just omit the shadow instead of crashing.
|
||||
super.getOutline(outline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPadding(padding: Rect): Boolean {
|
||||
if (isRtl) {
|
||||
padding[paddingEnd, 0, paddingStart] = 0
|
||||
} else {
|
||||
padding[paddingStart, 0, paddingEnd] = 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun isAutoMirrored(): Boolean = true
|
||||
|
||||
override fun setAlpha(alpha: Int) {}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {}
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
||||
private fun updatePath() {
|
||||
val r = bounds.height().toFloat() / 2
|
||||
val w = (r + SQRT2 * r).coerceAtLeast(bounds.width().toFloat())
|
||||
|
||||
path.apply {
|
||||
reset()
|
||||
|
||||
// Draw the left pill shape
|
||||
val o1X = w - SQRT2 * r
|
||||
arcToSafe(r, r, r, 90f, 180f)
|
||||
arcToSafe(o1X, r, r, -90f, 45f)
|
||||
|
||||
// Draw the right arrow shape
|
||||
val point = r / 5
|
||||
val o2X = w - SQRT2 * point
|
||||
arcToSafe(o2X, r, point, -45f, 90f)
|
||||
arcToSafe(o1X, r, r, 45f, 45f)
|
||||
|
||||
close()
|
||||
}
|
||||
|
||||
matrix.apply {
|
||||
reset()
|
||||
if (isRtl) setScale(-1f, 1f, w / 2, 0f)
|
||||
postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
|
||||
}
|
||||
|
||||
path.transform(matrix)
|
||||
}
|
||||
|
||||
private fun Path.arcToSafe(
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
radius: Float,
|
||||
startAngle: Float,
|
||||
sweepAngle: Float
|
||||
) {
|
||||
arcTo(
|
||||
centerX - radius,
|
||||
centerY - radius,
|
||||
centerX + radius,
|
||||
centerY + radius,
|
||||
startAngle,
|
||||
sweepAngle,
|
||||
false)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// Pre-calculate sqrt(2)
|
||||
const val SQRT2 = 1.4142135f
|
||||
}
|
||||
}
|
|
@ -16,18 +16,13 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
package org.oxycblt.auxio.home.fastscroll
|
||||
|
||||
import android.animation.Animator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
|
@ -35,22 +30,16 @@ import android.view.ViewGroup
|
|||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.view.isEmpty
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.updatePaddingRelative
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.MaterialFadingSlider
|
||||
import org.oxycblt.auxio.ui.MaterialSlider
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.isRtl
|
||||
import org.oxycblt.auxio.util.isUnder
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
@ -77,73 +66,52 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
* - Variable names are no longer prefixed with m
|
||||
* - Added drag listener
|
||||
* - Added documentation
|
||||
* - Completely new design
|
||||
* - New scroll position backend
|
||||
*
|
||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||
*
|
||||
* TODO: Add vibration when popup changes
|
||||
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
|
||||
*/
|
||||
class FastScrollRecyclerView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
||||
// Thumb
|
||||
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
|
||||
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
|
||||
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
|
||||
private var thumbAnimator: Animator? = null
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private val thumbView =
|
||||
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
|
||||
thumbSlider.jumpOut(this)
|
||||
View(context).apply {
|
||||
alpha = 0f
|
||||
background = context.getDrawableCompat(R.drawable.ui_scroll_thumb)
|
||||
}
|
||||
|
||||
private val thumbWidth = thumbView.background.intrinsicWidth
|
||||
private val thumbHeight = thumbView.background.intrinsicHeight
|
||||
private val thumbPadding = Rect(0, 0, 0, 0)
|
||||
private var thumbOffset = 0
|
||||
|
||||
private var showingThumb = false
|
||||
private val hideThumbRunnable = Runnable {
|
||||
if (!dragging) {
|
||||
hideThumb()
|
||||
hideScrollbar()
|
||||
}
|
||||
}
|
||||
|
||||
// Popup
|
||||
private val popupView =
|
||||
MaterialTextView(context).apply {
|
||||
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
|
||||
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||
|
||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
|
||||
setTextColor(
|
||||
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
|
||||
ellipsize = TextUtils.TruncateAt.MIDDLE
|
||||
gravity = Gravity.CENTER
|
||||
includeFontPadding = false
|
||||
|
||||
elevation =
|
||||
context
|
||||
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
|
||||
.toFloat()
|
||||
background = context.getDrawableCompat(R.drawable.ui_popup)
|
||||
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
|
||||
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
|
||||
updatePaddingRelative(start = paddingStart, end = paddingEnd)
|
||||
FastScrollPopupView(context).apply {
|
||||
layoutParams =
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
.apply {
|
||||
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
||||
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
|
||||
}
|
||||
}
|
||||
private val popupSlider =
|
||||
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
|
||||
jumpOut(popupView)
|
||||
}
|
||||
private var popupAnimator: Animator? = null
|
||||
|
||||
private var showingPopup = false
|
||||
|
||||
// Touch
|
||||
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||
private val minTouchTargetSize =
|
||||
context.getDimenPixels(R.dimen.fast_scroll_thumb_touch_target_size)
|
||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
|
||||
private var downX = 0f
|
||||
|
@ -152,24 +120,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
private var dragStartY = 0f
|
||||
private var dragStartThumbOffset = 0
|
||||
|
||||
private var fastScrollingPossible = true
|
||||
|
||||
var fastScrollingEnabled = true
|
||||
set(value) {
|
||||
if (field == value) {
|
||||
return
|
||||
}
|
||||
|
||||
field = value
|
||||
if (!value) {
|
||||
removeCallbacks(hideThumbRunnable)
|
||||
hideThumb()
|
||||
hidePopup()
|
||||
}
|
||||
|
||||
listener?.onFastScrollingChanged(field)
|
||||
}
|
||||
|
||||
private var dragging = false
|
||||
set(value) {
|
||||
if (field == value) {
|
||||
|
@ -189,13 +139,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
showScrollbar()
|
||||
showPopup()
|
||||
} else {
|
||||
hidePopup()
|
||||
postAutoHideScrollbar()
|
||||
hidePopup()
|
||||
}
|
||||
|
||||
listener?.onFastScrollingChanged(field)
|
||||
}
|
||||
|
||||
private val tRect = Rect()
|
||||
|
||||
var popupProvider: PopupProvider? = null
|
||||
var listener: Listener? = null
|
||||
|
||||
|
@ -230,22 +182,22 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
// --- RECYCLERVIEW EVENT MANAGEMENT ---
|
||||
|
||||
private fun onPreDraw() {
|
||||
updateThumbState()
|
||||
updateScrollbarState()
|
||||
|
||||
thumbView.layoutDirection = layoutDirection
|
||||
thumbView.measure(
|
||||
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
|
||||
val thumbTop = thumbPadding.top + thumbOffset
|
||||
popupView.layoutDirection = layoutDirection
|
||||
|
||||
val thumbLeft =
|
||||
if (isRtl) {
|
||||
thumbPadding.left
|
||||
} else {
|
||||
width - thumbPadding.right - thumbWidth
|
||||
}
|
||||
|
||||
val thumbTop = thumbPadding.top + thumbOffset
|
||||
|
||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
||||
|
||||
popupView.layoutDirection = layoutDirection
|
||||
val child = getChildAt(0)
|
||||
val firstAdapterPos =
|
||||
if (child != null) {
|
||||
|
@ -262,9 +214,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
popupText = provider.getPopup(firstAdapterPos) ?: "?"
|
||||
} else {
|
||||
// No valid position or provider, do not show the popup.
|
||||
popupView.isInvisible = false
|
||||
popupView.isInvisible = true
|
||||
popupText = ""
|
||||
}
|
||||
|
||||
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
|
||||
|
||||
if (popupView.text != popupText) {
|
||||
|
@ -290,9 +243,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
popupLayoutParams.height)
|
||||
|
||||
popupView.measure(widthMeasureSpec, heightMeasureSpec)
|
||||
if (showingPopup) {
|
||||
doPopupVibration()
|
||||
}
|
||||
}
|
||||
|
||||
val popupWidth = popupView.measuredWidth
|
||||
|
@ -305,7 +255,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
val popupAnchorY = popupHeight / 2
|
||||
val thumbAnchorY = thumbView.height / 2
|
||||
val thumbAnchorY = thumbView.paddingTop
|
||||
|
||||
val popupTop =
|
||||
(thumbTop + thumbAnchorY - popupAnchorY)
|
||||
|
@ -319,7 +269,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
override fun onScrolled(dx: Int, dy: Int) {
|
||||
super.onScrolled(dx, dy)
|
||||
|
||||
updateThumbState()
|
||||
updateScrollbarState()
|
||||
|
||||
// Measure or layout events result in a fake onScrolled call. Ignore those.
|
||||
if (dx == 0 && dy == 0) {
|
||||
|
@ -337,27 +287,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
return insets
|
||||
}
|
||||
|
||||
private fun updateThumbState() {
|
||||
// Then calculate the thumb position, which is just:
|
||||
// [proportion of scroll position to scroll range] * [total thumb range]
|
||||
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
|
||||
val offsetY = computeVerticalScrollOffset()
|
||||
if (computeVerticalScrollRange() < height || isEmpty()) {
|
||||
fastScrollingPossible = false
|
||||
hideThumb()
|
||||
hidePopup()
|
||||
private fun updateScrollbarState() {
|
||||
if (scrollRange <= height || childCount == 0) {
|
||||
return
|
||||
}
|
||||
val extentY = computeVerticalScrollExtent()
|
||||
val fraction = (offsetY).toFloat() / (computeVerticalScrollRange() - extentY)
|
||||
thumbOffset = (thumbOffsetRange * fraction).toInt()
|
||||
|
||||
// Combine the previous item dimensions with the current item top to find our scroll
|
||||
// position
|
||||
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
|
||||
val child = getChildAt(0)
|
||||
val firstAdapterPos =
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
|
||||
is LinearLayoutManager -> mgr.getPosition(child)
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
|
||||
|
||||
// Then calculate the thumb position, which is just:
|
||||
// [proportion of scroll position to scroll range] * [total thumb range]
|
||||
thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt()
|
||||
}
|
||||
|
||||
private fun onItemTouch(event: MotionEvent): Boolean {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
dragging = false
|
||||
return false
|
||||
}
|
||||
val eventX = event.x
|
||||
val eventY = event.y
|
||||
|
||||
|
@ -371,12 +324,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
|
||||
dragStartThumbOffset = thumbOffset
|
||||
} else if (eventX > thumbView.right - thumbWidth / 4) {
|
||||
} else {
|
||||
dragStartThumbOffset =
|
||||
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
||||
scrollToThumbOffset(dragStartThumbOffset)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
dragging = true
|
||||
|
@ -413,19 +364,44 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun scrollToThumbOffset(thumbOffset: Int) {
|
||||
val rangeY = computeVerticalScrollRange() - computeVerticalScrollExtent()
|
||||
val previousThumbOffset = this.thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||
val previousOffsetY = rangeY * (previousThumbOffset / thumbOffsetRange.toFloat())
|
||||
val newThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||
val newOffsetY = rangeY * (newThumbOffset / thumbOffsetRange.toFloat())
|
||||
if (newOffsetY == 0f) {
|
||||
// Hacky workaround to drift in vertical scroll offset where we just snap
|
||||
// to the top if the thumb offset hit zero.
|
||||
scrollToPosition(0)
|
||||
val clampedThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
|
||||
|
||||
val scrollOffset =
|
||||
(scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
|
||||
paddingTop
|
||||
|
||||
scrollTo(scrollOffset)
|
||||
}
|
||||
|
||||
private fun scrollTo(offset: Int) {
|
||||
if (childCount == 0) {
|
||||
return
|
||||
}
|
||||
val dy = newOffsetY - previousOffsetY
|
||||
scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset()))
|
||||
|
||||
stopScroll()
|
||||
|
||||
val trueOffset = offset - paddingTop
|
||||
val itemHeight = itemHeight
|
||||
|
||||
val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight)
|
||||
val firstItemTop = firstItemPosition * itemHeight - trueOffset
|
||||
|
||||
scrollToPositionWithOffset(firstItemPosition, firstItemTop)
|
||||
}
|
||||
|
||||
private fun scrollToPositionWithOffset(position: Int, offset: Int) {
|
||||
var targetPosition = position
|
||||
val trueOffset = offset - paddingTop
|
||||
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> {
|
||||
targetPosition *= mgr.spanCount
|
||||
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
|
||||
}
|
||||
is LinearLayoutManager -> {
|
||||
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- SCROLLBAR APPEARANCE ---
|
||||
|
@ -436,39 +412,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun showScrollbar() {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
return
|
||||
}
|
||||
if (showingThumb) {
|
||||
return
|
||||
}
|
||||
|
||||
showingThumb = true
|
||||
thumbAnimator?.cancel()
|
||||
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
|
||||
animateViewIn(thumbView)
|
||||
}
|
||||
|
||||
private fun hideThumb() {
|
||||
private fun hideScrollbar() {
|
||||
if (!showingThumb) {
|
||||
return
|
||||
}
|
||||
|
||||
showingThumb = false
|
||||
thumbAnimator?.cancel()
|
||||
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
|
||||
animateViewOut(thumbView)
|
||||
}
|
||||
|
||||
private fun showPopup() {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
return
|
||||
}
|
||||
if (showingPopup) {
|
||||
return
|
||||
}
|
||||
|
||||
showingPopup = true
|
||||
popupAnimator?.cancel()
|
||||
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
|
||||
animateViewIn(popupView)
|
||||
}
|
||||
|
||||
private fun hidePopup() {
|
||||
|
@ -477,17 +444,23 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
showingPopup = false
|
||||
popupAnimator?.cancel()
|
||||
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
|
||||
animateViewOut(popupView)
|
||||
}
|
||||
|
||||
private fun doPopupVibration() {
|
||||
performHapticFeedback(
|
||||
if (Build.VERSION.SDK_INT >= 27) {
|
||||
HapticFeedbackConstants.TEXT_HANDLE_MOVE
|
||||
} else {
|
||||
HapticFeedbackConstants.KEYBOARD_TAP
|
||||
})
|
||||
private fun animateViewIn(view: View) {
|
||||
view
|
||||
.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(context.getInteger(R.integer.anim_fade_enter_duration).toLong())
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun animateViewOut(view: View) {
|
||||
view
|
||||
.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(context.getInteger(R.integer.anim_fade_exit_duration).toLong())
|
||||
.start()
|
||||
}
|
||||
|
||||
// --- LAYOUT STATE ---
|
||||
|
@ -497,6 +470,45 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
|
||||
}
|
||||
|
||||
private val scrollRange: Int
|
||||
get() {
|
||||
val itemCount = itemCount
|
||||
|
||||
if (itemCount == 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val itemHeight = itemHeight
|
||||
|
||||
return if (itemHeight != 0) {
|
||||
paddingTop + itemCount * itemHeight + paddingBottom
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private val scrollOffsetRange: Int
|
||||
get() = scrollRange - height
|
||||
|
||||
private val itemHeight: Int
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val itemView = getChildAt(0)
|
||||
getDecoratedBoundsWithMargins(itemView, tRect)
|
||||
return tRect.height()
|
||||
}
|
||||
|
||||
private val itemCount: Int
|
||||
get() =
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
|
||||
is LinearLayoutManager -> mgr.itemCount
|
||||
else -> 0
|
||||
}
|
||||
|
||||
/** An interface to provide text to use in the popup when fast-scrolling. */
|
||||
interface PopupProvider {
|
||||
/**
|
||||
|
@ -520,6 +532,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private companion object {
|
||||
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 500
|
||||
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
||||
}
|
||||
}
|
|
@ -22,8 +22,6 @@ import android.os.Bundle
|
|||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.Formatter
|
||||
|
@ -31,23 +29,22 @@ 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.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Album]s.
|
||||
|
@ -82,16 +79,7 @@ class AlbumListFragment :
|
|||
listener = this@AlbumListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_album_48)
|
||||
contentDescription = getString(R.string.lbl_albums)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_albums)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.albumList, ::updateAlbums)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -111,10 +99,10 @@ class AlbumListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.albumSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> album.name.thumb()
|
||||
is Sort.Mode.ByName -> album.name.thumb
|
||||
|
||||
// By Artist -> Use name of first artist
|
||||
is Sort.Mode.ByArtist -> album.artists[0].name.thumb()
|
||||
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
|
||||
|
||||
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
|
||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
|
||||
|
@ -127,7 +115,7 @@ class AlbumListFragment :
|
|||
|
||||
// Last added -> Format as date
|
||||
is Sort.Mode.ByDateAdded -> {
|
||||
val dateAddedMillis = album.addedMs
|
||||
val dateAddedMillis = album.dateAdded.secsToMs()
|
||||
formatterSb.setLength(0)
|
||||
DateUtils.formatDateRange(
|
||||
context,
|
||||
|
@ -159,14 +147,6 @@ class AlbumListFragment :
|
|||
albumAdapter.update(albums, homeModel.albumInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -21,31 +21,28 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
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.positiveOrNull
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Artist]s.
|
||||
|
@ -77,16 +74,7 @@ class ArtistListFragment :
|
|||
listener = this@ArtistListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_artist_48)
|
||||
contentDescription = getString(R.string.lbl_artists)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_artists)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.artistList, ::updateArtists)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -106,7 +94,7 @@ class ArtistListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.artistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> artist.name.thumb()
|
||||
is Sort.Mode.ByName -> artist.name.thumb
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||
|
@ -135,14 +123,6 @@ class ArtistListFragment :
|
|||
artistAdapter.update(artists, homeModel.artistInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -21,30 +21,27 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.recycler.GenreViewHolder
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
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.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Genre]s.
|
||||
|
@ -76,16 +73,7 @@ class GenreListFragment :
|
|||
listener = this@GenreListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_genre_48)
|
||||
contentDescription = getString(R.string.lbl_genres)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_genres)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.genreList, ::updateGenres)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -105,7 +93,7 @@ class GenreListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.genreSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> genre.name.thumb()
|
||||
is Sort.Mode.ByName -> genre.name.thumb
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
||||
|
@ -134,14 +122,6 @@ class GenreListFragment :
|
|||
genreAdapter.update(genres, homeModel.genreInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* ListUtil.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.list
|
||||
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import org.oxycblt.musikr.tag.Name
|
||||
|
||||
fun Name.thumb() =
|
||||
when (this) {
|
||||
is Name.Known ->
|
||||
tokens.firstOrNull()?.let {
|
||||
if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase()
|
||||
}
|
||||
is Name.Unknown -> "?"
|
||||
}
|
|
@ -21,29 +21,26 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
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.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
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.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Playlist]s.
|
||||
|
@ -74,18 +71,7 @@ class PlaylistListFragment :
|
|||
listener = this@PlaylistListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_playlist_48)
|
||||
contentDescription = getString(R.string.lbl_playlists)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_playlists)
|
||||
|
||||
collectImmediately(homeModel.playlistList, ::updatePlaylists)
|
||||
collectImmediately(
|
||||
homeModel.empty,
|
||||
homeModel.playlistList,
|
||||
musicModel.indexingState,
|
||||
::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -105,7 +91,7 @@ class PlaylistListFragment :
|
|||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.playlistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> playlist.name.thumb()
|
||||
is Sort.Mode.ByName -> playlist.name.thumb
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
|
||||
|
@ -134,26 +120,6 @@ class PlaylistListFragment :
|
|||
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(
|
||||
empty: Boolean,
|
||||
playlists: List<Playlist>,
|
||||
indexingState: IndexingState?
|
||||
) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty && playlists.isNotEmpty()
|
||||
if (!empty && playlists.isEmpty()) {
|
||||
binding.homeNoMusicAction.isVisible = true
|
||||
binding.homeNoMusicAction.text = getString(R.string.lbl_new_playlist)
|
||||
binding.homeNoMusicAction.setOnClickListener { musicModel.createPlaylist() }
|
||||
} else {
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
binding.homeNoMusicAction.text = getString(R.string.lbl_music_sources)
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -22,30 +22,27 @@ import android.os.Bundle
|
|||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
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.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.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [ListFragment] that shows a list of [Song]s.
|
||||
|
@ -62,7 +59,6 @@ class SongListFragment :
|
|||
override val musicModel: MusicViewModel 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)
|
||||
private val formatter = Formatter(formatterSb)
|
||||
|
@ -80,16 +76,7 @@ class SongListFragment :
|
|||
listener = this@SongListFragment
|
||||
}
|
||||
|
||||
binding.homeNoMusicPlaceholder.apply {
|
||||
setImageResource(R.drawable.ic_song_48)
|
||||
contentDescription = getString(R.string.lbl_songs)
|
||||
}
|
||||
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_songs)
|
||||
|
||||
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
|
||||
|
||||
collectImmediately(homeModel.songList, ::updateSongs)
|
||||
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
|
@ -111,23 +98,23 @@ class SongListFragment :
|
|||
// based off the names of the parent objects and not the child objects.
|
||||
return when (homeModel.songSort.mode) {
|
||||
// Name -> Use name
|
||||
is Sort.Mode.ByName -> song.name.thumb()
|
||||
is Sort.Mode.ByName -> song.name.thumb
|
||||
|
||||
// Artist -> Use name of first artist
|
||||
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb()
|
||||
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
|
||||
|
||||
// Album -> Use Album Name
|
||||
is Sort.Mode.ByAlbum -> song.album.name.thumb()
|
||||
is Sort.Mode.ByAlbum -> song.album.name.thumb
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByDate -> song.album.dates?.resolve(requireContext())
|
||||
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||
|
||||
// Last added -> Format as date
|
||||
is Sort.Mode.ByDateAdded -> {
|
||||
val dateAddedMillis = song.addedMs
|
||||
val dateAddedMillis = song.dateAdded.secsToMs()
|
||||
formatterSb.setLength(0)
|
||||
DateUtils.formatDateRange(
|
||||
context,
|
||||
|
@ -159,14 +146,6 @@ class SongListFragment :
|
|||
songAdapter.update(songs, homeModel.songInstructions.consume())
|
||||
}
|
||||
|
||||
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
|
||||
val binding = requireBinding()
|
||||
binding.homeRecycler.isInvisible = empty
|
||||
binding.homeNoMusic.isInvisible = !empty
|
||||
binding.homeNoMusicAction.isVisible =
|
||||
indexingState == null || (empty && indexingState is IndexingState.Completed)
|
||||
}
|
||||
|
||||
private fun updateSelection(selection: List<Music>) {
|
||||
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* AdaptiveTabStrategy.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.tabs
|
||||
|
||||
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.MusicType
|
||||
|
||||
/**
|
||||
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
|
||||
* depending on the screen configuration.
|
||||
*
|
||||
* @param context [Context] required to obtain window information
|
||||
* @param tabs Current tab configuration from settings
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val homeTab = tabs[position]
|
||||
val icon =
|
||||
when (homeTab) {
|
||||
MusicType.SONGS -> R.drawable.ic_song_24
|
||||
MusicType.ALBUMS -> R.drawable.ic_album_24
|
||||
MusicType.ARTISTS -> R.drawable.ic_artist_24
|
||||
MusicType.GENRES -> R.drawable.ic_genre_24
|
||||
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
|
||||
}
|
||||
|
||||
// Use expected sw* size thresholds when choosing a configuration.
|
||||
when {
|
||||
// On small screens, only display an icon.
|
||||
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
|
||||
// On large screens, display an icon and text.
|
||||
width < 600 -> tab.setText(homeTab.nameRes)
|
||||
// On medium-size screens, display text.
|
||||
else -> tab.setIcon(icon).setText(homeTab.nameRes)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* NamedTabStrategy.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.tabs
|
||||
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
|
||||
class NamedTabStrategy(private val homeTabs: List<MusicType>) : TabConfigurationStrategy {
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
tab.setText(homeTabs[position].nameRes)
|
||||
}
|
||||
}
|
|
@ -19,7 +19,8 @@
|
|||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A representation of a library tab suitable for configuration.
|
||||
|
@ -85,7 +86,7 @@ sealed class Tab(open val type: MusicType) {
|
|||
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||
val distinct = tabs.distinctBy { it.type }
|
||||
if (tabs.size != distinct.size) {
|
||||
L.w(
|
||||
logW(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
}
|
||||
|
||||
|
@ -132,13 +133,13 @@ sealed class Tab(open val type: MusicType) {
|
|||
// Make sure there are no duplicate tabs
|
||||
val distinct = tabs.distinctBy { it.type }
|
||||
if (tabs.size != distinct.size) {
|
||||
L.w(
|
||||
logW(
|
||||
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
|
||||
}
|
||||
|
||||
// For safety, return null if we have an empty or larger-than-expected tab array.
|
||||
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
|
||||
L.e("Sequence size was ${distinct.size}, which is invalid")
|
||||
logE("Sequence size was ${distinct.size}, which is invalid")
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
|
|||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
||||
|
@ -55,7 +55,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param newTabs The new array of tabs to show.
|
||||
*/
|
||||
fun submitTabs(newTabs: Array<Tab>) {
|
||||
L.d("Force-updating tab information")
|
||||
logD("Force-updating tab information")
|
||||
tabs = newTabs
|
||||
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param tab The new tab.
|
||||
*/
|
||||
fun setTab(at: Int, tab: Tab) {
|
||||
L.d("Updating tab [at: $at, tab: $tab]")
|
||||
logD("Updating tab [at: $at, tab: $tab]")
|
||||
tabs[at] = tab
|
||||
// Use a payload to avoid an item change animation.
|
||||
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
|
||||
|
@ -80,7 +80,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
|
|||
* @param b The position of the second tab to swap.
|
||||
*/
|
||||
fun swapTabs(a: Int, b: Int) {
|
||||
L.d("Swapping tabs [a: $a, b: $b]")
|
||||
logD("Swapping tabs [a: $a, b: $b]")
|
||||
val tmp = tabs[b]
|
||||
tabs[b] = tabs[a]
|
||||
tabs[a] = tmp
|
||||
|
|
|
@ -31,7 +31,7 @@ import org.oxycblt.auxio.databinding.DialogTabsBinding
|
|||
import org.oxycblt.auxio.home.HomeSettings
|
||||
import org.oxycblt.auxio.list.EditClickListListener
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab]
|
||||
|
@ -52,7 +52,7 @@ class TabCustomizeDialog :
|
|||
builder
|
||||
.setTitle(R.string.set_lib_tabs)
|
||||
.setPositiveButton(R.string.lbl_ok) { _, _ ->
|
||||
L.d("Committing tab changes")
|
||||
logD("Committing tab changes")
|
||||
homeSettings.homeTabs = tabAdapter.tabs
|
||||
}
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
|
@ -99,7 +99,7 @@ class TabCustomizeDialog :
|
|||
is Tab.Visible -> Tab.Invisible(old.type)
|
||||
is Tab.Invisible -> Tab.Visible(old.type)
|
||||
}
|
||||
L.d("Flipping tab visibility [from: $old to: $new]")
|
||||
logD("Flipping tab visibility [from: $old to: $new]")
|
||||
tabAdapter.setTab(index, new)
|
||||
|
||||
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
|
||||
|
|
|
@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import coil3.toBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
* A utility to provide bitmaps in a race-less manner.
|
||||
|
@ -94,7 +94,7 @@ constructor(
|
|||
target
|
||||
.onConfigRequest(
|
||||
ImageRequest.Builder(context)
|
||||
.data(song.cover)
|
||||
.data(listOf(song.cover))
|
||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||
.size(Size.ORIGINAL))
|
||||
.target(
|
||||
|
|
|
@ -26,11 +26,12 @@ import org.oxycblt.auxio.IntegerTable
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class CoverMode {
|
||||
/** Do not load album covers ("Off"). */
|
||||
OFF,
|
||||
SAVE_SPACE,
|
||||
BALANCED,
|
||||
HIGH_QUALITY,
|
||||
AS_IS;
|
||||
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
|
||||
MEDIA_STORE,
|
||||
/** Load high-quality covers directly from music files ("Quality"). */
|
||||
QUALITY;
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
|
@ -41,10 +42,8 @@ enum class CoverMode {
|
|||
get() =
|
||||
when (this) {
|
||||
OFF -> IntegerTable.COVER_MODE_OFF
|
||||
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE
|
||||
BALANCED -> IntegerTable.COVER_MODE_BALANCED
|
||||
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
|
||||
AS_IS -> IntegerTable.COVER_MODE_AS_IS
|
||||
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
|
||||
QUALITY -> IntegerTable.COVER_MODE_QUALITY
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -58,10 +57,8 @@ enum class CoverMode {
|
|||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.COVER_MODE_OFF -> OFF
|
||||
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE
|
||||
IntegerTable.COVER_MODE_BALANCED -> BALANCED
|
||||
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
|
||||
IntegerTable.COVER_MODE_AS_IS -> AS_IS
|
||||
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
|
||||
IntegerTable.COVER_MODE_QUALITY -> QUALITY
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* CoverProvider.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.image
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.UriMatcher
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
|
||||
class CoverProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
if (mode != "r" || uriMatcher.match(uri) != 1) {
|
||||
return null
|
||||
}
|
||||
val id = uri.lastPathSegment ?: return null
|
||||
return runBlocking {
|
||||
when (val result = SettingCovers.immutable(requireNotNull(context)).obtain(id)) {
|
||||
is CoverResult.Hit -> result.cover.fd()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String {
|
||||
check(uriMatcher.match(uri) == 1) { "Unknown URI: $uri" }
|
||||
return "image/*"
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor = throw UnsupportedOperationException()
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?
|
||||
): Int = 0
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.image.CoverProvider"
|
||||
private const val IMAGES_PATH = "covers"
|
||||
private val uriMatcher =
|
||||
UriMatcher(UriMatcher.NO_MATCH).apply { addURI(AUTHORITY, "$IMAGES_PATH/*", 1) }
|
||||
|
||||
val CONTENT_URI: Uri =
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_CONTENT)
|
||||
.authority(AUTHORITY)
|
||||
.appendPath(IMAGES_PATH)
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio.image
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
|
@ -33,39 +33,36 @@ import android.view.Gravity
|
|||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.DimenRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.content.res.getIntOrThrow
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isEmpty
|
||||
import androidx.core.view.updateMarginsRelative
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.target
|
||||
import coil3.request.transformations
|
||||
import coil3.util.CoilUtils
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
|
||||
import org.oxycblt.auxio.image.coil.SquareCropTransformation
|
||||
import org.oxycblt.auxio.ui.MaterialFader
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
|
||||
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.UISettings
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
|
||||
/**
|
||||
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
|
||||
|
@ -95,41 +92,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
private val playbackIndicator: PlaybackIndicator?
|
||||
private val selectionBadge: ImageView?
|
||||
private val iconSize: Int?
|
||||
|
||||
private val fader = MaterialFader.quickLopsided(context)
|
||||
private var fadeAnimator: Animator? = null
|
||||
private val sizing: Int
|
||||
@DimenRes private val iconSizeRes: Int?
|
||||
@DimenRes private var cornerRadiusRes: Int?
|
||||
|
||||
private var fadeAnimator: ValueAnimator? = null
|
||||
private val indicatorMatrix = Matrix()
|
||||
private val indicatorMatrixSrc = RectF()
|
||||
private val indicatorMatrixDst = RectF()
|
||||
|
||||
private val shapeAppearance: ShapeAppearanceModel
|
||||
|
||||
init {
|
||||
// Obtain some StyledImageView attributes to use later when theming the custom view.
|
||||
@SuppressLint("CustomViewStyleable")
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
|
||||
|
||||
val shapeAppearanceRes = styledAttrs.getResourceId(R.styleable.CoverView_shapeAppearance, 0)
|
||||
shapeAppearance =
|
||||
if (uiSettings.roundMode) {
|
||||
if (shapeAppearanceRes != 0) {
|
||||
ShapeAppearanceModel.builder(context, shapeAppearanceRes, -1).build()
|
||||
} else {
|
||||
ShapeAppearanceModel.builder(
|
||||
context,
|
||||
com.google.android.material.R.style
|
||||
.ShapeAppearance_Material3_Corner_Medium,
|
||||
-1)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
ShapeAppearanceModel.builder().build()
|
||||
}
|
||||
iconSize =
|
||||
styledAttrs.getDimensionPixelSize(R.styleable.CoverView_iconSize, -1).takeIf {
|
||||
it != -1
|
||||
}
|
||||
sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
|
||||
iconSizeRes = SIZING_ICON_SIZE[sizing]
|
||||
cornerRadiusRes = getCornerRadiusRes()
|
||||
|
||||
val playbackIndicatorEnabled =
|
||||
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
|
||||
|
@ -173,7 +153,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
super.onFinishInflate()
|
||||
|
||||
// The image isn't added if other children have populated the body. This is by design.
|
||||
if (isEmpty()) {
|
||||
if (childCount == 0) {
|
||||
addView(image)
|
||||
}
|
||||
|
||||
|
@ -203,7 +183,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
// AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
|
||||
// behavior with a matrix.
|
||||
val playbackIndicator = (playbackIndicator ?: return).view
|
||||
val iconSize = iconSize ?: (measuredWidth / 2)
|
||||
val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2)
|
||||
playbackIndicator.apply {
|
||||
imageMatrix =
|
||||
indicatorMatrix.apply {
|
||||
|
@ -267,8 +247,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
}
|
||||
|
||||
private fun applyBackgroundsToChildren() {
|
||||
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 {
|
||||
|
@ -278,7 +264,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
background =
|
||||
MaterialShapeDrawable().apply {
|
||||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
||||
shapeAppearanceModel = shapeAppearance
|
||||
setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -304,10 +290,43 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) {
|
||||
// Set up a target transition for the selection indicator.
|
||||
val targetAlpha: Float
|
||||
val targetDuration: Long
|
||||
|
||||
if (isActivated) {
|
||||
// View is "activated" (i.e marked as selected), so show the selection indicator.
|
||||
targetAlpha = 1f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
// View is not "activated", hide the selection indicator.
|
||||
targetAlpha = 0f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
|
||||
if (selectionBadge.alpha == targetAlpha) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLaidOut) {
|
||||
// Not laid out, initialize it without animation before drawing.
|
||||
selectionBadge.alpha = targetAlpha
|
||||
return
|
||||
}
|
||||
|
||||
if (fadeAnimator != null) {
|
||||
// Cancel any previous animation.
|
||||
fadeAnimator?.cancel()
|
||||
fadeAnimator = null
|
||||
}
|
||||
|
||||
fadeAnimator =
|
||||
(if (isActivated) fader.fadeIn(selectionBadge) else fader.fadeOut(selectionBadge))
|
||||
.also { it.start() }
|
||||
ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply {
|
||||
duration = targetDuration
|
||||
addUpdateListener { selectionBadge.alpha = it.animatedValue as Float }
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -317,7 +336,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(song: Song) =
|
||||
bindImpl(
|
||||
song.cover,
|
||||
listOf(song.cover),
|
||||
context.getString(R.string.desc_album_cover, song.album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -328,7 +347,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(album: Album) =
|
||||
bindImpl(
|
||||
album.covers,
|
||||
album.cover.all,
|
||||
context.getString(R.string.desc_album_cover, album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -339,7 +358,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(artist: Artist) =
|
||||
bindImpl(
|
||||
artist.covers,
|
||||
artist.cover.all,
|
||||
context.getString(R.string.desc_artist_image, artist.name),
|
||||
R.drawable.ic_artist_24)
|
||||
|
||||
|
@ -350,7 +369,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(genre: Genre) =
|
||||
bindImpl(
|
||||
genre.covers,
|
||||
genre.cover.all,
|
||||
context.getString(R.string.desc_genre_image, genre.name),
|
||||
R.drawable.ic_genre_24)
|
||||
|
||||
|
@ -361,7 +380,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(playlist: Playlist) =
|
||||
bindImpl(
|
||||
playlist.covers,
|
||||
playlist.cover?.all ?: emptyList(),
|
||||
context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
R.drawable.ic_playlist_24)
|
||||
|
||||
|
@ -373,21 +392,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
|
||||
*/
|
||||
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
|
||||
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
|
||||
bindImpl(Cover.order(songs), desc, errorRes)
|
||||
|
||||
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
|
||||
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
|
||||
val request =
|
||||
ImageRequest.Builder(context)
|
||||
.data(cover)
|
||||
.error(
|
||||
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
||||
.asImage())
|
||||
.data(covers)
|
||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
|
||||
.target(image)
|
||||
|
||||
val cornersTransformation =
|
||||
RoundedRectTransformation(
|
||||
shapeAppearance.topLeftCornerSize.getCornerSize(
|
||||
RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())))
|
||||
RoundedRectTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f)
|
||||
if (imageSettings.forceSquareCovers) {
|
||||
request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation)
|
||||
} else {
|
||||
|
@ -407,7 +422,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
private class StyledDrawable(
|
||||
context: Context,
|
||||
private val inner: Drawable,
|
||||
@Px val iconSize: Int?
|
||||
@DimenRes iconSizeRes: Int?
|
||||
) : Drawable() {
|
||||
init {
|
||||
// Re-tint the drawable to use the analogous "on surface" color for
|
||||
|
@ -415,10 +430,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||
}
|
||||
|
||||
private val dimen = iconSizeRes?.let(context::getDimenPixels)
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
// Resize the drawable such that it's always 1/4 the size of the image and
|
||||
// centered in the middle of the canvas.
|
||||
val adj = iconSize?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
|
||||
val adj = dimen?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
|
||||
inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
|
||||
inner.draw(canvas)
|
||||
}
|
||||
|
@ -435,4 +452,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SIZING_CORNER_RADII =
|
||||
arrayOf(
|
||||
R.dimen.size_corners_small, R.dimen.size_corners_small, R.dimen.size_corners_medium)
|
||||
val SIZING_ICON_SIZE = arrayOf(R.dimen.size_icon_small, R.dimen.size_icon_medium, null)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* User configuration specific to image loading.
|
||||
|
@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
get() =
|
||||
CoverMode.fromIntCode(
|
||||
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
|
||||
?: CoverMode.BALANCED
|
||||
?: CoverMode.MEDIA_STORE
|
||||
|
||||
override val forceSquareCovers: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
|
||||
|
@ -58,14 +58,14 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
// Show album covers and Ignore MediaStore covers were unified in 3.0.0
|
||||
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
|
||||
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
|
||||
L.d("Migrating cover settings")
|
||||
logD("Migrating cover settings")
|
||||
|
||||
val mode =
|
||||
when {
|
||||
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
|
||||
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
|
||||
CoverMode.BALANCED
|
||||
else -> CoverMode.BALANCED
|
||||
CoverMode.MEDIA_STORE
|
||||
else -> CoverMode.QUALITY
|
||||
}
|
||||
|
||||
sharedPreferences.edit {
|
||||
|
@ -74,30 +74,12 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
remove(OLD_KEY_QUALITY_COVERS)
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedPreferences.contains(OLD_KEY_COVER_MODE)) {
|
||||
L.d("Migrating cover mode setting")
|
||||
|
||||
var mode =
|
||||
CoverMode.fromIntCode(sharedPreferences.getInt(OLD_KEY_COVER_MODE, Int.MIN_VALUE))
|
||||
?: CoverMode.BALANCED
|
||||
if (mode == CoverMode.HIGH_QUALITY) {
|
||||
// High quality now has space characteristics that could be
|
||||
// undesirable, clamp to balanced.
|
||||
mode = CoverMode.BALANCED
|
||||
}
|
||||
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_cover_mode), mode.intCode)
|
||||
remove(OLD_KEY_COVER_MODE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
||||
if (key == getString(R.string.set_key_cover_mode) ||
|
||||
key == getString(R.string.set_key_square_covers)) {
|
||||
L.d("Dispatching image setting change")
|
||||
logD("Dispatching image setting change")
|
||||
listener.onImageSettingsChanged()
|
||||
}
|
||||
}
|
||||
|
@ -105,6 +87,5 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
private companion object {
|
||||
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
|
||||
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
|
||||
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,140 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* CoverCollectionFetcher.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.image.coil
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
class CoverCollectionFetcher
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val covers: CoverCollection,
|
||||
private val size: Size,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
|
||||
// We don't immediately check for mosaic feasibility from album count alone, as that
|
||||
// does not factor in InputStreams failing to load. Instead, only check once we
|
||||
// definitely have image data to use.
|
||||
if (streams.size == 4) {
|
||||
// Make sure we free the InputStreams once we've transformed them into a
|
||||
// mosaic.
|
||||
return createMosaic(streams, size).also {
|
||||
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
|
||||
}
|
||||
}
|
||||
|
||||
// Not enough covers for a mosaic, take the first one (if that even exists)
|
||||
val first = streams.firstOrNull() ?: return null
|
||||
|
||||
// All but the first stream will be unused, free their resources
|
||||
withContext(Dispatchers.IO) {
|
||||
for (i in 1 until streams.size) {
|
||||
streams[i].close()
|
||||
}
|
||||
}
|
||||
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
||||
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
||||
// Use whatever size coil gives us to create the mosaic.
|
||||
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
|
||||
val mosaicFrameSize =
|
||||
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
||||
|
||||
val mosaicBitmap = createBitmap(mosaicSize.width, mosaicSize.height)
|
||||
val canvas = Canvas(mosaicBitmap)
|
||||
|
||||
var x = 0
|
||||
var y = 0
|
||||
|
||||
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
|
||||
// and place it on a corner of the canvas.
|
||||
for (stream in streams) {
|
||||
if (y == mosaicSize.height) {
|
||||
break
|
||||
}
|
||||
|
||||
// Crop the bitmap down to a square so it leaves no empty space
|
||||
// TODO: Work around this
|
||||
val bitmap =
|
||||
SquareCropTransformation.INSTANCE.transform(
|
||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||
|
||||
x += bitmap.width
|
||||
if (x == mosaicSize.width) {
|
||||
x = 0
|
||||
y += bitmap.height
|
||||
}
|
||||
}
|
||||
|
||||
// It's way easier to map this into a drawable then try to serialize it into an
|
||||
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
|
||||
// load low-res mosaics into high-res ImageViews.
|
||||
return ImageFetchResult(
|
||||
image = mosaicBitmap.toDrawable(context.resources).asImage(),
|
||||
isSampled = true,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
private fun Dimension.mosaicSize(): Int {
|
||||
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
||||
// odd image sizes upwards to prevent the mosaic creation from failing.
|
||||
val size = pxOrElse { 512 }
|
||||
return if (size.mod(2) > 0) size + 1 else size
|
||||
}
|
||||
|
||||
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
|
||||
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
|
||||
CoverCollectionFetcher(options.context, data, options.size)
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* CoverFetcher.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.image.coil
|
||||
|
||||
import coil3.ImageLoader
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import javax.inject.Inject
|
||||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
|
||||
class CoverFetcher private constructor(private val cover: Cover) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val stream = cover.open() ?: return null
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
|
||||
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
|
||||
CoverFetcher(data)
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Keyers.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.image.coil
|
||||
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
class CoverKeyer @Inject constructor() : Keyer<Cover> {
|
||||
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
|
||||
}
|
||||
|
||||
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
|
||||
override fun key(data: CoverCollection, options: Options) =
|
||||
"multi:${data.hashCode()}&${options.size}"
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* NullCovers.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.image.covers
|
||||
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.covers.stored.CoverStorage
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Metadata
|
||||
|
||||
class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
|
||||
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
|
||||
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
|
||||
|
||||
override suspend fun cleanup(excluding: Collection<Cover>) {
|
||||
storage.ls(setOf()).map { storage.rm(it) }
|
||||
}
|
||||
}
|
||||
|
||||
data object NullCover : Cover {
|
||||
override val id = "null"
|
||||
|
||||
override suspend fun open() = null
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* RevisionedTranscoding.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.image.covers
|
||||
|
||||
import java.util.UUID
|
||||
import org.oxycblt.musikr.covers.stored.Transcoding
|
||||
|
||||
class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
|
||||
override val tag = "_$revision${inner.tag}"
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* SettingCovers.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.image.covers
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.Covers
|
||||
import org.oxycblt.musikr.covers.FDCover
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.covers.chained.ChainedCovers
|
||||
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
|
||||
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
|
||||
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
|
||||
import org.oxycblt.musikr.covers.fs.FSCovers
|
||||
import org.oxycblt.musikr.covers.fs.MutableFSCovers
|
||||
import org.oxycblt.musikr.covers.stored.Compress
|
||||
import org.oxycblt.musikr.covers.stored.CoverStorage
|
||||
import org.oxycblt.musikr.covers.stored.MutableStoredCovers
|
||||
import org.oxycblt.musikr.covers.stored.NoTranscoding
|
||||
import org.oxycblt.musikr.covers.stored.StoredCovers
|
||||
|
||||
interface SettingCovers {
|
||||
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
|
||||
|
||||
companion object {
|
||||
suspend fun immutable(context: Context): Covers<FDCover> =
|
||||
ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
|
||||
}
|
||||
}
|
||||
|
||||
class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings) :
|
||||
SettingCovers {
|
||||
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
|
||||
val coverStorage = CoverStorage.at(context.coversDir())
|
||||
val transcoding =
|
||||
when (imageSettings.coverMode) {
|
||||
CoverMode.OFF -> return NullCovers(coverStorage)
|
||||
CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
|
||||
CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
|
||||
CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
|
||||
CoverMode.AS_IS -> NoTranscoding
|
||||
}
|
||||
val revisionedTranscoding = RevisionedTranscoding(revision, transcoding)
|
||||
val storedCovers =
|
||||
MutableStoredCovers(
|
||||
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
|
||||
val fsCovers = MutableFSCovers(context)
|
||||
return MutableChainedCovers(storedCovers, fsCovers)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.coversDir() = filesDir.resolve("covers").apply { mkdirs() }
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
* Components.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.image.extractor
|
||||
|
||||
import coil.ImageLoader
|
||||
import coil.fetch.Fetcher
|
||||
import coil.key.Keyer
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import javax.inject.Inject
|
||||
|
||||
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
|
||||
override fun key(data: Collection<Cover>, options: Options) =
|
||||
"${data.map { it.key }.hashCode()}"
|
||||
}
|
||||
|
||||
class CoverFetcher
|
||||
private constructor(
|
||||
private val covers: Collection<Cover>,
|
||||
private val size: Size,
|
||||
private val coverExtractor: CoverExtractor,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch() = coverExtractor.extract(covers, size)
|
||||
|
||||
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||
Fetcher.Factory<Collection<Cover>> {
|
||||
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
|
||||
CoverFetcher(data, options.size, coverExtractor)
|
||||
}
|
||||
}
|
66
app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
Normal file
66
app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Cover.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.image.extractor
|
||||
|
||||
import android.net.Uri
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
sealed interface Cover {
|
||||
val key: String
|
||||
val mediaStoreCoverUri: Uri
|
||||
|
||||
/**
|
||||
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
|
||||
*/
|
||||
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
|
||||
Cover {
|
||||
override val mediaStoreCoverUri = songCoverUri
|
||||
override val key = perceptualHash
|
||||
}
|
||||
|
||||
/**
|
||||
* We couldn't find any embedded cover art ourselves, but the android system might have some
|
||||
* through a cover.jpg file or something similar.
|
||||
*/
|
||||
data class External(val albumCoverUri: Uri) : Cover {
|
||||
override val mediaStoreCoverUri = albumCoverUri
|
||||
override val key = albumCoverUri.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
|
||||
|
||||
fun order(songs: Collection<Song>) =
|
||||
FALLBACK_SORT.songs(songs)
|
||||
.map { it.cover }
|
||||
.groupBy { it.key }
|
||||
.entries
|
||||
.sortedByDescending { it.value.size }
|
||||
.map { it.value.first() }
|
||||
}
|
||||
}
|
||||
|
||||
data class ParentCover(val single: Cover, val all: List<Cover>) {
|
||||
companion object {
|
||||
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
|
||||
|
||||
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,249 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CoverExtractor.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.image.extractor
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.util.Size as AndroidSize
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Metadata
|
||||
import androidx.media3.exoplayer.MetadataRetriever
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.extractor.metadata.flac.PictureFrame
|
||||
import androidx.media3.extractor.metadata.id3.ApicFrame
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.SourceResult
|
||||
import coil.size.Dimension
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.guava.asDeferred
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.image.ImageSettings
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
* Provides functionality for extracting album cover information. Meant for internal use only.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class CoverExtractor
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val imageSettings: ImageSettings,
|
||||
private val mediaSourceFactory: MediaSource.Factory
|
||||
) {
|
||||
/**
|
||||
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
|
||||
*
|
||||
* @param covers The [Cover]s to load.
|
||||
* @param size The [Size] of the image to load.
|
||||
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
|
||||
* will be returned of a mosaic composed of four album covers ordered by
|
||||
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
|
||||
*/
|
||||
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
|
||||
val streams = mutableListOf<InputStream>()
|
||||
for (cover in covers) {
|
||||
openCoverInputStream(cover)?.let(streams::add)
|
||||
// We don't immediately check for mosaic feasibility from album count alone, as that
|
||||
// does not factor in InputStreams failing to load. Instead, only check once we
|
||||
// definitely have image data to use.
|
||||
if (streams.size == 4) {
|
||||
// Make sure we free the InputStreams once we've transformed them into a mosaic.
|
||||
return createMosaic(streams, size).also {
|
||||
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not enough covers for a mosaic, take the first one (if that even exists)
|
||||
val first = streams.firstOrNull() ?: return null
|
||||
|
||||
// All but the first stream will be unused, free their resources
|
||||
withContext(Dispatchers.IO) {
|
||||
for (i in 1 until streams.size) {
|
||||
streams[i].close()
|
||||
}
|
||||
}
|
||||
|
||||
return SourceResult(
|
||||
source = ImageSource(first.source().buffer(), context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
|
||||
var stream: ByteArrayInputStream? = null
|
||||
|
||||
for (i in 0 until metadata.length()) {
|
||||
// We can only extract pictures from two tags with this method, ID3v2's APIC or
|
||||
// Vorbis picture comments.
|
||||
val pic: ByteArray?
|
||||
val type: Int
|
||||
|
||||
when (val entry = metadata.get(i)) {
|
||||
is ApicFrame -> {
|
||||
pic = entry.pictureData
|
||||
type = entry.pictureType
|
||||
}
|
||||
is PictureFrame -> {
|
||||
pic = entry.pictureData
|
||||
type = entry.pictureType
|
||||
}
|
||||
else -> continue
|
||||
}
|
||||
|
||||
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
|
||||
stream = ByteArrayInputStream(pic)
|
||||
break
|
||||
} else if (stream == null) {
|
||||
stream = ByteArrayInputStream(pic)
|
||||
}
|
||||
}
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
private suspend fun openCoverInputStream(cover: Cover) =
|
||||
try {
|
||||
when (cover) {
|
||||
is Cover.Embedded ->
|
||||
when (imageSettings.coverMode) {
|
||||
CoverMode.OFF -> null
|
||||
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
|
||||
CoverMode.QUALITY -> extractQualityCover(cover)
|
||||
}
|
||||
is Cover.External -> {
|
||||
extractMediaStoreCover(cover)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to extract album cover due to an error: $e")
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun extractQualityCover(cover: Cover.Embedded) =
|
||||
extractExoplayerCover(cover)
|
||||
?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover)
|
||||
|
||||
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
|
||||
MediaMetadataRetriever().run {
|
||||
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
||||
// so it's probably fine not to wrap it.rmt
|
||||
setDataSource(context, cover.songUri)
|
||||
|
||||
// Get the embedded picture from MediaMetadataRetriever, which will return a full
|
||||
// ByteArray of the cover without any compression artifacts.
|
||||
// If its null [i.e there is no embedded cover], than just ignore it and move on
|
||||
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
|
||||
}
|
||||
|
||||
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
|
||||
val tracks =
|
||||
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
|
||||
.asDeferred()
|
||||
.await()
|
||||
|
||||
// The metadata extraction process of ExoPlayer results in a dump of all metadata
|
||||
// it found, which must be iterated through.
|
||||
val metadata = tracks[0].getFormat(0).metadata
|
||||
|
||||
if (metadata == null || metadata.length() == 0) {
|
||||
// No (parsable) metadata. This is also expected.
|
||||
return null
|
||||
}
|
||||
|
||||
return findCoverDataInMetadata(metadata)
|
||||
}
|
||||
|
||||
private suspend fun extractMediaStoreCover(cover: Cover) =
|
||||
// Eliminate any chance that this blocking call might mess up the loading process
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
|
||||
}
|
||||
|
||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
||||
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
||||
// Use whatever size coil gives us to create the mosaic.
|
||||
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
|
||||
val mosaicFrameSize =
|
||||
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
||||
|
||||
val mosaicBitmap =
|
||||
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(mosaicBitmap)
|
||||
|
||||
var x = 0
|
||||
var y = 0
|
||||
|
||||
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
|
||||
// and place it on a corner of the canvas.
|
||||
for (stream in streams) {
|
||||
if (y == mosaicSize.height) {
|
||||
break
|
||||
}
|
||||
|
||||
// Crop the bitmap down to a square so it leaves no empty space
|
||||
// TODO: Work around this
|
||||
val bitmap =
|
||||
SquareCropTransformation.INSTANCE.transform(
|
||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||
|
||||
x += bitmap.width
|
||||
if (x == mosaicSize.width) {
|
||||
x = 0
|
||||
y += bitmap.height
|
||||
}
|
||||
}
|
||||
|
||||
// It's way easier to map this into a drawable then try to serialize it into an
|
||||
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
|
||||
// load low-res mosaics into high-res ImageViews.
|
||||
return DrawableResult(
|
||||
drawable = mosaicBitmap.toDrawable(context.resources),
|
||||
isSampled = true,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
private fun Dimension.mosaicSize(): Int {
|
||||
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
||||
// odd image sizes upwards to prevent the mosaic creation from failing.
|
||||
val size = pxOrElse { 512 }
|
||||
return if (size.mod(2) > 0) size + 1 else size
|
||||
}
|
||||
}
|
60
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal file
60
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DHash.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.image.extractor
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorMatrix
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Paint
|
||||
import java.math.BigInteger
|
||||
|
||||
@Suppress("UNUSED")
|
||||
fun Bitmap.dHash(hashSize: Int = 16): String {
|
||||
// Step 1: Resize the bitmap to a fixed size
|
||||
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
|
||||
|
||||
// Step 2: Convert the bitmap to grayscale
|
||||
val grayBitmap =
|
||||
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(grayBitmap)
|
||||
val paint = Paint()
|
||||
val colorMatrix = ColorMatrix()
|
||||
colorMatrix.setSaturation(0f)
|
||||
val filter = ColorMatrixColorFilter(colorMatrix)
|
||||
paint.colorFilter = filter
|
||||
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
|
||||
|
||||
// Step 3: Compute the difference between adjacent pixels
|
||||
var hash = BigInteger.valueOf(0)
|
||||
val one = BigInteger.valueOf(1)
|
||||
for (y in 0 until hashSize) {
|
||||
for (x in 0 until hashSize) {
|
||||
val pixel1 = grayBitmap.getPixel(x, y)
|
||||
val pixel2 = grayBitmap.getPixel(x + 1, y)
|
||||
val diff = Color.red(pixel1) - Color.red(pixel2)
|
||||
if (diff > 0) {
|
||||
hash = hash.or(one.shl(y * hashSize + x))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hash.toString(16)
|
||||
}
|
|
@ -16,15 +16,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import coil3.decode.DataSource
|
||||
import coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.transition.CrossfadeDrawable
|
||||
import coil3.transition.CrossfadeTransition
|
||||
import coil3.transition.Transition
|
||||
import coil3.transition.TransitionTarget
|
||||
import coil.decode.DataSource
|
||||
import coil.drawable.CrossfadeDrawable
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.transition.CrossfadeTransition
|
||||
import coil.transition.Transition
|
||||
import coil.transition.TransitionTarget
|
||||
|
||||
/**
|
||||
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CoilModule.kt is part of Auxio.
|
||||
* ExtractorModule.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,12 +16,11 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.content.Context
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.transitionFactory
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
@ -31,22 +30,19 @@ import javax.inject.Singleton
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CoilModule {
|
||||
class ExtractorModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun imageLoader(
|
||||
@ApplicationContext context: Context,
|
||||
coverKeyer: CoverKeyer,
|
||||
coverFactory: CoverFetcher.Factory,
|
||||
coverCollectionKeyer: CoverCollectionKeyer,
|
||||
coverCollectionFactory: CoverCollectionFetcher.Factory
|
||||
keyer: CoverKeyer,
|
||||
factory: CoverFetcher.Factory
|
||||
) =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(coverKeyer)
|
||||
add(coverFactory)
|
||||
add(coverCollectionKeyer)
|
||||
add(coverCollectionFactory)
|
||||
// Add fetchers for Music components to make them usable with ImageRequest
|
||||
add(keyer)
|
||||
add(factory)
|
||||
}
|
||||
// Use our own crossfade with error drawable support
|
||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Bitmap.createBitmap
|
||||
|
@ -30,16 +30,16 @@ import android.graphics.RectF
|
|||
import android.graphics.Shader
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.transform.Transformation
|
||||
import coil.decode.DecodeUtils
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import coil.transform.Transformation
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
|
||||
* without cropping them.
|
||||
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
|
||||
* images without cropping them.
|
||||
*
|
||||
* @author Coil Team, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -48,7 +48,7 @@ class RoundedRectTransformation(
|
|||
@Px private val topRight: Float = 0f,
|
||||
@Px private val bottomLeft: Float = 0f,
|
||||
@Px private val bottomRight: Float = 0f
|
||||
) : Transformation() {
|
||||
) : Transformation {
|
||||
|
||||
constructor(@Px radius: Float) : this(radius, radius, radius, radius)
|
||||
|
||||
|
@ -65,11 +65,7 @@ class RoundedRectTransformation(
|
|||
|
||||
val (outputWidth, outputHeight) = calculateOutputSize(input, size)
|
||||
|
||||
val output =
|
||||
createBitmap(
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
requireNotNull(input.config) { "unsupported bitmap format" })
|
||||
val output = createBitmap(outputWidth, outputHeight, input.config)
|
||||
output.applyCanvas {
|
||||
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||
|
|
@ -16,13 +16,12 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.core.graphics.scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.transform.Transformation
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import coil.transform.Transformation
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
|
@ -31,7 +30,7 @@ import kotlin.math.min
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SquareCropTransformation : Transformation() {
|
||||
class SquareCropTransformation : Transformation {
|
||||
override val cacheKey: String
|
||||
get() = "SquareCropTransformation"
|
||||
|
||||
|
@ -47,7 +46,7 @@ class SquareCropTransformation : Transformation() {
|
|||
val desiredHeight = size.height.pxOrElse { dstSize }
|
||||
if (dstSize != desiredWidth || dstSize != desiredHeight) {
|
||||
// Image is not the desired size, upscale it.
|
||||
return dst.scale(desiredWidth, desiredHeight)
|
||||
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
|
||||
}
|
||||
return dst
|
||||
}
|
|
@ -22,16 +22,14 @@ import androidx.annotation.StringRes
|
|||
|
||||
// TODO: Consider breaking this up into sealed classes for individual adapters
|
||||
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
|
||||
typealias Item = Any
|
||||
|
||||
interface Header
|
||||
interface Item
|
||||
|
||||
/**
|
||||
* A "header" used for delimiting groups of data.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface PlainHeader : Header {
|
||||
interface Header : Item {
|
||||
/** The string resource used for the header's title. */
|
||||
val titleRes: Int
|
||||
}
|
||||
|
@ -42,16 +40,12 @@ interface PlainHeader : Header {
|
|||
* @param titleRes The string resource used for the header's title.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||
|
||||
interface Divider<T> {
|
||||
val anchor: T?
|
||||
}
|
||||
data class BasicHeader(@StringRes override val titleRes: Int) : Header
|
||||
|
||||
/**
|
||||
* A divider decoration used to delimit groups of data.
|
||||
*
|
||||
* @param anchor The [PlainHeader] this divider should be next to in a list. Used as a way to
|
||||
* preserve divider continuity during list updates.
|
||||
* @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve
|
||||
* divider continuity during list updates.
|
||||
*/
|
||||
data class PlainDivider(override val anchor: PlainHeader?) : Divider<PlainHeader>
|
||||
data class Divider(val anchor: Header?) : Item
|
||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
|
|||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.auxio.music.Music
|
||||
|
||||
/**
|
||||
* A Fragment containing a selectable list.
|
||||
|
|
|
@ -25,18 +25,19 @@ 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.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [ViewModel] that orchestrates menu dialogs and selection state.
|
||||
|
@ -64,17 +65,18 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
val library = musicRepository.library ?: return
|
||||
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 -> library.findSong(it.uid)
|
||||
is Album -> library.findAlbum(it.uid)
|
||||
is Artist -> library.findArtist(it.uid)
|
||||
is Genre -> library.findGenre(it.uid)
|
||||
is Playlist -> library.findPlaylist(it.uid)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -92,16 +94,16 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
*/
|
||||
fun select(music: Music) {
|
||||
if (music is MusicParent && music.songs.isEmpty()) {
|
||||
L.d("Cannot select empty parent, ignoring operation")
|
||||
logD("Cannot select empty parent, ignoring operation")
|
||||
return
|
||||
}
|
||||
|
||||
val selected = _selected.value.toMutableList()
|
||||
if (!selected.remove(music)) {
|
||||
L.d("Adding $music to selection")
|
||||
logD("Adding $music to selection")
|
||||
selected.add(music)
|
||||
} else {
|
||||
L.d("Removed $music from selection")
|
||||
logD("Removed $music from selection")
|
||||
}
|
||||
|
||||
_selected.value = selected
|
||||
|
@ -129,7 +131,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @return A list of [Song]s collated from each item selected.
|
||||
*/
|
||||
fun takeSelection(): List<Song> {
|
||||
L.d("Taking selection")
|
||||
logD("Taking selection")
|
||||
return peekSelection().also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
|
@ -139,7 +141,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @return true if the prior selection was non-empty, false otherwise.
|
||||
*/
|
||||
fun dropSelection(): Boolean {
|
||||
L.d("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
|
||||
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
|
||||
}
|
||||
|
||||
|
@ -153,7 +155,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* should do.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) {
|
||||
L.d("Opening menu for $song")
|
||||
logD("Opening menu for $song")
|
||||
openImpl(Menu.ForSong(menuRes, song, playWith))
|
||||
}
|
||||
|
||||
|
@ -165,7 +167,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param album The [Album] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, album: Album) {
|
||||
L.d("Opening menu for $album")
|
||||
logD("Opening menu for $album")
|
||||
openImpl(Menu.ForAlbum(menuRes, album))
|
||||
}
|
||||
|
||||
|
@ -177,7 +179,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param artist The [Artist] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
|
||||
L.d("Opening menu for $artist")
|
||||
logD("Opening menu for $artist")
|
||||
openImpl(Menu.ForArtist(menuRes, artist))
|
||||
}
|
||||
|
||||
|
@ -189,7 +191,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param genre The [Genre] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
|
||||
L.d("Opening menu for $genre")
|
||||
logD("Opening menu for $genre")
|
||||
openImpl(Menu.ForGenre(menuRes, genre))
|
||||
}
|
||||
|
||||
|
@ -201,7 +203,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param playlist The [Playlist] to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
|
||||
L.d("Opening menu for $playlist")
|
||||
logD("Opening menu for $playlist")
|
||||
openImpl(Menu.ForPlaylist(menuRes, playlist))
|
||||
}
|
||||
|
||||
|
@ -213,14 +215,14 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
* @param songs The [Song] selection to show.
|
||||
*/
|
||||
fun openMenu(@MenuRes menuRes: Int, songs: List<Song>) {
|
||||
L.d("Opening menu for ${songs.size} songs")
|
||||
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) {
|
||||
L.w("Already opening $existing, ignoring $menu")
|
||||
logW("Already opening $existing, ignoring $menu")
|
||||
return
|
||||
}
|
||||
_menu.put(menu)
|
||||
|
|
|
@ -25,7 +25,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
|
|||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.util.concurrent.Executor
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A variant of ListDiffer with more flexible updates.
|
||||
|
@ -57,7 +57,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
instructions: UpdateInstructions?,
|
||||
callback: (() -> Unit)? = null
|
||||
) {
|
||||
L.d("Updating list to ${newList.size} items with $instructions")
|
||||
logD("Updating list to ${newList.size} items with $instructions")
|
||||
differ.update(newList, instructions, callback)
|
||||
}
|
||||
}
|
||||
|
@ -171,7 +171,7 @@ private class FlexibleListDiffer<T>(
|
|||
) {
|
||||
// fast simple remove all
|
||||
if (newList.isEmpty()) {
|
||||
L.d("Short-circuiting diff to remove all")
|
||||
logD("Short-circuiting diff to remove all")
|
||||
val countRemoved = oldList.size
|
||||
currentList = emptyList()
|
||||
// notify last, after list is updated
|
||||
|
@ -182,7 +182,7 @@ private class FlexibleListDiffer<T>(
|
|||
|
||||
// fast simple first insert
|
||||
if (oldList.isEmpty()) {
|
||||
L.d("Short-circuiting diff to insert all")
|
||||
logD("Short-circuiting diff to insert all")
|
||||
currentList = newList
|
||||
// notify last, after list is updated
|
||||
updateCallback.onInserted(0, newList.size)
|
||||
|
@ -244,7 +244,7 @@ private class FlexibleListDiffer<T>(
|
|||
|
||||
mainThreadExecutor.execute {
|
||||
if (maxScheduledGeneration == runGeneration) {
|
||||
L.d("Applying calculated diff")
|
||||
logD("Applying calculated diff")
|
||||
currentList = newList
|
||||
result.dispatchUpdatesTo(updateCallback)
|
||||
callback?.invoke()
|
||||
|
|
|
@ -21,7 +21,8 @@ package org.oxycblt.auxio.list.adapter
|
|||
import android.view.View
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
|
||||
|
@ -58,7 +59,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
* @param isPlaying Whether playback is ongoing or paused.
|
||||
*/
|
||||
fun setPlaying(item: T?, isPlaying: Boolean) {
|
||||
L.d("Updating playing item [old: $currentItem new: $item]")
|
||||
logD("Updating playing item [old: $currentItem new: $item]")
|
||||
|
||||
var updatedItem = false
|
||||
if (currentItem != item) {
|
||||
|
@ -71,7 +72,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
L.w("oldItem was not in adapter data")
|
||||
logW("oldItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,7 +82,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
L.w("newItem was not in adapter data")
|
||||
logW("newItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,7 +100,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
L.w("newItem was not in adapter data")
|
||||
logW("newItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,8 +21,8 @@ package org.oxycblt.auxio.list.adapter
|
|||
import android.view.View
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.musikr.Music
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
||||
|
@ -55,7 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
|
|||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
L.d("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
||||
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
|
||||
|
||||
selectedItems = newSelectedItems
|
||||
for (i in currentList.indices) {
|
||||
|
|
|
@ -21,13 +21,13 @@ 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
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* Command to navigate to a specific menu dialog configuration.
|
||||
|
|
|
@ -33,7 +33,7 @@ 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 timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [ViewBindingBottomSheetDialogFragment] that displays basic music information and a series of
|
||||
|
@ -102,7 +102,7 @@ abstract class MenuDialogFragment<M : Menu> :
|
|||
|
||||
private fun updateMenu(menu: Menu?) {
|
||||
if (menu == null) {
|
||||
L.d("No menu to show, navigating away")
|
||||
logD("No menu to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -27,18 +27,17 @@ 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.resolve
|
||||
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
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* [MenuDialogFragment] implementation for a [Song].
|
||||
|
@ -113,7 +112,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
|
|||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
|
||||
val context = requireContext()
|
||||
binding.menuCover.bind(menu.album)
|
||||
binding.menuType.text = menu.album.releaseType.resolve(context)
|
||||
binding.menuType.text = getString(menu.album.releaseType.stringRes)
|
||||
binding.menuName.text = menu.album.name.resolve(context)
|
||||
binding.menuInfo.text = menu.album.artists.resolveNames(context)
|
||||
}
|
||||
|
|
|
@ -82,7 +82,7 @@ class MenuItemViewHolder private constructor(private val binding: ItemMenuOption
|
|||
oldItem == newItem
|
||||
|
||||
override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) =
|
||||
oldItem.title.toString() == newItem.title.toString()
|
||||
oldItem.title == newItem.title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,10 +23,10 @@ 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.musikr.MusicParent
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* Manages the state information for [MenuDialogFragment] implementations.
|
||||
|
@ -55,7 +55,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
|
|||
fun setMenu(parcel: Menu.Parcel) {
|
||||
_currentMenu.value = unpackParcel(parcel)
|
||||
if (_currentMenu.value == null) {
|
||||
L.w("Given menu parcel $parcel was invalid")
|
||||
logW("Given menu parcel $parcel was invalid")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,35 +70,35 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
|
|||
}
|
||||
|
||||
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
|
||||
val song = musicRepository.library?.findSong(parcel.songUid) ?: return null
|
||||
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.library?.findAlbum(parcel.albumUid) ?: return null
|
||||
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.library?.findArtist(parcel.artistUid) ?: return null
|
||||
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.library?.findGenre(parcel.genreUid) ?: return null
|
||||
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.library?.findPlaylist(parcel.playlistUid) ?: return null
|
||||
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 library = musicRepository.library ?: return null
|
||||
val songs = parcel.songUids.mapNotNull(library::findSong)
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
||||
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
|
||||
return Menu.ForSelection(parcel.res, songs)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
|
@ -39,7 +38,6 @@ open class AuxioRecyclerView
|
|||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
RecyclerView(context, attrs, defStyleAttr) {
|
||||
private val initialPaddingBottom = paddingBottom
|
||||
private var savedState: Parcelable? = null
|
||||
|
||||
init {
|
||||
// Prevent children from being clipped by window insets
|
||||
|
@ -62,18 +60,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
// Update the RecyclerView's padding such that the bottom insets are applied
|
||||
// while still preserving bottom padding.
|
||||
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
|
||||
if (savedState != null) {
|
||||
// State restore happens before we get insets, so there will be scroll drift unless
|
||||
// we restore the state after the insets are applied.
|
||||
// We must only do this once, otherwise we'll get jumpy behavior.
|
||||
super.onRestoreInstanceState(savedState)
|
||||
savedState = null
|
||||
}
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
super.onRestoreInstanceState(state)
|
||||
savedState = state
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,7 +25,6 @@ import android.view.animation.AccelerateDecelerateInterpolator
|
|||
import androidx.core.view.isInvisible
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.R as MR
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
@ -34,7 +33,7 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import timber.log.Timber as L
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in editable UIs,
|
||||
|
@ -92,11 +91,12 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
|
||||
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
|
||||
// this is only done once when the item is initially picked up.
|
||||
// TODO: I think this is possible to improve with a raw ValueAnimator.
|
||||
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
L.d("Lifting ViewHolder")
|
||||
logD("Lifting ViewHolder")
|
||||
|
||||
val bg = holder.background
|
||||
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
|
||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
||||
holder.root
|
||||
.animate()
|
||||
.translationZ(elevation)
|
||||
|
@ -135,10 +135,10 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
// This function can be called multiple times, so only start the animation when the view's
|
||||
// translationZ is already non-zero.
|
||||
if (holder.root.translationZ != 0f) {
|
||||
L.d("Lifting ViewHolder")
|
||||
logD("Lifting ViewHolder")
|
||||
|
||||
val bg = holder.background
|
||||
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
|
||||
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
|
||||
holder.root
|
||||
.animate()
|
||||
.translationZ(0f)
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDivider
|
||||
|
@ -28,21 +27,20 @@ import org.oxycblt.auxio.databinding.ItemHeaderBinding
|
|||
import org.oxycblt.auxio.databinding.ItemParentBinding
|
||||
import org.oxycblt.auxio.databinding.ItemSongBinding
|
||||
import org.oxycblt.auxio.list.BasicHeader
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
import org.oxycblt.auxio.list.Divider
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.areNamesTheSame
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
|
||||
|
@ -362,7 +360,7 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
|
|||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [PlainDivider]. Use [from] to create an instance.
|
||||
* A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -383,9 +381,8 @@ class DividerViewHolder private constructor(divider: MaterialDivider) :
|
|||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleDiffCallback<PlainDivider>() {
|
||||
@SuppressLint("DiffUtilEquals")
|
||||
override fun areContentsTheSame(oldItem: PlainDivider, newItem: PlainDivider) =
|
||||
object : SimpleDiffCallback<Divider>() {
|
||||
override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
|
||||
oldItem.anchor == newItem.anchor
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
|
|||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
* A sorting method.
|
||||
|
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
|
||||
songs.sortBy { it.name }
|
||||
when (direction) {
|
||||
Direction.ASCENDING -> songs.sortBy { it.addedMs }
|
||||
Direction.DESCENDING -> songs.sortByDescending { it.addedMs }
|
||||
Direction.ASCENDING -> songs.sortBy { it.dateAdded }
|
||||
Direction.DESCENDING -> songs.sortByDescending { it.dateAdded }
|
||||
}
|
||||
}
|
||||
|
||||
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
|
||||
albums.sortBy { it.name }
|
||||
when (direction) {
|
||||
Direction.ASCENDING -> albums.sortBy { it.addedMs }
|
||||
Direction.DESCENDING -> albums.sortByDescending { it.addedMs }
|
||||
Direction.ASCENDING -> albums.sortBy { it.dateAdded }
|
||||
Direction.DESCENDING -> albums.sortByDescending { it.dateAdded }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -96,17 +96,17 @@ abstract class SortDialog :
|
|||
|
||||
private fun updateButtons() {
|
||||
val binding = requireBinding()
|
||||
binding.sortSave.isEnabled = getCurrentSort().let { it != null && it != getInitialSort() }
|
||||
binding.sortSave.isEnabled = getCurrentSort() != getInitialSort()
|
||||
}
|
||||
|
||||
private fun getCurrentSort(): Sort? {
|
||||
val initial = getInitialSort()
|
||||
val mode = modeAdapter.currentMode ?: return null
|
||||
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 -> return null
|
||||
else -> initial?.direction ?: return null
|
||||
}
|
||||
return Sort(mode, direction)
|
||||
}
|
||||
|
|
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Indexing.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
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/** Version-aware permission identifier for reading audio files. */
|
||||
val PERMISSION_READ_AUDIO =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
android.Manifest.permission.READ_MEDIA_AUDIO
|
||||
} else {
|
||||
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current state of the music loader.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface IndexingState {
|
||||
/**
|
||||
* Music loading is on-going.
|
||||
*
|
||||
* @param progress The current progress of the music loading.
|
||||
*/
|
||||
data class Indexing(val progress: IndexingProgress) : IndexingState
|
||||
|
||||
/**
|
||||
* Music loading has completed.
|
||||
*
|
||||
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
|
||||
* will be null.
|
||||
*/
|
||||
data class Completed(val error: Exception?) : IndexingState
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current progress of music loading.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface IndexingProgress {
|
||||
/** Other work is being done that does not have a defined progress. */
|
||||
data object Indeterminate : IndexingProgress
|
||||
|
||||
/**
|
||||
* Songs are currently being loaded.
|
||||
*
|
||||
* @param current The current amount of songs loaded.
|
||||
* @param total The projected total amount of songs.
|
||||
*/
|
||||
data class Songs(val current: Int, val total: Int) : IndexingProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class NoAudioPermissionException : Exception() {
|
||||
override val message = "Storage permissions are required to load music"
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when no music was found.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class NoMusicException : Exception() {
|
||||
override val message = "No music was found on the device"
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue