Compare commits

..

No commits in common. "dev" and "hotfixes" have entirely different histories.

542 changed files with 14589 additions and 23097 deletions

View file

@ -34,7 +34,6 @@ body:
attributes: attributes:
label: What android version do you use? label: What android version do you use?
options: options:
- Android 15
- Android 14 - Android 14
- Android 13 - Android 13
- Android 12L - Android 12L

View file

@ -14,25 +14,23 @@ jobs:
- name: Install ninja-build - name: Install ninja-build
run: sudo apt-get install -y ninja-build run: sudo apt-get install -y ninja-build
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Clone submodules - name: Clone submodules
run: git submodule update --init --recursive --remote run: git submodule update --init --recursive --remote
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v3
with: with:
java-version: '17' java-version: '17'
distribution: 'temurin' distribution: 'temurin'
cache: gradle cache: gradle
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Check formatting with spotless - name: Test app with Gradle
run: ./gradlew spotlessCheck run: ./gradlew app:testDebug
- name: Test musikr with Gradle
run: ./gradlew musikr:testDebug
- name: Build debug APK with Gradle - name: Build debug APK with Gradle
run: ./gradlew app:packageDebug run: ./gradlew app:packageDebug
- name: Upload debug APK artifact - name: Upload debug APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3.1.1
with: with:
name: Auxio_Canary name: Auxio_Canary
path: ./app/build/outputs/apk/debug/app-debug.apk path: ./app/build/outputs/apk/debug/app-debug.apk

3
.gitignore vendored
View file

@ -13,6 +13,3 @@ captures/
.externalNativeBuild .externalNativeBuild
*.iml *.iml
.cxx .cxx
.kotlin
.aider*
.env

5
.gitmodules vendored
View file

@ -1,8 +1,3 @@
[submodule "media"] [submodule "media"]
path = media path = media
url = https://github.com/OxygenCobalt/media.git 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

View file

@ -1,101 +1,5 @@
# Changelog # 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 ## 3.6.3
#### What's Fixed #### What's Fixed

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.4"> <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=v4.0.4&color=64B5F6&style=flat"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.6.3&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -15,12 +15,7 @@
</p> </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> <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"> <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://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://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://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></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> </p>
@ -33,12 +28,14 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
## Screenshots ## Screenshots
<p align="center"> <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/shot0.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=250> <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=250> <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=250> <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=250> <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=250> <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> </p>
@ -64,13 +61,13 @@ precise/original dates, sort tags, and more
- Headset autoplay - Headset autoplay
- Stylish widgets that automatically adapt to their size - Stylish widgets that automatically adapt to their size
- Completely private and offline - Completely private and offline
- No rounded album covers (if you want them) - No rounded album covers (by default)
## Permissions ## Permissions
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files - 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 - 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 ## 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"><b>$16/month supporters:</b></p>
<p align="center"> <p align="center">
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></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>
<br/>
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
</p> </p>
<p align="center"><b>$8/month supporters:</b></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"> <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/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/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/gtsiam"><img src="https://avatars.githubusercontent.com/u/7459196?v=4" width=50 /></a>
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
</p> </p>
## Building ## Building
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
parsing. This adds some caveats to the build process:
1. `cmake` and `ninja-build` must be installed before building the project. 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 2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download the external code. download the external code.

View file

View file

@ -2,6 +2,7 @@ plugins {
id "com.android.application" id "com.android.application"
id "kotlin-android" id "kotlin-android"
id "androidx.navigation.safeargs.kotlin" id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize" id "kotlin-parcelize"
id "dagger.hilt.android.plugin" id "dagger.hilt.android.plugin"
id "kotlin-kapt" id "kotlin-kapt"
@ -10,19 +11,21 @@ plugins {
} }
android { android {
compileSdk 35 compileSdk 34
// Auxio implicitly depends on the native modules, explicitly specify it // NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
// here so the libraries are still stripped. // it here so that binary stripping will work.
ndkVersion ndk_version // 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" namespace "org.oxycblt.auxio"
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "4.0.4" versionName "3.6.3"
versionCode 63 versionCode 53
minSdk min_sdk minSdk 24
targetSdk target_sdk targetSdk 34
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
@ -67,7 +70,6 @@ android {
buildFeatures { buildFeatures {
viewBinding true viewBinding true
buildConfig true
} }
} }
@ -77,16 +79,16 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version" def coroutines_version = '1.7.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
// --- SUPPORT --- // --- SUPPORT ---
// General // General
implementation "androidx.core:core-ktx:$core_version" implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.7.0" implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.activity:activity-ktx:1.9.3" implementation "androidx.activity:activity-ktx:1.8.2"
// noinspection GradleDependency
implementation "androidx.fragment:fragment-ktx:1.6.2" implementation "androidx.fragment:fragment-ktx:1.6.2"
// Components // Components
@ -95,13 +97,11 @@ dependencies {
// TODO: Report this issue and hope for a timely fix // TODO: Report this issue and hope for a timely fix
// noinspection GradleDependency // noinspection GradleDependency
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.2.0" implementation "androidx.constraintlayout:constraintlayout:2.1.4"
// 1.1.0 upgrades recyclerview to 1.3.0, keep it on 1.0.0
//noinspection GradleDependency
implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "androidx.viewpager2:viewpager2:1.0.0"
// Lifecycle // 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:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -121,31 +121,25 @@ dependencies {
implementation "androidx.preference:preference-ktx:1.2.1" implementation "androidx.preference:preference-ktx:1.2.1"
// Database // Database
def room_version = '2.6.1'
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version" ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$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 --- // --- THIRD PARTY ---
// Exoplayer (Vendored) // Exoplayer (Vendored)
implementation project(":media-lib-exoplayer") implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
// Image loading // Image loading
implementation 'io.coil-kt.coil3:coil-core:3.0.2' implementation 'io.coil-kt:coil-base:2.4.0'
// Material // Material
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
// PR a fix. // PR a fix.
implementation "com.google.android.material:material:1.13.0-alpha07" implementation "com.google.android.material:material:1.10.0"
// Dependency Injection // Dependency Injection
implementation "com.google.dagger:dagger:$hilt_version" implementation "com.google.dagger:dagger:$hilt_version"
@ -164,4 +158,25 @@ dependencies {
// Fuzzy search // Fuzzy search
implementation 'org.apache.commons:commons-text:1.9' 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
} }

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="info_app_name" translatable="false">Auxio Debug</string> <string name="info_app_name" translatable="false">Auxio Debug</string>
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
</resources> </resources>

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> 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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@ -45,7 +48,6 @@
android:exported="true" android:exported="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask" android:launchMode="singleTask"
android:allowCrossUidActivitySwitchFromBelow="false"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustPan">
@ -90,22 +92,12 @@
android:foregroundServiceType="mediaPlayback" android:foregroundServiceType="mediaPlayback"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:exported="true" android:exported="true"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher">
tools:ignore="ExportedService">
<intent-filter> <intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/> <action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter> </intent-filter>
</service> </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. Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info. See the class for more info.

View file

@ -1309,6 +1309,7 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
+ " should not be set externally."); + " should not be set externally.");
} }
if (!hideable && state == STATE_HIDDEN) { if (!hideable && state == STATE_HIDDEN) {
Log.w(TAG, "Cannot set state: " + state);
return; return;
} }
final int finalState; final int finalState;
@ -1389,10 +1390,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return shouldRemoveExpandedCorners; return shouldRemoveExpandedCorners;
} }
public void killCorners() {
materialShapeDrawable.setCornerSize(0f);
}
/** /**
* Gets the current state of the bottom sheet. * Gets the current state of the bottom sheet.
* *
@ -1632,13 +1629,12 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return; return;
} }
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked(); BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
boolean canActuallyHide = hideable && isHideableWhenDragging();
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { 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. // 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; return;
} }
if (canActuallyHide) { if (hideable) {
bottomContainerBackHelper.finishBackProgressNotPersistent( bottomContainerBackHelper.finishBackProgressNotPersistent(
backEvent, backEvent,
new AnimatorListenerAdapter() { new AnimatorListenerAdapter() {

View file

@ -29,7 +29,6 @@ import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.CopyleftNoticeTree
import timber.log.Timber import timber.log.Timber
/** /**
@ -46,11 +45,7 @@ class Auxio : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@Suppress("KotlinConstantConditions") if (BuildConfig.DEBUG) {
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
Timber.plant(CopyleftNoticeTree())
} else if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} }

View file

@ -36,7 +36,7 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
import timber.log.Timber import org.oxycblt.auxio.util.logD
@AndroidEntryPoint @AndroidEntryPoint
class AuxioService : class AuxioService :
@ -54,30 +54,24 @@ class AuxioService :
musicFragment = musicFragmentFactory.create(this, this, this) musicFragment = musicFragmentFactory.create(this, this, this)
sessionToken = playbackFragment.attach() sessionToken = playbackFragment.attach()
musicFragment.attach() musicFragment.attach()
Timber.d("Service Created")
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached // TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here. // service, we might need more handling here.
super.onStartCommand(intent, flags, startId)
onHandleForeground(intent) onHandleForeground(intent)
// If we die we want to not restart, we will immediately try to foreground in and just return super.onStartCommand(intent, flags, startId)
// 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
} }
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
val binder = super.onBind(intent)
onHandleForeground(intent) onHandleForeground(intent)
return binder return super.onBind(intent)
} }
private fun onHandleForeground(intent: Intent?) { private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
musicFragment.start() musicFragment.start()
playbackFragment.start(intent) playbackFragment.start(startId)
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
@ -123,7 +117,8 @@ class AuxioService :
private fun getRootChildrenLimit(): Int { private fun getRootChildrenLimit(): Int {
return browserRootHints?.getInt( 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? { 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 // Nothing changed, but don't show anything music related since we can always
// index during playback. // index during playback.
isForeground = true
} else { } else {
musicFragment.createNotification { musicFragment.createNotification {
if (it != null) { if (it != null) {
@ -156,12 +150,11 @@ class AuxioService :
} }
override fun invalidateMusic(mediaId: String) { override fun invalidateMusic(mediaId: String) {
logD(mediaId)
notifyChildrenChanged(mediaId) notifyChildrenChanged(mediaId)
} }
companion object { companion object {
const val ACTION_START = BuildConfig.APPLICATION_ID + ".service.START"
var isForeground = false var isForeground = false
private set private set

View file

@ -49,10 +49,8 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST_SONG = 0xA00A const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */ /** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00B const val VIEW_TYPE_DISC_HEADER = 0xA00B
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_DIVIDER = 0xA00C
/** EditHeaderViewHolder */ /** EditHeaderViewHolder */
const val VIEW_TYPE_EDIT_HEADER = 0xA00D const val VIEW_TYPE_EDIT_HEADER = 0xA00C
/** PlaylistSongViewHolder */ /** PlaylistSongViewHolder */
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
/** "Music playback" notification code */ /** "Music playback" notification code */
@ -65,8 +63,6 @@ object IntegerTable {
const val START_ID_ACTIVITY = 0xA050 const val START_ID_ACTIVITY = 0xA050
/** Tasker AuxioService Start ID */ /** Tasker AuxioService Start ID */
const val START_ID_TASKER = 0xA051 const val START_ID_TASKER = 0xA051
/** MediaButtonReceiver AuxioService Start ID */
const val START_ID_MEDIA_BUTTON = 0xA052
/** RepeatMode.NONE */ /** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100 const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */ /** RepeatMode.ALL */
@ -125,10 +121,10 @@ object IntegerTable {
const val ACTION_MODE_SHUFFLE = 0xA11B const val ACTION_MODE_SHUFFLE = 0xA11B
/** CoverMode.Off */ /** CoverMode.Off */
const val COVER_MODE_OFF = 0xA11C const val COVER_MODE_OFF = 0xA11C
/** CoverMode.Balanced */ /** CoverMode.MediaStore */
const val COVER_MODE_BALANCED = 0xA11D const val COVER_MODE_MEDIA_STORE = 0xA11D
/** CoverMode.Quality */ /** CoverMode.Quality */
const val COVER_MODE_HIGH_QUALITY = 0xA11E const val COVER_MODE_QUALITY = 0xA11E
/** PlaySong.FromAll */ /** PlaySong.FromAll */
const val PLAY_SONG_FROM_ALL = 0xA11F const val PLAY_SONG_FROM_ALL = 0xA11F
/** PlaySong.FromAlbum */ /** PlaySong.FromAlbum */
@ -141,8 +137,4 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123 const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */ /** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124 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
} }

View file

@ -33,8 +33,9 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.DeferredPlayback import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight 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 org.oxycblt.auxio.util.systemBarInsetsCompat
import timber.log.Timber as L
/** /**
* Auxio's single [AppCompatActivity]. * Auxio's single [AppCompatActivity].
@ -62,7 +63,7 @@ class MainActivity : AppCompatActivity() {
val binding = ActivityMainBinding.inflate(layoutInflater) val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setupEdgeToEdge(binding.root) setupEdgeToEdge(binding.root)
L.d("Activity created") logD("Activity created")
} }
override fun onResume() { override fun onResume() {
@ -70,7 +71,6 @@ class MainActivity : AppCompatActivity() {
startService( startService(
Intent(this, AuxioService::class.java) Intent(this, AuxioService::class.java)
.setAction(AuxioService.ACTION_START)
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY)) .putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
if (!startIntentAction(intent)) { 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 // 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. // it's not possible to modify the themes at run-time.
if (isNight && uiSettings.useBlackTheme) { if (isNight && uiSettings.useBlackTheme) {
L.d("Applying black theme [accent ${uiSettings.accent}]") logD("Applying black theme [accent ${uiSettings.accent}]")
setTheme(uiSettings.accent.blackTheme) setTheme(uiSettings.accent.blackTheme)
} else { } else {
L.d("Applying normal theme [accent ${uiSettings.accent}]") logD("Applying normal theme [accent ${uiSettings.accent}]")
setTheme(uiSettings.accent.theme) setTheme(uiSettings.accent.theme)
} }
} }
@ -120,7 +120,7 @@ class MainActivity : AppCompatActivity() {
private fun startIntentAction(intent: Intent?): Boolean { private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) { if (intent == null) {
// Nothing to do. // Nothing to do.
L.d("No intent to handle") logD("No intent to handle")
return false return false
} }
@ -129,7 +129,7 @@ class MainActivity : AppCompatActivity() {
// This is because onStart can run multiple times, and thus we really don't // 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 // want to return false and override the original delayed action with a
// RestoreState action. // RestoreState action.
L.d("Already used this intent") logD("Already used this intent")
return true return true
} }
intent.putExtra(KEY_INTENT_USED, true) intent.putExtra(KEY_INTENT_USED, true)
@ -139,11 +139,11 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false) Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
else -> { else -> {
L.w("Unexpected intent ${intent.action}") logW("Unexpected intent ${intent.action}")
return false return false
} }
} }
L.d("Translated intent to $action") logD("Translated intent to $action")
playbackModel.playDeferred(action) playbackModel.playDeferred(action)
return true return true
} }

View file

@ -22,25 +22,21 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowInsets import android.view.WindowInsets
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.google.android.material.R as MR import com.google.android.material.R as MR
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior 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.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialFadeThrough
import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialOverlayLayout
import com.leinardi.android.speeddial.SpeedDialView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Method import java.lang.reflect.Field
import javax.inject.Inject
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding 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.HomeViewModel
import org.oxycblt.auxio.home.Outer import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.OpenPanel import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately 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.coordinatorLayoutBehavior
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen 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.navigateSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.unlikelyToBeNull 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. * 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 @AndroidEntryPoint
class MainFragment : class MainFragment :
ViewBindingFragment<FragmentMainBinding>(), ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
ViewTreeObserver.OnPreDrawListener,
SpeedDialView.OnActionSelectedListener {
private val musicModel: MusicViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels()
private val listModel: ListViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels()
@ -91,13 +81,9 @@ class MainFragment :
private var detailBackCallback: DetailBackPressedCallback? = null private var detailBackCallback: DetailBackPressedCallback? = null
private var selectionBackCallback: SelectionBackPressedCallback? = null private var selectionBackCallback: SelectionBackPressedCallback? = null
private var speedDialBackCallback: SpeedDialBackPressedCallback? = null private var speedDialBackCallback: SpeedDialBackPressedCallback? = null
private var navigationListener: DialogAwareNavigationListener? = null private var selectionNavigationListener: DialogAwareNavigationListener? = null
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -112,13 +98,10 @@ class MainFragment :
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
playbackSheetBehavior.uiSettings = uiSettings
playbackSheetBehavior.makeBackgroundDrawable(requireContext())
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? 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 // 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 // that instantiating these callbacks in their respective fragments would result in the
@ -131,9 +114,10 @@ class MainFragment :
DetailBackPressedCallback(detailModel).also { detailBackCallback = it } DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
val selectionBackCallback = val selectionBackCallback =
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it } SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
speedDialBackCallback = SpeedDialBackPressedCallback() val speedDialBackCallback =
SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it }
navigationListener = DialogAwareNavigationListener(::onExploreNavigate) selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
// --- UI SETUP --- // --- UI SETUP ---
val context = requireActivity() val context = requireActivity()
@ -151,50 +135,30 @@ class MainFragment :
if (queueSheetBehavior != null) { if (queueSheetBehavior != null) {
// In portrait mode, set up click listeners on the stacked sheets. // 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 { unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
playbackModel.openQueue() playbackModel.openQueue()
} }
} else { } else {
// Dual-pane mode, manually style the static queue sheet. // 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 { binding.queueSheet.apply {
// Emulate the elevated bottom sheet style. // Emulate the elevated bottom sheet style.
background = background =
MaterialShapeDrawable.createWithElevationOverlay(context).apply { MaterialShapeDrawable.createWithElevationOverlay(context).apply {
shapeAppearanceModel = fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
ShapeAppearanceModel.builder( elevation = context.getDimen(R.dimen.elevation_normal)
context,
MR.style.ShapeAppearance_Material3_Corner_ExtraLarge,
MR.style.ShapeAppearanceOverlay_Material3_Corner_Top)
.build()
fillColor = context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
} }
// Apply bar insets for the queue's RecyclerView to use.
setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(top = insets.systemBarInsetsCompat.top)
insets
}
} }
} }
normalCornerSize = playbackSheetBehavior.sheetBackgroundDrawable.topLeftCornerResolvedSize binding.mainScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
maxScaleXDistance = binding.sheetScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
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)
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// This has to be done here instead of the playback panel to make sure that it's prioritized // 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) collect(detailModel.toShow.flow, ::handleShow)
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.speedDialOpen, ::handleSpeedDialState)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexingState, ::updateIndexerState)
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.openPanel.flow, ::handlePanel) collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
@ -217,7 +179,7 @@ class MainFragment :
val binding = requireBinding() val binding = requireBinding()
// Once we add the destination change callback, we will receive another initialization call, // Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag. // so handle that by resetting the flag.
requireNotNull(navigationListener) { "NavigationListener was not available" } requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
.attach(binding.exploreNavHost.findNavController()) .attach(binding.exploreNavHost.findNavController())
// Listener could still reasonably fire even if we clear the binding, attach/detach // Listener could still reasonably fire even if we clear the binding, attach/detach
// our pre-draw listener our listener in onStart/onStop respectively. // our pre-draw listener our listener in onStart/onStop respectively.
@ -240,7 +202,7 @@ class MainFragment :
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
val binding = requireBinding() val binding = requireBinding()
requireNotNull(navigationListener) { "NavigationListener was not available" } requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
.release(binding.exploreNavHost.findNavController()) .release(binding.exploreNavHost.findNavController())
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this) binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
} }
@ -251,15 +213,13 @@ class MainFragment :
sheetBackCallback = null sheetBackCallback = null
detailBackCallback = null detailBackCallback = null
selectionBackCallback = null selectionBackCallback = null
navigationListener = null selectionNavigationListener = null
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
} }
override fun onPreDraw(): Boolean { override fun onPreDraw(): Boolean {
// This is where I shove literally all the UI logic that won't behave any callback // TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
// or "normal" method I've tried. Surely running this on every frame will actually cause // sheets continually getting stuck. I need something with even more frequent updates,
// it to work properly! // or otherwise bottom sheets get stuck.
// We overload CoordinatorLayout far too much to rely on any of it's typical // We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should // listener functionality. Just update all transitions before every draw. Should
@ -271,55 +231,28 @@ class MainFragment :
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f) val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
// Stupid hack to prevent you from sliding the sheet up without closing the speed if (playbackRatio > 0f && homeModel.speedDialOpen.value) {
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the // Stupid hack to prevent you from sliding the sheet up without closing the speed
// speed dial, which is super finicky behavior. // dial. Filtering out ACTION_MOVE events will cause back gestures to close the speed
val rising = playbackRatio > 0f // dial, which is super finicky behavior.
if (rising != sheetRising) { homeModel.setSpeedDialOpen(false)
sheetRising = rising
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
} }
val playbackOutRatio = 1 - min(playbackRatio * 2, 1f) val outPlaybackRatio = 1 - playbackRatio
val playbackInRatio = max(playbackRatio - 0.5f, 0f) * 2 val halfOutRatio = min(playbackRatio * 2, 1f)
val halfInPlaybackRatio = 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
if (queueSheetBehavior != null) { 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 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 binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio)
val queueBackRatio = binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
max(1 - ((1 - binding.queueSheet.scaleX) / queueMaxXScaleDelta), 0f) binding.queueFragment.alpha = queueRatio
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
if (playbackModel.song.value != null) { if (playbackModel.song.value != null) {
// Playback sheet intercepts queue sheet touch events, prevent that from // Playback sheet intercepts queue sheet touch events, prevent that from
@ -329,18 +262,33 @@ class MainFragment :
} }
} else { } else {
// No queue sheet, fade normally based on the playback sheet // No queue sheet, fade normally based on the playback sheet
binding.playbackBarFragment.alpha = playbackOutRatio binding.playbackBarFragment.alpha = 1 - halfOutRatio
binding.playbackPanelFragment.alpha = playbackInRatio binding.playbackPanelFragment.alpha = halfInPlaybackRatio
(binding.queueSheet.background as MaterialShapeDrawable).shapeAppearanceModel =
ShapeAppearanceModel.builder()
.setTopLeftCornerSize(normalCornerSize)
.setTopRightCornerSize(normalCornerSize * (1 - playbackLastStretchRatio))
.build()
} }
// 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. // Fade out the playback bar as the panel expands.
binding.playbackBarFragment.apply { binding.playbackBarFragment.apply {
// Prevent interactions when the playback bar fully fades out. // Prevent interactions when the playback bar fully fades out.
isInvisible = alpha == 0f 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. // Prevent interactions when the playback panel fully fades out.
@ -348,7 +296,7 @@ class MainFragment :
binding.queueSheet.apply { binding.queueSheet.apply {
// Queue sheet (not queue content) should fade out with the playback panel. // 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. // Prevent interactions when the queue sheet fully fades out.
binding.queueSheet.isInvisible = alpha == 0f binding.queueSheet.isInvisible = alpha == 0f
} }
@ -367,160 +315,9 @@ class MainFragment :
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" } requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled() .invalidateEnabled()
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
binding.mainFabContainer.isVisible =
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
return true 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?) { private fun handleShow(show: Show?) {
when (show) { when (show) {
is Show.SongAlbumDetails, is Show.SongAlbumDetails,
@ -546,6 +343,13 @@ class MainFragment :
homeModel.showOuter.consume() 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?) { private fun updateSong(song: Song?) {
if (song != null) { if (song != null) {
tryShowSheets() tryShowSheets()
@ -556,7 +360,7 @@ class MainFragment :
private fun handlePanel(panel: OpenPanel?) { private fun handlePanel(panel: OpenPanel?) {
if (panel == null) return if (panel == null) return
L.d("Trying to update panel to $panel") logD("Trying to update panel to $panel")
when (panel) { when (panel) {
OpenPanel.MAIN -> tryClosePlaybackPanel() OpenPanel.MAIN -> tryClosePlaybackPanel()
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel() OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
@ -572,7 +376,7 @@ class MainFragment :
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) { if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it. // Playback sheet is not expanded and not hidden, we can expand it.
L.d("Expanding playback sheet") logD("Expanding playback sheet")
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
return return
} }
@ -583,7 +387,7 @@ class MainFragment :
queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) { queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Queue sheet and playback sheet is expanded, close the queue sheet so the // Queue sheet and playback sheet is expanded, close the queue sheet so the
// playback panel can shown. // playback panel can shown.
L.d("Collapsing queue sheet") logD("Collapsing queue sheet")
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
} }
} }
@ -594,7 +398,7 @@ class MainFragment :
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) { if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Playback sheet (and possibly queue) needs to be collapsed. // Playback sheet (and possibly queue) needs to be collapsed.
L.d("Collapsing playback and queue sheets") logD("Collapsing playback and queue sheets")
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@ -620,7 +424,7 @@ class MainFragment :
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) { if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
L.d("Unhiding and enabling playback sheet") logD("Unhiding and enabling playback sheet")
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed // Queue sheet behavior is either collapsed or expanded, no hiding needed
@ -641,7 +445,7 @@ class MainFragment :
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior? 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. // Make both bottom sheets non-draggable so the user can't halt the hiding event.
queueSheetBehavior?.apply { queueSheetBehavior?.apply {
@ -660,49 +464,19 @@ class MainFragment :
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>, private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
private val queueSheetBehavior: QueueBottomSheetBehavior<*>? private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
) : OnBackPressedCallback(false) { ) : 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() { override fun handleOnBackPressed() {
// If expanded, collapse the queue sheet first.
if (queueSheetShown()) { if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).handleBackInvoked() unlikelyToBeNull(queueSheetBehavior).state =
BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed queue sheet")
return return
} }
// If expanded, collapse the playback sheet next.
if (playbackSheetShown()) { if (playbackSheetShown()) {
playbackSheetBehavior.handleBackInvoked() playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
return logD("Collapsed playback sheet")
}
}
override fun handleOnBackCancelled() {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).cancelBackProgress()
return
}
if (playbackSheetShown()) {
playbackSheetBehavior.cancelBackProgress()
return return
} }
} }
@ -725,7 +499,7 @@ class MainFragment :
OnBackPressedCallback(false) { OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
if (detailModel.dropPlaylistEdit()) { if (detailModel.dropPlaylistEdit()) {
L.d("Dropped playlist edits") logD("Dropped playlist edits")
} }
} }
@ -738,7 +512,7 @@ class MainFragment :
OnBackPressedCallback(false) { OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
if (listModel.dropSelection()) { 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() { override fun handleOnBackPressed() {
val binding = requireBinding() if (homeModel.speedDialOpen.value) {
if (binding.homeNewPlaylistFab.isOpen) { homeModel.setSpeedDialOpen(false)
binding.homeNewPlaylistFab.close()
} }
} }
@ -761,11 +535,7 @@ class MainFragment :
} }
private companion object { private companion object {
val FAB_HIDE_FROM_USER_FIELD: Method by val SPEED_DIAL_OVERLAY_ANIMATION_DURATION_FIELD: Field by
lazyReflectedMethod( lazyReflectedField(SpeedDialOverlayLayout::class, "mAnimationDuration")
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
} }
} }

View file

@ -19,34 +19,45 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding 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.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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu 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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.playback.PlaybackDecision 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.collect
import org.oxycblt.auxio.util.collectImmediately 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.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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]. * A [ListFragment] that shows information about an [Album].
@ -54,17 +65,60 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @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 // Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album. // as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs() private val args: AlbumDetailFragmentArgs by navArgs()
private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this)
private val albumListAdapter = AlbumDetailListAdapter(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?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) 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 --- // -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbum(args.albumUid) detailModel.setAlbum(args.albumUid)
@ -82,6 +136,8 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) 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 // 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. // during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.albumSongInstructions.consume() detailModel.albumSongInstructions.consume()
@ -91,68 +147,34 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
playbackModel.play(item, detailModel.playInAlbumWith) playbackModel.play(item, detailModel.playInAlbumWith)
} }
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onOpenMenu(item: Song) { override fun onOpenMenu(item: Song) {
listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith) 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() { override fun onOpenSortMenu() {
findNavController().navigateSafe(AlbumDetailFragmentDirections.sort()) findNavController().navigateSafe(AlbumDetailFragmentDirections.sort())
} }
override fun onNavigateToParentArtist() {
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
}
private fun updateAlbum(album: Album?) { private fun updateAlbum(album: Album?) {
if (album == null) { if (album == null) {
L.d("No album to show, navigating away") logD("No album to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
val binding = requireBinding() albumHeaderAdapter.setParent(album)
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)
} }
private fun updateList(list: List<Item>) { private fun updateList(list: List<Item>) {
@ -163,7 +185,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
val binding = requireBinding() val binding = requireBinding()
when (show) { when (show) {
is Show.SongDetails -> { is Show.SongDetails -> {
L.d("Navigating to ${show.song}") logD("Navigating to ${show.song}")
findNavController() findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid)) .navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
} }
@ -172,11 +194,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// fragment should be launched otherwise. // fragment should be launched otherwise.
is Show.SongAlbumDetails -> { is Show.SongAlbumDetails -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) { 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) scrollToAlbumSong(show.song)
detailModel.toShow.consume() detailModel.toShow.consume()
} else { } else {
L.d("Navigating to the album of ${show.song}") logD("Navigating to the album of ${show.song}")
findNavController() findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid)) .navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
} }
@ -186,27 +208,27 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// detail fragment. // detail fragment.
is Show.AlbumDetails -> { is Show.AlbumDetails -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) { 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) binding.detailRecycler.scrollToPosition(0)
detailModel.toShow.consume() detailModel.toShow.consume()
} else { } else {
L.d("Navigating to ${show.album}") logD("Navigating to ${show.album}")
findNavController() findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid)) .navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
} }
} }
is Show.ArtistDetails -> { is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}") logD("Navigating to ${show.artist}")
findNavController() findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid)) .navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
} }
is Show.SongArtistDecision -> { is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}") logD("Navigating to artist choices for ${show.song}")
findNavController() findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid)) .navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
} }
is Show.AlbumArtistDecision -> { is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}") logD("Navigating to artist choices for ${show.album}")
findNavController() findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid)) .navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
} }
@ -249,7 +271,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
val directions = val directions =
when (decision) { when (decision) {
is PlaylistDecision.Add -> { is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist") logD("Adding ${decision.songs.size} songs to a playlist")
AlbumDetailFragmentDirections.addToPlaylist( AlbumDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
@ -278,11 +300,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
val directions = val directions =
when (decision) { when (decision) {
is PlaybackDecision.PlayFromArtist -> { 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) AlbumDetailFragmentDirections.playFromArtist(decision.song.uid)
} }
is PlaybackDecision.PlayFromGenre -> { 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) AlbumDetailFragmentDirections.playFromGenre(decision.song.uid)
} }
} }
@ -296,14 +318,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
if (pos != -1) { if (pos != -1) {
// Only scroll if the song is within this album. // Only scroll if the song is within this album.
val binding = requireBinding() 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 { binding.detailRecycler.post {
// Use a custom smooth scroller that will settle the item in the middle of // Use a custom smooth scroller that will settle the item in the middle of
// the screen rather than the end. // 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 // Make sure to increment the position to make up for the detail header
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller) binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
// 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()
} }
} }
} }
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
} }

View file

@ -19,33 +19,44 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle 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.findNavController
import androidx.navigation.fragment.navArgs 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 dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding 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.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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu 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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe 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.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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]. * A [ListFragment] that shows information about an [Artist].
@ -53,17 +64,63 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @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 // Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist. // as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs() private val args: ArtistDetailFragmentArgs by navArgs()
private val artistHeaderAdapter = ArtistDetailHeaderAdapter(this)
private val artistListAdapter = ArtistDetailListAdapter(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?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) 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 --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtist(args.artistUid) detailModel.setArtist(args.artistUid)
@ -81,6 +138,8 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) 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 // 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. // during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.artistSongInstructions.consume() 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) { override fun onOpenMenu(item: Music) {
when (item) { when (item) {
is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith) 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() { override fun onOpenSortMenu() {
findNavController().navigateSafe(ArtistDetailFragmentDirections.sort()) findNavController().navigateSafe(ArtistDetailFragmentDirections.sort())
} }
private fun updateArtist(artist: Artist?) { private fun updateArtist(artist: Artist?) {
if (artist == null) { if (artist == null) {
L.d("No artist to show, navigating away") logD("No artist to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
val binding = requireBinding() requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
val context = requireContext() artistHeaderAdapter.setParent(artist)
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)
} }
private fun updateList(list: List<Item>) { private fun updateList(list: List<Item>) {
@ -185,14 +191,14 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
val binding = requireBinding() val binding = requireBinding()
when (show) { when (show) {
is Show.SongDetails -> { is Show.SongDetails -> {
L.d("Navigating to ${show.song}") logD("Navigating to ${show.song}")
findNavController() findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid)) .navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
} }
// Songs should be shown in their album, not in their artist. // Songs should be shown in their album, not in their artist.
is Show.SongAlbumDetails -> { is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}") logD("Navigating to the album of ${show.song}")
findNavController() findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid)) .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 // Launch a new detail view for an album, even if it is part of
// this artist. // this artist.
is Show.AlbumDetails -> { is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}") logD("Navigating to ${show.album}")
findNavController() findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid)) .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. // scroll back to the top. Otherwise launch a new detail view.
is Show.ArtistDetails -> { is Show.ArtistDetails -> {
if (show.artist == detailModel.currentArtist.value) { 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) binding.detailRecycler.scrollToPosition(0)
detailModel.toShow.consume() detailModel.toShow.consume()
} else { } else {
L.d("Navigating to ${show.artist}") logD("Navigating to ${show.artist}")
findNavController() findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid)) .navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
} }
} }
is Show.SongArtistDecision -> { is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}") logD("Navigating to artist choices for ${show.song}")
findNavController() findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid)) .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
} }
is Show.AlbumArtistDecision -> { is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}") logD("Navigating to artist choices for ${show.album}")
findNavController() findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid)) .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
} }
@ -268,7 +274,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
val directions = val directions =
when (decision) { when (decision) {
is PlaylistDecision.Add -> { is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist") logD("Adding ${decision.songs.size} songs to a playlist")
ArtistDetailFragmentDirections.addToPlaylist( ArtistDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
@ -309,7 +315,7 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
is PlaybackDecision.PlayFromArtist -> is PlaybackDecision.PlayFromArtist ->
error("Unexpected playback decision $decision") error("Unexpected playback decision $decision")
is PlaybackDecision.PlayFromGenre -> { 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) ArtistDetailFragmentDirections.playFromGenre(decision.song.uid)
} }
} }

View file

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

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

View file

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

View file

@ -23,18 +23,18 @@ import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Playlist
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.musikr.MusicParent import org.oxycblt.auxio.util.logD
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
interface DetailGenerator { interface DetailGenerator {
fun any(uid: Music.UID): Detail<out MusicParent>? fun any(uid: Music.UID): Detail<out MusicParent>?
@ -121,7 +121,7 @@ private class DetailGeneratorImpl(
} }
override fun album(uid: Music.UID): Detail<Album>? { 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 songs = listSettings.albumSongSort.songs(album.songs)
val discs = songs.groupBy { it.disc } val discs = songs.groupBy { it.disc }
val section = val section =
@ -134,7 +134,7 @@ private class DetailGeneratorImpl(
} }
override fun artist(uid: Music.UID): Detail<Artist>? { 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 = val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) { artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into detail sections // Remap the complicated ReleaseType data structure into detail sections
@ -156,7 +156,7 @@ private class DetailGeneratorImpl(
} }
if (artist.implicitAlbums.isNotEmpty()) { if (artist.implicitAlbums.isNotEmpty()) {
L.d("Implicit albums present, adding to list") logD("Implicit albums present, adding to list")
grouping[DetailSection.Albums.Category.APPEARANCES] = grouping[DetailSection.Albums.Category.APPEARANCES] =
artist.implicitAlbums.toMutableList() artist.implicitAlbums.toMutableList()
} }
@ -173,14 +173,14 @@ private class DetailGeneratorImpl(
} }
override fun genre(uid: Music.UID): Detail<Genre>? { 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 artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs)) val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
return Detail(genre, listOf(artists, songs)) return Detail(genre, listOf(artists, songs))
} }
override fun playlist(uid: Music.UID): Detail<Playlist>? { 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()) { if (playlist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(playlist.songs) val songs = DetailSection.Songs(playlist.songs)
return Detail(playlist, listOf(songs)) return Detail(playlist, listOf(songs))

View file

@ -22,37 +22,40 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R 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.DiscHeader
import org.oxycblt.auxio.detail.list.EditHeader 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.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader 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.Item
import org.oxycblt.auxio.list.ListSettings 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.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType 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.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent 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.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 * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
@ -66,11 +69,11 @@ class DetailViewModel
constructor( constructor(
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val audioPropertiesFactory: AudioProperties.Factory,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
detailGeneratorFactory: DetailGenerator.Factory detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator { ) : ViewModel(), DetailGenerator.Invalidator {
private val _toShow = MutableEvent<Show>() private val _toShow = MutableEvent<Show>()
/** /**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently. * A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
*/ */
@ -79,34 +82,30 @@ constructor(
// --- SONG --- // --- 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. */ /** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?> val currentSong: StateFlow<Song?>
get() = _currentSong get() = _currentSong
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf()) private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
/** The current properties of [currentSong]. Empty if nothing to show. */ val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
val currentSongProperties: StateFlow<List<SongProperty>>
get() = _currentSongProperties
// --- ALBUM --- // --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null) private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */ /** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?> val currentAlbum: StateFlow<Album?>
get() = _currentAlbum get() = _currentAlbum
private val _albumSongList = MutableStateFlow(listOf<Item>()) private val _albumSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentAlbum]. */ /** The current list data derived from [currentAlbum]. */
val albumSongList: StateFlow<List<Item>> val albumSongList: StateFlow<List<Item>>
get() = _albumSongList get() = _albumSongList
private val _albumSongInstructions = MutableEvent<UpdateInstructions>() private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumSongList] in the UI. */ /** Instructions for updating [albumSongList] in the UI. */
val albumSongInstructions: Event<UpdateInstructions> val albumSongInstructions: Event<UpdateInstructions>
get() = _albumSongInstructions get() = _albumSongInstructions
@ -122,18 +121,15 @@ constructor(
// --- ARTIST --- // --- ARTIST ---
private val _currentArtist = MutableStateFlow<Artist?>(null) private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */ /** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?> val currentArtist: StateFlow<Artist?>
get() = _currentArtist get() = _currentArtist
private val _artistSongList = MutableStateFlow(listOf<Item>()) private val _artistSongList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */ /** The current list derived from [currentArtist]. */
val artistSongList: StateFlow<List<Item>> = _artistSongList val artistSongList: StateFlow<List<Item>> = _artistSongList
private val _artistSongInstructions = MutableEvent<UpdateInstructions>() private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */ /** Instructions for updating [artistSongList] in the UI. */
val artistSongInstructions: Event<UpdateInstructions> val artistSongInstructions: Event<UpdateInstructions>
get() = _artistSongInstructions get() = _artistSongInstructions
@ -149,18 +145,15 @@ constructor(
// --- GENRE --- // --- GENRE ---
private val _currentGenre = MutableStateFlow<Genre?>(null) private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */ /** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?> val currentGenre: StateFlow<Genre?>
get() = _currentGenre get() = _currentGenre
private val _genreSongList = MutableStateFlow(listOf<Item>()) private val _genreSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */ /** The current list data derived from [currentGenre]. */
val genreSongList: StateFlow<List<Item>> = _genreSongList val genreSongList: StateFlow<List<Item>> = _genreSongList
private val _genreSongInstructions = MutableEvent<UpdateInstructions>() private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */ /** Instructions for updating [artistSongList] in the UI. */
val genreSongInstructions: Event<UpdateInstructions> val genreSongInstructions: Event<UpdateInstructions>
get() = _genreSongInstructions get() = _genreSongInstructions
@ -176,24 +169,20 @@ constructor(
// --- PLAYLIST --- // --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null) private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */ /** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?> val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist get() = _currentPlaylist
private val _playlistSongList = MutableStateFlow(listOf<Item>()) private val _playlistSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */ /** The current list data derived from [currentPlaylist] */
val playlistSongList: StateFlow<List<Item>> = _playlistSongList val playlistSongList: StateFlow<List<Item>> = _playlistSongList
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>() private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistSongList] in the UI. */ /** Instructions for updating [playlistSongList] in the UI. */
val playlistSongInstructions: Event<UpdateInstructions> val playlistSongInstructions: Event<UpdateInstructions>
get() = _playlistSongInstructions get() = _playlistSongInstructions
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null) private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/** /**
* The new playlist songs created during the current editing session. Null if no editing session * The new playlist songs created during the current editing session. Null if no editing session
* is occurring. * is occurring.
@ -312,23 +301,23 @@ constructor(
private fun showImpl(show: Show) { private fun showImpl(show: Show) {
val existing = toShow.flow.value val existing = toShow.flow.value
if (existing != null) { if (existing != null) {
L.d("Already have pending show command $existing, ignoring $show") logD("Already have pending show command $existing, ignoring $show")
return return
} }
_toShow.put(show) _toShow.put(show)
} }
/** /**
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with * Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* the new [Song]. * be updated to align with the new [Song].
* *
* @param uid The UID of the [Song] to load. Must be valid. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSong(uid: Music.UID) { fun setSong(uid: Music.UID) {
L.d("Opening song $uid") logD("Opening song $uid")
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo) _currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
if (_currentSong.value == null) { 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. * @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/ */
fun setAlbum(uid: Music.UID) { fun setAlbum(uid: Music.UID) {
L.d("Opening album $uid") logD("Opening album $uid")
if (uid === _currentAlbum.value?.uid) { if (uid === _currentAlbum.value?.uid) {
return return
} }
val album = detailGenerator.album(uid) val album = detailGenerator.album(uid)
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null) refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
if (_currentAlbum.value == 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. * @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/ */
fun setArtist(uid: Music.UID) { fun setArtist(uid: Music.UID) {
L.d("Opening artist $uid") logD("Opening artist $uid")
if (uid === _currentArtist.value?.uid) { if (uid === _currentArtist.value?.uid) {
return return
} }
@ -390,7 +379,7 @@ constructor(
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid. * @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/ */
fun setGenre(uid: Music.UID) { fun setGenre(uid: Music.UID) {
L.d("Opening genre $uid") logD("Opening genre $uid")
if (uid === _currentGenre.value?.uid) { if (uid === _currentGenre.value?.uid) {
return return
} }
@ -414,7 +403,7 @@ constructor(
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid. * @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
*/ */
fun setPlaylist(uid: Music.UID) { fun setPlaylist(uid: Music.UID) {
L.d("Opening playlist $uid") logD("Opening playlist $uid")
if (uid === _currentPlaylist.value?.uid) { if (uid === _currentPlaylist.value?.uid) {
return return
} }
@ -424,7 +413,7 @@ constructor(
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */ /** Start a playlist editing session. Does nothing if a playlist is not being shown. */
fun startPlaylistEdit() { fun startPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
L.d("Starting playlist edit") logD("Starting playlist edit")
_editedPlaylist.value = playlist.songs _editedPlaylist.value = playlist.songs
refreshPlaylist(playlist.uid) refreshPlaylist(playlist.uid)
} }
@ -436,7 +425,7 @@ constructor(
fun savePlaylistEdit() { fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = _editedPlaylist.value ?: return val editedPlaylist = _editedPlaylist.value ?: return
L.d("Committing playlist edits") logD("Committing playlist edits")
viewModelScope.launch { viewModelScope.launch {
musicRepository.rewritePlaylist(playlist, editedPlaylist) musicRepository.rewritePlaylist(playlist, editedPlaylist)
// TODO: The user could probably press some kind of button if they were fast enough. // 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 { fun movePlaylistSongs(from: Int, to: Int): Boolean {
val playlist = _currentPlaylist.value ?: return false val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList() val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 1 val realFrom = from - 2
val realTo = to - 1 val realTo = to - 2
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) { if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false 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.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist _editedPlaylist.value = editedPlaylist
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to)) refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
@ -504,11 +493,11 @@ constructor(
fun removePlaylistSong(at: Int) { fun removePlaylistSong(at: Int) {
val playlist = _currentPlaylist.value ?: return val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList() val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 1 val realAt = at - 2
if (realAt !in editedPlaylist.indices) { if (realAt !in editedPlaylist.indices) {
return return
} }
L.d("Removing playlist song at $realAt [$at]") logD("Removing playlist song at $realAt [$at]")
editedPlaylist.removeAt(realAt) editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist _editedPlaylist.value = editedPlaylist
refreshPlaylist( refreshPlaylist(
@ -516,39 +505,23 @@ constructor(
if (editedPlaylist.isNotEmpty()) { if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1) UpdateInstructions.Remove(at, 1)
} else { } else {
L.d("Playlist will be empty after removal, removing header") logD("Playlist will be empty after removal, removing header")
UpdateInstructions.Remove(at - 1, 3) UpdateInstructions.Remove(at - 2, 3)
}) })
} }
private fun refreshAudioInfo(song: Song) { private fun refreshAudioInfo(song: Song) {
_currentSongProperties.value = buildList { logD("Refreshing audio info")
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song))) // Clear any previous job in order to avoid stale data from appearing in the UI.
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album))) currentSongJob?.cancel()
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists))) _songAudioProperties.value = null
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres))) currentSongJob =
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) } viewModelScope.launch(Dispatchers.IO) {
song.track?.let { val info = audioPropertiesFactory.extract(song)
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null))) yield()
logD("Updating audio info to $info")
_songAudioProperties.value = info
} }
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)))
}
}
} }
private inline fun <T : MusicParent> refreshDetail( private inline fun <T : MusicParent> refreshDetail(
@ -557,7 +530,7 @@ constructor(
list: MutableStateFlow<List<Item>>, list: MutableStateFlow<List<Item>>,
instructions: MutableEvent<UpdateInstructions>, instructions: MutableEvent<UpdateInstructions>,
replace: Int?, replace: Int?,
songHeader: (Int) -> PlainHeader = { SortHeader(it) } songHeader: (Int) -> Header = { SortHeader(it) }
) { ) {
if (detail == null) { if (detail == null) {
parent.value = null parent.value = null
@ -572,28 +545,15 @@ constructor(
val header = val header =
if (section is DetailSection.Songs) songHeader(section.stringRes) if (section is DetailSection.Songs) songHeader(section.stringRes)
else BasicHeader(section.stringRes) else BasicHeader(section.stringRes)
if (newList.isNotEmpty()) { newList.add(Divider(header))
newList.add(PlainDivider(header))
}
newList.add(header) newList.add(header)
section.items section.items
} }
is DetailSection.Discs -> { is DetailSection.Discs -> {
val header = SortHeader(section.stringRes) val header = SortHeader(section.stringRes)
if (newList.isNotEmpty()) { newList.add(Divider(header))
newList.add(PlainDivider(header))
}
newList.add(header) newList.add(header)
buildList<Item> { section.discs.flatMap { listOf(DiscHeader(it.key)) + it.value }
for (entry in section.discs) {
val discHeader = DiscHeader(inner = entry.key)
if (isNotEmpty()) {
add(DiscDivider(discHeader))
}
add(discHeader)
addAll(entry.value)
}
}
} }
} }
// Currently only the final section (songs, which can be sorted) are invalidatable // Currently only the final section (songs, which can be sorted) are invalidatable
@ -613,7 +573,7 @@ constructor(
uid: Music.UID, uid: Music.UID,
instructions: UpdateInstructions = UpdateInstructions.Diff instructions: UpdateInstructions = UpdateInstructions.Diff
) { ) {
L.d("Refreshing playlist list") logD("Refreshing playlist list")
val edited = editedPlaylist.value val edited = editedPlaylist.value
if (edited == null) { if (edited == null) {
val playlist = detailGenerator.playlist(uid) val playlist = detailGenerator.playlist(uid)
@ -626,6 +586,7 @@ constructor(
val list = mutableListOf<Item>() val list = mutableListOf<Item>()
if (edited.isNotEmpty()) { if (edited.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs) val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header) list.add(header)
list.addAll(edited) list.addAll(edited)
} }

View file

@ -19,32 +19,44 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle 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.findNavController
import androidx.navigation.fragment.navArgs 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 dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding 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.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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu 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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage 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.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.navigateSafe 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.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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]. * A [ListFragment] that shows information for a particular [Genre].
@ -52,21 +64,65 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint @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 // Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre. // as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs() private val args: GenreDetailFragmentArgs by navArgs()
private val genreHeaderAdapter = GenreDetailHeaderAdapter(this)
private val genreListAdapter = GenreDetailListAdapter(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?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) 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 --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenre(args.genreUid) detailModel.setGenre(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updateGenre) collectImmediately(detailModel.currentGenre, ::updatePlaylist)
collectImmediately(detailModel.genreSongList, ::updateList) collectImmediately(detailModel.genreSongList, ::updateList)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
@ -80,6 +136,8 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding) 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 // 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. // during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.genreSongInstructions.consume() 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) { override fun onOpenMenu(item: Music) {
when (item) { when (item) {
is Artist -> listModel.openMenu(R.menu.parent, 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() { override fun onOpenSortMenu() {
findNavController().navigateSafe(GenreDetailFragmentDirections.sort()) findNavController().navigateSafe(GenreDetailFragmentDirections.sort())
} }
private fun updateGenre(genre: Genre?) { private fun updatePlaylist(genre: Genre?) {
if (genre == null) { if (genre == null) {
L.d("No genre to show, navigating away") logD("No genre to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
val binding = requireBinding() requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
val context = requireContext() genreHeaderAdapter.setParent(genre)
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)
} }
private fun updateList(list: List<Item>) { private fun updateList(list: List<Item>) {
@ -153,7 +188,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
private fun handleShow(show: Show?) { private fun handleShow(show: Show?) {
when (show) { when (show) {
is Show.SongDetails -> { is Show.SongDetails -> {
L.d("Navigating to ${show.song}") logD("Navigating to ${show.song}")
findNavController() findNavController()
.navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid)) .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 // Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise. // fragment should be launched otherwise.
is Show.SongAlbumDetails -> { is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}") logD("Navigating to the album of ${show.song}")
findNavController() findNavController()
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid)) .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 // If the album matches, no need to do anything. Otherwise launch a new
// detail fragment. // detail fragment.
is Show.AlbumDetails -> { is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}") logD("Navigating to ${show.album}")
findNavController() findNavController()
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid)) .navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid))
} }
// Always launch a new ArtistDetailFragment. // Always launch a new ArtistDetailFragment.
is Show.ArtistDetails -> { is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}") logD("Navigating to ${show.artist}")
findNavController() findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid)) .navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
} }
is Show.SongArtistDecision -> { is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}") logD("Navigating to artist choices for ${show.song}")
findNavController() findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid)) .navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
} }
is Show.AlbumArtistDecision -> { is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}") logD("Navigating to artist choices for ${show.album}")
findNavController() findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid)) .navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
} }
is Show.GenreDetails -> { is Show.GenreDetails -> {
L.d("Navigated to this genre") logD("Navigated to this genre")
detailModel.toShow.consume() detailModel.toShow.consume()
} }
is Show.PlaylistDetails -> { is Show.PlaylistDetails -> {
@ -232,7 +267,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
val directions = val directions =
when (decision) { when (decision) {
is PlaylistDecision.Add -> { is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist") logD("Adding ${decision.songs.size} songs to a playlist")
GenreDetailFragmentDirections.addToPlaylist( GenreDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
@ -271,7 +306,7 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
val directions = val directions =
when (decision) { when (decision) {
is PlaybackDecision.PlayFromArtist -> { 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) GenreDetailFragmentDirections.playFromArtist(decision.song.uid)
} }
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision") is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")

View file

@ -19,41 +19,51 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts 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.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding 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.PlaylistDetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDragCallback 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.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu 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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage 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.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe 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.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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]. * A [ListFragment] that shows information for a particular [Playlist].
@ -62,17 +72,35 @@ import timber.log.Timber as L
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class PlaylistDetailFragment : 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 // 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. // as a UID, as that is the only safe way to parcel an playlist.
private val args: PlaylistDetailFragmentArgs by navArgs() private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this) private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null
private var editNavigationListener: DialogAwareNavigationListener? = null private var editNavigationListener: DialogAwareNavigationListener? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = 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?) { override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
@ -82,31 +110,52 @@ class PlaylistDetailFragment :
getContentLauncher = getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) { if (uri == null) {
L.w("No URI returned from file picker") logW("No URI returned from file picker")
return@registerForActivityResult return@registerForActivityResult
} }
L.d("Received playlist URI $uri") logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget) musicModel.importPlaylist(uri, pendingImportTarget)
} }
// --- UI SETUP --- // --- 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 { binding.detailEditToolbar.apply {
setNavigationOnClickListener { detailModel.dropPlaylistEdit() } setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
setOnMenuItemClickListener(this@PlaylistDetailFragment) setOnMenuItemClickListener(this@PlaylistDetailFragment)
} }
touchHelper = binding.detailRecycler.apply {
ItemTouchHelper(PlaylistDragCallback(detailModel)).also { adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
it.attachToRecyclerView(binding.detailRecycler) touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
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 --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setPlaylist(args.playlistUid) detailModel.setPlaylist(args.playlistUid)
collectImmediately( collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
detailModel.currentPlaylist, detailModel.editedPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistSongList, ::updateList) collectImmediately(detailModel.playlistSongList, ::updateList)
collectImmediately(detailModel.editedPlaylist, ::updateEditedList) collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
@ -161,97 +210,41 @@ class PlaylistDetailFragment :
playbackModel.play(item, detailModel.playInPlaylistWith) playbackModel.play(item, detailModel.playInPlaylistWith)
} }
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(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) { override fun onOpenMenu(item: Song) {
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith) 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() { override fun onOpenSortMenu() {
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort()) findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
} }
private fun updatePlaylist(playlist: Playlist?, editedPlaylist: List<Song>?) { private fun updatePlaylist(playlist: Playlist?) {
if (playlist == null) { if (playlist == null) {
// Playlist we were showing no longer exists. // Playlist we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
val binding = requireBinding() val binding = requireBinding()
binding.detailToolbarTitle.text = playlist.name.resolve(requireContext()) binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
binding.detailEditToolbar.title = binding.detailEditToolbar.title =
getString(R.string.fmt_editing, playlist.name.resolve(requireContext())) getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
playlistHeaderAdapter.setParent(playlist)
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)
} }
private fun updateList(list: List<Item>) { private fun updateList(list: List<Item>) {
@ -260,10 +253,11 @@ class PlaylistDetailFragment :
private fun updateEditedList(editedPlaylist: List<Song>?) { private fun updateEditedList(editedPlaylist: List<Song>?) {
playlistListAdapter.setEditing(editedPlaylist != null) playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
listModel.dropSelection() listModel.dropSelection()
if (editedPlaylist != null) { if (editedPlaylist != null) {
L.d("Updating save button state") logD("Updating save button state")
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply { requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
} }
@ -275,38 +269,38 @@ class PlaylistDetailFragment :
private fun handleShow(show: Show?) { private fun handleShow(show: Show?) {
when (show) { when (show) {
is Show.SongDetails -> { is Show.SongDetails -> {
L.d("Navigating to ${show.song}") logD("Navigating to ${show.song}")
findNavController() findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid)) .navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
} }
is Show.SongAlbumDetails -> { is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}") logD("Navigating to the album of ${show.song}")
findNavController() findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid)) .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
} }
is Show.AlbumDetails -> { is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}") logD("Navigating to ${show.album}")
findNavController() findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid)) .navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
} }
is Show.ArtistDetails -> { is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}") logD("Navigating to ${show.artist}")
findNavController() findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid)) .navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
} }
is Show.SongArtistDecision -> { is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}") logD("Navigating to artist choices for ${show.song}")
findNavController() findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid)) .navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
} }
is Show.AlbumArtistDecision -> { is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}") logD("Navigating to artist choices for ${show.album}")
findNavController() findNavController()
.navigateSafe( .navigateSafe(
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid)) PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
} }
is Show.PlaylistDetails -> { is Show.PlaylistDetails -> {
L.d("Navigated to this playlist") logD("Navigated to this playlist")
detailModel.toShow.consume() detailModel.toShow.consume()
} }
is Show.GenreDetails -> { is Show.GenreDetails -> {
@ -347,7 +341,7 @@ class PlaylistDetailFragment :
val directions = val directions =
when (decision) { when (decision) {
is PlaylistDecision.Import -> { is PlaylistDecision.Import -> {
L.d("Importing playlist") logD("Importing playlist")
pendingImportTarget = decision.target pendingImportTarget = decision.target
requireNotNull(getContentLauncher) { requireNotNull(getContentLauncher) {
"Content picker launcher was not available" "Content picker launcher was not available"
@ -357,7 +351,7 @@ class PlaylistDetailFragment :
return return
} }
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
L.d("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
PlaylistDetailFragmentDirections.renamePlaylist( PlaylistDetailFragmentDirections.renamePlaylist(
decision.playlist.uid, decision.playlist.uid,
decision.template, decision.template,
@ -365,15 +359,15 @@ class PlaylistDetailFragment :
decision.reason) decision.reason)
} }
is PlaylistDecision.Export -> { is PlaylistDecision.Export -> {
L.d("Exporting ${decision.playlist}") logD("Exporting ${decision.playlist}")
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid) PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
} }
is PlaylistDecision.Delete -> { is PlaylistDecision.Delete -> {
L.d("Deleting ${decision.playlist}") logD("Deleting ${decision.playlist}")
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid) PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
} }
is PlaylistDecision.Add -> { is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} songs to a playlist") logD("Adding ${decision.songs.size} songs to a playlist")
PlaylistDetailFragmentDirections.addToPlaylist( PlaylistDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray()) decision.songs.map { it.uid }.toTypedArray())
} }
@ -399,11 +393,11 @@ class PlaylistDetailFragment :
val directions = val directions =
when (decision) { when (decision) {
is PlaybackDecision.PlayFromArtist -> { 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) PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid)
} }
is PlaybackDecision.PlayFromGenre -> { 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) PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid)
} }
} }
@ -414,15 +408,15 @@ class PlaylistDetailFragment :
val id = val id =
when { when {
detailModel.editedPlaylist.value != null -> { detailModel.editedPlaylist.value != null -> {
L.d("Currently editing playlist, showing edit toolbar") logD("Currently editing playlist, showing edit toolbar")
R.id.detail_edit_toolbar R.id.detail_edit_toolbar
} }
listModel.selected.value.isNotEmpty() -> { listModel.selected.value.isNotEmpty() -> {
L.d("Currently selecting, showing selection toolbar") logD("Currently selecting, showing selection toolbar")
R.id.detail_selection_toolbar R.id.detail_selection_toolbar
} }
else -> { else -> {
L.d("Using normal toolbar") logD("Using normal toolbar")
R.id.detail_normal_toolbar R.id.detail_normal_toolbar
} }
} }

View file

@ -18,7 +18,9 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels 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.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions 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.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Song import org.oxycblt.auxio.util.concatLocalized
import timber.log.Timber as L import org.oxycblt.auxio.util.logD
/** /**
* A [ViewBindingMaterialDialogFragment] that shows information about a Song. * A [ViewBindingMaterialDialogFragment] that shows information about a Song.
@ -62,19 +71,74 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSong(args.songUid) detailModel.setSong(args.songUid)
detailModel.toShow.consume() detailModel.toShow.consume()
collectImmediately(detailModel.currentSong, ::updateSong) collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
} }
private fun updateSong(song: Song?) { private fun updateSong(song: Song?, info: AudioProperties?) {
L.d("No song to show, navigating away")
if (song == null) { if (song == null) {
logD("No song to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return 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>) { private fun <T : Music> T.zipName(context: Context): String {
detailAdapter.update(songProperties, UpdateInstructions.Replace(0)) 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) }
} }

View file

@ -25,10 +25,9 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView 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.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist
/** /**
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with * A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with

View file

@ -23,13 +23,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.musikr.Library import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Music import org.oxycblt.auxio.util.logW
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/** /**
* A [ViewModel] that stores choice information for [ShowArtistDialog], and possibly others in the * 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) { override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return 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. // Need to sanitize different items depending on the current set of choices.
_artistChoices.value = _artistChoices.value?.sanitize(library) _artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
L.d("Updated artist choices: ${_artistChoices.value}") 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]. * @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
*/ */
fun setArtistChoiceUid(itemUid: Music.UID) { 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. // Support Songs and Albums, which have parent artists.
_artistChoices.value = _artistChoices.value =
when (val music = musicRepository.find(itemUid)) { when (val music = musicRepository.find(itemUid)) {
is Song -> { is Song -> {
L.d("Creating navigation choices for song") logD("Creating navigation choices for song")
ArtistShowChoices.FromSong(music) ArtistShowChoices.FromSong(music)
} }
is Album -> { is Album -> {
L.d("Creating navigation choices for album") logD("Creating navigation choices for album")
ArtistShowChoices.FromAlbum(music) ArtistShowChoices.FromAlbum(music)
} }
else -> { else -> {
L.w("Given song/album UID was invalid") logW("Given song/album UID was invalid")
null null
} }
} }
@ -98,15 +99,16 @@ sealed interface ArtistShowChoices {
val uid: Music.UID val uid: Music.UID
/** The current [Artist] choices. */ /** The current [Artist] choices. */
val choices: List<Artist> val choices: List<Artist>
/** Sanitize this instance with a [Library]. */ /** Sanitize this instance with a [DeviceLibrary]. */
fun sanitize(newLibrary: Library): ArtistShowChoices? fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */ /** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
class FromSong(val song: Song) : ArtistShowChoices { class FromSong(val song: Song) : ArtistShowChoices {
override val uid = song.uid override val uid = song.uid
override val choices = song.artists 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]. */ /** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
@ -114,7 +116,7 @@ sealed interface ArtistShowChoices {
override val uid = album.uid override val uid = album.uid
override val choices = album.artists override val choices = album.artists
override fun sanitize(newLibrary: Library) = override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findAlbum(uid)?.let { FromAlbum(it) } newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
} }
} }

View file

@ -32,10 +32,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/** /**
* A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous. * A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
@ -85,7 +85,7 @@ class ShowArtistDialog :
private fun updateChoices(choices: ArtistShowChoices?) { private fun updateChoices(choices: ArtistShowChoices?) {
if (choices == null) { if (choices == null) {
L.d("No choices to show, navigating away") logD("No choices to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,25 +24,21 @@ import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding 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.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback 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.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater 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. * 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)) { when (getItem(position)) {
// Support sub-headers for each disc, and special album songs. // Support sub-headers for each disc, and special album songs.
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
is DiscDivider -> DiscDividerViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
@ -64,7 +59,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent) DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
DiscDividerViewHolder.VIEW_TYPE -> DiscDividerViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
@ -85,8 +79,6 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
when { when {
oldItem is Disc && newItem is Disc -> oldItem is Disc && newItem is Disc ->
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is DiscDivider && newItem is DiscDivider ->
DiscDividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -102,9 +94,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class DiscHeader(val inner: Disc?) : Header data class DiscHeader(val inner: Disc?) : Item
data class DiscDivider(override val anchor: DiscHeader?) : Divider<DiscHeader>
/** /**
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use * 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) { fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner val disc = discHeader.inner
binding.discNumber.text = disc.resolve(binding.context) binding.discNumber.text = disc.resolveNumber(binding.context)
binding.discName.apply { binding.discName.apply {
text = disc?.name text = disc?.name
isGone = disc?.name == null 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 * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
* create an instance. * create an instance.

View file

@ -29,13 +29,12 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback 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.context
import org.oxycblt.auxio.util.inflater 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. * 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.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information // 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {

View file

@ -27,17 +27,17 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.list.BasicHeader 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.Item
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
import org.oxycblt.auxio.list.recycler.DividerViewHolder import org.oxycblt.auxio.list.recycler.DividerViewHolder
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
/** /**
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the * 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) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Implement support for headers and sort headers // Implement support for headers and sort headers
is PlainDivider -> DividerViewHolder.VIEW_TYPE is Divider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
@ -91,7 +91,7 @@ abstract class DetailListAdapter(
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is PlainDivider && newItem is PlainDivider -> oldItem is Divider && newItem is Divider ->
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader -> oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -110,7 +110,7 @@ abstract class DetailListAdapter(
* @param titleRes The string resource to use as the header title * @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt) * @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 * A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create

View file

@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.musikr.Song import org.oxycblt.auxio.music.Song
/** /**
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view. * A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.

View file

@ -30,24 +30,25 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
import org.oxycblt.auxio.databinding.ItemEditableSongBinding import org.oxycblt.auxio.databinding.ItemEditableSongBinding
import org.oxycblt.auxio.list.EditableListListener import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item 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.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder 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.music.resolveNames
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Playlist import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/** /**
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist] * 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. // Nothing to do.
return return
} }
L.d("Updating editing state [old: $isEditing new: $editing]") logD("Updating editing state [old: $isEditing new: $editing]")
this.isEditing = 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]. */ /** 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 * @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt) * @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. * 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 delete = binding.background
override val background = override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply { 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 alpha = 0
} }

View file

@ -18,26 +18,17 @@
package org.oxycblt.auxio.detail.list package org.oxycblt.auxio.detail.list
import android.text.format.Formatter
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding 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.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView 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.context
import org.oxycblt.auxio.util.inflater 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. * An adapter for [SongProperty] instances.
@ -62,31 +53,7 @@ class SongPropertyAdapter :
* @param value The value of the property. * @param value The value of the property.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class SongProperty(@StringRes val name: Int, val value: Value) { data class SongProperty(@StringRes val name: Int, val value: String) : Item
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
}
}
/** /**
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance. * 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) { fun bind(property: SongProperty) {
val context = binding.context val context = binding.context
binding.propertyName.hint = context.getString(property.name) binding.propertyName.hint = context.getString(property.name)
when (property.value) { binding.propertyValue.setText(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))
}
}
} }
companion object { companion object {

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/** /**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort]. * A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort].
@ -56,7 +56,7 @@ class AlbumSongSortDialog : SortDialog() {
private fun updateAlbum(album: Album?) { private fun updateAlbum(album: Album?) {
if (album == null) { if (album == null) {
L.d("No album to sort, navigating away") logD("No album to sort, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
} }
} }

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/** /**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort]. * A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort].
@ -57,7 +57,7 @@ class ArtistSongSortDialog : SortDialog() {
private fun updateArtist(artist: Artist?) { private fun updateArtist(artist: Artist?) {
if (artist == null) { if (artist == null) {
L.d("No artist to sort, navigating away") logD("No artist to sort, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
} }
} }

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/** /**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort]. * A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
@ -62,7 +62,7 @@ class GenreSongSortDialog : SortDialog() {
private fun updateGenre(genre: Genre?) { private fun updateGenre(genre: Genre?) {
if (genre == null) { if (genre == null) {
L.d("No genre to sort, navigating away") logD("No genre to sort, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
} }
} }

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Playlist import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/** /**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort]. * A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
@ -62,7 +62,7 @@ class PlaylistSongSortDialog : SortDialog() {
private fun updatePlaylist(genre: Playlist?) { private fun updatePlaylist(genre: Playlist?) {
if (genre == null) { if (genre == null) {
L.d("No genre to sort, navigating away") logD("No genre to sort, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
} }
} }

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.ui package org.oxycblt.auxio.home
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
@ -40,6 +40,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Prevent excessive layouts by using translation instead of padding.
updatePadding(bottom = insets.systemBarInsetsCompat.bottom) updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets return insets
} }

View file

@ -24,11 +24,9 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.openInBrowser import org.oxycblt.auxio.util.openInBrowser
@ -44,12 +42,10 @@ import org.oxycblt.auxio.util.showToast
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() { class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
private val args: ErrorDetailsDialogArgs by navArgs() private val args: ErrorDetailsDialogArgs by navArgs()
private var clipboardManager: ClipboardManager? = null private var clipboardManager: ClipboardManager? = null
private val musicModel: MusicViewModel by viewModels()
override fun onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
builder builder
.setTitle(R.string.lbl_error_info) .setTitle(R.string.lbl_error_info)
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
.setPositiveButton(R.string.lbl_report) { _, _ -> .setPositiveButton(R.string.lbl_report) { _, _ ->
requireContext().openInBrowser(LINK_ISSUES) requireContext().openInBrowser(LINK_ISSUES)
} }

View file

@ -22,10 +22,11 @@ import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuCompat import androidx.core.view.MenuCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -37,11 +38,16 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout 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.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis 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 dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.detail.DetailViewModel 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.GenreListFragment
import org.oxycblt.auxio.home.list.PlaylistListFragment import org.oxycblt.auxio.home.list.PlaylistListFragment
import org.oxycblt.auxio.home.list.SongListFragment 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.home.tabs.Tab
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectionFragment import org.oxycblt.auxio.list.SelectionFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel 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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage 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.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.lazyReflectedField 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.navigateSafe
import org.oxycblt.auxio.util.showToast 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 * 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 @AndroidEntryPoint
class HomeFragment : class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener { SelectionFragment<FragmentHomeBinding>(),
AppBarLayout.OnOffsetChangedListener,
SpeedDialView.OnActionSelectedListener {
override val listModel: ListViewModel by activityViewModels() override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
@ -95,10 +111,15 @@ class HomeFragment :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true) if (savedInstanceState != null) {
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false) // Orientation change will wipe whatever transition we were using prior, which will
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true) // result in no transition when the user navigates back. Make sure we re-initialize
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false) // our transitions.
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -1)
if (axis > -1) {
applyAxisTransition(axis)
}
}
} }
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
@ -118,11 +139,11 @@ class HomeFragment :
getContentLauncher = getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) { if (uri == null) {
L.w("No URI returned from file picker") logW("No URI returned from file picker")
return@registerForActivityResult return@registerForActivityResult
} }
L.d("Received playlist URI $uri") logD("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget) musicModel.importPlaylist(uri, pendingImportTarget)
} }
@ -134,6 +155,11 @@ class HomeFragment :
MenuCompat.setGroupDividerEnabled(menu, true) 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 { binding.homePager.apply {
// Update HomeViewModel whenever the user swipes through the ViewPager. // Update HomeViewModel whenever the user swipes through the ViewPager.
// This would be implemented in HomeFragment itself, but OnPageChangeCallback // This would be implemented in HomeFragment itself, but OnPageChangeCallback
@ -170,10 +196,25 @@ class HomeFragment :
// re-creating the ViewPager. // re-creating the ViewPager.
setupPager(binding) 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 --- // --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate) collect(homeModel.recreateTabs.flow, ::handleRecreate)
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collect(homeModel.speedDialOpen, ::updateSpeedDial)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
@ -183,11 +224,37 @@ class HomeFragment :
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) 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) { override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
storagePermissionLauncher = null storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this) binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeNormalToolbar.setOnMenuItemClickListener(null) binding.homeNormalToolbar.setOnMenuItemClickListener(null)
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
} }
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -209,17 +276,18 @@ class HomeFragment :
return when (item.itemId) { return when (item.itemId) {
// Handle main actions (Search, Settings, About) // Handle main actions (Search, Settings, About)
R.id.action_search -> { R.id.action_search -> {
L.d("Navigating to search") logD("Navigating to search")
applyAxisTransition(MaterialSharedAxis.Z)
findNavController().navigateSafe(HomeFragmentDirections.search()) findNavController().navigateSafe(HomeFragmentDirections.search())
true true
} }
R.id.action_settings -> { R.id.action_settings -> {
L.d("Navigating to preferences") logD("Navigating to preferences")
homeModel.showSettings() homeModel.showSettings()
true true
} }
R.id.action_about -> { R.id.action_about -> {
L.d("Navigating to about") logD("Navigating to about")
homeModel.showAbout() homeModel.showAbout()
true true
} }
@ -239,12 +307,30 @@ class HomeFragment :
true true
} }
else -> { else -> {
L.w("Unexpected menu item selected") logW("Unexpected menu item selected")
false 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) { private fun setupPager(binding: FragmentHomeBinding) {
binding.homePager.adapter = binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner) HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
@ -253,7 +339,7 @@ class HomeFragment :
if (homeModel.currentTabTypes.size == 1) { if (homeModel.currentTabTypes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing // A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior. // behavior.
L.d("Single tab shown, disabling TabLayout") logD("Single tab shown, disabling TabLayout")
binding.homeTabs.isVisible = false binding.homeTabs.isVisible = false
binding.homeAppbar.setExpanded(true, false) binding.homeAppbar.setExpanded(true, false)
toolbarParams.scrollFlags = 0 toolbarParams.scrollFlags = 0
@ -266,7 +352,9 @@ class HomeFragment :
// Set up the mapping between the ViewPager and TabLayout. // Set up the mapping between the ViewPager and TabLayout.
TabLayoutMediator( TabLayoutMediator(
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes)) binding.homeTabs,
binding.homePager,
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
.attach() .attach()
} }
@ -284,12 +372,14 @@ class HomeFragment :
MusicType.GENRES -> R.id.home_genre_recycler MusicType.GENRES -> R.id.home_genre_recycler
MusicType.PLAYLISTS -> R.id.home_playlist_recycler MusicType.PLAYLISTS -> R.id.home_playlist_recycler
} }
updateFabVisibility(homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
} }
private fun handleRecreate(recreate: Unit?) { private fun handleRecreate(recreate: Unit?) {
if (recreate == null) return if (recreate == null) return
val binding = requireBinding() val binding = requireBinding()
L.d("Recreating ViewPager") logD("Recreating ViewPager")
// Move back to position zero, as there must be a tab there. // Move back to position zero, as there must be a tab there.
binding.homePager.currentItem = 0 binding.homePager.currentItem = 0
// Make sure tabs are set up to also follow the new ViewPager configuration. // Make sure tabs are set up to also follow the new ViewPager configuration.
@ -297,49 +387,104 @@ class HomeFragment :
homeModel.recreateTabs.consume() homeModel.recreateTabs.consume()
} }
private fun handleChooseFolders(unit: Unit?) {
if (unit == null) {
return
}
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
homeModel.chooseMusicLocations.consume()
}
private fun updateIndexerState(state: IndexingState?) { 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() val binding = requireBinding()
when (state) { when (state) {
is IndexingState.Completed -> { is IndexingState.Completed -> setupCompleteState(binding, state.error)
binding.homeIndexingContainer.isInvisible = state.error == null is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
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
}
null -> { 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 = val directions =
when (decision) { when (decision) {
is PlaylistDecision.New -> { is PlaylistDecision.New -> {
L.d("Creating new playlist") logD("Creating new playlist")
HomeFragmentDirections.newPlaylist( HomeFragmentDirections.newPlaylist(
decision.songs.map { it.uid }.toTypedArray(), decision.songs.map { it.uid }.toTypedArray(),
decision.template, decision.template,
decision.reason) decision.reason)
} }
is PlaylistDecision.Import -> { is PlaylistDecision.Import -> {
L.d("Importing playlist") logD("Importing playlist")
pendingImportTarget = decision.target pendingImportTarget = decision.target
requireNotNull(getContentLauncher) { requireNotNull(getContentLauncher) {
"Content picker launcher was not available" "Content picker launcher was not available"
@ -366,7 +511,7 @@ class HomeFragment :
return return
} }
is PlaylistDecision.Rename -> { is PlaylistDecision.Rename -> {
L.d("Renaming ${decision.playlist}") logD("Renaming ${decision.playlist}")
HomeFragmentDirections.renamePlaylist( HomeFragmentDirections.renamePlaylist(
decision.playlist.uid, decision.playlist.uid,
decision.template, decision.template,
@ -374,15 +519,15 @@ class HomeFragment :
decision.reason) decision.reason)
} }
is PlaylistDecision.Export -> { is PlaylistDecision.Export -> {
L.d("Exporting ${decision.playlist}") logD("Exporting ${decision.playlist}")
HomeFragmentDirections.exportPlaylist(decision.playlist.uid) HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
} }
is PlaylistDecision.Delete -> { is PlaylistDecision.Delete -> {
L.d("Deleting ${decision.playlist}") logD("Deleting ${decision.playlist}")
HomeFragmentDirections.deletePlaylist(decision.playlist.uid) HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
} }
is PlaylistDecision.Add -> { is PlaylistDecision.Add -> {
L.d("Adding ${decision.songs.size} to a playlist") logD("Adding ${decision.songs.size} to a playlist")
HomeFragmentDirections.addToPlaylist( HomeFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray()) 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?) { private fun handleShow(show: Show?) {
when (show) { when (show) {
is Show.SongDetails -> { is Show.SongDetails -> {
L.d("Navigating to ${show.song}") logD("Navigating to ${show.song}")
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid)) findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
} }
is Show.SongAlbumDetails -> { is Show.SongAlbumDetails -> {
L.d("Navigating to the album of ${show.song}") logD("Navigating to the album of ${show.song}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController() findNavController()
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid)) .navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
} }
is Show.AlbumDetails -> { is Show.AlbumDetails -> {
L.d("Navigating to ${show.album}") logD("Navigating to ${show.album}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid)) findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
} }
is Show.ArtistDetails -> { is Show.ArtistDetails -> {
L.d("Navigating to ${show.artist}") logD("Navigating to ${show.artist}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid)) findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
} }
is Show.SongArtistDecision -> { is Show.SongArtistDecision -> {
L.d("Navigating to artist choices for ${show.song}") logD("Navigating to artist choices for ${show.song}")
findNavController() findNavController()
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid)) .navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
} }
is Show.AlbumArtistDecision -> { is Show.AlbumArtistDecision -> {
L.d("Navigating to artist choices for ${show.album}") logD("Navigating to artist choices for ${show.album}")
findNavController() findNavController()
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid)) .navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
} }
is Show.GenreDetails -> { is Show.GenreDetails -> {
L.d("Navigating to ${show.genre}") logD("Navigating to ${show.genre}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid)) findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
} }
is Show.PlaylistDetails -> { is Show.PlaylistDetails -> {
L.d("Navigating to ${show.playlist}") logD("Navigating to ${show.playlist}")
applyAxisTransition(MaterialSharedAxis.X)
findNavController() findNavController()
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid)) .navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
} }
@ -472,7 +733,7 @@ class HomeFragment :
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size) binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) { if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
// New selection started, show the AppBarLayout to indicate the new state. // 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() binding.homeAppbar.expandWithScrollingRecycler()
} }
} else { } 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. * [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
* *
@ -508,5 +781,12 @@ class HomeFragment :
private companion object { private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop") 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"
} }
} }

View file

@ -22,22 +22,20 @@ import javax.inject.Inject
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions 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.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Playlist
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
interface HomeGenerator { interface HomeGenerator {
fun attach() fun attach()
fun release() fun release()
fun empty(): Boolean
fun songs(): List<Song> fun songs(): List<Song>
fun albums(): List<Album> fun albums(): List<Album>
@ -51,8 +49,6 @@ interface HomeGenerator {
fun tabs(): List<MusicType> fun tabs(): List<MusicType>
interface Invalidator { interface Invalidator {
fun invalidateEmpty() {}
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
fun invalidateTabs() fun invalidateTabs()
@ -91,9 +87,6 @@ private class HomeGeneratorImpl(
} }
override fun onHideCollaboratorsChanged() { 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) invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
} }
@ -123,11 +116,9 @@ private class HomeGeneratorImpl(
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
invalidator.invalidateEmpty() val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
val library = musicRepository.library logD("Refreshing library")
if (changes.deviceLibrary && library != null) {
L.d("Refreshing library")
// Get the each list of items in the library to use as our list data. // Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them. // Applying the preferred sorting to them.
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff) invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff)
@ -136,8 +127,9 @@ private class HomeGeneratorImpl(
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff) invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
} }
if (changes.userLibrary && library != null) { val userLibrary = musicRepository.userLibrary
L.d("Refreshing playlists") if (changes.userLibrary && userLibrary != null) {
logD("Refreshing playlists")
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
} }
} }
@ -148,29 +140,30 @@ private class HomeGeneratorImpl(
homeSettings.unregisterListener(this) homeSettings.unregisterListener(this)
} }
override fun empty() = musicRepository.library?.empty() ?: true
override fun songs() = override fun songs() =
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
override fun albums() = override fun albums() =
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) }
?: emptyList()
override fun artists() = override fun artists() =
musicRepository.library?.let { deviceLibrary -> musicRepository.deviceLibrary?.let { deviceLibrary ->
val sorted = listSettings.artistSort.artists(deviceLibrary.artists) val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
if (homeSettings.shouldHideCollaborators) { if (homeSettings.shouldHideCollaborators) {
sorted.filter { it.explicitAlbums.isNotEmpty() } sorted.filter { it.explicitAlbums.isNotEmpty() }
} else { } else {
sorted sorted
} }
} ?: emptyList() }
?: emptyList()
override fun genres() = override fun genres() =
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) }
?: emptyList()
override fun playlists() = override fun playlists() =
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) } musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) }
?: emptyList() ?: emptyList()
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type } override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
/** /**
* User configuration specific to the home UI. * User configuration specific to the home UI.
@ -68,17 +68,17 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override fun migrate() { override fun migrate() {
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) { if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
L.d("Migrating tab setting") logD("Migrating tab setting")
val oldTabs = val oldTabs =
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT)) Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
?: unlikelyToBeNull(Tab.fromIntCode(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. // The playlist tab is now parsed, but it needs to be made visible.
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS } val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS) oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
L.d("New tabs: $oldTabs") logD("New tabs: $oldTabs")
sharedPreferences.edit { sharedPreferences.edit {
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs)) 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) { override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
when (key) { when (key) {
getString(R.string.set_key_home_tabs) -> { getString(R.string.set_key_home_tabs) -> {
L.d("Dispatching tab setting change") logD("Dispatching tab setting change")
listener.onTabsChanged() listener.onTabsChanged()
} }
getString(R.string.set_key_hide_collaborators) -> { getString(R.string.set_key_hide_collaborators) -> {
L.d("Dispatching collaborator setting change") logD("Dispatching collaborator setting change")
listener.onHideCollaboratorsChanged() listener.onHideCollaboratorsChanged()
} }
} }

View file

@ -27,17 +27,17 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicType 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.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album import org.oxycblt.auxio.util.logD
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
/** /**
* The ViewModel for managing the tab data and lists of the home view. * The ViewModel for managing the tab data and lists of the home view.
@ -120,10 +120,6 @@ constructor(
val playlistList: StateFlow<List<Playlist>> val playlistList: StateFlow<List<Playlist>>
get() = _playlistList get() = _playlistList
private val _empty = MutableStateFlow(false)
val empty: StateFlow<Boolean>
get() = _empty
private val _playlistInstructions = MutableEvent<UpdateInstructions>() private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genreList] in the UI. */ /** Instructions for how to update [genreList] in the UI. */
val playlistInstructions: Event<UpdateInstructions> val playlistInstructions: Event<UpdateInstructions>
@ -159,14 +155,14 @@ constructor(
/** A marker for whether the user is fast-scrolling in the home view or not. */ /** A marker for whether the user is fast-scrolling in the home view or not. */
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling 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>() private val _showOuter = MutableEvent<Outer>()
val showOuter: Event<Outer> val showOuter: Event<Outer>
get() = _showOuter get() = _showOuter
private val _chooseMusicLocations = MutableEvent<Unit>()
val chooseMusicLocations: Event<Unit>
get() = _chooseMusicLocations
init { init {
homeGenerator.attach() homeGenerator.attach()
} }
@ -176,10 +172,6 @@ constructor(
homeGenerator.release() homeGenerator.release()
} }
override fun invalidateEmpty() {
_empty.value = homeGenerator.empty()
}
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
when (type) { when (type) {
MusicType.SONGS -> { MusicType.SONGS -> {
@ -261,7 +253,7 @@ constructor(
* @param pagerPos The new position of the ViewPager2 instance. * @param pagerPos The new position of the ViewPager2 instance.
*/ */
fun synchronizeTabPosition(pagerPos: Int) { fun synchronizeTabPosition(pagerPos: Int) {
L.d("Updating current tab to ${currentTabTypes[pagerPos]}") logD("Updating current tab to ${currentTabTypes[pagerPos]}")
_currentTabType.value = currentTabTypes[pagerPos] _currentTabType.value = currentTabTypes[pagerPos]
} }
@ -271,12 +263,18 @@ constructor(
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise. * @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/ */
fun setFastScrolling(isFastScrolling: Boolean) { fun setFastScrolling(isFastScrolling: Boolean) {
L.d("Updating fast scrolling state: $isFastScrolling") logD("Updating fast scrolling state: $isFastScrolling")
_isFastScrolling.value = 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() { fun showSettings() {

View file

@ -39,6 +39,7 @@ import androidx.core.os.BundleCompat
import androidx.core.view.setMargins import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.leinardi.android.speeddial.FabWithLabelView import com.leinardi.android.speeddial.FabWithLabelView
import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialActionItem
@ -46,7 +47,6 @@ import com.leinardi.android.speeddial.SpeedDialView
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AnimConfig
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
@ -78,8 +78,6 @@ class ThemedSpeedDialView : SpeedDialView {
@AttrRes defStyleAttr: Int @AttrRes defStyleAttr: Int
) : super(context, attrs, defStyleAttr) ) : super(context, attrs, defStyleAttr)
private val stationaryConfig = AnimConfig.of(context, AnimConfig.STANDARD, AnimConfig.MEDIUM2)
init { init {
// Work around ripple bug on Android 12 when useCompatPadding = true. // Work around ripple bug on Android 12 when useCompatPadding = true.
// @see https://github.com/material-components/material-components-android/issues/2617 // @see https://github.com/material-components/material-components-android/issues/2617
@ -109,7 +107,7 @@ class ThemedSpeedDialView : SpeedDialView {
val mainFabDrawable = val mainFabDrawable =
RotateDrawable().apply { RotateDrawable().apply {
drawable = mainFab.drawable drawable = mainFab.drawable
toDegrees = 45f + 90f toDegrees = mainFabAnimationRotateAngle
} }
mainFabAnimationRotateAngle = 0f mainFabAnimationRotateAngle = 0f
setMainFabClosedDrawable(mainFabDrawable) setMainFabClosedDrawable(mainFabDrawable)
@ -118,13 +116,6 @@ class ThemedSpeedDialView : SpeedDialView {
override fun onMainActionSelected(): Boolean = false override fun onMainActionSelected(): Boolean = false
override fun onToggleChanged(isOpen: Boolean) { 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?.cancel()
mainFabAnimator = mainFabAnimator =
createMainFabAnimator(isOpen).apply { createMainFabAnimator(isOpen).apply {
@ -141,44 +132,22 @@ class ThemedSpeedDialView : SpeedDialView {
}) })
} }
private fun createMainFabAnimator(isOpen: Boolean): Animator { private fun createMainFabAnimator(isOpen: Boolean): Animator =
val totalDuration = stationaryConfig.duration AnimatorSet().apply {
val partialDuration = totalDuration / 2 // This is half of the total duration playTogether(
val delay = totalDuration / 4 // This is one fourth of the total duration ObjectAnimator.ofArgb(
val backgroundTintAnimator =
ObjectAnimator.ofArgb(
mainFab, mainFab,
VIEW_PROPERTY_BACKGROUND_TINT, VIEW_PROPERTY_BACKGROUND_TINT,
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor) if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor),
.apply { ObjectAnimator.ofArgb(
startDelay = delay
duration = partialDuration
}
val imageTintAnimator =
ObjectAnimator.ofArgb(
mainFab, mainFab,
IMAGE_VIEW_PROPERTY_IMAGE_TINT, IMAGE_VIEW_PROPERTY_IMAGE_TINT,
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor) if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor),
.apply { ObjectAnimator.ofInt(
startDelay = delay mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0))
duration = partialDuration duration = 200
} interpolator = FastOutSlowInInterpolator()
}
val levelAnimator =
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
}
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
@ -190,8 +159,6 @@ class ThemedSpeedDialView : SpeedDialView {
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f) val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor) overlayLayout.setBackgroundColor(overlayColor)
} }
// Fix default margins added by library
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
} }
private fun Int.withModulatedAlpha( private fun Int.withModulatedAlpha(
@ -232,24 +199,13 @@ class ThemedSpeedDialView : SpeedDialView {
return super.addActionItem(actionItem, position, animate)?.apply { return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply { fab.apply {
updateLayoutParams<MarginLayoutParams> { updateLayoutParams<MarginLayoutParams> {
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny) val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
if (position == actionItems.lastIndex) { setMargins(horizontalMargin, 0, horizontalMargin, 0)
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
} else {
setMargins(0, 0, rightMargin, 0)
}
} }
useCompatPadding = false useCompatPadding = false
} }
labelBackground.apply { labelBackground.apply {
updateLayoutParams<MarginLayoutParams> {
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
}
}
useCompatPadding = false useCompatPadding = false
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall) setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
background = background =
@ -306,7 +262,7 @@ class ThemedSpeedDialView : SpeedDialView {
private val DRAWABLE_PROPERTY_LEVEL = private val DRAWABLE_PROPERTY_LEVEL =
object : Property<Drawable, Int>(Int::class.java, "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?) { override fun set(drawable: Drawable, value: Int?) {
drawable.level = value!! drawable.level = value!!

View file

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

View file

@ -16,18 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.list.recycler package org.oxycblt.auxio.home.fastscroll
import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewConfiguration import android.view.ViewConfiguration
@ -35,22 +30,16 @@ import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isEmpty
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.updatePaddingRelative import androidx.recyclerview.widget.GridLayoutManager
import androidx.core.widget.TextViewCompat import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.MaterialFadingSlider import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
import org.oxycblt.auxio.ui.MaterialSlider
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat 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.isRtl
import org.oxycblt.auxio.util.isUnder import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -77,73 +66,52 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Variable names are no longer prefixed with m * - Variable names are no longer prefixed with m
* - Added drag listener * - Added drag listener
* - Added documentation * - Added documentation
* - Completely new design
* - New scroll position backend
* *
* @author Hai Zhang, Alexander Capehart (OxygenCobalt) * @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 class FastScrollRecyclerView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) { AuxioRecyclerView(context, attrs, defStyleAttr) {
// Thumb // 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 = private val thumbView =
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { View(context).apply {
thumbSlider.jumpOut(this) 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 val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0 private var thumbOffset = 0
private var showingThumb = false private var showingThumb = false
private val hideThumbRunnable = Runnable { private val hideThumbRunnable = Runnable {
if (!dragging) { if (!dragging) {
hideThumb() hideScrollbar()
} }
} }
// Popup
private val popupView = private val popupView =
MaterialTextView(context).apply { FastScrollPopupView(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)
layoutParams = layoutParams =
FrameLayout.LayoutParams( FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply { .apply {
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP 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 private var showingPopup = false
// Touch // 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 val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f private var downX = 0f
@ -152,24 +120,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private var dragStartY = 0f private var dragStartY = 0f
private var dragStartThumbOffset = 0 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 private var dragging = false
set(value) { set(value) {
if (field == value) { if (field == value) {
@ -189,13 +139,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
showScrollbar() showScrollbar()
showPopup() showPopup()
} else { } else {
hidePopup()
postAutoHideScrollbar() postAutoHideScrollbar()
hidePopup()
} }
listener?.onFastScrollingChanged(field) listener?.onFastScrollingChanged(field)
} }
private val tRect = Rect()
var popupProvider: PopupProvider? = null var popupProvider: PopupProvider? = null
var listener: Listener? = null var listener: Listener? = null
@ -230,22 +182,22 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT --- // --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() { private fun onPreDraw() {
updateThumbState() updateScrollbarState()
thumbView.layoutDirection = layoutDirection thumbView.layoutDirection = layoutDirection
thumbView.measure( popupView.layoutDirection = layoutDirection
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
val thumbTop = thumbPadding.top + thumbOffset
val thumbLeft = val thumbLeft =
if (isRtl) { if (isRtl) {
thumbPadding.left thumbPadding.left
} else { } else {
width - thumbPadding.right - thumbWidth width - thumbPadding.right - thumbWidth
} }
val thumbTop = thumbPadding.top + thumbOffset
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight) thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
popupView.layoutDirection = layoutDirection
val child = getChildAt(0) val child = getChildAt(0)
val firstAdapterPos = val firstAdapterPos =
if (child != null) { if (child != null) {
@ -262,9 +214,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
popupText = provider.getPopup(firstAdapterPos) ?: "?" popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else { } else {
// No valid position or provider, do not show the popup. // No valid position or provider, do not show the popup.
popupView.isInvisible = false popupView.isInvisible = true
popupText = "" popupText = ""
} }
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) { if (popupView.text != popupText) {
@ -290,9 +243,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
popupLayoutParams.height) popupLayoutParams.height)
popupView.measure(widthMeasureSpec, heightMeasureSpec) popupView.measure(widthMeasureSpec, heightMeasureSpec)
if (showingPopup) {
doPopupVibration()
}
} }
val popupWidth = popupView.measuredWidth val popupWidth = popupView.measuredWidth
@ -305,7 +255,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
val popupAnchorY = popupHeight / 2 val popupAnchorY = popupHeight / 2
val thumbAnchorY = thumbView.height / 2 val thumbAnchorY = thumbView.paddingTop
val popupTop = val popupTop =
(thumbTop + thumbAnchorY - popupAnchorY) (thumbTop + thumbAnchorY - popupAnchorY)
@ -319,7 +269,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun onScrolled(dx: Int, dy: Int) { override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy) super.onScrolled(dx, dy)
updateThumbState() updateScrollbarState()
// Measure or layout events result in a fake onScrolled call. Ignore those. // Measure or layout events result in a fake onScrolled call. Ignore those.
if (dx == 0 && dy == 0) { if (dx == 0 && dy == 0) {
@ -337,27 +287,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return insets return insets
} }
private fun updateThumbState() { private fun updateScrollbarState() {
// Then calculate the thumb position, which is just: if (scrollRange <= height || childCount == 0) {
// [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()
return return
} }
val extentY = computeVerticalScrollExtent()
val fraction = (offsetY).toFloat() / (computeVerticalScrollRange() - extentY) // Combine the previous item dimensions with the current item top to find our scroll
thumbOffset = (thumbOffsetRange * fraction).toInt() // 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 { private fun onItemTouch(event: MotionEvent): Boolean {
if (!fastScrollingEnabled || !fastScrollingPossible) {
dragging = false
return false
}
val eventX = event.x val eventX = event.x
val eventY = event.y val eventY = event.y
@ -371,12 +324,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) { if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else if (eventX > thumbView.right - thumbWidth / 4) { } else {
dragStartThumbOffset = dragStartThumbOffset =
(eventY - thumbPadding.top - thumbHeight / 2f).toInt() (eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} else {
return false
} }
dragging = true dragging = true
@ -413,19 +364,44 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun scrollToThumbOffset(thumbOffset: Int) { private fun scrollToThumbOffset(thumbOffset: Int) {
val rangeY = computeVerticalScrollRange() - computeVerticalScrollExtent() val clampedThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val previousThumbOffset = this.thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val previousOffsetY = rangeY * (previousThumbOffset / thumbOffsetRange.toFloat()) val scrollOffset =
val newThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange) (scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
val newOffsetY = rangeY * (newThumbOffset / thumbOffsetRange.toFloat()) paddingTop
if (newOffsetY == 0f) {
// Hacky workaround to drift in vertical scroll offset where we just snap scrollTo(scrollOffset)
// to the top if the thumb offset hit zero. }
scrollToPosition(0)
private fun scrollTo(offset: Int) {
if (childCount == 0) {
return 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 --- // --- SCROLLBAR APPEARANCE ---
@ -436,39 +412,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun showScrollbar() { private fun showScrollbar() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingThumb) { if (showingThumb) {
return return
} }
showingThumb = true showingThumb = true
thumbAnimator?.cancel() animateViewIn(thumbView)
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
} }
private fun hideThumb() { private fun hideScrollbar() {
if (!showingThumb) { if (!showingThumb) {
return return
} }
showingThumb = false showingThumb = false
thumbAnimator?.cancel() animateViewOut(thumbView)
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
} }
private fun showPopup() { private fun showPopup() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingPopup) { if (showingPopup) {
return return
} }
showingPopup = true showingPopup = true
popupAnimator?.cancel() animateViewIn(popupView)
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
} }
private fun hidePopup() { private fun hidePopup() {
@ -477,17 +444,23 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
showingPopup = false showingPopup = false
popupAnimator?.cancel() animateViewOut(popupView)
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
} }
private fun doPopupVibration() { private fun animateViewIn(view: View) {
performHapticFeedback( view
if (Build.VERSION.SDK_INT >= 27) { .animate()
HapticFeedbackConstants.TEXT_HANDLE_MOVE .alpha(1f)
} else { .setDuration(context.getInteger(R.integer.anim_fade_enter_duration).toLong())
HapticFeedbackConstants.KEYBOARD_TAP .start()
}) }
private fun animateViewOut(view: View) {
view
.animate()
.alpha(0f)
.setDuration(context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.start()
} }
// --- LAYOUT STATE --- // --- LAYOUT STATE ---
@ -497,6 +470,45 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight 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. */ /** An interface to provide text to use in the popup when fast-scrolling. */
interface PopupProvider { interface PopupProvider {
/** /**
@ -520,6 +532,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private companion object { private companion object {
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 500 const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
} }
} }

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
@ -31,23 +29,22 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort 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.MusicViewModel
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately 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. * A [ListFragment] that shows a list of [Album]s.
@ -82,16 +79,7 @@ class AlbumListFragment :
listener = this@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.albumList, ::updateAlbums)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.albumSort.mode) { return when (homeModel.albumSort.mode) {
// By Name -> Use Name // 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 // 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) // 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()) } is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
@ -127,7 +115,7 @@ class AlbumListFragment :
// Last added -> Format as date // Last added -> Format as date
is Sort.Mode.ByDateAdded -> { is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = album.addedMs val dateAddedMillis = album.dateAdded.secsToMs()
formatterSb.setLength(0) formatterSb.setLength(0)
DateUtils.formatDateRange( DateUtils.formatDateRange(
context, context,
@ -159,14 +147,6 @@ class AlbumListFragment :
albumAdapter.update(albums, homeModel.albumInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -21,31 +21,28 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort 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.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.positiveOrNull 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. * A [ListFragment] that shows a list of [Artist]s.
@ -77,16 +74,7 @@ class ArtistListFragment :
listener = this@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.artistList, ::updateArtists)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.artistSort.mode) { return when (homeModel.artistSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> artist.name.thumb() is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -135,14 +123,6 @@ class ArtistListFragment :
artistAdapter.update(artists, homeModel.artistInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -21,30 +21,27 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter 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.recycler.GenreViewHolder
import org.oxycblt.auxio.list.sort.Sort 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.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.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. * A [ListFragment] that shows a list of [Genre]s.
@ -76,16 +73,7 @@ class GenreListFragment :
listener = this@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.genreList, ::updateGenres)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.genreSort.mode) { return when (homeModel.genreSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> genre.name.thumb() is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -134,14 +122,6 @@ class GenreListFragment :
genreAdapter.update(genres, homeModel.genreInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

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

View file

@ -21,29 +21,26 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter 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.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.sort.Sort 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.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.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. * A [ListFragment] that shows a list of [Playlist]s.
@ -74,18 +71,7 @@ class PlaylistListFragment :
listener = this@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.playlistList, ::updatePlaylists)
collectImmediately(
homeModel.empty,
homeModel.playlistList,
musicModel.indexingState,
::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.playlistSort.mode) { return when (homeModel.playlistSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> playlist.name.thumb() is Sort.Mode.ByName -> playlist.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
@ -134,26 +120,6 @@ class PlaylistListFragment :
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -22,30 +22,27 @@ import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter 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.recycler.SongViewHolder
import org.oxycblt.auxio.list.sort.Sort 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.MusicViewModel
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately 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. * A [ListFragment] that shows a list of [Song]s.
@ -62,7 +59,6 @@ class SongListFragment :
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
private val songAdapter = SongAdapter(this) private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text // Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64) private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb) private val formatter = Formatter(formatterSb)
@ -80,16 +76,7 @@ class SongListFragment :
listener = this@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.songList, ::updateSongs)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // based off the names of the parent objects and not the child objects.
return when (homeModel.songSort.mode) { return when (homeModel.songSort.mode) {
// Name -> Use name // Name -> Use name
is Sort.Mode.ByName -> song.name.thumb() is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist // 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 // Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.name.thumb() is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year // 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 // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
// Last added -> Format as date // Last added -> Format as date
is Sort.Mode.ByDateAdded -> { is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = song.addedMs val dateAddedMillis = song.dateAdded.secsToMs()
formatterSb.setLength(0) formatterSb.setLength(0)
DateUtils.formatDateRange( DateUtils.formatDateRange(
context, context,
@ -159,14 +146,6 @@ class SongListFragment :
songAdapter.update(songs, homeModel.songInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

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

View file

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

View file

@ -19,7 +19,8 @@
package org.oxycblt.auxio.home.tabs package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.music.MusicType 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. * 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. // Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.type } val distinct = tabs.distinctBy { it.type }
if (tabs.size != distinct.size) { if (tabs.size != distinct.size) {
L.w( logW(
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]") "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 // Make sure there are no duplicate tabs
val distinct = tabs.distinctBy { it.type } val distinct = tabs.distinctBy { it.type }
if (tabs.size != distinct.size) { if (tabs.size != distinct.size) {
L.w( logW(
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]") "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. // For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) { 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 return null
} }

View file

@ -28,7 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.util.inflater 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. * 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. * @param newTabs The new array of tabs to show.
*/ */
fun submitTabs(newTabs: Array<Tab>) { fun submitTabs(newTabs: Array<Tab>) {
L.d("Force-updating tab information") logD("Force-updating tab information")
tabs = newTabs tabs = newTabs
@Suppress("NotifyDatasetChanged") notifyDataSetChanged() @Suppress("NotifyDatasetChanged") notifyDataSetChanged()
} }
@ -67,7 +67,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param tab The new tab. * @param tab The new tab.
*/ */
fun setTab(at: Int, tab: 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 tabs[at] = tab
// Use a payload to avoid an item change animation. // Use a payload to avoid an item change animation.
notifyItemChanged(at, PAYLOAD_TAB_CHANGED) 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. * @param b The position of the second tab to swap.
*/ */
fun swapTabs(a: Int, b: Int) { 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] val tmp = tabs[b]
tabs[b] = tabs[a] tabs[b] = tabs[a]
tabs[a] = tmp tabs[a] = tmp

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.list.EditClickListListener import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment 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] * A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab]
@ -52,7 +52,7 @@ class TabCustomizeDialog :
builder builder
.setTitle(R.string.set_lib_tabs) .setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
L.d("Committing tab changes") logD("Committing tab changes")
homeSettings.homeTabs = tabAdapter.tabs homeSettings.homeTabs = tabAdapter.tabs
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
@ -99,7 +99,7 @@ class TabCustomizeDialog :
is Tab.Visible -> Tab.Invisible(old.type) is Tab.Visible -> Tab.Invisible(old.type)
is Tab.Invisible -> Tab.Visible(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) tabAdapter.setTab(index, new)
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state. // Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.

View file

@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import coil3.ImageLoader import androidx.core.graphics.drawable.toBitmap
import coil3.request.Disposable import coil.ImageLoader
import coil3.request.ImageRequest import coil.request.Disposable
import coil3.size.Size import coil.request.ImageRequest
import coil3.toBitmap import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject 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. * A utility to provide bitmaps in a race-less manner.
@ -94,7 +94,7 @@ constructor(
target target
.onConfigRequest( .onConfigRequest(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(song.cover) .data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component. // Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)) .size(Size.ORIGINAL))
.target( .target(

View file

@ -26,11 +26,12 @@ import org.oxycblt.auxio.IntegerTable
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
enum class CoverMode { enum class CoverMode {
/** Do not load album covers ("Off"). */
OFF, OFF,
SAVE_SPACE, /** Load covers from the fast, but lower-quality media store database ("Fast"). */
BALANCED, MEDIA_STORE,
HIGH_QUALITY, /** Load high-quality covers directly from music files ("Quality"). */
AS_IS; QUALITY;
/** /**
* The integer representation of this instance. * The integer representation of this instance.
@ -41,10 +42,8 @@ enum class CoverMode {
get() = get() =
when (this) { when (this) {
OFF -> IntegerTable.COVER_MODE_OFF OFF -> IntegerTable.COVER_MODE_OFF
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
BALANCED -> IntegerTable.COVER_MODE_BALANCED QUALITY -> IntegerTable.COVER_MODE_QUALITY
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
AS_IS -> IntegerTable.COVER_MODE_AS_IS
} }
companion object { companion object {
@ -58,10 +57,8 @@ enum class CoverMode {
fun fromIntCode(intCode: Int) = fun fromIntCode(intCode: Int) =
when (intCode) { when (intCode) {
IntegerTable.COVER_MODE_OFF -> OFF IntegerTable.COVER_MODE_OFF -> OFF
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
IntegerTable.COVER_MODE_BALANCED -> BALANCED IntegerTable.COVER_MODE_QUALITY -> QUALITY
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
IntegerTable.COVER_MODE_AS_IS -> AS_IS
else -> null else -> null
} }
} }

View file

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

View file

@ -18,7 +18,7 @@
package org.oxycblt.auxio.image package org.oxycblt.auxio.image
import android.animation.Animator import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
@ -33,39 +33,36 @@ import android.view.Gravity
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.Px import androidx.core.content.res.getIntOrThrow
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isEmpty
import androidx.core.view.updateMarginsRelative import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat import androidx.core.widget.ImageViewCompat
import coil3.ImageLoader import coil.ImageLoader
import coil3.asImage import coil.request.ImageRequest
import coil3.request.ImageRequest import coil.util.CoilUtils
import coil3.request.target
import coil3.request.transformations
import coil3.util.CoilUtils
import com.google.android.material.R as MR import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.coil.RoundedRectTransformation import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.coil.SquareCropTransformation import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.ui.MaterialFader 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.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.musikr.Album import org.oxycblt.auxio.util.getInteger
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
/** /**
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and * 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 playbackIndicator: PlaybackIndicator?
private val selectionBadge: ImageView? private val selectionBadge: ImageView?
private val iconSize: Int?
private val fader = MaterialFader.quickLopsided(context) private val sizing: Int
private var fadeAnimator: Animator? = null @DimenRes private val iconSizeRes: Int?
@DimenRes private var cornerRadiusRes: Int?
private var fadeAnimator: ValueAnimator? = null
private val indicatorMatrix = Matrix() private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF() private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF() private val indicatorMatrixDst = RectF()
private val shapeAppearance: ShapeAppearanceModel
init { init {
// Obtain some StyledImageView attributes to use later when theming the custom view. // Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable") @SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView) val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
val shapeAppearanceRes = styledAttrs.getResourceId(R.styleable.CoverView_shapeAppearance, 0) sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
shapeAppearance = iconSizeRes = SIZING_ICON_SIZE[sizing]
if (uiSettings.roundMode) { cornerRadiusRes = getCornerRadiusRes()
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
}
val playbackIndicatorEnabled = val playbackIndicatorEnabled =
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true) styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
@ -173,7 +153,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.onFinishInflate() super.onFinishInflate()
// The image isn't added if other children have populated the body. This is by design. // The image isn't added if other children have populated the body. This is by design.
if (isEmpty()) { if (childCount == 0) {
addView(image) 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 // AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
// behavior with a matrix. // behavior with a matrix.
val playbackIndicator = (playbackIndicator ?: return).view val playbackIndicator = (playbackIndicator ?: return).view
val iconSize = iconSize ?: (measuredWidth / 2) val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2)
playbackIndicator.apply { playbackIndicator.apply {
imageMatrix = imageMatrix =
indicatorMatrix.apply { 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 // Add backgrounds to each child for visual consistency
for (child in children) { for (child in children) {
child.apply { child.apply {
@ -278,7 +264,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background = background =
MaterialShapeDrawable().apply { MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg) fillColor = context.getColorCompat(R.color.sel_cover_bg)
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) { private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) {
fadeAnimator?.cancel() // 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 = fadeAnimator =
(if (isActivated) fader.fadeIn(selectionBadge) else fader.fadeOut(selectionBadge)) ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply {
.also { it.start() } 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) = fun bind(song: Song) =
bindImpl( bindImpl(
song.cover, listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name), context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -328,7 +347,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(album: Album) = fun bind(album: Album) =
bindImpl( bindImpl(
album.covers, album.cover.all,
context.getString(R.string.desc_album_cover, album.name), context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -339,7 +358,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(artist: Artist) = fun bind(artist: Artist) =
bindImpl( bindImpl(
artist.covers, artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name), context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24) R.drawable.ic_artist_24)
@ -350,7 +369,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(genre: Genre) = fun bind(genre: Genre) =
bindImpl( bindImpl(
genre.covers, genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name), context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24) R.drawable.ic_genre_24)
@ -361,7 +380,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist) =
bindImpl( bindImpl(
playlist.covers, playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name), context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24) 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. * @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) = 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 = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(cover) .data(covers)
.error( .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
.asImage())
.target(image) .target(image)
val cornersTransformation = val cornersTransformation =
RoundedRectTransformation( RoundedRectTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f)
shapeAppearance.topLeftCornerSize.getCornerSize(
RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())))
if (imageSettings.forceSquareCovers) { if (imageSettings.forceSquareCovers) {
request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation) request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation)
} else { } else {
@ -407,7 +422,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private class StyledDrawable( private class StyledDrawable(
context: Context, context: Context,
private val inner: Drawable, private val inner: Drawable,
@Px val iconSize: Int? @DimenRes iconSizeRes: Int?
) : Drawable() { ) : Drawable() {
init { init {
// Re-tint the drawable to use the analogous "on surface" color for // 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)) DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
} }
private val dimen = iconSizeRes?.let(context::getDimenPixels)
override fun draw(canvas: Canvas) { override fun draw(canvas: Canvas) {
// Resize the drawable such that it's always 1/4 the size of the image and // Resize the drawable such that it's always 1/4 the size of the image and
// centered in the middle of the canvas. // 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.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
inner.draw(canvas) inner.draw(canvas)
} }
@ -435,4 +452,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT 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)
}
} }

View file

@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import timber.log.Timber as L import org.oxycblt.auxio.util.logD
/** /**
* User configuration specific to image loading. * User configuration specific to image loading.
@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
get() = get() =
CoverMode.fromIntCode( CoverMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.BALANCED ?: CoverMode.MEDIA_STORE
override val forceSquareCovers: Boolean override val forceSquareCovers: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false) 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 // Show album covers and Ignore MediaStore covers were unified in 3.0.0
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) || if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) { sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
L.d("Migrating cover settings") logD("Migrating cover settings")
val mode = val mode =
when { when {
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) -> !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
CoverMode.BALANCED CoverMode.MEDIA_STORE
else -> CoverMode.BALANCED else -> CoverMode.QUALITY
} }
sharedPreferences.edit { sharedPreferences.edit {
@ -74,30 +74,12 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
remove(OLD_KEY_QUALITY_COVERS) 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) { override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
if (key == getString(R.string.set_key_cover_mode) || if (key == getString(R.string.set_key_cover_mode) ||
key == getString(R.string.set_key_square_covers)) { key == getString(R.string.set_key_square_covers)) {
L.d("Dispatching image setting change") logD("Dispatching image setting change")
listener.onImageSettingsChanged() listener.onImageSettingsChanged()
} }
} }
@ -105,6 +87,5 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
private companion object { private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS" const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS" const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

@ -16,15 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import coil3.decode.DataSource import coil.decode.DataSource
import coil3.request.ImageResult import coil.drawable.CrossfadeDrawable
import coil3.request.SuccessResult import coil.request.ImageResult
import coil3.transition.CrossfadeDrawable import coil.request.SuccessResult
import coil3.transition.CrossfadeTransition import coil.transition.CrossfadeTransition
import coil3.transition.Transition import coil.transition.Transition
import coil3.transition.TransitionTarget import coil.transition.TransitionTarget
/** /**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results. * A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * 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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import android.content.Context import android.content.Context
import coil3.ImageLoader import coil.ImageLoader
import coil3.request.CachePolicy import coil.request.CachePolicy
import coil3.request.transitionFactory
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -31,22 +30,19 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class CoilModule { class ExtractorModule {
@Singleton @Singleton
@Provides @Provides
fun imageLoader( fun imageLoader(
@ApplicationContext context: Context, @ApplicationContext context: Context,
coverKeyer: CoverKeyer, keyer: CoverKeyer,
coverFactory: CoverFetcher.Factory, factory: CoverFetcher.Factory
coverCollectionKeyer: CoverCollectionKeyer,
coverCollectionFactory: CoverCollectionFetcher.Factory
) = ) =
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
add(coverKeyer) // Add fetchers for Music components to make them usable with ImageRequest
add(coverFactory) add(keyer)
add(coverCollectionKeyer) add(factory)
add(coverCollectionFactory)
} }
// Use our own crossfade with error drawable support // Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory()) .transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Bitmap.createBitmap import android.graphics.Bitmap.createBitmap
@ -30,16 +30,16 @@ import android.graphics.RectF
import android.graphics.Shader import android.graphics.Shader
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.graphics.applyCanvas import androidx.core.graphics.applyCanvas
import coil3.decode.DecodeUtils import coil.decode.DecodeUtils
import coil3.size.Scale import coil.size.Scale
import coil3.size.Size import coil.size.Size
import coil3.size.pxOrElse import coil.size.pxOrElse
import coil3.transform.Transformation import coil.transform.Transformation
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images * A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
* without cropping them. * images without cropping them.
* *
* @author Coil Team, Alexander Capehart (OxygenCobalt) * @author Coil Team, Alexander Capehart (OxygenCobalt)
*/ */
@ -48,7 +48,7 @@ class RoundedRectTransformation(
@Px private val topRight: Float = 0f, @Px private val topRight: Float = 0f,
@Px private val bottomLeft: Float = 0f, @Px private val bottomLeft: Float = 0f,
@Px private val bottomRight: Float = 0f @Px private val bottomRight: Float = 0f
) : Transformation() { ) : Transformation {
constructor(@Px radius: Float) : this(radius, radius, radius, radius) constructor(@Px radius: Float) : this(radius, radius, radius, radius)
@ -65,11 +65,7 @@ class RoundedRectTransformation(
val (outputWidth, outputHeight) = calculateOutputSize(input, size) val (outputWidth, outputHeight) = calculateOutputSize(input, size)
val output = val output = createBitmap(outputWidth, outputHeight, input.config)
createBitmap(
outputWidth,
outputHeight,
requireNotNull(input.config) { "unsupported bitmap format" })
output.applyCanvas { output.applyCanvas {
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)

View file

@ -16,13 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.graphics.scale import coil.size.Size
import coil3.size.Size import coil.size.pxOrElse
import coil3.size.pxOrElse import coil.transform.Transformation
import coil3.transform.Transformation
import kotlin.math.min import kotlin.math.min
/** /**
@ -31,7 +30,7 @@ import kotlin.math.min
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SquareCropTransformation : Transformation() { class SquareCropTransformation : Transformation {
override val cacheKey: String override val cacheKey: String
get() = "SquareCropTransformation" get() = "SquareCropTransformation"
@ -47,7 +46,7 @@ class SquareCropTransformation : Transformation() {
val desiredHeight = size.height.pxOrElse { dstSize } val desiredHeight = size.height.pxOrElse { dstSize }
if (dstSize != desiredWidth || dstSize != desiredHeight) { if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Image is not the desired size, upscale it. // Image is not the desired size, upscale it.
return dst.scale(desiredWidth, desiredHeight) return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
} }
return dst return dst
} }

View file

@ -22,16 +22,14 @@ import androidx.annotation.StringRes
// TODO: Consider breaking this up into sealed classes for individual adapters // 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. */ /** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
typealias Item = Any interface Item
interface Header
/** /**
* A "header" used for delimiting groups of data. * A "header" used for delimiting groups of data.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface PlainHeader : Header { interface Header : Item {
/** The string resource used for the header's title. */ /** The string resource used for the header's title. */
val titleRes: Int val titleRes: Int
} }
@ -42,16 +40,12 @@ interface PlainHeader : Header {
* @param titleRes The string resource used for the header's title. * @param titleRes The string resource used for the header's title.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader data class BasicHeader(@StringRes override val titleRes: Int) : Header
interface Divider<T> {
val anchor: T?
}
/** /**
* A divider decoration used to delimit groups of data. * 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 * @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve
* preserve divider continuity during list updates. * divider continuity during list updates.
*/ */
data class PlainDivider(override val anchor: PlainHeader?) : Divider<PlainHeader> data class Divider(val anchor: Header?) : Item

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.Music
/** /**
* A Fragment containing a selectable list. * A Fragment containing a selectable list.

View file

@ -25,18 +25,19 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.menu.Menu 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.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.util.logW
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
/** /**
* A [ViewModel] that orchestrates menu dialogs and selection state. * 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) { 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 // Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list. // won't appear in any list.
_selected.value = _selected.value =
_selected.value.mapNotNull { _selected.value.mapNotNull {
when (it) { when (it) {
is Song -> library.findSong(it.uid) is Song -> deviceLibrary.findSong(it.uid)
is Album -> library.findAlbum(it.uid) is Album -> deviceLibrary.findAlbum(it.uid)
is Artist -> library.findArtist(it.uid) is Artist -> deviceLibrary.findArtist(it.uid)
is Genre -> library.findGenre(it.uid) is Genre -> deviceLibrary.findGenre(it.uid)
is Playlist -> library.findPlaylist(it.uid) is Playlist -> userLibrary.findPlaylist(it.uid)
} }
} }
} }
@ -92,16 +94,16 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
*/ */
fun select(music: Music) { fun select(music: Music) {
if (music is MusicParent && music.songs.isEmpty()) { if (music is MusicParent && music.songs.isEmpty()) {
L.d("Cannot select empty parent, ignoring operation") logD("Cannot select empty parent, ignoring operation")
return return
} }
val selected = _selected.value.toMutableList() val selected = _selected.value.toMutableList()
if (!selected.remove(music)) { if (!selected.remove(music)) {
L.d("Adding $music to selection") logD("Adding $music to selection")
selected.add(music) selected.add(music)
} else { } else {
L.d("Removed $music from selection") logD("Removed $music from selection")
} }
_selected.value = selected _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. * @return A list of [Song]s collated from each item selected.
*/ */
fun takeSelection(): List<Song> { fun takeSelection(): List<Song> {
L.d("Taking selection") logD("Taking selection")
return peekSelection().also { _selected.value = listOf() } 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. * @return true if the prior selection was non-empty, false otherwise.
*/ */
fun dropSelection(): Boolean { 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() } return _selected.value.isNotEmpty().also { _selected.value = listOf() }
} }
@ -153,7 +155,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* should do. * should do.
*/ */
fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) { 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)) openImpl(Menu.ForSong(menuRes, song, playWith))
} }
@ -165,7 +167,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param album The [Album] to show. * @param album The [Album] to show.
*/ */
fun openMenu(@MenuRes menuRes: Int, album: Album) { fun openMenu(@MenuRes menuRes: Int, album: Album) {
L.d("Opening menu for $album") logD("Opening menu for $album")
openImpl(Menu.ForAlbum(menuRes, album)) openImpl(Menu.ForAlbum(menuRes, album))
} }
@ -177,7 +179,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param artist The [Artist] to show. * @param artist The [Artist] to show.
*/ */
fun openMenu(@MenuRes menuRes: Int, artist: Artist) { fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
L.d("Opening menu for $artist") logD("Opening menu for $artist")
openImpl(Menu.ForArtist(menuRes, artist)) openImpl(Menu.ForArtist(menuRes, artist))
} }
@ -189,7 +191,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param genre The [Genre] to show. * @param genre The [Genre] to show.
*/ */
fun openMenu(@MenuRes menuRes: Int, genre: Genre) { fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
L.d("Opening menu for $genre") logD("Opening menu for $genre")
openImpl(Menu.ForGenre(menuRes, genre)) openImpl(Menu.ForGenre(menuRes, genre))
} }
@ -201,7 +203,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param playlist The [Playlist] to show. * @param playlist The [Playlist] to show.
*/ */
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) { fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
L.d("Opening menu for $playlist") logD("Opening menu for $playlist")
openImpl(Menu.ForPlaylist(menuRes, 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. * @param songs The [Song] selection to show.
*/ */
fun openMenu(@MenuRes menuRes: Int, songs: List<Song>) { 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)) openImpl(Menu.ForSelection(menuRes, songs))
} }
private fun openImpl(menu: Menu) { private fun openImpl(menu: Menu) {
val existing = _menu.flow.value val existing = _menu.flow.value
if (existing != null) { if (existing != null) {
L.w("Already opening $existing, ignoring $menu") logW("Already opening $existing, ignoring $menu")
return return
} }
_menu.put(menu) _menu.put(menu)

View file

@ -25,7 +25,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.Executor 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. * A variant of ListDiffer with more flexible updates.
@ -57,7 +57,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
instructions: UpdateInstructions?, instructions: UpdateInstructions?,
callback: (() -> Unit)? = null 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) differ.update(newList, instructions, callback)
} }
} }
@ -171,7 +171,7 @@ private class FlexibleListDiffer<T>(
) { ) {
// fast simple remove all // fast simple remove all
if (newList.isEmpty()) { if (newList.isEmpty()) {
L.d("Short-circuiting diff to remove all") logD("Short-circuiting diff to remove all")
val countRemoved = oldList.size val countRemoved = oldList.size
currentList = emptyList() currentList = emptyList()
// notify last, after list is updated // notify last, after list is updated
@ -182,7 +182,7 @@ private class FlexibleListDiffer<T>(
// fast simple first insert // fast simple first insert
if (oldList.isEmpty()) { if (oldList.isEmpty()) {
L.d("Short-circuiting diff to insert all") logD("Short-circuiting diff to insert all")
currentList = newList currentList = newList
// notify last, after list is updated // notify last, after list is updated
updateCallback.onInserted(0, newList.size) updateCallback.onInserted(0, newList.size)
@ -244,7 +244,7 @@ private class FlexibleListDiffer<T>(
mainThreadExecutor.execute { mainThreadExecutor.execute {
if (maxScheduledGeneration == runGeneration) { if (maxScheduledGeneration == runGeneration) {
L.d("Applying calculated diff") logD("Applying calculated diff")
currentList = newList currentList = newList
result.dispatchUpdatesTo(updateCallback) result.dispatchUpdatesTo(updateCallback)
callback?.invoke() callback?.invoke()

View file

@ -21,7 +21,8 @@ package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView 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. * 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. * @param isPlaying Whether playback is ongoing or paused.
*/ */
fun setPlaying(item: T?, isPlaying: Boolean) { 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 var updatedItem = false
if (currentItem != item) { if (currentItem != item) {
@ -71,7 +72,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else { } 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) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else { } 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) { if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED) notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else { } else {
L.w("newItem was not in adapter data") logW("newItem was not in adapter data")
} }
} }
} }

View file

@ -21,8 +21,8 @@ package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.Music
import timber.log.Timber as L import org.oxycblt.auxio.util.logD
/** /**
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of * A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
@ -55,7 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
// Nothing to do. // Nothing to do.
return return
} }
L.d("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}") logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
selectedItems = newSelectedItems selectedItems = newSelectedItems
for (i in currentList.indices) { for (i in currentList.indices) {

View file

@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import kotlinx.parcelize.Parcelize 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.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. * Command to navigate to a specific menu dialog configuration.

View file

@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
import org.oxycblt.auxio.util.collectImmediately 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 * 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?) { private fun updateMenu(menu: Menu?) {
if (menu == null) { if (menu == null) {
L.d("No menu to show, navigating away") logD("No menu to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }

View file

@ -27,18 +27,17 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMenuBinding import org.oxycblt.auxio.databinding.DialogMenuBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ListViewModel 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.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.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.share import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast 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]. * [MenuDialogFragment] implementation for a [Song].
@ -113,7 +112,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) { override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
val context = requireContext() val context = requireContext()
binding.menuCover.bind(menu.album) 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.menuName.text = menu.album.name.resolve(context)
binding.menuInfo.text = menu.album.artists.resolveNames(context) binding.menuInfo.text = menu.album.artists.resolveNames(context)
} }

View file

@ -82,7 +82,7 @@ class MenuItemViewHolder private constructor(private val binding: ItemMenuOption
oldItem == newItem oldItem == newItem
override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) = override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) =
oldItem.title.toString() == newItem.title.toString() oldItem.title == newItem.title
} }
} }
} }

View file

@ -23,10 +23,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.MusicParent import org.oxycblt.auxio.util.logW
import timber.log.Timber as L
/** /**
* Manages the state information for [MenuDialogFragment] implementations. * Manages the state information for [MenuDialogFragment] implementations.
@ -55,7 +55,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
fun setMenu(parcel: Menu.Parcel) { fun setMenu(parcel: Menu.Parcel) {
_currentMenu.value = unpackParcel(parcel) _currentMenu.value = unpackParcel(parcel)
if (_currentMenu.value == null) { 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? { 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 parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
return Menu.ForSong(parcel.res, song, playWith) return Menu.ForSong(parcel.res, song, playWith)
} }
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? { 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) return Menu.ForAlbum(parcel.res, album)
} }
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? { 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) return Menu.ForArtist(parcel.res, artist)
} }
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? { 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) return Menu.ForGenre(parcel.res, genre)
} }
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? { 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) return Menu.ForPlaylist(parcel.res, playlist)
} }
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? { private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
val library = musicRepository.library ?: return null val deviceLibrary = musicRepository.deviceLibrary ?: return null
val songs = parcel.songUids.mapNotNull(library::findSong) val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
return Menu.ForSelection(parcel.res, songs) return Menu.ForSelection(parcel.res, songs)
} }
} }

View file

@ -19,7 +19,6 @@
package org.oxycblt.auxio.list.recycler package org.oxycblt.auxio.list.recycler
import android.content.Context import android.content.Context
import android.os.Parcelable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
@ -39,7 +38,6 @@ open class AuxioRecyclerView
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
RecyclerView(context, attrs, defStyleAttr) { RecyclerView(context, attrs, defStyleAttr) {
private val initialPaddingBottom = paddingBottom private val initialPaddingBottom = paddingBottom
private var savedState: Parcelable? = null
init { init {
// Prevent children from being clipped by window insets // 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 // Update the RecyclerView's padding such that the bottom insets are applied
// while still preserving bottom padding. // while still preserving bottom padding.
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom) 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 return insets
} }
override fun onRestoreInstanceState(state: Parcelable?) {
super.onRestoreInstanceState(state)
savedState = state
}
} }

View file

@ -25,7 +25,6 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min 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.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getInteger 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, * 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 // 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. // 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) { if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
L.d("Lifting ViewHolder") logD("Lifting ViewHolder")
val bg = holder.background 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 holder.root
.animate() .animate()
.translationZ(elevation) .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 // This function can be called multiple times, so only start the animation when the view's
// translationZ is already non-zero. // translationZ is already non-zero.
if (holder.root.translationZ != 0f) { if (holder.root.translationZ != 0f) {
L.d("Lifting ViewHolder") logD("Lifting ViewHolder")
val bg = holder.background 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 holder.root
.animate() .animate()
.translationZ(0f) .translationZ(0f)

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.list.recycler package org.oxycblt.auxio.list.recycler
import android.annotation.SuppressLint
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider 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.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.BasicHeader 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.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback 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.areNamesTheSame
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.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. * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
@ -383,9 +381,8 @@ class DividerViewHolder private constructor(divider: MaterialDivider) :
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<PlainDivider>() { object : SimpleDiffCallback<Divider>() {
@SuppressLint("DiffUtilEquals") override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
override fun areContentsTheSame(oldItem: PlainDivider, newItem: PlainDivider) =
oldItem.anchor == newItem.anchor oldItem.anchor == newItem.anchor
} }
} }

View file

@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.musikr.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.musikr.Song import org.oxycblt.auxio.music.Song
/** /**
* A sorting method. * A sorting method.
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun sortSongs(songs: MutableList<Song>, direction: Direction) { override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name } songs.sortBy { it.name }
when (direction) { when (direction) {
Direction.ASCENDING -> songs.sortBy { it.addedMs } Direction.ASCENDING -> songs.sortBy { it.dateAdded }
Direction.DESCENDING -> songs.sortByDescending { it.addedMs } Direction.DESCENDING -> songs.sortByDescending { it.dateAdded }
} }
} }
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) { override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name } albums.sortBy { it.name }
when (direction) { when (direction) {
Direction.ASCENDING -> albums.sortBy { it.addedMs } Direction.ASCENDING -> albums.sortBy { it.dateAdded }
Direction.DESCENDING -> albums.sortByDescending { it.addedMs } Direction.DESCENDING -> albums.sortByDescending { it.dateAdded }
} }
} }
} }

View file

@ -96,17 +96,17 @@ abstract class SortDialog :
private fun updateButtons() { private fun updateButtons() {
val binding = requireBinding() val binding = requireBinding()
binding.sortSave.isEnabled = getCurrentSort().let { it != null && it != getInitialSort() } binding.sortSave.isEnabled = getCurrentSort() != getInitialSort()
} }
private fun getCurrentSort(): Sort? { private fun getCurrentSort(): Sort? {
val initial = getInitialSort() val initial = getInitialSort()
val mode = modeAdapter.currentMode ?: return null val mode = modeAdapter.currentMode ?: initial?.mode ?: return null
val direction = val direction =
when (requireBinding().sortDirectionGroup.checkedButtonId) { when (requireBinding().sortDirectionGroup.checkedButtonId) {
R.id.sort_direction_asc -> Sort.Direction.ASCENDING R.id.sort_direction_asc -> Sort.Direction.ASCENDING
R.id.sort_direction_dsc -> Sort.Direction.DESCENDING R.id.sort_direction_dsc -> Sort.Direction.DESCENDING
else -> return null else -> initial?.direction ?: return null
} }
return Sort(mode, direction) return Sort(mode, direction)
} }

View 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