Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a784f73c5e |
430 changed files with 12130 additions and 14783 deletions
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -34,7 +34,6 @@ body:
|
||||||
attributes:
|
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
|
||||||
|
|
6
.github/workflows/android.yml
vendored
6
.github/workflows/android.yml
vendored
|
@ -25,10 +25,8 @@ jobs:
|
||||||
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
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -14,5 +14,3 @@ captures/
|
||||||
*.iml
|
*.iml
|
||||||
.cxx
|
.cxx
|
||||||
.kotlin
|
.kotlin
|
||||||
.aider*
|
|
||||||
.env
|
|
||||||
|
|
5
.gitmodules
vendored
5
.gitmodules
vendored
|
@ -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
|
|
||||||
|
|
84
CHANGELOG.md
84
CHANGELOG.md
|
@ -1,100 +1,30 @@
|
||||||
# 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
|
## 4.0.0
|
||||||
|
|
||||||
#### What's New
|
#### 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
|
- Android 15 support
|
||||||
|
- New app branding and icon
|
||||||
|
- Refreshed playback design
|
||||||
|
- Live widget preview on Android 15+
|
||||||
|
- Added GitHub/email feedback forms to about page
|
||||||
|
|
||||||
#### What's Improved
|
#### What's Improved
|
||||||
- Initial music loading is signifigantly faster and less resource intensive
|
- Album grouping no longer done with artist in mind by default
|
||||||
- Album grouping no longer done with artist
|
|
||||||
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries
|
- 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
|
- 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
|
- Sorting songs by date now uses songs date first, before the earliest album date
|
||||||
- Added working layouts for small split-screen form factors
|
- 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
|
#### What's Fixed
|
||||||
|
- Music loader no longer spawns thousands of threads when scanning
|
||||||
|
- Excessive CPU no longer spent showing music loading process
|
||||||
- Fixed playback sheet flickering on warm start
|
- Fixed playback sheet flickering on warm start
|
||||||
- No longer possible to save a sort with no direction specified
|
- No longer possible to save a sort with no direction specified
|
||||||
- Fixed inconsistent corner radii in widget
|
- 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
|
#### Dev/Meta
|
||||||
- No longer using custom logging setup
|
- No longer using custom logging setup
|
||||||
- Music loading split off into separate musikr module
|
|
||||||
|
|
||||||
## 3.6.3
|
## 3.6.3
|
||||||
|
|
||||||
|
|
43
README.md
43
README.md
|
@ -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,39 +61,29 @@ 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
|
||||||
|
|
||||||
You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
|
You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
|
||||||
|
|
||||||
<p align="center"><b>$16/month supporters:</b></p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></a>
|
|
||||||
<br/>
|
|
||||||
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center"><b>$8/month supporters:</b></p>
|
<p align="center"><b>$8/month supporters:</b></p>
|
||||||
|
|
||||||
<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/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?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.
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -11,18 +12,20 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk 35
|
compileSdk 35
|
||||||
// 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 35
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
@ -77,13 +80,14 @@ 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.15.0"
|
||||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||||
implementation "androidx.activity:activity-ktx:1.9.3"
|
implementation "androidx.activity:activity-ktx:1.9.3"
|
||||||
// noinspection GradleDependency
|
// noinspection GradleDependency
|
||||||
|
@ -121,26 +125,20 @@ 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.1.3"
|
||||||
|
|
||||||
// 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
|
||||||
|
@ -164,4 +162,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.6.1'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
spotless {
|
||||||
|
kotlin {
|
||||||
|
target "src/**/*.kt"
|
||||||
|
ktfmt().dropboxStyle()
|
||||||
|
licenseHeaderFile("NOTICE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
preDebugBuild.dependsOn spotlessApply
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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>
|
|
@ -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" />
|
||||||
|
@ -97,15 +100,6 @@
|
||||||
</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.
|
||||||
|
|
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -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;
|
||||||
|
@ -1632,13 +1633,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 && isHideableWhenDragging()) {
|
||||||
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
||||||
backEvent,
|
backEvent,
|
||||||
new AnimatorListenerAdapter() {
|
new AnimatorListenerAdapter() {
|
||||||
|
|
|
@ -36,7 +36,6 @@ 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
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AuxioService :
|
class AuxioService :
|
||||||
|
@ -54,30 +53,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?) {
|
||||||
|
@ -141,7 +134,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) {
|
||||||
|
|
|
@ -65,8 +65,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 +123,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 +139,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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio
|
package org.oxycblt.auxio
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
|
@ -26,7 +27,6 @@ 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.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
|
||||||
|
@ -50,8 +50,10 @@ 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.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.Song
|
||||||
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
|
||||||
|
@ -68,8 +70,6 @@ import org.oxycblt.auxio.util.getDimen
|
||||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||||
import org.oxycblt.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -257,9 +257,9 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
@ -367,10 +367,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -408,6 +404,9 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
if (state is IndexingState.Completed && state.error == null) {
|
if (state is IndexingState.Completed && state.error == null) {
|
||||||
L.d("Received ok response")
|
L.d("Received ok response")
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
|
@ -513,6 +512,8 @@ class MainFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var scrimAnimator: ValueAnimator? = null
|
||||||
|
|
||||||
private fun updateSpeedDial(open: Boolean) {
|
private fun updateSpeedDial(open: Boolean) {
|
||||||
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
||||||
.invalidateEnabled(open)
|
.invalidateEnabled(open)
|
||||||
|
|
|
@ -22,7 +22,6 @@ import android.os.Bundle
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
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
|
||||||
|
@ -30,9 +29,12 @@ import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
|
||||||
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.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.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.resolveNames
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.playback.formatDurationMs
|
import org.oxycblt.auxio.playback.formatDurationMs
|
||||||
|
@ -42,10 +44,6 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -117,7 +115,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
binding.detailToolbarTitle.text = name
|
binding.detailToolbarTitle.text = name
|
||||||
binding.detailCover.bind(album)
|
binding.detailCover.bind(album)
|
||||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||||
binding.detailType.text = album.releaseType.resolve(context)
|
binding.detailType.text = getString(album.releaseType.stringRes)
|
||||||
binding.detailName.text = name
|
binding.detailName.text = name
|
||||||
// Artist name maps to the subhead text
|
// Artist name maps to the subhead text
|
||||||
binding.detailSubhead.apply {
|
binding.detailSubhead.apply {
|
||||||
|
@ -133,7 +131,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
// Date, song count, and duration map to the info text
|
// Date, song count, and duration map to the info text
|
||||||
binding.detailInfo.apply {
|
binding.detailInfo.apply {
|
||||||
// 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
|
||||||
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
|
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 songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||||
val duration = album.durationMs.formatDurationMs(true)
|
val duration = album.durationMs.formatDurationMs(true)
|
||||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||||
|
@ -142,15 +140,9 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
binding.detailPlayButton?.setOnClickListener {
|
binding.detailPlayButton?.setOnClickListener {
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
}
|
}
|
||||||
binding.detailToolbarPlay.setOnClickListener {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
|
||||||
}
|
|
||||||
binding.detailShuffleButton?.setOnClickListener {
|
binding.detailShuffleButton?.setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||||
}
|
}
|
||||||
binding.detailToolbarShuffle.setOnClickListener {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
|
||||||
}
|
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
@ -299,11 +291,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
// RecyclerView will scroll assuming it has the total height of the screen (i.e a
|
// 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.
|
// collapsed appbar), so we need to collapse the appbar if that's the case.
|
||||||
binding.detailAppbar.setExpanded(false)
|
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.
|
||||||
|
@ -329,6 +316,4 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
||||||
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.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.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.resolveNames
|
||||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||||
import org.oxycblt.auxio.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
|
@ -40,11 +44,6 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -164,15 +163,9 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
||||||
binding.detailPlayButton?.setOnClickListener {
|
binding.detailPlayButton?.setOnClickListener {
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
}
|
}
|
||||||
binding.detailToolbarPlay.setOnClickListener {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
|
||||||
}
|
|
||||||
binding.detailShuffleButton?.setOnClickListener {
|
binding.detailShuffleButton?.setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||||
}
|
}
|
||||||
binding.detailToolbarShuffle.setOnClickListener {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
|
||||||
}
|
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,13 +35,13 @@ import org.oxycblt.auxio.list.ListFragment
|
||||||
import org.oxycblt.auxio.list.ListViewModel
|
import org.oxycblt.auxio.list.ListViewModel
|
||||||
import org.oxycblt.auxio.list.PlainDivider
|
import org.oxycblt.auxio.list.PlainDivider
|
||||||
import org.oxycblt.auxio.list.PlainHeader
|
import org.oxycblt.auxio.list.PlainHeader
|
||||||
|
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.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.util.getDimenPixels
|
import org.oxycblt.auxio.util.getDimenPixels
|
||||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||||
import org.oxycblt.musikr.Music
|
|
||||||
import org.oxycblt.musikr.MusicParent
|
|
||||||
|
|
||||||
abstract class DetailFragment<P : MusicParent, C : Music> :
|
abstract class DetailFragment<P : MusicParent, C : Music> :
|
||||||
ListFragment<C, FragmentDetailBinding>(),
|
ListFragment<C, FragmentDetailBinding>(),
|
||||||
|
@ -123,9 +123,6 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
|
||||||
val detailContent = binding.detailToolbarContent
|
val detailContent = binding.detailToolbarContent
|
||||||
detailContent.alpha = inRatio
|
detailContent.alpha = inRatio
|
||||||
detailContent.translationY = spacingSmall * (1 - inRatio)
|
detailContent.translationY = spacingSmall * (1 - inRatio)
|
||||||
|
|
||||||
// Enable fast scrolling once fully collapsed
|
|
||||||
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun onOpenParentMenu()
|
abstract fun onOpenParentMenu()
|
||||||
|
|
|
@ -23,17 +23,17 @@ 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.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import org.oxycblt.musikr.tag.Disc
|
|
||||||
import org.oxycblt.musikr.tag.ReleaseType
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
interface DetailGenerator {
|
interface DetailGenerator {
|
||||||
|
@ -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
|
||||||
|
@ -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))
|
||||||
|
|
|
@ -22,14 +22,16 @@ 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.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.Item
|
import org.oxycblt.auxio.list.Item
|
||||||
|
@ -38,20 +40,21 @@ import org.oxycblt.auxio.list.PlainDivider
|
||||||
import org.oxycblt.auxio.list.PlainHeader
|
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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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.
|
||||||
|
@ -319,14 +308,14 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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")
|
L.d("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")
|
L.w("Given song UID was invalid")
|
||||||
}
|
}
|
||||||
|
@ -522,32 +511,16 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshAudioInfo(song: Song) {
|
private fun refreshAudioInfo(song: Song) {
|
||||||
_currentSongProperties.value = buildList {
|
L.d("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()
|
||||||
}
|
L.d("Updating audio info to $info")
|
||||||
song.disc?.let {
|
_songAudioProperties.value = info
|
||||||
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)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
||||||
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.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.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.util.collect
|
import org.oxycblt.auxio.util.collect
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
|
@ -39,11 +43,6 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -133,15 +132,9 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
||||||
binding.detailPlayButton?.setOnClickListener {
|
binding.detailPlayButton?.setOnClickListener {
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
}
|
}
|
||||||
binding.detailToolbarPlay.setOnClickListener {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
|
||||||
}
|
|
||||||
binding.detailShuffleButton?.setOnClickListener {
|
binding.detailShuffleButton?.setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||||
}
|
}
|
||||||
binding.detailToolbarShuffle.setOnClickListener {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
|
||||||
}
|
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,9 +35,13 @@ import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
||||||
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.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.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.formatDurationMs
|
||||||
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
|
||||||
|
@ -48,11 +52,6 @@ import org.oxycblt.auxio.util.getPlural
|
||||||
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.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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -232,24 +231,12 @@ class PlaylistDetailFragment :
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.detailToolbarPlay.apply {
|
|
||||||
isEnabled = playable
|
|
||||||
setOnClickListener {
|
|
||||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
binding.detailShuffleButton?.apply {
|
binding.detailShuffleButton?.apply {
|
||||||
isEnabled = playable
|
isEnabled = playable
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.detailToolbarShuffle.apply {
|
|
||||||
isEnabled = playable
|
|
||||||
setOnClickListener {
|
|
||||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updatePlayback(
|
updatePlayback(
|
||||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,9 +32,16 @@ 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 timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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) {
|
||||||
|
L.d("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) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -23,12 +23,12 @@ 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.musikr.Music
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -56,9 +56,9 @@ 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}")
|
L.d("Updated artist choices: ${_artistChoices.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,15 +98,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 +115,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,9 +32,9 @@ 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 timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -35,14 +35,14 @@ 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.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.
|
||||||
|
@ -121,7 +121,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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -35,9 +35,9 @@ 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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -40,13 +40,12 @@ 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.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
import org.oxycblt.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -26,8 +26,8 @@ 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 timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,8 +26,8 @@ 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 timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,8 +26,8 @@ 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 timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -26,8 +26,8 @@ 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 timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,10 +22,10 @@ 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.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,10 +37,12 @@ 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 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.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||||
|
@ -51,27 +53,31 @@ 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.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.lazyReflectedField
|
import org.oxycblt.auxio.util.lazyReflectedField
|
||||||
|
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||||
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
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -172,7 +178,6 @@ class HomeFragment :
|
||||||
|
|
||||||
// --- 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)
|
||||||
collect(detailModel.toShow.flow, ::handleShow)
|
collect(detailModel.toShow.flow, ::handleShow)
|
||||||
collect(listModel.menu.flow, ::handleMenu)
|
collect(listModel.menu.flow, ::handleMenu)
|
||||||
|
@ -266,7 +271,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -297,49 +304,98 @@ 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
|
L.d("Indexer is in indeterminate state")
|
||||||
|
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
|
||||||
|
if (error == null) {
|
||||||
|
L.d("Received ok response")
|
||||||
|
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
L.d("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 -> {
|
||||||
|
L.d("Showing permission prompt")
|
||||||
|
binding.homeIndexingStatus.setText(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 -> {
|
||||||
|
L.d("Showing no music error")
|
||||||
|
binding.homeIndexingStatus.setText(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 -> {
|
||||||
|
L.d("Showing generic error")
|
||||||
|
binding.homeIndexingStatus.setText(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
|
||||||
|
|
||||||
|
binding.homeIndexingStatus.setText(R.string.lng_indexing)
|
||||||
|
when (progress) {
|
||||||
|
is IndexingProgress.Indeterminate -> {
|
||||||
|
// In a query/initialization state, show a generic loading status.
|
||||||
|
binding.homeIndexingProgress.isIndeterminate = true
|
||||||
|
}
|
||||||
|
is IndexingProgress.Songs -> {
|
||||||
|
// Actively loading songs, show the current progress.
|
||||||
|
binding.homeIndexingProgress.apply {
|
||||||
|
isIndeterminate = false
|
||||||
|
max = progress.total
|
||||||
|
this.progress = progress.current
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -508,5 +564,11 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,13 +22,13 @@ 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.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
interface HomeGenerator {
|
interface HomeGenerator {
|
||||||
|
@ -36,8 +36,6 @@ interface HomeGenerator {
|
||||||
|
|
||||||
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()
|
||||||
|
@ -123,10 +119,8 @@ 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
|
|
||||||
if (changes.deviceLibrary && library != null) {
|
|
||||||
L.d("Refreshing library")
|
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.
|
||||||
|
@ -136,7 +130,8 @@ private class HomeGeneratorImpl(
|
||||||
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
|
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changes.userLibrary && library != null) {
|
val userLibrary = musicRepository.userLibrary
|
||||||
|
if (changes.userLibrary && userLibrary != null) {
|
||||||
L.d("Refreshing playlists")
|
L.d("Refreshing playlists")
|
||||||
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
|
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
|
||||||
}
|
}
|
||||||
|
@ -148,16 +143,15 @@ 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() }
|
||||||
|
@ -167,10 +161,11 @@ private class HomeGeneratorImpl(
|
||||||
} ?: 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 }
|
||||||
|
|
|
@ -27,16 +27,16 @@ 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.musikr.Artist
|
|
||||||
import org.oxycblt.musikr.Genre
|
|
||||||
import org.oxycblt.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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>
|
||||||
|
@ -163,10 +159,6 @@ constructor(
|
||||||
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 +168,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 -> {
|
||||||
|
@ -275,10 +263,6 @@ constructor(
|
||||||
_isFastScrolling.value = isFastScrolling
|
_isFastScrolling.value = isFastScrolling
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startChooseMusicLocations() {
|
|
||||||
_chooseMusicLocations.put(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showSettings() {
|
fun showSettings() {
|
||||||
_showOuter.put(Outer.Settings)
|
_showOuter.put(Outer.Settings)
|
||||||
}
|
}
|
||||||
|
|
|
@ -190,8 +190,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 +230,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 =
|
||||||
|
|
|
@ -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
|
||||||
|
@ -38,16 +36,15 @@ 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.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()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,8 +21,6 @@ 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
|
||||||
|
@ -36,16 +34,15 @@ 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.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()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,8 +21,6 @@ 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
|
||||||
|
@ -36,15 +34,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
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()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* ListUtil.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.home.list
|
|
||||||
|
|
||||||
import androidx.core.text.isDigitsOnly
|
|
||||||
import org.oxycblt.musikr.tag.Name
|
|
||||||
|
|
||||||
fun Name.thumb() =
|
|
||||||
when (this) {
|
|
||||||
is Name.Known ->
|
|
||||||
tokens.firstOrNull()?.let {
|
|
||||||
if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase()
|
|
||||||
}
|
|
||||||
is Name.Unknown -> "?"
|
|
||||||
}
|
|
|
@ -21,8 +21,6 @@ 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
|
||||||
|
@ -35,15 +33,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
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()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
@ -37,15 +35,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||||
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
|
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()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 Auxio Project
|
||||||
|
* AdaptiveTabStrategy.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.home.tabs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.music.MusicType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
|
||||||
|
* depending on the screen configuration.
|
||||||
|
*
|
||||||
|
* @param context [Context] required to obtain window information
|
||||||
|
* @param tabs Current tab configuration from settings
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
|
||||||
|
TabLayoutMediator.TabConfigurationStrategy {
|
||||||
|
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||||
|
|
||||||
|
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||||
|
val homeTab = tabs[position]
|
||||||
|
val icon =
|
||||||
|
when (homeTab) {
|
||||||
|
MusicType.SONGS -> R.drawable.ic_song_24
|
||||||
|
MusicType.ALBUMS -> R.drawable.ic_album_24
|
||||||
|
MusicType.ARTISTS -> R.drawable.ic_artist_24
|
||||||
|
MusicType.GENRES -> R.drawable.ic_genre_24
|
||||||
|
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use expected sw* size thresholds when choosing a configuration.
|
||||||
|
when {
|
||||||
|
// On small screens, only display an icon.
|
||||||
|
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
|
||||||
|
// On large screens, display an icon and text.
|
||||||
|
width < 600 -> tab.setText(homeTab.nameRes)
|
||||||
|
// On medium-size screens, display text.
|
||||||
|
else -> tab.setIcon(icon).setText(homeTab.nameRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,29 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2025 Auxio Project
|
|
||||||
* NamedTabStrategy.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.home.tabs
|
|
||||||
|
|
||||||
import com.google.android.material.tabs.TabLayout
|
|
||||||
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
|
|
||||||
import org.oxycblt.auxio.music.MusicType
|
|
||||||
|
|
||||||
class NamedTabStrategy(private val homeTabs: List<MusicType>) : TabConfigurationStrategy {
|
|
||||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
|
||||||
tab.setText(homeTabs[position].nameRes)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -37,35 +37,31 @@ import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.Px
|
import androidx.annotation.Px
|
||||||
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 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.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.MaterialFader
|
import org.oxycblt.auxio.ui.MaterialFader
|
||||||
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.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.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
|
||||||
|
@ -173,7 +169,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -317,7 +313,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 +324,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 +335,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 +346,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 +357,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,15 +369,13 @@ 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), iconSize))
|
||||||
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
|
||||||
.asImage())
|
|
||||||
.target(image)
|
.target(image)
|
||||||
|
|
||||||
val cornersTransformation =
|
val cornersTransformation =
|
||||||
|
@ -410,7 +404,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
@Px val iconSize: Int?
|
@Px val iconSize: 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 surfaceg" color for
|
||||||
// StyledImageView.
|
// StyledImageView.
|
||||||
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -64,8 +64,8 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
||||||
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,24 +74,6 @@ 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) {
|
||||||
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,140 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* CoverCollectionFetcher.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.coil
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import androidx.core.graphics.createBitmap
|
|
||||||
import androidx.core.graphics.drawable.toDrawable
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.asImage
|
|
||||||
import coil3.decode.DataSource
|
|
||||||
import coil3.decode.ImageSource
|
|
||||||
import coil3.fetch.FetchResult
|
|
||||||
import coil3.fetch.Fetcher
|
|
||||||
import coil3.fetch.ImageFetchResult
|
|
||||||
import coil3.fetch.SourceFetchResult
|
|
||||||
import coil3.request.Options
|
|
||||||
import coil3.size.Dimension
|
|
||||||
import coil3.size.Size
|
|
||||||
import coil3.size.pxOrElse
|
|
||||||
import java.io.InputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.asFlow
|
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
|
||||||
import kotlinx.coroutines.flow.take
|
|
||||||
import kotlinx.coroutines.flow.toList
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okio.FileSystem
|
|
||||||
import okio.buffer
|
|
||||||
import okio.source
|
|
||||||
import org.oxycblt.musikr.covers.CoverCollection
|
|
||||||
|
|
||||||
class CoverCollectionFetcher
|
|
||||||
private constructor(
|
|
||||||
private val context: Context,
|
|
||||||
private val covers: CoverCollection,
|
|
||||||
private val size: Size,
|
|
||||||
) : Fetcher {
|
|
||||||
override suspend fun fetch(): FetchResult? {
|
|
||||||
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
|
|
||||||
// We don't immediately check for mosaic feasibility from album count alone, as that
|
|
||||||
// does not factor in InputStreams failing to load. Instead, only check once we
|
|
||||||
// definitely have image data to use.
|
|
||||||
if (streams.size == 4) {
|
|
||||||
// Make sure we free the InputStreams once we've transformed them into a
|
|
||||||
// mosaic.
|
|
||||||
return createMosaic(streams, size).also {
|
|
||||||
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not enough covers for a mosaic, take the first one (if that even exists)
|
|
||||||
val first = streams.firstOrNull() ?: return null
|
|
||||||
|
|
||||||
// All but the first stream will be unused, free their resources
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
for (i in 1 until streams.size) {
|
|
||||||
streams[i].close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SourceFetchResult(
|
|
||||||
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
|
|
||||||
mimeType = null,
|
|
||||||
dataSource = DataSource.DISK)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
|
||||||
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
|
||||||
// Use whatever size coil gives us to create the mosaic.
|
|
||||||
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
|
|
||||||
val mosaicFrameSize =
|
|
||||||
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
|
||||||
|
|
||||||
val mosaicBitmap = createBitmap(mosaicSize.width, mosaicSize.height)
|
|
||||||
val canvas = Canvas(mosaicBitmap)
|
|
||||||
|
|
||||||
var x = 0
|
|
||||||
var y = 0
|
|
||||||
|
|
||||||
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
|
|
||||||
// and place it on a corner of the canvas.
|
|
||||||
for (stream in streams) {
|
|
||||||
if (y == mosaicSize.height) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crop the bitmap down to a square so it leaves no empty space
|
|
||||||
// TODO: Work around this
|
|
||||||
val bitmap =
|
|
||||||
SquareCropTransformation.INSTANCE.transform(
|
|
||||||
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
|
||||||
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
|
||||||
|
|
||||||
x += bitmap.width
|
|
||||||
if (x == mosaicSize.width) {
|
|
||||||
x = 0
|
|
||||||
y += bitmap.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// It's way easier to map this into a drawable then try to serialize it into an
|
|
||||||
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
|
|
||||||
// load low-res mosaics into high-res ImageViews.
|
|
||||||
return ImageFetchResult(
|
|
||||||
image = mosaicBitmap.toDrawable(context.resources).asImage(),
|
|
||||||
isSampled = true,
|
|
||||||
dataSource = DataSource.DISK)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Dimension.mosaicSize(): Int {
|
|
||||||
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
|
||||||
// odd image sizes upwards to prevent the mosaic creation from failing.
|
|
||||||
val size = pxOrElse { 512 }
|
|
||||||
return if (size.mod(2) > 0) size + 1 else size
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
|
|
||||||
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
|
|
||||||
CoverCollectionFetcher(options.context, data, options.size)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* CoverFetcher.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.coil
|
|
||||||
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.decode.DataSource
|
|
||||||
import coil3.decode.ImageSource
|
|
||||||
import coil3.fetch.FetchResult
|
|
||||||
import coil3.fetch.Fetcher
|
|
||||||
import coil3.fetch.SourceFetchResult
|
|
||||||
import coil3.request.Options
|
|
||||||
import javax.inject.Inject
|
|
||||||
import okio.FileSystem
|
|
||||||
import okio.buffer
|
|
||||||
import okio.source
|
|
||||||
import org.oxycblt.musikr.covers.Cover
|
|
||||||
|
|
||||||
class CoverFetcher private constructor(private val cover: Cover) : Fetcher {
|
|
||||||
override suspend fun fetch(): FetchResult? {
|
|
||||||
val stream = cover.open() ?: return null
|
|
||||||
return SourceFetchResult(
|
|
||||||
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
|
|
||||||
mimeType = null,
|
|
||||||
dataSource = DataSource.DISK)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
|
|
||||||
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
|
|
||||||
CoverFetcher(data)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2021 Auxio Project
|
|
||||||
* Keyers.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.coil
|
|
||||||
|
|
||||||
import coil3.key.Keyer
|
|
||||||
import coil3.request.Options
|
|
||||||
import javax.inject.Inject
|
|
||||||
import org.oxycblt.musikr.covers.Cover
|
|
||||||
import org.oxycblt.musikr.covers.CoverCollection
|
|
||||||
|
|
||||||
class CoverKeyer @Inject constructor() : Keyer<Cover> {
|
|
||||||
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
|
|
||||||
}
|
|
||||||
|
|
||||||
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
|
|
||||||
override fun key(data: CoverCollection, options: Options) =
|
|
||||||
"multi:${data.hashCode()}&${options.size}"
|
|
||||||
}
|
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* NullCovers.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.covers
|
|
||||||
|
|
||||||
import org.oxycblt.musikr.covers.Cover
|
|
||||||
import org.oxycblt.musikr.covers.CoverResult
|
|
||||||
import org.oxycblt.musikr.covers.MutableCovers
|
|
||||||
import org.oxycblt.musikr.covers.stored.CoverStorage
|
|
||||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
|
||||||
import org.oxycblt.musikr.metadata.Metadata
|
|
||||||
|
|
||||||
class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
|
|
||||||
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
|
|
||||||
|
|
||||||
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
|
|
||||||
|
|
||||||
override suspend fun cleanup(excluding: Collection<Cover>) {
|
|
||||||
storage.ls(setOf()).map { storage.rm(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data object NullCover : Cover {
|
|
||||||
override val id = "null"
|
|
||||||
|
|
||||||
override suspend fun open() = null
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2025 Auxio Project
|
|
||||||
* RevisionedTranscoding.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.covers
|
|
||||||
|
|
||||||
import java.util.UUID
|
|
||||||
import org.oxycblt.musikr.covers.stored.Transcoding
|
|
||||||
|
|
||||||
class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
|
|
||||||
override val tag = "_$revision${inner.tag}"
|
|
||||||
}
|
|
|
@ -1,73 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* SettingCovers.kt is part of Auxio.
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.covers
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
|
||||||
import org.oxycblt.musikr.covers.Cover
|
|
||||||
import org.oxycblt.musikr.covers.Covers
|
|
||||||
import org.oxycblt.musikr.covers.FDCover
|
|
||||||
import org.oxycblt.musikr.covers.MutableCovers
|
|
||||||
import org.oxycblt.musikr.covers.chained.ChainedCovers
|
|
||||||
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
|
|
||||||
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
|
|
||||||
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
|
|
||||||
import org.oxycblt.musikr.covers.fs.FSCovers
|
|
||||||
import org.oxycblt.musikr.covers.fs.MutableFSCovers
|
|
||||||
import org.oxycblt.musikr.covers.stored.Compress
|
|
||||||
import org.oxycblt.musikr.covers.stored.CoverStorage
|
|
||||||
import org.oxycblt.musikr.covers.stored.MutableStoredCovers
|
|
||||||
import org.oxycblt.musikr.covers.stored.NoTranscoding
|
|
||||||
import org.oxycblt.musikr.covers.stored.StoredCovers
|
|
||||||
|
|
||||||
interface SettingCovers {
|
|
||||||
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
suspend fun immutable(context: Context): Covers<FDCover> =
|
|
||||||
ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings) :
|
|
||||||
SettingCovers {
|
|
||||||
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
|
|
||||||
val coverStorage = CoverStorage.at(context.coversDir())
|
|
||||||
val transcoding =
|
|
||||||
when (imageSettings.coverMode) {
|
|
||||||
CoverMode.OFF -> return NullCovers(coverStorage)
|
|
||||||
CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
|
|
||||||
CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
|
|
||||||
CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
|
|
||||||
CoverMode.AS_IS -> NoTranscoding
|
|
||||||
}
|
|
||||||
val revisionedTranscoding = RevisionedTranscoding(revision, transcoding)
|
|
||||||
val storedCovers =
|
|
||||||
MutableStoredCovers(
|
|
||||||
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
|
|
||||||
val fsCovers = MutableFSCovers(context)
|
|
||||||
return MutableChainedCovers(storedCovers, fsCovers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Context.coversDir() = filesDir.resolve("covers").apply { mkdirs() }
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 Auxio Project
|
||||||
|
* Components.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.fetch.Fetcher
|
||||||
|
import coil.key.Keyer
|
||||||
|
import coil.request.Options
|
||||||
|
import coil.size.Size
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
|
||||||
|
override fun key(data: Collection<Cover>, options: Options) =
|
||||||
|
"${data.map { it.key }.hashCode()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
class CoverFetcher
|
||||||
|
private constructor(
|
||||||
|
private val covers: Collection<Cover>,
|
||||||
|
private val size: Size,
|
||||||
|
private val coverExtractor: CoverExtractor,
|
||||||
|
) : Fetcher {
|
||||||
|
override suspend fun fetch() = coverExtractor.extract(covers, size)
|
||||||
|
|
||||||
|
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
|
Fetcher.Factory<Collection<Cover>> {
|
||||||
|
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
|
||||||
|
CoverFetcher(data, options.size, coverExtractor)
|
||||||
|
}
|
||||||
|
}
|
66
app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
Normal file
66
app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* Cover.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
|
|
||||||
|
sealed interface Cover {
|
||||||
|
val key: String
|
||||||
|
val mediaStoreCoverUri: Uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
|
||||||
|
*/
|
||||||
|
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
|
||||||
|
Cover {
|
||||||
|
override val mediaStoreCoverUri = songCoverUri
|
||||||
|
override val key = perceptualHash
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We couldn't find any embedded cover art ourselves, but the android system might have some
|
||||||
|
* through a cover.jpg file or something similar.
|
||||||
|
*/
|
||||||
|
data class External(val albumCoverUri: Uri) : Cover {
|
||||||
|
override val mediaStoreCoverUri = albumCoverUri
|
||||||
|
override val key = albumCoverUri.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
|
||||||
|
|
||||||
|
fun order(songs: Collection<Song>) =
|
||||||
|
FALLBACK_SORT.songs(songs)
|
||||||
|
.map { it.cover }
|
||||||
|
.groupBy { it.key }
|
||||||
|
.entries
|
||||||
|
.sortedByDescending { it.value.size }
|
||||||
|
.map { it.value.first() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ParentCover(val single: Cover, val all: List<Cover>) {
|
||||||
|
companion object {
|
||||||
|
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
|
||||||
|
|
||||||
|
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,253 @@
|
||||||
|
/*
|
||||||
|
* 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.annotation.SuppressLint
|
||||||
|
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 timber.log.Timber as L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 the first four loaded album covers. 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) {
|
||||||
|
L.e("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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("Recycle")
|
||||||
|
private suspend fun extractMediaStoreCover(cover: Cover) =
|
||||||
|
// Eliminate any chance that this blocking call might mess up the loading process
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// Coil will recycle this InputStream, so we don't need to worry about it.
|
||||||
|
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
|
||||||
|
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
|
||||||
|
// Use whatever size coil gives us to create the mosaic.
|
||||||
|
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
|
||||||
|
val mosaicFrameSize =
|
||||||
|
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
|
||||||
|
|
||||||
|
val mosaicBitmap =
|
||||||
|
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(mosaicBitmap)
|
||||||
|
|
||||||
|
var x = 0
|
||||||
|
var y = 0
|
||||||
|
|
||||||
|
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
|
||||||
|
// and place it on a corner of the canvas.
|
||||||
|
for (stream in streams) {
|
||||||
|
if (y == mosaicSize.height) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop the bitmap down to a square so it leaves no empty space
|
||||||
|
// TODO: Work around this
|
||||||
|
val bitmap =
|
||||||
|
SquareCropTransformation.INSTANCE.transform(
|
||||||
|
BitmapFactory.decodeStream(stream), mosaicFrameSize)
|
||||||
|
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
|
||||||
|
|
||||||
|
x += bitmap.width
|
||||||
|
if (x == mosaicSize.width) {
|
||||||
|
x = 0
|
||||||
|
y += bitmap.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's way easier to map this into a drawable then try to serialize it into an
|
||||||
|
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
|
||||||
|
// load low-res mosaics into high-res ImageViews.
|
||||||
|
return DrawableResult(
|
||||||
|
drawable = mosaicBitmap.toDrawable(context.resources),
|
||||||
|
isSampled = true,
|
||||||
|
dataSource = DataSource.DISK)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Dimension.mosaicSize(): Int {
|
||||||
|
// Since we want the mosaic to be perfectly divisible into two, we need to round any
|
||||||
|
// odd image sizes upwards to prevent the mosaic creation from failing.
|
||||||
|
val size = pxOrElse { 512 }
|
||||||
|
return if (size.mod(2) > 0) size + 1 else size
|
||||||
|
}
|
||||||
|
}
|
60
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal file
60
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* DHash.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.ColorMatrix
|
||||||
|
import android.graphics.ColorMatrixColorFilter
|
||||||
|
import android.graphics.Paint
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
@Suppress("UNUSED")
|
||||||
|
fun Bitmap.dHash(hashSize: Int = 16): String {
|
||||||
|
// Step 1: Resize the bitmap to a fixed size
|
||||||
|
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
|
||||||
|
|
||||||
|
// Step 2: Convert the bitmap to grayscale
|
||||||
|
val grayBitmap =
|
||||||
|
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(grayBitmap)
|
||||||
|
val paint = Paint()
|
||||||
|
val colorMatrix = ColorMatrix()
|
||||||
|
colorMatrix.setSaturation(0f)
|
||||||
|
val filter = ColorMatrixColorFilter(colorMatrix)
|
||||||
|
paint.colorFilter = filter
|
||||||
|
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
|
||||||
|
|
||||||
|
// Step 3: Compute the difference between adjacent pixels
|
||||||
|
var hash = BigInteger.valueOf(0)
|
||||||
|
val one = BigInteger.valueOf(1)
|
||||||
|
for (y in 0 until hashSize) {
|
||||||
|
for (x in 0 until hashSize) {
|
||||||
|
val pixel1 = grayBitmap.getPixel(x, y)
|
||||||
|
val pixel2 = grayBitmap.getPixel(x + 1, y)
|
||||||
|
val diff = Color.red(pixel1) - Color.red(pixel2)
|
||||||
|
if (diff > 0) {
|
||||||
|
hash = hash.or(one.shl(y * hashSize + x))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.toString(16)
|
||||||
|
}
|
|
@ -16,15 +16,15 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.
|
|
@ -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())
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
|
@ -22,9 +22,9 @@ 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
|
interface Header : Item
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A "header" used for delimiting groups of data.
|
* A "header" used for delimiting groups of data.
|
||||||
|
@ -44,7 +44,7 @@ interface PlainHeader : Header {
|
||||||
*/
|
*/
|
||||||
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
|
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||||
|
|
||||||
interface Divider<T> {
|
interface Divider<T> : Item {
|
||||||
val anchor: T?
|
val anchor: T?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -25,17 +25,17 @@ 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.musikr.Artist
|
|
||||||
import org.oxycblt.musikr.Genre
|
|
||||||
import org.oxycblt.musikr.Music
|
|
||||||
import org.oxycblt.musikr.MusicParent
|
|
||||||
import org.oxycblt.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -64,17 +64,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ 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 timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,9 +23,9 @@ 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 timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,37 +19,21 @@
|
||||||
package org.oxycblt.auxio.list.recycler
|
package org.oxycblt.auxio.list.recycler
|
||||||
|
|
||||||
import android.animation.Animator
|
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.HapticFeedbackConstants
|
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewConfiguration
|
import android.view.ViewConfiguration
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
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.updatePaddingRelative
|
|
||||||
import androidx.core.widget.TextViewCompat
|
|
||||||
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.max
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.ui.MaterialFadingSlider
|
|
||||||
import org.oxycblt.auxio.ui.MaterialSlider
|
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.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
import org.oxycblt.auxio.util.isRtl
|
import org.oxycblt.auxio.util.isRtl
|
||||||
import org.oxycblt.auxio.util.isUnder
|
import org.oxycblt.auxio.util.isUnder
|
||||||
|
@ -78,70 +62,33 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||||
* - Added drag listener
|
* - Added drag listener
|
||||||
* - Added documentation
|
* - Added documentation
|
||||||
* - Completely new design
|
* - 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 thumbSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||||
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
|
private val slider = MaterialSlider(context, thumbSize)
|
||||||
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
|
|
||||||
private var thumbAnimator: Animator? = null
|
private var thumbAnimator: Animator? = null
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
private val thumbView =
|
private val thumbView =
|
||||||
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
|
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) }
|
||||||
thumbSlider.jumpOut(this)
|
|
||||||
}
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val popupView =
|
|
||||||
MaterialTextView(context).apply {
|
|
||||||
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
|
|
||||||
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
|
|
||||||
|
|
||||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
|
|
||||||
setTextColor(
|
|
||||||
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
|
|
||||||
ellipsize = TextUtils.TruncateAt.MIDDLE
|
|
||||||
gravity = Gravity.CENTER
|
|
||||||
includeFontPadding = false
|
|
||||||
|
|
||||||
elevation =
|
|
||||||
context
|
|
||||||
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
|
|
||||||
.toFloat()
|
|
||||||
background = context.getDrawableCompat(R.drawable.ui_popup)
|
|
||||||
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
|
|
||||||
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
|
|
||||||
updatePaddingRelative(start = paddingStart, end = paddingEnd)
|
|
||||||
layoutParams =
|
|
||||||
FrameLayout.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
||||||
.apply {
|
|
||||||
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
|
|
||||||
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val popupSlider =
|
|
||||||
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
|
|
||||||
jumpOut(popupView)
|
|
||||||
}
|
|
||||||
private var popupAnimator: Animator? = null
|
|
||||||
private var showingPopup = false
|
|
||||||
|
|
||||||
// Touch
|
// Touch
|
||||||
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||||
|
@ -152,24 +99,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) {
|
||||||
|
@ -187,9 +116,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
if (field) {
|
if (field) {
|
||||||
removeCallbacks(hideThumbRunnable)
|
removeCallbacks(hideThumbRunnable)
|
||||||
showScrollbar()
|
showScrollbar()
|
||||||
showPopup()
|
|
||||||
} else {
|
} else {
|
||||||
hidePopup()
|
|
||||||
postAutoHideScrollbar()
|
postAutoHideScrollbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -201,7 +128,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
|
|
||||||
init {
|
init {
|
||||||
overlay.add(thumbView)
|
overlay.add(thumbView)
|
||||||
overlay.add(popupView)
|
|
||||||
|
|
||||||
addItemDecoration(
|
addItemDecoration(
|
||||||
object : ItemDecoration() {
|
object : ItemDecoration() {
|
||||||
|
@ -230,96 +156,26 @@ 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(
|
thumbView.measure(
|
||||||
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
|
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY),
|
||||||
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
|
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY))
|
||||||
val thumbTop = thumbPadding.top + thumbOffset
|
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 - thumbSize
|
||||||
}
|
}
|
||||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize)
|
||||||
|
|
||||||
popupView.layoutDirection = layoutDirection
|
|
||||||
val child = getChildAt(0)
|
|
||||||
val firstAdapterPos =
|
|
||||||
if (child != null) {
|
|
||||||
layoutManager?.getPosition(child) ?: NO_POSITION
|
|
||||||
} else {
|
|
||||||
NO_POSITION
|
|
||||||
}
|
|
||||||
|
|
||||||
val popupText: String
|
|
||||||
val provider = popupProvider
|
|
||||||
if (firstAdapterPos != NO_POSITION && provider != null) {
|
|
||||||
popupView.isInvisible = false
|
|
||||||
// Get the popup text. If there is none, we default to "?".
|
|
||||||
popupText = provider.getPopup(firstAdapterPos) ?: "?"
|
|
||||||
} else {
|
|
||||||
// No valid position or provider, do not show the popup.
|
|
||||||
popupView.isInvisible = false
|
|
||||||
popupText = ""
|
|
||||||
}
|
|
||||||
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
|
|
||||||
|
|
||||||
if (popupView.text != popupText) {
|
|
||||||
popupView.text = popupText
|
|
||||||
|
|
||||||
val widthMeasureSpec =
|
|
||||||
ViewGroup.getChildMeasureSpec(
|
|
||||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
||||||
thumbPadding.left +
|
|
||||||
thumbPadding.right +
|
|
||||||
thumbWidth +
|
|
||||||
popupLayoutParams.leftMargin +
|
|
||||||
popupLayoutParams.rightMargin,
|
|
||||||
popupLayoutParams.width)
|
|
||||||
|
|
||||||
val heightMeasureSpec =
|
|
||||||
ViewGroup.getChildMeasureSpec(
|
|
||||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
|
||||||
thumbPadding.top +
|
|
||||||
thumbPadding.bottom +
|
|
||||||
popupLayoutParams.topMargin +
|
|
||||||
popupLayoutParams.bottomMargin,
|
|
||||||
popupLayoutParams.height)
|
|
||||||
|
|
||||||
popupView.measure(widthMeasureSpec, heightMeasureSpec)
|
|
||||||
if (showingPopup) {
|
|
||||||
doPopupVibration()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val popupWidth = popupView.measuredWidth
|
|
||||||
val popupHeight = popupView.measuredHeight
|
|
||||||
val popupLeft =
|
|
||||||
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
|
|
||||||
thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin
|
|
||||||
} else {
|
|
||||||
width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
val popupAnchorY = popupHeight / 2
|
|
||||||
val thumbAnchorY = thumbView.height / 2
|
|
||||||
|
|
||||||
val popupTop =
|
|
||||||
(thumbTop + thumbAnchorY - popupAnchorY)
|
|
||||||
.coerceAtLeast(thumbPadding.top + popupLayoutParams.topMargin)
|
|
||||||
.coerceAtMost(
|
|
||||||
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
|
|
||||||
|
|
||||||
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,15 +193,11 @@ 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:
|
// Then calculate the thumb position, which is just:
|
||||||
// [proportion of scroll position to scroll range] * [total thumb range]
|
// [proportion of scroll position to scroll range] * [total thumb range]
|
||||||
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
|
|
||||||
val offsetY = computeVerticalScrollOffset()
|
val offsetY = computeVerticalScrollOffset()
|
||||||
if (computeVerticalScrollRange() < height || isEmpty()) {
|
if (computeVerticalScrollRange() < height || childCount == 0) {
|
||||||
fastScrollingPossible = false
|
|
||||||
hideThumb()
|
|
||||||
hidePopup()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val extentY = computeVerticalScrollExtent()
|
val extentY = computeVerticalScrollExtent()
|
||||||
|
@ -354,10 +206,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
}
|
}
|
||||||
|
|
||||||
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,9 +219,8 @@ 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 if (eventX > thumbView.right - thumbSize / 4) {
|
||||||
dragStartThumbOffset =
|
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
|
||||||
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
|
||||||
scrollToThumbOffset(dragStartThumbOffset)
|
scrollToThumbOffset(dragStartThumbOffset)
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
|
@ -391,8 +238,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
dragStartThumbOffset = thumbOffset
|
dragStartThumbOffset = thumbOffset
|
||||||
} else {
|
} else {
|
||||||
dragStartY = eventY
|
dragStartY = eventY
|
||||||
dragStartThumbOffset =
|
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
|
||||||
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
|
||||||
scrollToThumbOffset(dragStartThumbOffset)
|
scrollToThumbOffset(dragStartThumbOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -436,65 +282,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()
|
thumbAnimator?.cancel()
|
||||||
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
|
thumbAnimator = slider.slideIn(thumbView).also { it.start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideThumb() {
|
private fun hideScrollbar() {
|
||||||
if (!showingThumb) {
|
if (!showingThumb) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showingThumb = false
|
showingThumb = false
|
||||||
thumbAnimator?.cancel()
|
thumbAnimator?.cancel()
|
||||||
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
|
thumbAnimator = slider.slideOut(thumbView).also { it.start() }
|
||||||
}
|
|
||||||
|
|
||||||
private fun showPopup() {
|
|
||||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (showingPopup) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showingPopup = true
|
|
||||||
popupAnimator?.cancel()
|
|
||||||
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hidePopup() {
|
|
||||||
if (!showingPopup) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showingPopup = false
|
|
||||||
popupAnimator?.cancel()
|
|
||||||
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun doPopupVibration() {
|
|
||||||
performHapticFeedback(
|
|
||||||
if (Build.VERSION.SDK_INT >= 27) {
|
|
||||||
HapticFeedbackConstants.TEXT_HANDLE_MOVE
|
|
||||||
} else {
|
|
||||||
HapticFeedbackConstants.KEYBOARD_TAP
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LAYOUT STATE ---
|
// --- LAYOUT STATE ---
|
||||||
|
|
||||||
private val thumbOffsetRange: Int
|
private val thumbOffsetRange: Int
|
||||||
get() {
|
get() {
|
||||||
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
|
return height - thumbPadding.top - thumbPadding.bottom - thumbSize
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
|
|
|
@ -92,6 +92,7 @@ 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")
|
L.d("Lifting ViewHolder")
|
||||||
|
|
||||||
|
|
|
@ -32,17 +32,16 @@ import org.oxycblt.auxio.list.PlainDivider
|
||||||
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.
|
||||||
|
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* Indexing.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/** Version-aware permission identifier for reading audio files. */
|
||||||
|
val PERMISSION_READ_AUDIO =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
android.Manifest.permission.READ_MEDIA_AUDIO
|
||||||
|
} else {
|
||||||
|
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the current state of the music loader.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
sealed interface IndexingState {
|
||||||
|
/**
|
||||||
|
* Music loading is on-going.
|
||||||
|
*
|
||||||
|
* @param progress The current progress of the music loading.
|
||||||
|
*/
|
||||||
|
data class Indexing(val progress: IndexingProgress) : IndexingState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Music loading has completed.
|
||||||
|
*
|
||||||
|
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
|
||||||
|
* will be null.
|
||||||
|
*/
|
||||||
|
data class Completed(val error: Exception?) : IndexingState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the current progress of music loading.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
sealed interface IndexingProgress {
|
||||||
|
/** Other work is being done that does not have a defined progress. */
|
||||||
|
data object Indeterminate : IndexingProgress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Songs are currently being loaded.
|
||||||
|
*
|
||||||
|
* @param current The current amount of songs loaded.
|
||||||
|
* @param total The projected total amount of songs.
|
||||||
|
*/
|
||||||
|
data class Songs(val current: Int, val total: Int) : IndexingProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class NoAudioPermissionException : Exception() {
|
||||||
|
override val message = "Storage permissions are required to load music"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when no music was found.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class NoMusicException : Exception() {
|
||||||
|
override val message = "No music was found on the device"
|
||||||
|
}
|
|
@ -16,25 +16,29 @@
|
||||||
* 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.musikr
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.math.max
|
||||||
import kotlinx.parcelize.IgnoredOnParcel
|
import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.oxycblt.musikr.covers.Cover
|
import org.oxycblt.auxio.image.extractor.Cover
|
||||||
import org.oxycblt.musikr.covers.CoverCollection
|
import org.oxycblt.auxio.image.extractor.ParentCover
|
||||||
import org.oxycblt.musikr.fs.Format
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.musikr.fs.Path
|
import org.oxycblt.auxio.music.fs.MimeType
|
||||||
import org.oxycblt.musikr.tag.Date
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
import org.oxycblt.musikr.tag.Disc
|
import org.oxycblt.auxio.music.info.Date
|
||||||
import org.oxycblt.musikr.tag.Name
|
import org.oxycblt.auxio.music.info.Disc
|
||||||
import org.oxycblt.musikr.tag.ReleaseType
|
import org.oxycblt.auxio.music.info.Name
|
||||||
import org.oxycblt.musikr.tag.ReplayGainAdjustment
|
import org.oxycblt.auxio.music.info.ReleaseType
|
||||||
import org.oxycblt.musikr.util.toUuidOrNull
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
|
||||||
|
import org.oxycblt.auxio.util.concatLocalized
|
||||||
|
import org.oxycblt.auxio.util.toUuidOrNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract music data. This contains universal information about all concrete music
|
* Abstract music data. This contains universal information about all concrete music
|
||||||
|
@ -42,7 +46,7 @@ import org.oxycblt.musikr.util.toUuidOrNull
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
sealed interface Music {
|
sealed interface Music : Item {
|
||||||
/**
|
/**
|
||||||
* A unique identifier for this music item.
|
* A unique identifier for this music item.
|
||||||
*
|
*
|
||||||
|
@ -77,34 +81,23 @@ sealed interface Music {
|
||||||
class UID
|
class UID
|
||||||
private constructor(
|
private constructor(
|
||||||
private val format: Format,
|
private val format: Format,
|
||||||
private val item: Item,
|
private val type: MusicType,
|
||||||
private val uuid: UUID
|
private val uuid: UUID
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
// Cache the hashCode for HashMap efficiency.
|
// Cache the hashCode for HashMap efficiency.
|
||||||
@IgnoredOnParcel private var hashCode = format.hashCode()
|
@IgnoredOnParcel private var hashCode = format.hashCode()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
hashCode = 31 * hashCode + item.hashCode()
|
hashCode = 31 * hashCode + type.hashCode()
|
||||||
hashCode = 31 * hashCode + uuid.hashCode()
|
hashCode = 31 * hashCode + uuid.hashCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode() = hashCode
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
override fun equals(other: Any?) =
|
override fun equals(other: Any?) =
|
||||||
other is UID && format == other.format && item == other.item && uuid == other.uuid
|
other is UID && format == other.format && type == other.type && uuid == other.uuid
|
||||||
|
|
||||||
override fun toString() = "${format.namespace}:${item.intCode.toString(16)}-$uuid"
|
override fun toString() = "${format.namespace}:${type.intCode.toString(16)}-$uuid"
|
||||||
|
|
||||||
internal enum class Item(val intCode: Int) {
|
|
||||||
// Item used to be MusicType back when the music module was
|
|
||||||
// part of Auxio, so these old integer codes remain.
|
|
||||||
// TODO: Introduce new UID format that removes these.
|
|
||||||
SONG(0xA10B),
|
|
||||||
ALBUM(0xA10A),
|
|
||||||
ARTIST(0xA109),
|
|
||||||
GENRE(0xA108),
|
|
||||||
PLAYLIST(0xA107)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal marker of [Music.UID] format type.
|
* Internal marker of [Music.UID] format type.
|
||||||
|
@ -124,7 +117,7 @@ sealed interface Music {
|
||||||
@TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString()
|
@TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString()
|
||||||
|
|
||||||
/** @see [Music.UID.fromString] */
|
/** @see [Music.UID.fromString] */
|
||||||
@TypeConverter fun toMusicUid(string: String?) = string?.let(Companion::fromString)
|
@TypeConverter fun toMusicUid(string: String?) = string?.let(UID::fromString)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -132,23 +125,23 @@ sealed interface Music {
|
||||||
* Creates an Auxio-style [UID] of random composition. Used if there is no
|
* Creates an Auxio-style [UID] of random composition. Used if there is no
|
||||||
* non-subjective, unlikely-to-change metadata of the music.
|
* non-subjective, unlikely-to-change metadata of the music.
|
||||||
*
|
*
|
||||||
* @param item The type of [Item] that created this [UID].
|
* @param type The analogous [MusicType] of the item that created this [UID].
|
||||||
*/
|
*/
|
||||||
internal fun auxio(item: Item): UID {
|
fun auxio(type: MusicType): UID {
|
||||||
return UID(Format.AUXIO, item, UUID.randomUUID())
|
return UID(Format.AUXIO, type, UUID.randomUUID())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
|
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
|
||||||
* unlikely-to-change metadata of the music.
|
* unlikely-to-change metadata of the music.
|
||||||
*
|
*
|
||||||
* @param item The type of [Item] that created this [UID].
|
* @param type The analogous [MusicType] of the item that created this [UID].
|
||||||
* @param updates Block to update the [MessageDigest] hash with the metadata of the
|
* @param updates Block to update the [MessageDigest] hash with the metadata of the
|
||||||
* item. Make sure the metadata hashed semantically aligns with the format
|
* item. Make sure the metadata hashed semantically aligns with the format
|
||||||
* specification.
|
* specification.
|
||||||
* @return A new auxio-style [UID].
|
* @return A new auxio-style [UID].
|
||||||
*/
|
*/
|
||||||
internal fun auxio(item: Item, updates: MessageDigest.() -> Unit): UID {
|
fun auxio(type: MusicType, updates: MessageDigest.() -> Unit): UID {
|
||||||
val digest =
|
val digest =
|
||||||
MessageDigest.getInstance("SHA-256").run {
|
MessageDigest.getInstance("SHA-256").run {
|
||||||
updates()
|
updates()
|
||||||
|
@ -178,19 +171,19 @@ sealed interface Music {
|
||||||
.or(digest[13].toLong().and(0xFF).shl(16))
|
.or(digest[13].toLong().and(0xFF).shl(16))
|
||||||
.or(digest[14].toLong().and(0xFF).shl(8))
|
.or(digest[14].toLong().and(0xFF).shl(8))
|
||||||
.or(digest[15].toLong().and(0xFF)))
|
.or(digest[15].toLong().and(0xFF)))
|
||||||
return UID(Format.AUXIO, item, uuid)
|
return UID(Format.AUXIO, type, uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
|
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
|
||||||
* extracted from a file.
|
* extracted from a file.
|
||||||
*
|
*
|
||||||
* @param item The [Item] that created this [UID].
|
* @param type The analogous [MusicType] of the item that created this [UID].
|
||||||
* @param mbid The analogous MusicBrainz ID for this item that was extracted from a
|
* @param mbid The analogous MusicBrainz ID for this item that was extracted from a
|
||||||
* file.
|
* file.
|
||||||
* @return A new MusicBrainz-style [UID].
|
* @return A new MusicBrainz-style [UID].
|
||||||
*/
|
*/
|
||||||
internal fun musicBrainz(item: Item, mbid: UUID) = UID(Format.MUSICBRAINZ, item, mbid)
|
fun musicBrainz(type: MusicType, mbid: UUID) = UID(Format.MUSICBRAINZ, type, mbid)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a [UID]'s string representation back into a concrete [UID] instance.
|
* Convert a [UID]'s string representation back into a concrete [UID] instance.
|
||||||
|
@ -218,8 +211,8 @@ sealed interface Music {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val intCode = ids[0].toIntOrNull(16) ?: return null
|
val type =
|
||||||
val type = Item.entries.firstOrNull { it.intCode == intCode } ?: return null
|
MusicType.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null
|
||||||
val uuid = ids[1].toUuidOrNull() ?: return null
|
val uuid = ids[1].toUuidOrNull() ?: return null
|
||||||
return UID(format, type, uuid)
|
return UID(format, type, uuid)
|
||||||
}
|
}
|
||||||
|
@ -243,7 +236,6 @@ sealed interface MusicParent : Music {
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface Song : Music {
|
interface Song : Music {
|
||||||
override val name: Name.Known
|
|
||||||
/** The track number. Will be null if no valid track number was present in the metadata. */
|
/** The track number. Will be null if no valid track number was present in the metadata. */
|
||||||
val track: Int?
|
val track: Int?
|
||||||
/** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
|
/** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
|
||||||
|
@ -255,32 +247,23 @@ interface Song : Music {
|
||||||
* audio file in a way that is scoped-storage-safe.
|
* audio file in a way that is scoped-storage-safe.
|
||||||
*/
|
*/
|
||||||
val uri: Uri
|
val uri: Uri
|
||||||
|
/** Useful information to quickly obtain the album cover. */
|
||||||
|
val cover: Cover
|
||||||
/**
|
/**
|
||||||
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
|
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
|
||||||
* instead for accessing the audio file.
|
* instead for accessing the audio file.
|
||||||
*/
|
*/
|
||||||
val path: Path
|
val path: Path
|
||||||
/** The [Format] of the audio file. Only intended for display. */
|
/** The [MimeType] of the audio file. Only intended for display. */
|
||||||
val format: Format
|
val mimeType: MimeType
|
||||||
/** The size of the audio file, in bytes. */
|
/** The size of the audio file, in bytes. */
|
||||||
val size: Long
|
val size: Long
|
||||||
/** The duration of the audio file, in milliseconds. */
|
/** The duration of the audio file, in milliseconds. */
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
/** The bitrate of the audio file, in kbps. */
|
|
||||||
val bitrateKbps: Int
|
|
||||||
/** The sample rate of the audio file, in Hz. */
|
|
||||||
val sampleRateHz: Int
|
|
||||||
/** The ReplayGain adjustment to apply during playback. */
|
/** The ReplayGain adjustment to apply during playback. */
|
||||||
val replayGainAdjustment: ReplayGainAdjustment
|
val replayGainAdjustment: ReplayGainAdjustment
|
||||||
/**
|
/** The date the audio file was added to the device, as a unix epoch timestamp. */
|
||||||
* The date last modified the audio file was last modified, in milliseconds since the unix
|
val dateAdded: Long
|
||||||
* epoch.
|
|
||||||
*/
|
|
||||||
val modifiedMs: Long
|
|
||||||
/** The time the audio file was added to the device, in milliseconds since the unix epoch. */
|
|
||||||
val addedMs: Long
|
|
||||||
/** Useful information to quickly obtain the album cover. */
|
|
||||||
val cover: Cover?
|
|
||||||
/**
|
/**
|
||||||
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
|
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
|
||||||
* instead.
|
* instead.
|
||||||
|
@ -313,12 +296,12 @@ interface Album : MusicParent {
|
||||||
* [ReleaseType.Album].
|
* [ReleaseType.Album].
|
||||||
*/
|
*/
|
||||||
val releaseType: ReleaseType
|
val releaseType: ReleaseType
|
||||||
/** Cover information from album's songs. */
|
/** Cover information from the template song used for the album. */
|
||||||
val covers: CoverCollection
|
val cover: ParentCover
|
||||||
/** The duration of all songs in the album, in milliseconds. */
|
/** The duration of all songs in the album, in milliseconds. */
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
/** The earliest date a song in this album was added, in milliseconds since the unix epoch. */
|
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
|
||||||
val addedMs: Long
|
val dateAdded: Long
|
||||||
/**
|
/**
|
||||||
* The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than
|
* The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than
|
||||||
* one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
|
* one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
|
||||||
|
@ -344,7 +327,7 @@ interface Artist : MusicParent {
|
||||||
*/
|
*/
|
||||||
val durationMs: Long?
|
val durationMs: Long?
|
||||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||||
val covers: CoverCollection
|
val cover: ParentCover
|
||||||
/** The [Genre]s of this artist. */
|
/** The [Genre]s of this artist. */
|
||||||
val genres: List<Genre>
|
val genres: List<Genre>
|
||||||
}
|
}
|
||||||
|
@ -360,7 +343,7 @@ interface Genre : MusicParent {
|
||||||
/** The total duration of the songs in this genre, in milliseconds. */
|
/** The total duration of the songs in this genre, in milliseconds. */
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||||
val covers: CoverCollection
|
val cover: ParentCover
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -374,5 +357,34 @@ interface Playlist : MusicParent {
|
||||||
/** The total duration of the songs in this genre, in milliseconds. */
|
/** The total duration of the songs in this genre, in milliseconds. */
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||||
val covers: CoverCollection
|
val cover: ParentCover?
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
|
||||||
|
* localized manner.
|
||||||
|
*
|
||||||
|
* @param context [Context] required
|
||||||
|
* @return A concatenated string.
|
||||||
|
*/
|
||||||
|
fun <T : Music> List<T>.resolveNames(context: Context) =
|
||||||
|
concatLocalized(context) { it.name.resolve(context) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
|
||||||
|
* information of an item must be compared without a context.
|
||||||
|
*
|
||||||
|
* @param other The list of items to compare to.
|
||||||
|
* @return True if they are the same (by [Music.name]), false otherwise.
|
||||||
|
*/
|
||||||
|
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
|
||||||
|
for (i in 0 until max(size, other.size)) {
|
||||||
|
val a = getOrNull(i) ?: return false
|
||||||
|
val b = other.getOrNull(i) ?: return false
|
||||||
|
if (a.name != b.name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
|
@ -19,30 +19,31 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import android.content.pm.PackageManager
|
||||||
import java.util.UUID
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.util.LinkedList
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
import org.oxycblt.auxio.music.cache.CacheRepository
|
||||||
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||||
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
import org.oxycblt.musikr.IndexingProgress
|
import org.oxycblt.auxio.music.fs.MediaStoreExtractor
|
||||||
import org.oxycblt.musikr.Interpretation
|
import org.oxycblt.auxio.music.info.Name
|
||||||
import org.oxycblt.musikr.Library
|
import org.oxycblt.auxio.music.metadata.Separators
|
||||||
import org.oxycblt.musikr.Music
|
import org.oxycblt.auxio.music.metadata.TagExtractor
|
||||||
import org.oxycblt.musikr.Musikr
|
import org.oxycblt.auxio.music.user.MutableUserLibrary
|
||||||
import org.oxycblt.musikr.MutableLibrary
|
import org.oxycblt.auxio.music.user.UserLibrary
|
||||||
import org.oxycblt.musikr.Playlist
|
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
|
||||||
import org.oxycblt.musikr.Song
|
import org.oxycblt.auxio.util.forEachWithTimeout
|
||||||
import org.oxycblt.musikr.Storage
|
|
||||||
import org.oxycblt.musikr.cache.MutableCache
|
|
||||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
|
||||||
import org.oxycblt.musikr.tag.interpret.Naming
|
|
||||||
import org.oxycblt.musikr.tag.interpret.Separators
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -57,9 +58,10 @@ import timber.log.Timber as L
|
||||||
* configurations
|
* configurations
|
||||||
*/
|
*/
|
||||||
interface MusicRepository {
|
interface MusicRepository {
|
||||||
/** The current library */
|
/** The current music information found on the device. */
|
||||||
val library: Library?
|
val deviceLibrary: DeviceLibrary?
|
||||||
|
/** The current user-defined music information. */
|
||||||
|
val userLibrary: UserLibrary?
|
||||||
/** The current state of music loading. Null if no load has occurred yet. */
|
/** The current state of music loading. Null if no load has occurred yet. */
|
||||||
val indexingState: IndexingState?
|
val indexingState: IndexingState?
|
||||||
|
|
||||||
|
@ -173,7 +175,7 @@ interface MusicRepository {
|
||||||
* @param withCache Whether to load with the music cache or not.
|
* @param withCache Whether to load with the music cache or not.
|
||||||
* @return The top-level music loading [Job] started.
|
* @return The top-level music loading [Job] started.
|
||||||
*/
|
*/
|
||||||
suspend fun index(worker: IndexingWorker, withCache: Boolean)
|
fun index(worker: IndexingWorker, withCache: Boolean): Job
|
||||||
|
|
||||||
/** A listener for changes to the stored music information. */
|
/** A listener for changes to the stored music information. */
|
||||||
interface UpdateListener {
|
interface UpdateListener {
|
||||||
|
@ -188,8 +190,8 @@ interface MusicRepository {
|
||||||
/**
|
/**
|
||||||
* Flags indicating which kinds of music information changed.
|
* Flags indicating which kinds of music information changed.
|
||||||
*
|
*
|
||||||
* @param deviceLibrary Whether the current songs/albums/artists/genres has changed.
|
* @param deviceLibrary Whether the current [DeviceLibrary] has changed.
|
||||||
* @param userLibrary Whether the current playlists have changed.
|
* @param userLibrary Whether the current [Playlist]s have changed.
|
||||||
*/
|
*/
|
||||||
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
|
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
|
||||||
|
|
||||||
|
@ -201,6 +203,12 @@ interface MusicRepository {
|
||||||
|
|
||||||
/** A persistent worker that can load music in the background. */
|
/** A persistent worker that can load music in the background. */
|
||||||
interface IndexingWorker {
|
interface IndexingWorker {
|
||||||
|
/** A [Context] required to read device storage */
|
||||||
|
val workerContext: Context
|
||||||
|
|
||||||
|
/** The [CoroutineScope] to perform coroutine music loading work on. */
|
||||||
|
val scope: CoroutineScope
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request that the music loading process ([index]) should be started. Any prior loads
|
* Request that the music loading process ([index]) should be started. Any prior loads
|
||||||
* should be canceled.
|
* should be canceled.
|
||||||
|
@ -211,42 +219,22 @@ interface MusicRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
|
|
||||||
class MusicRepositoryImpl
|
class MusicRepositoryImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext private val context: Context,
|
private val cacheRepository: CacheRepository,
|
||||||
private val cache: MutableCache,
|
private val mediaStoreExtractor: MediaStoreExtractor,
|
||||||
private val storedPlaylists: StoredPlaylists,
|
private val tagExtractor: TagExtractor,
|
||||||
private val settingCovers: SettingCovers,
|
private val deviceLibraryFactory: DeviceLibrary.Factory,
|
||||||
|
private val userLibraryFactory: UserLibrary.Factory,
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
) : MusicRepository {
|
) : MusicRepository {
|
||||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||||
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
||||||
@Volatile private var indexingWorker: IndexingWorker? = null
|
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
|
||||||
|
|
||||||
@Volatile override var library: MutableLibrary? = null
|
@Volatile override var deviceLibrary: DeviceLibrary? = null
|
||||||
|
@Volatile override var userLibrary: MutableUserLibrary? = null
|
||||||
@Volatile private var previousCompletedState: IndexingState.Completed? = null
|
@Volatile private var previousCompletedState: IndexingState.Completed? = null
|
||||||
@Volatile private var currentIndexingState: IndexingState? = null
|
@Volatile private var currentIndexingState: IndexingState? = null
|
||||||
override val indexingState: IndexingState?
|
override val indexingState: IndexingState?
|
||||||
|
@ -283,7 +271,7 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun registerWorker(worker: IndexingWorker) {
|
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
|
||||||
if (indexingWorker != null) {
|
if (indexingWorker != null) {
|
||||||
L.w("Worker is already registered")
|
L.w("Worker is already registered")
|
||||||
return
|
return
|
||||||
|
@ -293,7 +281,7 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun unregisterWorker(worker: IndexingWorker) {
|
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
|
||||||
if (indexingWorker !== worker) {
|
if (indexingWorker !== worker) {
|
||||||
L.w("Given worker did not match current worker")
|
L.w("Given worker did not match current worker")
|
||||||
return
|
return
|
||||||
|
@ -305,51 +293,41 @@ constructor(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun find(uid: Music.UID) =
|
override fun find(uid: Music.UID) =
|
||||||
(library?.run {
|
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
|
||||||
findSong(uid)
|
?: userLibrary?.findPlaylist(uid))
|
||||||
?: findAlbum(uid)
|
|
||||||
?: findArtist(uid)
|
|
||||||
?: findGenre(uid)
|
|
||||||
?: findPlaylist(uid)
|
|
||||||
})
|
|
||||||
|
|
||||||
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
override suspend fun createPlaylist(name: String, songs: List<Song>) {
|
||||||
val library = synchronized(this) { library ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
L.d("Creating playlist $name with ${songs.size} songs")
|
L.d("Creating playlist $name with ${songs.size} songs")
|
||||||
val newLibrary = library.createPlaylist(name, songs)
|
userLibrary.createPlaylist(name, songs)
|
||||||
synchronized(this) { this.library = newLibrary }
|
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
override suspend fun renamePlaylist(playlist: Playlist, name: String) {
|
||||||
val library = synchronized(this) { library ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
L.d("Renaming $playlist to $name")
|
L.d("Renaming $playlist to $name")
|
||||||
val newLibrary = library.renamePlaylist(playlist, name)
|
userLibrary.renamePlaylist(playlist, name)
|
||||||
synchronized(this) { this.library = newLibrary }
|
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deletePlaylist(playlist: Playlist) {
|
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||||
val library = synchronized(this) { library ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
L.d("Deleting $playlist")
|
L.d("Deleting $playlist")
|
||||||
val newLibrary = library.deletePlaylist(playlist)
|
userLibrary.deletePlaylist(playlist)
|
||||||
synchronized(this) { this.library = newLibrary }
|
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
|
||||||
val library = synchronized(this) { library ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
L.d("Adding ${songs.size} songs to $playlist")
|
L.d("Adding ${songs.size} songs to $playlist")
|
||||||
val newLibrary = library.addToPlaylist(playlist, songs)
|
userLibrary.addToPlaylist(playlist, songs)
|
||||||
synchronized(this) { this.library = newLibrary }
|
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
|
||||||
val library = synchronized(this) { library ?: return }
|
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||||
L.d("Rewriting $playlist with ${songs.size} songs")
|
L.d("Rewriting $playlist with ${songs.size} songs")
|
||||||
val newLibrary = library.rewritePlaylist(playlist, songs)
|
userLibrary.rewritePlaylist(playlist, songs)
|
||||||
synchronized(this) { this.library = newLibrary }
|
|
||||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -359,53 +337,241 @@ constructor(
|
||||||
indexingWorker?.requestIndex(withCache)
|
indexingWorker?.requestIndex(withCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
|
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
|
||||||
L.d("Begin index [cache=$withCache]")
|
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
|
||||||
|
|
||||||
|
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
||||||
try {
|
try {
|
||||||
indexImpl(withCache)
|
indexImpl(context, scope, withCache)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// Got cancelled, propagate upwards to top-level co-routine.
|
// Got cancelled, propagate upwards to top-level co-routine.
|
||||||
L.d("Loading routine was cancelled")
|
L.d("Loading routine was cancelled")
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Music loading process failed due to something we have not handled.
|
// Music loading process failed due to something we have not handled.
|
||||||
|
// TODO: Still want to display this error eventually
|
||||||
L.e("Music indexing failed")
|
L.e("Music indexing failed")
|
||||||
L.e(e.stackTraceToString())
|
L.e(e.stackTraceToString())
|
||||||
emitIndexingCompletion(e)
|
emitIndexingCompletion(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun indexImpl(withCache: Boolean) {
|
private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
||||||
|
// TODO: Find a way to break up this monster of a method, preferably as another class.
|
||||||
|
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
// Make sure we have permissions before going forward. Theoretically this would be better
|
||||||
|
// done at the UI level, but that intertwines logic and display too much.
|
||||||
|
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_DENIED) {
|
||||||
|
L.e("Permissions were not granted")
|
||||||
|
throw NoAudioPermissionException()
|
||||||
|
}
|
||||||
|
|
||||||
// Obtain configuration information
|
// Obtain configuration information
|
||||||
|
val constraints =
|
||||||
|
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
|
||||||
val separators = Separators.from(musicSettings.separators)
|
val separators = Separators.from(musicSettings.separators)
|
||||||
val nameFactory =
|
val nameFactory =
|
||||||
if (musicSettings.intelligentSorting) {
|
if (musicSettings.intelligentSorting) {
|
||||||
Naming.intelligent()
|
Name.Known.IntelligentFactory
|
||||||
} else {
|
} else {
|
||||||
Naming.simple()
|
Name.Known.SimpleFactory
|
||||||
}
|
}
|
||||||
val locations = musicSettings.musicLocations
|
|
||||||
val withHidden = musicSettings.withHidden
|
|
||||||
|
|
||||||
val currentRevision = musicSettings.revision
|
// Begin with querying MediaStore and the music cache. The former is needed for Auxio
|
||||||
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
|
// to figure out what songs are (probably) on the device, and the latter will be needed
|
||||||
val cache = if (withCache) cache else WriteOnlyMutableCache(cache)
|
// for discovery (described later). These have no shared state, so they are done in
|
||||||
val covers = settingCovers.mutate(context, newRevision)
|
// parallel.
|
||||||
val storage = Storage(cache, covers, storedPlaylists)
|
L.d("Starting MediaStore query")
|
||||||
val interpretation = Interpretation(nameFactory, separators, withHidden)
|
emitIndexingProgress(IndexingProgress.Indeterminate)
|
||||||
val result =
|
|
||||||
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
|
val mediaStoreQueryJob =
|
||||||
// Music loading completed, update the revision right now so we re-use this work
|
scope.async {
|
||||||
// later.
|
val query =
|
||||||
musicSettings.revision = newRevision
|
try {
|
||||||
// Deliver the library to the rest of the app
|
mediaStoreExtractor.query(constraints)
|
||||||
// This will more or less block until all required item translation and
|
} catch (e: Exception) {
|
||||||
// cleanup finishes.
|
// Normally, errors in an async call immediately bubble up to the Looper
|
||||||
emitLibrary(result.library)
|
// and crash the app. Thus, we have to wrap any error into a Result
|
||||||
// Clean up old data that is now impossible for the app to be using.
|
// and then manually forward it to the try block that indexImpl is
|
||||||
result.cleanup()
|
// called from.
|
||||||
// Finish up loading.
|
return@async Result.failure(e)
|
||||||
|
}
|
||||||
|
Result.success(query)
|
||||||
|
}
|
||||||
|
// Since this main thread is a co-routine, we can do operations in parallel in a way
|
||||||
|
// identical to calling async.
|
||||||
|
val cache =
|
||||||
|
if (withCache) {
|
||||||
|
L.d("Reading cache")
|
||||||
|
cacheRepository.readCache()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
L.d("Awaiting MediaStore query")
|
||||||
|
val query = mediaStoreQueryJob.await().getOrThrow()
|
||||||
|
|
||||||
|
// We now have all the information required to start the "discovery" process. This
|
||||||
|
// is the point at which Auxio starts scanning each file given from MediaStore and
|
||||||
|
// transforming it into a music library. MediaStore normally
|
||||||
|
L.d("Starting discovery")
|
||||||
|
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
|
||||||
|
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
|
||||||
|
val processedSongs = Channel<RawSong>(Channel.UNLIMITED) // Transformed into SongImpl
|
||||||
|
|
||||||
|
// MediaStoreExtractor discovers all music on the device, and forwards them to either
|
||||||
|
// DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata
|
||||||
|
// does not exist. In the latter situation, it also applies it's own (inferior) metadata.
|
||||||
|
L.d("Starting MediaStore discovery")
|
||||||
|
val mediaStoreJob =
|
||||||
|
scope.async {
|
||||||
|
try {
|
||||||
|
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// To prevent a deadlock, we want to close the channel with an exception
|
||||||
|
// to cascade to and cancel all other routines before finally bubbling up
|
||||||
|
// to the main extractor loop.
|
||||||
|
L.e("MediaStore extraction failed: $e")
|
||||||
|
incompleteSongs.close(
|
||||||
|
Exception("MediaStore extraction failed: ${e.stackTraceToString()}"))
|
||||||
|
return@async
|
||||||
|
}
|
||||||
|
incompleteSongs.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date
|
||||||
|
// metadata for them, and then forwards it to DeviceLibrary.
|
||||||
|
L.d("Starting tag extraction")
|
||||||
|
val tagJob =
|
||||||
|
scope.async {
|
||||||
|
try {
|
||||||
|
tagExtractor.consume(incompleteSongs, completeSongs)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
L.e("Tag extraction failed: $e")
|
||||||
|
completeSongs.close(
|
||||||
|
Exception("Tag extraction failed: ${e.stackTraceToString()}"))
|
||||||
|
return@async
|
||||||
|
}
|
||||||
|
completeSongs.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceLibrary constructs music parent instances as song information is provided,
|
||||||
|
// and then forwards them to the primary loading loop.
|
||||||
|
L.d("Starting DeviceLibrary creation")
|
||||||
|
val deviceLibraryJob =
|
||||||
|
scope.async(Dispatchers.Default) {
|
||||||
|
val deviceLibrary =
|
||||||
|
try {
|
||||||
|
deviceLibraryFactory.create(
|
||||||
|
completeSongs, processedSongs, separators, nameFactory)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
L.e("DeviceLibrary creation failed: $e")
|
||||||
|
processedSongs.close(
|
||||||
|
Exception("DeviceLibrary creation failed: ${e.stackTraceToString()}"))
|
||||||
|
return@async Result.failure(e)
|
||||||
|
}
|
||||||
|
processedSongs.close()
|
||||||
|
Result.success(deviceLibrary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We could keep track of a total here, but we also need to collate this RawSong information
|
||||||
|
// for when we write the cache later on in the finalization step.
|
||||||
|
val rawSongs = LinkedList<RawSong>()
|
||||||
|
// Use a longer timeout so that dependent components can timeout and throw errors that
|
||||||
|
// provide more context than if we timed out here.
|
||||||
|
processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
|
||||||
|
rawSongs.add(it)
|
||||||
|
// Since discovery takes up the bulk of the music loading process, we switch to
|
||||||
|
// indicating a defined amount of loaded songs in comparison to the projected amount
|
||||||
|
// of songs that were queried.
|
||||||
|
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
|
||||||
|
}
|
||||||
|
|
||||||
|
withTimeout(DEFAULT_TIMEOUT) {
|
||||||
|
mediaStoreJob.await()
|
||||||
|
tagJob.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately done after the involved initialization step to make it less likely
|
||||||
|
// that the short-circuit occurs so quickly as to break the UI.
|
||||||
|
// TODO: Do not error, instead just wipe the entire library.
|
||||||
|
if (rawSongs.isEmpty()) {
|
||||||
|
L.e("Music library was empty")
|
||||||
|
throw NoMusicException()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that the library is effectively loaded, we can start the finalization step, which
|
||||||
|
// involves writing new cache information and creating more music data that is derived
|
||||||
|
// from the library (e.g playlists)
|
||||||
|
L.d("Discovered ${rawSongs.size} songs, starting finalization")
|
||||||
|
|
||||||
|
// We have no idea how long the cache will take, and the playlist construction
|
||||||
|
// will be too fast to indicate, so switch back to an indeterminate state.
|
||||||
|
emitIndexingProgress(IndexingProgress.Indeterminate)
|
||||||
|
|
||||||
|
// The UserLibrary job is split into a query and construction step, a la MediaStore.
|
||||||
|
// This way, we can start working on playlists even as DeviceLibrary might still be
|
||||||
|
// working on parent information.
|
||||||
|
L.d("Starting UserLibrary query")
|
||||||
|
val userLibraryQueryJob =
|
||||||
|
scope.async {
|
||||||
|
val rawPlaylists =
|
||||||
|
try {
|
||||||
|
userLibraryFactory.query()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return@async Result.failure(e)
|
||||||
|
}
|
||||||
|
Result.success(rawPlaylists)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cache might not exist, or we might have encountered a song not present in it.
|
||||||
|
// Both situations require us to rewrite the cache in bulk. This is also done parallel
|
||||||
|
// since the playlist read will probably take some time.
|
||||||
|
// TODO: Read/write from the cache incrementally instead of in bulk?
|
||||||
|
if (cache == null || cache.invalidated) {
|
||||||
|
L.d("Writing cache [why=${cache?.invalidated}]")
|
||||||
|
cacheRepository.writeCache(rawSongs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create UserLibrary once we finally get the required components for it.
|
||||||
|
L.d("Awaiting UserLibrary query")
|
||||||
|
val rawPlaylists = userLibraryQueryJob.await().getOrThrow()
|
||||||
|
L.d("Awaiting DeviceLibrary creation")
|
||||||
|
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
|
||||||
|
L.d("Starting UserLibrary creation")
|
||||||
|
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
|
||||||
|
|
||||||
|
// Loading process is functionally done, indicate such
|
||||||
|
L.d(
|
||||||
|
"Successfully indexed music library [device=$deviceLibrary " +
|
||||||
|
"user=$userLibrary time=${System.currentTimeMillis() - start}]")
|
||||||
emitIndexingCompletion(null)
|
emitIndexingCompletion(null)
|
||||||
|
|
||||||
|
val deviceLibraryChanged: Boolean
|
||||||
|
val userLibraryChanged: Boolean
|
||||||
|
// We want to make sure that all reads and writes are synchronized due to the sheer
|
||||||
|
// amount of consumers of MusicRepository.
|
||||||
|
// TODO: Would Atomics not be a better fit here?
|
||||||
|
synchronized(this) {
|
||||||
|
// It's possible that this reload might have changed nothing, so make sure that
|
||||||
|
// hasn't happened before dispatching a change to all consumers.
|
||||||
|
deviceLibraryChanged = this.deviceLibrary != deviceLibrary
|
||||||
|
userLibraryChanged = this.userLibrary != userLibrary
|
||||||
|
if (!deviceLibraryChanged && !userLibraryChanged) {
|
||||||
|
L.d("Library has not changed, skipping update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deviceLibrary = deviceLibrary
|
||||||
|
this.userLibrary = userLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consumers expect their updates to be on the main thread (notably PlaybackService),
|
||||||
|
// so switch to it.
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun emitIndexingProgress(progress: IndexingProgress) {
|
private suspend fun emitIndexingProgress(progress: IndexingProgress) {
|
||||||
|
@ -418,39 +584,6 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun emitLibrary(newLibrary: MutableLibrary) {
|
|
||||||
val deviceLibraryChanged: Boolean
|
|
||||||
val userLibraryChanged: Boolean
|
|
||||||
// We want to make sure that all reads and writes are synchronized due to the sheer
|
|
||||||
// amount of consumers of MusicRepository.
|
|
||||||
synchronized(this) {
|
|
||||||
// It's possible that this reload might have changed nothing, so make sure that
|
|
||||||
// hasn't happened before dispatching a change to all consumers.
|
|
||||||
|
|
||||||
// This is an old compat shim back when device library and user library were different
|
|
||||||
// thinks. For the sake of avoiding drastic changes, it sticks around.
|
|
||||||
// TODO: Remove this once you start work on kindred.
|
|
||||||
deviceLibraryChanged =
|
|
||||||
this.library?.songs != newLibrary.songs ||
|
|
||||||
this.library?.albums != newLibrary.albums ||
|
|
||||||
this.library?.artists != newLibrary.artists ||
|
|
||||||
this.library?.genres != newLibrary.genres
|
|
||||||
userLibraryChanged = this.library?.playlists != newLibrary.playlists
|
|
||||||
if (!deviceLibraryChanged && !userLibraryChanged) {
|
|
||||||
L.d("Library has not changed, skipping update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.library = newLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consumers expect their updates to be on the main thread (notably PlaybackService),
|
|
||||||
// so switch to it.
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun emitIndexingCompletion(error: Exception?) {
|
private suspend fun emitIndexingCompletion(error: Exception?) {
|
||||||
yield()
|
yield()
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
|
|
|
@ -21,11 +21,11 @@ package org.oxycblt.auxio.music
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.music.dirs.MusicDirectories
|
||||||
|
import org.oxycblt.auxio.music.fs.DocumentPathFactory
|
||||||
import org.oxycblt.auxio.settings.Settings
|
import org.oxycblt.auxio.settings.Settings
|
||||||
import org.oxycblt.musikr.fs.MusicLocation
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -34,14 +34,10 @@ import timber.log.Timber as L
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface MusicSettings : Settings<MusicSettings.Listener> {
|
interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
/** The current library revision. */
|
/** The configuration on how to handle particular directories in the music library. */
|
||||||
var revision: UUID?
|
var musicDirs: MusicDirectories
|
||||||
/** The locations of music to load. */
|
|
||||||
var musicLocations: List<MusicLocation>
|
|
||||||
/** Whether to exclude non-music audio files from the music library. */
|
/** Whether to exclude non-music audio files from the music library. */
|
||||||
val excludeNonMusic: Boolean
|
val excludeNonMusic: Boolean
|
||||||
/** Whether to ignore hidden files and directories during music loading. */
|
|
||||||
val withHidden: Boolean
|
|
||||||
/** Whether to be actively watching for changes in the music library. */
|
/** Whether to be actively watching for changes in the music library. */
|
||||||
val shouldBeObserving: Boolean
|
val shouldBeObserving: Boolean
|
||||||
/** A [String] of characters representing the desired characters to denote multi-value tags. */
|
/** A [String] of characters representing the desired characters to denote multi-value tags. */
|
||||||
|
@ -50,8 +46,6 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
val intelligentSorting: Boolean
|
val intelligentSorting: Boolean
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
/** Called when the current music locations changed. */
|
|
||||||
fun onMusicLocationsChanged() {}
|
|
||||||
/** Called when a setting controlling how music is loaded has changed. */
|
/** Called when a setting controlling how music is loaded has changed. */
|
||||||
fun onIndexingSettingChanged() {}
|
fun onIndexingSettingChanged() {}
|
||||||
/** Called when the [shouldBeObserving] configuration has changed. */
|
/** Called when the [shouldBeObserving] configuration has changed. */
|
||||||
|
@ -59,45 +53,35 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MusicSettingsImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
class MusicSettingsImpl
|
||||||
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
@Inject
|
||||||
|
constructor(
|
||||||
override var revision: UUID?
|
@ApplicationContext context: Context,
|
||||||
get() =
|
private val documentPathFactory: DocumentPathFactory
|
||||||
sharedPreferences
|
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||||
.getString(getString(R.string.set_key_library_revision), null)
|
override var musicDirs: MusicDirectories
|
||||||
?.let(UUID::fromString)
|
|
||||||
set(value) {
|
|
||||||
sharedPreferences.edit {
|
|
||||||
putString(getString(R.string.set_key_library_revision), value.toString())
|
|
||||||
apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override var musicLocations: List<MusicLocation>
|
|
||||||
get() {
|
get() {
|
||||||
val locations =
|
val dirs =
|
||||||
sharedPreferences.getString(getString(R.string.set_key_music_locations), null)
|
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
|
||||||
?: return emptyList()
|
?: emptySet())
|
||||||
return MusicLocation.existing(context, locations)
|
.mapNotNull(documentPathFactory::fromDocumentId)
|
||||||
|
return MusicDirectories(
|
||||||
|
dirs,
|
||||||
|
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
|
||||||
}
|
}
|
||||||
set(value) {
|
set(value) {
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString(
|
putStringSet(
|
||||||
getString(R.string.set_key_music_locations), MusicLocation.toString(value))
|
getString(R.string.set_key_music_dirs),
|
||||||
commit()
|
value.dirs.map(documentPathFactory::toDocumentId).toSet())
|
||||||
// Sometimes changing this setting just won't actually trigger the listener.
|
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
|
||||||
// Only this one. No idea why.
|
apply()
|
||||||
listener?.onMusicLocationsChanged()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override val excludeNonMusic: Boolean
|
override val excludeNonMusic: Boolean
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
|
||||||
|
|
||||||
override val withHidden: Boolean
|
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), false)
|
|
||||||
|
|
||||||
override val shouldBeObserving: Boolean
|
override val shouldBeObserving: Boolean
|
||||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
|
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
|
||||||
|
|
||||||
|
@ -119,14 +103,11 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont
|
||||||
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
|
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
|
||||||
// (just need to manipulate data)
|
// (just need to manipulate data)
|
||||||
when (key) {
|
when (key) {
|
||||||
getString(R.string.set_key_music_locations) -> {
|
getString(R.string.set_key_exclude_non_music),
|
||||||
L.d("Dispatching music locations change")
|
getString(R.string.set_key_music_dirs),
|
||||||
listener.onMusicLocationsChanged()
|
getString(R.string.set_key_music_dirs_include),
|
||||||
}
|
|
||||||
getString(R.string.set_key_separators),
|
getString(R.string.set_key_separators),
|
||||||
getString(R.string.set_key_auto_sort_names),
|
getString(R.string.set_key_auto_sort_names) -> {
|
||||||
getString(R.string.set_key_with_hidden),
|
|
||||||
getString(R.string.set_key_exclude_non_music) -> {
|
|
||||||
L.d("Dispatching indexing setting change for $key")
|
L.d("Dispatching indexing setting change for $key")
|
||||||
listener.onIndexingSettingChanged()
|
listener.onIndexingSettingChanged()
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,10 +27,15 @@ import org.oxycblt.auxio.R
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
enum class MusicType {
|
enum class MusicType {
|
||||||
|
/** @see Song */
|
||||||
SONGS,
|
SONGS,
|
||||||
|
/** @see Album */
|
||||||
ALBUMS,
|
ALBUMS,
|
||||||
|
/** @see Artist */
|
||||||
ARTISTS,
|
ARTISTS,
|
||||||
|
/** @see Genre */
|
||||||
GENRES,
|
GENRES,
|
||||||
|
/** @see Playlist */
|
||||||
PLAYLISTS;
|
PLAYLISTS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,173 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2024 Auxio Project
|
|
||||||
* MusicUtil.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.content.Context
|
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import kotlin.math.max
|
|
||||||
import org.oxycblt.auxio.R
|
|
||||||
import org.oxycblt.auxio.util.concatLocalized
|
|
||||||
import org.oxycblt.musikr.Music
|
|
||||||
import org.oxycblt.musikr.fs.Format
|
|
||||||
import org.oxycblt.musikr.tag.Date
|
|
||||||
import org.oxycblt.musikr.tag.Disc
|
|
||||||
import org.oxycblt.musikr.tag.Name
|
|
||||||
import org.oxycblt.musikr.tag.Placeholder
|
|
||||||
import org.oxycblt.musikr.tag.ReleaseType
|
|
||||||
import org.oxycblt.musikr.tag.ReleaseType.Refinement
|
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
fun Name.resolve(context: Context) =
|
|
||||||
when (this) {
|
|
||||||
is Name.Known -> raw
|
|
||||||
is Name.Unknown ->
|
|
||||||
when (placeholder) {
|
|
||||||
Placeholder.ALBUM -> context.getString(R.string.def_album)
|
|
||||||
Placeholder.ARTIST -> context.getString(R.string.def_artist)
|
|
||||||
Placeholder.GENRE -> context.getString(R.string.def_genre)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
|
|
||||||
* localized manner.
|
|
||||||
*
|
|
||||||
* @param context [Context] required
|
|
||||||
* @return A concatenated string.
|
|
||||||
*/
|
|
||||||
fun <T : Music> List<T>.resolveNames(context: Context) =
|
|
||||||
concatLocalized(context) { it.name.resolve(context) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
|
|
||||||
* information of an item must be compared without a context.
|
|
||||||
*
|
|
||||||
* @param other The list of items to compare to.
|
|
||||||
* @return True if they are the same (by [Music.name]), false otherwise.
|
|
||||||
*/
|
|
||||||
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
|
|
||||||
for (i in 0 until max(size, other.size)) {
|
|
||||||
val a = getOrNull(i) ?: return false
|
|
||||||
val b = other.getOrNull(i) ?: return false
|
|
||||||
if (a.name != b.name) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve this instance into a human-readable date.
|
|
||||||
*
|
|
||||||
* @param context [Context] required to get human-readable names.
|
|
||||||
* @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan 2020")
|
|
||||||
* will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will be
|
|
||||||
* properly localized.
|
|
||||||
*/
|
|
||||||
fun Date.resolve(context: Context) =
|
|
||||||
// Unable to create fine-grained date, just format as a year.
|
|
||||||
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
|
|
||||||
|
|
||||||
private fun Date.resolveFineGrained(): String? {
|
|
||||||
// We can't directly load a date with our own
|
|
||||||
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
|
|
||||||
format.applyPattern("yyyy-MM")
|
|
||||||
val date =
|
|
||||||
try {
|
|
||||||
format.parse("$year-$month")
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
Timber.e("Unable to parse fine-grained date: $e")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reformat as a readable month and year
|
|
||||||
format.applyPattern("MMM yyyy")
|
|
||||||
return format.format(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Disc?.resolve(context: Context) =
|
|
||||||
this?.run { context.getString(R.string.fmt_disc_no, number) }
|
|
||||||
?: context.getString(R.string.def_disc)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve this instance into a human-readable date range.
|
|
||||||
*
|
|
||||||
* @param context [Context] required to get human-readable names.
|
|
||||||
* @return If the date has a maximum value, then a `min - max` formatted string will be returned
|
|
||||||
* with the formatted [Date]s of the minimum and maximum dates respectively. Otherwise, the
|
|
||||||
* formatted name of the minimum [Date] will be returned.
|
|
||||||
*/
|
|
||||||
fun Date.Range.resolve(context: Context) =
|
|
||||||
if (min != max) {
|
|
||||||
context.getString(R.string.fmt_date_range, min.resolve(context), max.resolve(context))
|
|
||||||
} else {
|
|
||||||
min.resolve(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ReleaseType.resolve(context: Context) =
|
|
||||||
when (this) {
|
|
||||||
is ReleaseType.Album ->
|
|
||||||
when (refinement) {
|
|
||||||
null -> context.getString(R.string.lbl_album)
|
|
||||||
Refinement.LIVE -> context.getString(R.string.lbl_album_live)
|
|
||||||
Refinement.REMIX -> context.getString(R.string.lbl_album_remix)
|
|
||||||
}
|
|
||||||
is ReleaseType.EP ->
|
|
||||||
when (refinement) {
|
|
||||||
null -> context.getString(R.string.lbl_ep)
|
|
||||||
Refinement.LIVE -> context.getString(R.string.lbl_ep_live)
|
|
||||||
Refinement.REMIX -> context.getString(R.string.lbl_ep_remix)
|
|
||||||
}
|
|
||||||
is ReleaseType.Single ->
|
|
||||||
when (refinement) {
|
|
||||||
null -> context.getString(R.string.lbl_single)
|
|
||||||
Refinement.LIVE -> context.getString(R.string.lbl_single_live)
|
|
||||||
Refinement.REMIX -> context.getString(R.string.lbl_single_remix)
|
|
||||||
}
|
|
||||||
is ReleaseType.Compilation ->
|
|
||||||
when (refinement) {
|
|
||||||
null -> context.getString(R.string.lbl_compilation)
|
|
||||||
Refinement.LIVE -> context.getString(R.string.lbl_compilation_live)
|
|
||||||
Refinement.REMIX -> context.getString(R.string.lbl_compilation_remix)
|
|
||||||
}
|
|
||||||
is ReleaseType.Soundtrack -> context.getString(R.string.lbl_soundtrack)
|
|
||||||
is ReleaseType.Mix -> context.getString(R.string.lbl_mix)
|
|
||||||
is ReleaseType.Mixtape -> context.getString(R.string.lbl_mixtape)
|
|
||||||
is ReleaseType.Demo -> context.getString(R.string.lbl_demo)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Format.resolve(context: Context): String =
|
|
||||||
when (this) {
|
|
||||||
is Format.MPEG3 -> context.getString(R.string.cdc_mp3)
|
|
||||||
is Format.MPEG4 ->
|
|
||||||
containing?.let { context.getString(R.string.cnt_mp4, it.resolve(context)) }
|
|
||||||
?: context.getString(R.string.cdc_mp4)
|
|
||||||
is Format.AAC -> context.getString(R.string.cdc_aac)
|
|
||||||
is Format.ALAC -> context.getString(R.string.cdc_alac)
|
|
||||||
is Format.Ogg ->
|
|
||||||
containing?.let { context.getString(R.string.cnt_ogg, it.resolve(context)) }
|
|
||||||
?: context.getString(R.string.cdc_ogg)
|
|
||||||
is Format.Opus -> context.getString(R.string.cdc_opus)
|
|
||||||
is Format.Vorbis -> context.getString(R.string.cdc_vorbis)
|
|
||||||
is Format.FLAC -> context.getString(R.string.cdc_flac)
|
|
||||||
is Format.Wav -> context.getString(R.string.cdc_wav)
|
|
||||||
is Format.Unknown -> extension ?: context.getString(R.string.cdc_unknown)
|
|
||||||
}
|
|
|
@ -18,12 +18,10 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
@ -31,15 +29,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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.music.external.ExportConfig
|
||||||
|
import org.oxycblt.auxio.music.external.ExternalPlaylistManager
|
||||||
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.musikr.Artist
|
|
||||||
import org.oxycblt.musikr.Genre
|
|
||||||
import org.oxycblt.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import org.oxycblt.musikr.playlist.ExportConfig
|
|
||||||
import org.oxycblt.musikr.playlist.ExternalPlaylistManager
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -51,11 +44,10 @@ import timber.log.Timber as L
|
||||||
class MusicViewModel
|
class MusicViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext context: Context,
|
|
||||||
private val listSettings: ListSettings,
|
private val listSettings: ListSettings,
|
||||||
private val musicRepository: MusicRepository
|
private val musicRepository: MusicRepository,
|
||||||
|
private val externalPlaylistManager: ExternalPlaylistManager
|
||||||
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
|
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
|
||||||
private val externalPlaylistManager = ExternalPlaylistManager.from(context)
|
|
||||||
|
|
||||||
private val _indexingState = MutableStateFlow<IndexingState?>(null)
|
private val _indexingState = MutableStateFlow<IndexingState?>(null)
|
||||||
|
|
||||||
|
@ -93,14 +85,14 @@ constructor(
|
||||||
|
|
||||||
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
|
||||||
_statistics.value =
|
_statistics.value =
|
||||||
Statistics(
|
Statistics(
|
||||||
library.songs.size,
|
deviceLibrary.songs.size,
|
||||||
library.albums.size,
|
deviceLibrary.albums.size,
|
||||||
library.artists.size,
|
deviceLibrary.artists.size,
|
||||||
library.genres.size,
|
deviceLibrary.genres.size,
|
||||||
library.songs.sumOf { it.durationMs })
|
deviceLibrary.songs.sumOf { it.durationMs })
|
||||||
L.d("Updated statistics: ${_statistics.value}")
|
L.d("Updated statistics: ${_statistics.value}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,10 +162,10 @@ constructor(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val library = musicRepository.library ?: return@launch
|
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
||||||
val songs =
|
val songs =
|
||||||
importedPlaylist.paths.mapNotNull {
|
importedPlaylist.paths.mapNotNull {
|
||||||
it.firstNotNullOfOrNull(library::findSongByPath)
|
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (songs.isEmpty()) {
|
if (songs.isEmpty()) {
|
||||||
|
|
187
app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
vendored
Normal file
187
app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
vendored
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* CacheDatabase.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.cache
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.TypeConverter
|
||||||
|
import androidx.room.TypeConverters
|
||||||
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
|
import org.oxycblt.auxio.music.info.Date
|
||||||
|
import org.oxycblt.auxio.music.metadata.correctWhitespace
|
||||||
|
import org.oxycblt.auxio.music.metadata.splitEscaped
|
||||||
|
|
||||||
|
@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
|
||||||
|
abstract class CacheDatabase : RoomDatabase() {
|
||||||
|
abstract fun cachedSongsDao(): CachedSongsDao
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CachedSongsDao {
|
||||||
|
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
|
||||||
|
|
||||||
|
@Query("DELETE FROM CachedSong") suspend fun nukeSongs()
|
||||||
|
|
||||||
|
@Insert suspend fun insertSongs(songs: List<CachedSong>)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@TypeConverters(CachedSong.Converters::class)
|
||||||
|
data class CachedSong(
|
||||||
|
/**
|
||||||
|
* The ID of the [RawSong]'s audio file, obtained from MediaStore. Note that this ID is highly
|
||||||
|
* unstable and should only be used for accessing the audio file.
|
||||||
|
*/
|
||||||
|
@PrimaryKey var mediaStoreId: Long,
|
||||||
|
/** @see RawSong.dateAdded */
|
||||||
|
var dateAdded: Long,
|
||||||
|
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
|
||||||
|
var dateModified: Long,
|
||||||
|
/** @see RawSong.size */
|
||||||
|
var size: Long? = null,
|
||||||
|
/** @see RawSong */
|
||||||
|
var durationMs: Long,
|
||||||
|
/** @see RawSong.replayGainTrackAdjustment */
|
||||||
|
val replayGainTrackAdjustment: Float? = null,
|
||||||
|
/** @see RawSong.replayGainAlbumAdjustment */
|
||||||
|
val replayGainAlbumAdjustment: Float? = null,
|
||||||
|
/** @see RawSong.musicBrainzId */
|
||||||
|
var musicBrainzId: String? = null,
|
||||||
|
/** @see RawSong.name */
|
||||||
|
var name: String,
|
||||||
|
/** @see RawSong.sortName */
|
||||||
|
var sortName: String? = null,
|
||||||
|
/** @see RawSong.track */
|
||||||
|
var track: Int? = null,
|
||||||
|
/** @see RawSong.name */
|
||||||
|
var disc: Int? = null,
|
||||||
|
/** @See RawSong.subtitle */
|
||||||
|
var subtitle: String? = null,
|
||||||
|
/** @see RawSong.date */
|
||||||
|
var date: Date? = null,
|
||||||
|
/** @see RawSong.coverPerceptualHash */
|
||||||
|
var coverPerceptualHash: String? = null,
|
||||||
|
/** @see RawSong.albumMusicBrainzId */
|
||||||
|
var albumMusicBrainzId: String? = null,
|
||||||
|
/** @see RawSong.albumName */
|
||||||
|
var albumName: String,
|
||||||
|
/** @see RawSong.albumSortName */
|
||||||
|
var albumSortName: String? = null,
|
||||||
|
/** @see RawSong.releaseTypes */
|
||||||
|
var releaseTypes: List<String> = listOf(),
|
||||||
|
/** @see RawSong.artistMusicBrainzIds */
|
||||||
|
var artistMusicBrainzIds: List<String> = listOf(),
|
||||||
|
/** @see RawSong.artistNames */
|
||||||
|
var artistNames: List<String> = listOf(),
|
||||||
|
/** @see RawSong.artistSortNames */
|
||||||
|
var artistSortNames: List<String> = listOf(),
|
||||||
|
/** @see RawSong.albumArtistMusicBrainzIds */
|
||||||
|
var albumArtistMusicBrainzIds: List<String> = listOf(),
|
||||||
|
/** @see RawSong.albumArtistNames */
|
||||||
|
var albumArtistNames: List<String> = listOf(),
|
||||||
|
/** @see RawSong.albumArtistSortNames */
|
||||||
|
var albumArtistSortNames: List<String> = listOf(),
|
||||||
|
/** @see RawSong.genreNames */
|
||||||
|
var genreNames: List<String> = listOf()
|
||||||
|
) {
|
||||||
|
fun copyToRaw(rawSong: RawSong) {
|
||||||
|
rawSong.musicBrainzId = musicBrainzId
|
||||||
|
rawSong.name = name
|
||||||
|
rawSong.sortName = sortName
|
||||||
|
|
||||||
|
rawSong.size = size
|
||||||
|
rawSong.durationMs = durationMs
|
||||||
|
|
||||||
|
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
|
||||||
|
rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment
|
||||||
|
|
||||||
|
rawSong.track = track
|
||||||
|
rawSong.disc = disc
|
||||||
|
rawSong.subtitle = subtitle
|
||||||
|
rawSong.date = date
|
||||||
|
|
||||||
|
rawSong.coverPerceptualHash = coverPerceptualHash
|
||||||
|
|
||||||
|
rawSong.albumMusicBrainzId = albumMusicBrainzId
|
||||||
|
rawSong.albumName = albumName
|
||||||
|
rawSong.albumSortName = albumSortName
|
||||||
|
rawSong.releaseTypes = releaseTypes
|
||||||
|
|
||||||
|
rawSong.artistMusicBrainzIds = artistMusicBrainzIds
|
||||||
|
rawSong.artistNames = artistNames
|
||||||
|
rawSong.artistSortNames = artistSortNames
|
||||||
|
|
||||||
|
rawSong.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
|
||||||
|
rawSong.albumArtistNames = albumArtistNames
|
||||||
|
rawSong.albumArtistSortNames = albumArtistSortNames
|
||||||
|
|
||||||
|
rawSong.genreNames = genreNames
|
||||||
|
}
|
||||||
|
|
||||||
|
object Converters {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromMultiValue(values: List<String>) =
|
||||||
|
values.joinToString(";") { it.replace(";", "\\;") }
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
|
||||||
|
|
||||||
|
@TypeConverter fun fromDate(date: Date?) = date?.toString()
|
||||||
|
|
||||||
|
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromRaw(rawSong: RawSong) =
|
||||||
|
CachedSong(
|
||||||
|
mediaStoreId =
|
||||||
|
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },
|
||||||
|
dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" },
|
||||||
|
dateModified =
|
||||||
|
requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" },
|
||||||
|
musicBrainzId = rawSong.musicBrainzId,
|
||||||
|
name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
|
||||||
|
sortName = rawSong.sortName,
|
||||||
|
size = rawSong.size,
|
||||||
|
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
|
||||||
|
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
|
||||||
|
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,
|
||||||
|
track = rawSong.track,
|
||||||
|
disc = rawSong.disc,
|
||||||
|
subtitle = rawSong.subtitle,
|
||||||
|
date = rawSong.date,
|
||||||
|
coverPerceptualHash = rawSong.coverPerceptualHash,
|
||||||
|
albumMusicBrainzId = rawSong.albumMusicBrainzId,
|
||||||
|
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
|
||||||
|
albumSortName = rawSong.albumSortName,
|
||||||
|
releaseTypes = rawSong.releaseTypes,
|
||||||
|
artistMusicBrainzIds = rawSong.artistMusicBrainzIds,
|
||||||
|
artistNames = rawSong.artistNames,
|
||||||
|
artistSortNames = rawSong.artistSortNames,
|
||||||
|
albumArtistMusicBrainzIds = rawSong.albumArtistMusicBrainzIds,
|
||||||
|
albumArtistNames = rawSong.albumArtistNames,
|
||||||
|
albumArtistSortNames = rawSong.albumArtistSortNames,
|
||||||
|
genreNames = rawSong.genreNames)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Auxio Project
|
* Copyright (c) 2023 Auxio Project
|
||||||
* MusikrShimModule.kt is part of Auxio.
|
* CacheModule.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,31 +16,34 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.shim
|
package org.oxycblt.auxio.music.cache
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.oxycblt.musikr.cache.MutableCache
|
|
||||||
import org.oxycblt.musikr.cache.db.MutableDBCache
|
|
||||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
class MusikrShimModule {
|
interface CacheModule {
|
||||||
@Singleton
|
@Binds fun cacheRepository(cacheRepository: CacheRepositoryImpl): CacheRepository
|
||||||
@Provides
|
}
|
||||||
fun cache(@ApplicationContext context: Context): MutableCache = MutableDBCache.from(context)
|
|
||||||
|
@Module
|
||||||
@Singleton
|
@InstallIn(SingletonComponent::class)
|
||||||
@Provides
|
class CacheRoomModule {
|
||||||
fun storedPlaylists(@ApplicationContext context: Context) = StoredPlaylists.from(context)
|
@Singleton
|
||||||
|
@Provides
|
||||||
@Provides
|
fun database(@ApplicationContext context: Context) =
|
||||||
fun updateTrackerFactory(@ApplicationContext context: Context): UpdateTrackerFactory =
|
Room.databaseBuilder(
|
||||||
UpdateTrackerFactoryImpl(context)
|
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao()
|
||||||
}
|
}
|
121
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal file
121
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 Auxio Project
|
||||||
|
* CacheRepository.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.cache
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import org.oxycblt.auxio.music.device.RawSong
|
||||||
|
import timber.log.Timber as L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A repository allowing access to cached metadata obtained in prior music loading operations.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
interface CacheRepository {
|
||||||
|
/**
|
||||||
|
* Read the current [Cache], if it exists.
|
||||||
|
*
|
||||||
|
* @return The stored [Cache], or null if it could not be obtained.
|
||||||
|
*/
|
||||||
|
suspend fun readCache(): Cache?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
|
||||||
|
*
|
||||||
|
* @param rawSongs The [rawSongs] to write to the cache.
|
||||||
|
*/
|
||||||
|
suspend fun writeCache(rawSongs: List<RawSong>)
|
||||||
|
}
|
||||||
|
|
||||||
|
class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: CachedSongsDao) :
|
||||||
|
CacheRepository {
|
||||||
|
override suspend fun readCache(): Cache? =
|
||||||
|
try {
|
||||||
|
// Faster to load the whole database into memory than do a query on each
|
||||||
|
// populate call.
|
||||||
|
val songs = cachedSongsDao.readSongs()
|
||||||
|
L.d("Successfully read ${songs.size} songs from cache")
|
||||||
|
CacheImpl(songs)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
L.e("Unable to load cache database.")
|
||||||
|
L.e(e.stackTraceToString())
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun writeCache(rawSongs: List<RawSong>) {
|
||||||
|
try {
|
||||||
|
// Still write out whatever data was extracted.
|
||||||
|
cachedSongsDao.nukeSongs()
|
||||||
|
L.d("Successfully deleted old cache")
|
||||||
|
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
|
||||||
|
L.d("Successfully wrote ${rawSongs.size} songs to cache")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
L.e("Unable to save cache database.")
|
||||||
|
L.e(e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with
|
||||||
|
* [CacheRepository].
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
interface Cache {
|
||||||
|
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
|
||||||
|
val invalidated: Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate a [RawSong] from a cache entry, if it exists.
|
||||||
|
*
|
||||||
|
* @param rawSong The [RawSong] to populate.
|
||||||
|
* @return true if a cache entry could be applied to [rawSong], false otherwise.
|
||||||
|
*/
|
||||||
|
fun populate(rawSong: RawSong): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
|
||||||
|
private val cacheMap = buildMap {
|
||||||
|
for (cachedSong in cachedSongs) {
|
||||||
|
put(cachedSong.mediaStoreId, cachedSong)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var invalidated = false
|
||||||
|
|
||||||
|
override fun populate(rawSong: RawSong): Boolean {
|
||||||
|
// For a cached raw song to be used, it must exist within the cache and have matching
|
||||||
|
// addition and modification timestamps. Technically the addition timestamp doesn't
|
||||||
|
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
|
||||||
|
// check for it anyway.
|
||||||
|
val cachedSong = cacheMap[rawSong.mediaStoreId]
|
||||||
|
if (cachedSong != null &&
|
||||||
|
cachedSong.dateAdded == rawSong.dateAdded &&
|
||||||
|
cachedSong.dateModified == rawSong.dateModified) {
|
||||||
|
cachedSong.copyToRaw(rawSong)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// We could not populate this song. This means our cache is stale and should be
|
||||||
|
// re-written with newly-loaded music.
|
||||||
|
invalidated = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,10 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
|
||||||
import org.oxycblt.auxio.list.ClickableListListener
|
import org.oxycblt.auxio.list.ClickableListListener
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
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.auxio.util.navigateSafe
|
import org.oxycblt.auxio.util.navigateSafe
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -29,11 +29,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
|
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
|
||||||
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.ui.ViewBindingMaterialDialogFragment
|
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||||
import org.oxycblt.auxio.util.collectImmediately
|
import org.oxycblt.auxio.util.collectImmediately
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
import org.oxycblt.musikr.Playlist
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -53,9 +52,6 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlayl
|
||||||
builder
|
builder
|
||||||
.setTitle(R.string.lbl_confirm_delete_playlist)
|
.setTitle(R.string.lbl_confirm_delete_playlist)
|
||||||
.setPositiveButton(R.string.lbl_delete) { _, _ ->
|
.setPositiveButton(R.string.lbl_delete) { _, _ ->
|
||||||
// Normally the navigateUp will occur after this, which then collides with the
|
|
||||||
// playlist view's navigation. Forcefully navigate up to stop this.
|
|
||||||
findNavController().navigateUp()
|
|
||||||
// Now we can delete the playlist for-real this time.
|
// Now we can delete the playlist for-real this time.
|
||||||
musicModel.deletePlaylist(
|
musicModel.deletePlaylist(
|
||||||
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
|
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
|
||||||
|
|
|
@ -31,13 +31,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
|
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
|
||||||
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.external.ExportConfig
|
||||||
|
import org.oxycblt.auxio.music.external.M3U
|
||||||
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.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
import org.oxycblt.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.playlist.ExportConfig
|
|
||||||
import org.oxycblt.musikr.playlist.m3u.M3U
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -25,7 +25,6 @@ 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.util.context
|
import org.oxycblt.auxio.util.context
|
||||||
import org.oxycblt.auxio.util.inflater
|
import org.oxycblt.auxio.util.inflater
|
||||||
|
|
||||||
|
|
|
@ -25,14 +25,14 @@ 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.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.list.Item
|
||||||
import org.oxycblt.auxio.list.sort.Sort
|
import org.oxycblt.auxio.list.sort.Sort
|
||||||
|
import org.oxycblt.auxio.music.Music
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
|
import org.oxycblt.auxio.music.Playlist
|
||||||
import org.oxycblt.auxio.music.PlaylistDecision
|
import org.oxycblt.auxio.music.PlaylistDecision
|
||||||
import org.oxycblt.auxio.music.resolve
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.musikr.Music
|
import org.oxycblt.auxio.music.external.ExportConfig
|
||||||
import org.oxycblt.musikr.Playlist
|
|
||||||
import org.oxycblt.musikr.Song
|
|
||||||
import org.oxycblt.musikr.playlist.ExportConfig
|
|
||||||
import timber.log.Timber as L
|
import timber.log.Timber as L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -89,13 +89,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
|
|
||||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
var refreshChoicesWith: List<Song>? = null
|
var refreshChoicesWith: List<Song>? = null
|
||||||
val library = musicRepository.library
|
val deviceLibrary = musicRepository.deviceLibrary
|
||||||
if (changes.deviceLibrary && library != null) {
|
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||||
_currentPendingNewPlaylist.value =
|
_currentPendingNewPlaylist.value =
|
||||||
_currentPendingNewPlaylist.value?.let { pendingPlaylist ->
|
_currentPendingNewPlaylist.value?.let { pendingPlaylist ->
|
||||||
PendingNewPlaylist(
|
PendingNewPlaylist(
|
||||||
pendingPlaylist.preferredName,
|
pendingPlaylist.preferredName,
|
||||||
pendingPlaylist.songs.mapNotNull { library.findSong(it.uid) },
|
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) },
|
||||||
pendingPlaylist.template,
|
pendingPlaylist.template,
|
||||||
pendingPlaylist.reason)
|
pendingPlaylist.reason)
|
||||||
}
|
}
|
||||||
|
@ -104,7 +104,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
_currentSongsToAdd.value =
|
_currentSongsToAdd.value =
|
||||||
_currentSongsToAdd.value?.let { pendingSongs ->
|
_currentSongsToAdd.value?.let { pendingSongs ->
|
||||||
pendingSongs
|
pendingSongs
|
||||||
.mapNotNull { library.findSong(it.uid) }
|
.mapNotNull { deviceLibrary.findSong(it.uid) }
|
||||||
.ifEmpty { null }
|
.ifEmpty { null }
|
||||||
.also { refreshChoicesWith = it }
|
.also { refreshChoicesWith = it }
|
||||||
}
|
}
|
||||||
|
@ -127,7 +127,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
|
|
||||||
_currentPlaylistToExport.value =
|
_currentPlaylistToExport.value =
|
||||||
_currentPlaylistToExport.value?.let { playlist ->
|
_currentPlaylistToExport.value?.let { playlist ->
|
||||||
musicRepository.library?.findPlaylist(playlist.uid)
|
musicRepository.userLibrary?.findPlaylist(playlist.uid)
|
||||||
}
|
}
|
||||||
L.d("Updated playlist to export to ${_currentPlaylistToExport.value}")
|
L.d("Updated playlist to export to ${_currentPlaylistToExport.value}")
|
||||||
}
|
}
|
||||||
|
@ -153,14 +153,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
reason: PlaylistDecision.New.Reason
|
reason: PlaylistDecision.New.Reason
|
||||||
) {
|
) {
|
||||||
L.d("Opening ${songUids.size} songs to create a playlist from")
|
L.d("Opening ${songUids.size} songs to create a playlist from")
|
||||||
val library = musicRepository.library ?: return
|
val userLibrary = musicRepository.userLibrary ?: return
|
||||||
val songs =
|
val songs =
|
||||||
musicRepository.library
|
musicRepository.deviceLibrary
|
||||||
?.let { songUids.mapNotNull(it::findSong) }
|
?.let { songUids.mapNotNull(it::findSong) }
|
||||||
?.also(::refreshPlaylistChoices)
|
?.also(::refreshPlaylistChoices)
|
||||||
|
|
||||||
val possibleName =
|
val possibleName =
|
||||||
musicRepository.library?.let {
|
musicRepository.userLibrary?.let {
|
||||||
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
|
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
|
||||||
var i = 1
|
var i = 1
|
||||||
var possibleName: String
|
var possibleName: String
|
||||||
|
@ -168,7 +168,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
possibleName = context.getString(R.string.fmt_def_playlist, i)
|
possibleName = context.getString(R.string.fmt_def_playlist, i)
|
||||||
L.d("Trying $possibleName as a playlist name")
|
L.d("Trying $possibleName as a playlist name")
|
||||||
++i
|
++i
|
||||||
} while (library.playlists.any { it.name.resolve(context) == possibleName })
|
} while (userLibrary.playlists.any { it.name.resolve(context) == possibleName })
|
||||||
L.d("$possibleName is unique, using it as the playlist name")
|
L.d("$possibleName is unique, using it as the playlist name")
|
||||||
possibleName
|
possibleName
|
||||||
}
|
}
|
||||||
|
@ -194,8 +194,9 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
reason: PlaylistDecision.Rename.Reason
|
reason: PlaylistDecision.Rename.Reason
|
||||||
) {
|
) {
|
||||||
L.d("Opening playlist $playlistUid to rename")
|
L.d("Opening playlist $playlistUid to rename")
|
||||||
val playlist = musicRepository.library?.findPlaylist(playlistUid)
|
val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||||
val applySongs = musicRepository.library?.let { applySongUids.mapNotNull(it::findSong) }
|
val applySongs =
|
||||||
|
musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
|
||||||
|
|
||||||
_currentPendingRenamePlaylist.value =
|
_currentPendingRenamePlaylist.value =
|
||||||
if (playlist != null && applySongs != null) {
|
if (playlist != null && applySongs != null) {
|
||||||
|
@ -215,7 +216,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
L.d("Opening playlist $playlistUid to export")
|
L.d("Opening playlist $playlistUid to export")
|
||||||
// TODO: Add this guard to the rest of the methods here
|
// TODO: Add this guard to the rest of the methods here
|
||||||
if (_currentPlaylistToExport.value?.uid == playlistUid) return
|
if (_currentPlaylistToExport.value?.uid == playlistUid) return
|
||||||
_currentPlaylistToExport.value = musicRepository.library?.findPlaylist(playlistUid)
|
_currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||||
if (_currentPlaylistToExport.value == null) {
|
if (_currentPlaylistToExport.value == null) {
|
||||||
L.w("Given playlist UID to export was invalid")
|
L.w("Given playlist UID to export was invalid")
|
||||||
} else {
|
} else {
|
||||||
|
@ -240,7 +241,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
*/
|
*/
|
||||||
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
||||||
L.d("Opening playlist $playlistUid to delete")
|
L.d("Opening playlist $playlistUid to delete")
|
||||||
_currentPlaylistToDelete.value = musicRepository.library?.findPlaylist(playlistUid)
|
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||||
if (_currentPlaylistToDelete.value == null) {
|
if (_currentPlaylistToDelete.value == null) {
|
||||||
L.w("Given playlist UID to delete was invalid")
|
L.w("Given playlist UID to delete was invalid")
|
||||||
}
|
}
|
||||||
|
@ -265,8 +266,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val trimmed = name.trim()
|
val trimmed = name.trim()
|
||||||
val library = musicRepository.library
|
val userLibrary = musicRepository.userLibrary
|
||||||
if (library != null && library.findPlaylistByName(trimmed) == null) {
|
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
|
||||||
L.d("Chosen name is valid")
|
L.d("Chosen name is valid")
|
||||||
ChosenName.Valid(trimmed)
|
ChosenName.Valid(trimmed)
|
||||||
} else {
|
} else {
|
||||||
|
@ -285,7 +286,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
||||||
L.d("Opening ${songUids.size} songs to add to a playlist")
|
L.d("Opening ${songUids.size} songs to add to a playlist")
|
||||||
_currentSongsToAdd.value =
|
_currentSongsToAdd.value =
|
||||||
musicRepository.library
|
musicRepository.deviceLibrary
|
||||||
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
|
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
|
||||||
?.also(::refreshPlaylistChoices)
|
?.also(::refreshPlaylistChoices)
|
||||||
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
|
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
|
||||||
|
@ -294,10 +295,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshPlaylistChoices(songs: List<Song>) {
|
private fun refreshPlaylistChoices(songs: List<Song>) {
|
||||||
val library = musicRepository.library ?: return
|
val userLibrary = musicRepository.userLibrary ?: return
|
||||||
L.d("Refreshing playlist choices")
|
L.d("Refreshing playlist choices")
|
||||||
_playlistAddChoices.value =
|
_playlistAddChoices.value =
|
||||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(library.playlists).map {
|
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
|
||||||
val songSet = it.songs.toSet()
|
val songSet = it.songs.toSet()
|
||||||
PlaylistChoice(it, songs.all(songSet::contains))
|
PlaylistChoice(it, songs.all(songSet::contains))
|
||||||
}
|
}
|
||||||
|
@ -354,4 +355,4 @@ sealed interface ChosenName {
|
||||||
* [Playlist].
|
* [Playlist].
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean)
|
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item
|
||||||
|
|
|
@ -30,7 +30,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
||||||
import org.oxycblt.auxio.music.MusicViewModel
|
import org.oxycblt.auxio.music.MusicViewModel
|
||||||
import org.oxycblt.auxio.music.resolve
|
|
||||||
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.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
package org.oxycblt.auxio.music.device
|
||||||
|
|
||||||
|
|
||||||
|
interface AlbumTree {
|
||||||
|
fun register(linkedSong: ArtistTree.LinkedSong): LinkedSong
|
||||||
|
fun resolve(): Collection<AlbumImpl>
|
||||||
|
|
||||||
|
data class LinkedSong(
|
||||||
|
val linkedArtistSong: ArtistTree.LinkedSong,
|
||||||
|
val album: Linked<AlbumImpl, SongImpl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistTree {
|
||||||
|
fun register(preSong: GenreTree.LinkedSong): LinkedSong
|
||||||
|
fun resolve(): Collection<ArtistImpl>
|
||||||
|
|
||||||
|
data class LinkedSong(
|
||||||
|
val linkedGenreSong: GenreTree.LinkedSong,
|
||||||
|
val linkedAlbum: LinkedAlbum,
|
||||||
|
val artists: Linked<List<ArtistImpl>, SongImpl>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LinkedAlbum(
|
||||||
|
val preAlbum: PreAlbum,
|
||||||
|
val artists: Linked<List<ArtistImpl>, AlbumImpl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenreTree {
|
||||||
|
fun register(preSong: PreSong): LinkedSong
|
||||||
|
fun resolve(): Collection<GenreImpl>
|
||||||
|
|
||||||
|
data class LinkedSong(
|
||||||
|
val preSong: PreSong,
|
||||||
|
val genres: Linked<List<GenreImpl>, SongImpl>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Linked<P, C> {
|
||||||
|
fun resolve(child: C): P
|
||||||
|
}
|
|
@ -0,0 +1,399 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* DeviceLibrary.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.device
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
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.Song
|
||||||
|
import org.oxycblt.auxio.music.fs.Path
|
||||||
|
import org.oxycblt.auxio.music.fs.contentResolverSafe
|
||||||
|
import org.oxycblt.auxio.music.fs.useQuery
|
||||||
|
import org.oxycblt.auxio.music.info.Name
|
||||||
|
import org.oxycblt.auxio.music.metadata.Separators
|
||||||
|
import org.oxycblt.auxio.util.forEachWithTimeout
|
||||||
|
import org.oxycblt.auxio.util.sendWithTimeout
|
||||||
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
import timber.log.Timber as L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organized music library information obtained from device storage.
|
||||||
|
*
|
||||||
|
* This class allows for the creation of a well-formed music library graph from raw song
|
||||||
|
* information. Instances are immutable. It's generally not expected to create this yourself and
|
||||||
|
* instead use [MusicRepository].
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart
|
||||||
|
*/
|
||||||
|
interface DeviceLibrary {
|
||||||
|
/** All [Song]s in this [DeviceLibrary]. */
|
||||||
|
val songs: Collection<Song>
|
||||||
|
|
||||||
|
/** All [Album]s in this [DeviceLibrary]. */
|
||||||
|
val albums: Collection<Album>
|
||||||
|
|
||||||
|
/** All [Artist]s in this [DeviceLibrary]. */
|
||||||
|
val artists: Collection<Artist>
|
||||||
|
|
||||||
|
/** All [Genre]s in this [DeviceLibrary]. */
|
||||||
|
val genres: Collection<Genre>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a [Song] instance corresponding to the given [Music.UID].
|
||||||
|
*
|
||||||
|
* @param uid The [Music.UID] to search for.
|
||||||
|
* @return The corresponding [Song], or null if one was not found.
|
||||||
|
*/
|
||||||
|
fun findSong(uid: Music.UID): Song?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
|
||||||
|
*
|
||||||
|
* @param context [Context] required to analyze the [Uri].
|
||||||
|
* @param uri [Uri] to search for.
|
||||||
|
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
|
||||||
|
*/
|
||||||
|
fun findSongForUri(context: Context, uri: Uri): Song?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a [Song] instance corresponding to the given [Path].
|
||||||
|
*
|
||||||
|
* @param path [Path] to search for.
|
||||||
|
* @return A [Song] corresponding to the given [Path], or null if one could not be found.
|
||||||
|
*/
|
||||||
|
fun findSongByPath(path: Path): Song?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a [Album] instance corresponding to the given [Music.UID].
|
||||||
|
*
|
||||||
|
* @param uid The [Music.UID] to search for.
|
||||||
|
* @return The corresponding [Album], or null if one was not found.
|
||||||
|
*/
|
||||||
|
fun findAlbum(uid: Music.UID): Album?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a [Artist] instance corresponding to the given [Music.UID].
|
||||||
|
*
|
||||||
|
* @param uid The [Music.UID] to search for.
|
||||||
|
* @return The corresponding [Artist], or null if one was not found.
|
||||||
|
*/
|
||||||
|
fun findArtist(uid: Music.UID): Artist?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a [Genre] instance corresponding to the given [Music.UID].
|
||||||
|
*
|
||||||
|
* @param uid The [Music.UID] to search for.
|
||||||
|
* @return The corresponding [Genre], or null if one was not found.
|
||||||
|
*/
|
||||||
|
fun findGenre(uid: Music.UID): Genre?
|
||||||
|
|
||||||
|
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
|
||||||
|
interface Factory {
|
||||||
|
/**
|
||||||
|
* Creates a new [DeviceLibrary] instance asynchronously based on the incoming stream of
|
||||||
|
* [RawSong] instances.
|
||||||
|
*
|
||||||
|
* @param rawSongs A stream of [RawSong] instances to process.
|
||||||
|
* @param processedSongs A stream of [RawSong] instances that will have been processed by
|
||||||
|
* the instance.
|
||||||
|
*/
|
||||||
|
suspend fun create(
|
||||||
|
rawSongs: Channel<RawSong>,
|
||||||
|
processedSongs: Channel<RawSong>,
|
||||||
|
separators: Separators,
|
||||||
|
nameFactory: Name.Known.Factory
|
||||||
|
): DeviceLibrary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceLibraryFactoryImpl2 @Inject constructor(
|
||||||
|
val interpreterFactory: Interpreter.Factory
|
||||||
|
) : DeviceLibrary.Factory {
|
||||||
|
override suspend fun create(
|
||||||
|
rawSongs: Channel<RawSong>,
|
||||||
|
processedSongs: Channel<RawSong>,
|
||||||
|
separators: Separators,
|
||||||
|
nameFactory: Name.Known.Factory
|
||||||
|
): DeviceLibrary {
|
||||||
|
val interpreter = interpreterFactory.create(nameFactory, separators)
|
||||||
|
rawSongs.forEachWithTimeout { rawSong ->
|
||||||
|
interpreter.consume(rawSong)
|
||||||
|
processedSongs.sendWithTimeout(rawSong)
|
||||||
|
}
|
||||||
|
return interpreter.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
|
||||||
|
override suspend fun create(
|
||||||
|
rawSongs: Channel<RawSong>,
|
||||||
|
processedSongs: Channel<RawSong>,
|
||||||
|
separators: Separators,
|
||||||
|
nameFactory: Name.Known.Factory
|
||||||
|
): DeviceLibrary {
|
||||||
|
val songGrouping = mutableMapOf<Music.UID, SongImpl>()
|
||||||
|
val albumGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawAlbum, SongImpl>>>()
|
||||||
|
val artistGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawArtist, Music>>>()
|
||||||
|
val genreGrouping = mutableMapOf<String?, Grouping<RawGenre, SongImpl>>()
|
||||||
|
|
||||||
|
// All music information is grouped as it is indexed by other components.
|
||||||
|
rawSongs.forEachWithTimeout { rawSong ->
|
||||||
|
val song = SongImpl(rawSong, nameFactory, separators)
|
||||||
|
// At times the indexer produces duplicate songs, try to filter these. Comparing by
|
||||||
|
// UID is sufficient for something like this, and also prevents collisions from
|
||||||
|
// causing severe issues elsewhere.
|
||||||
|
if (songGrouping.containsKey(song.uid)) {
|
||||||
|
L.w(
|
||||||
|
"Duplicate song found: ${song.path} " +
|
||||||
|
"collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}")
|
||||||
|
// We still want to say that we "processed" the song so that the user doesn't
|
||||||
|
// get confused at why the bar was only partly filled by the end of the loading
|
||||||
|
// process.
|
||||||
|
processedSongs.sendWithTimeout(rawSong)
|
||||||
|
return@forEachWithTimeout
|
||||||
|
}
|
||||||
|
songGrouping[song.uid] = song
|
||||||
|
|
||||||
|
// Group the new song into an album.
|
||||||
|
appendToMusicBrainzIdTree(song, song.rawAlbum, albumGrouping) { old, new ->
|
||||||
|
compareSongTracks(old, new)
|
||||||
|
}
|
||||||
|
// Group the song into each of it's artists.
|
||||||
|
for (rawArtist in song.rawArtists) {
|
||||||
|
appendToMusicBrainzIdTree(song, rawArtist, artistGrouping) { old, new ->
|
||||||
|
// Artist information from earlier dates is prioritized, as it is less likely to
|
||||||
|
// change with the addition of new tracks. Fall back to the name otherwise.
|
||||||
|
check(old is SongImpl) // This should always be the case.
|
||||||
|
compareSongDates(old, new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group the song into each of it's genres.
|
||||||
|
for (rawGenre in song.rawGenres) {
|
||||||
|
appendToNameTree(song, rawGenre, genreGrouping) { old, new -> new.name < old.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
processedSongs.sendWithTimeout(rawSong)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that all songs are processed, also process albums and group them into their
|
||||||
|
// respective artists.
|
||||||
|
pruneMusicBrainzIdTree(albumGrouping) { old, new -> compareSongTracks(old, new) }
|
||||||
|
val albums = flattenMusicBrainzIdTree(albumGrouping) { AlbumImpl(it, nameFactory) }
|
||||||
|
for (album in albums) {
|
||||||
|
for (rawArtist in album.rawArtists) {
|
||||||
|
appendToMusicBrainzIdTree(album, rawArtist, artistGrouping) { old, new ->
|
||||||
|
when (old) {
|
||||||
|
// Immediately replace any songs that initially held the priority position.
|
||||||
|
is SongImpl -> true
|
||||||
|
is AlbumImpl -> {
|
||||||
|
compareAlbumDates(old, new)
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artists and genres do not need to be grouped and can be processed immediately.
|
||||||
|
pruneMusicBrainzIdTree(artistGrouping) { old, new ->
|
||||||
|
when {
|
||||||
|
// Immediately replace any songs that initially held the priority position.
|
||||||
|
old is SongImpl && new is AlbumImpl -> true
|
||||||
|
old is AlbumImpl && new is SongImpl -> false
|
||||||
|
old is SongImpl && new is SongImpl -> {
|
||||||
|
compareSongDates(old, new)
|
||||||
|
}
|
||||||
|
old is AlbumImpl && new is AlbumImpl -> {
|
||||||
|
compareAlbumDates(old, new)
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val artists = flattenMusicBrainzIdTree(artistGrouping) { ArtistImpl(it, nameFactory) }
|
||||||
|
val genres = flattenNameTree(genreGrouping) { GenreImpl(it, nameFactory) }
|
||||||
|
|
||||||
|
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <R : NameGroupable, O : Music, N : O> appendToNameTree(
|
||||||
|
music: N,
|
||||||
|
raw: R,
|
||||||
|
tree: MutableMap<String?, Grouping<R, O>>,
|
||||||
|
prioritize: (old: O, new: N) -> Boolean,
|
||||||
|
) {
|
||||||
|
val nameKey = raw.name?.lowercase()
|
||||||
|
val body = tree[nameKey]
|
||||||
|
if (body != null) {
|
||||||
|
body.music.add(music)
|
||||||
|
if (prioritize(body.raw.src, music)) {
|
||||||
|
body.raw = PrioritizedRaw(raw, music)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Need to initialize this grouping.
|
||||||
|
tree[nameKey] = Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <R : NameGroupable, O : Music, P : MusicParent> flattenNameTree(
|
||||||
|
tree: MutableMap<String?, Grouping<R, O>>,
|
||||||
|
map: (Grouping<R, O>) -> P
|
||||||
|
): Set<P> = tree.values.mapTo(mutableSetOf()) { map(it) }
|
||||||
|
|
||||||
|
private inline fun <R : MusicBrainzGroupable, O : Music, N : O> appendToMusicBrainzIdTree(
|
||||||
|
music: N,
|
||||||
|
raw: R,
|
||||||
|
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, O>>>,
|
||||||
|
prioritize: (old: O, new: N) -> Boolean,
|
||||||
|
) {
|
||||||
|
val nameKey = raw.name?.lowercase()
|
||||||
|
val musicBrainzIdGroups = tree[nameKey]
|
||||||
|
if (musicBrainzIdGroups != null) {
|
||||||
|
val body = musicBrainzIdGroups[raw.musicBrainzId]
|
||||||
|
if (body != null) {
|
||||||
|
body.music.add(music)
|
||||||
|
if (prioritize(body.raw.src, music)) {
|
||||||
|
body.raw = PrioritizedRaw(raw, music)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Need to initialize this grouping.
|
||||||
|
musicBrainzIdGroups[raw.musicBrainzId] =
|
||||||
|
Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Need to initialize this grouping.
|
||||||
|
tree[nameKey] =
|
||||||
|
mutableMapOf(
|
||||||
|
raw.musicBrainzId to Grouping(PrioritizedRaw(raw, music), mutableSetOf(music)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <R, M : Music> pruneMusicBrainzIdTree(
|
||||||
|
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
|
||||||
|
prioritize: (old: M, new: M) -> Boolean
|
||||||
|
) {
|
||||||
|
for ((_, musicBrainzIdGroups) in tree) {
|
||||||
|
var nullGroup = musicBrainzIdGroups[null]
|
||||||
|
if (nullGroup == null) {
|
||||||
|
// Full MusicBrainz ID tagging. Nothing to do.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Only partial MusicBrainz ID tagging. For the sake of basic sanity, just
|
||||||
|
// collapse all of them into the null group.
|
||||||
|
// TODO: More advanced heuristics eventually (tm)
|
||||||
|
musicBrainzIdGroups
|
||||||
|
.filter { it.key != null }
|
||||||
|
.forEach {
|
||||||
|
val (_, group) = it
|
||||||
|
nullGroup.music.addAll(group.music)
|
||||||
|
if (prioritize(group.raw.src, nullGroup.raw.src)) {
|
||||||
|
nullGroup.raw = group.raw
|
||||||
|
}
|
||||||
|
musicBrainzIdGroups.remove(it.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <R, M : Music, T : MusicParent> flattenMusicBrainzIdTree(
|
||||||
|
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
|
||||||
|
map: (Grouping<R, M>) -> T
|
||||||
|
): Set<T> {
|
||||||
|
val result = mutableSetOf<T>()
|
||||||
|
for ((_, musicBrainzIdGroups) in tree) {
|
||||||
|
for (group in musicBrainzIdGroups.values) {
|
||||||
|
result += map(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareSongTracks(old: SongImpl, new: SongImpl) =
|
||||||
|
new.track != null &&
|
||||||
|
(old.track == null ||
|
||||||
|
new.track < old.track ||
|
||||||
|
(new.track == old.track && new.name < old.name))
|
||||||
|
|
||||||
|
private fun compareAlbumDates(old: AlbumImpl, new: AlbumImpl) =
|
||||||
|
new.dates != null &&
|
||||||
|
(old.dates == null ||
|
||||||
|
new.dates < old.dates ||
|
||||||
|
(new.dates == old.dates && new.name < old.name))
|
||||||
|
|
||||||
|
private fun compareSongDates(old: SongImpl, new: SongImpl) =
|
||||||
|
new.date != null &&
|
||||||
|
(old.date == null ||
|
||||||
|
new.date < old.date ||
|
||||||
|
(new.date == old.date && new.name < old.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Avoid redundant data creation
|
||||||
|
|
||||||
|
class DeviceLibraryImpl(
|
||||||
|
override val songs: Collection<SongImpl>,
|
||||||
|
override val albums: Collection<AlbumImpl>,
|
||||||
|
override val artists: Collection<ArtistImpl>,
|
||||||
|
override val genres: Collection<GenreImpl>
|
||||||
|
) : DeviceLibrary {
|
||||||
|
// Use a mapping to make finding information based on it's UID much faster.
|
||||||
|
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
|
||||||
|
private val songPathMap = buildMap { songs.forEach { put(it.path, it) } }
|
||||||
|
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
|
||||||
|
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
|
||||||
|
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
|
||||||
|
|
||||||
|
// All other music is built from songs, so comparison only needs to check songs.
|
||||||
|
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
|
||||||
|
|
||||||
|
override fun hashCode() = songs.hashCode()
|
||||||
|
|
||||||
|
override fun toString() =
|
||||||
|
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " +
|
||||||
|
"artists=${artists.size}, genres=${genres.size})"
|
||||||
|
|
||||||
|
override fun findSong(uid: Music.UID): Song? = songUidMap[uid]
|
||||||
|
|
||||||
|
override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid]
|
||||||
|
|
||||||
|
override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid]
|
||||||
|
|
||||||
|
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
|
||||||
|
|
||||||
|
override fun findSongByPath(path: Path) = songPathMap[path]
|
||||||
|
|
||||||
|
override fun findSongForUri(context: Context, uri: Uri) =
|
||||||
|
context.contentResolverSafe.useQuery(
|
||||||
|
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
|
||||||
|
// song. Do what we can to hopefully find the song the user wanted to open.
|
||||||
|
val displayName =
|
||||||
|
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||||
|
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
|
||||||
|
songs.find { it.path.name == displayName && it.size == size }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Auxio Project
|
* Copyright (c) 2023 Auxio Project
|
||||||
* JClassRef.cpp is part of Auxio.
|
* DeviceModule.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,19 +16,16 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "JClassRef.h"
|
package org.oxycblt.auxio.music.device
|
||||||
JClassRef::JClassRef(JNIEnv *env, const char *classpath) : env(env) {
|
|
||||||
clazz = env->FindClass(classpath);
|
|
||||||
}
|
|
||||||
|
|
||||||
JClassRef::~JClassRef() {
|
import dagger.Binds
|
||||||
env->DeleteLocalRef(clazz);
|
import dagger.Module
|
||||||
}
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
|
||||||
jmethodID JClassRef::method(const char *name, const char *signature) {
|
@Module
|
||||||
return env->GetMethodID(clazz, name, signature);
|
@InstallIn(SingletonComponent::class)
|
||||||
}
|
interface DeviceModule {
|
||||||
|
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl2): DeviceLibrary.Factory
|
||||||
jclass& JClassRef::operator*() {
|
@Binds fun interpreterFactory(factory: InterpreterFactoryImpl): Interpreter.Factory
|
||||||
return clazz;
|
|
||||||
}
|
}
|
|
@ -0,0 +1,335 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 Auxio Project
|
||||||
|
* DeviceMusicImpl.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.device
|
||||||
|
|
||||||
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.image.extractor.ParentCover
|
||||||
|
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.MusicType
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
|
import org.oxycblt.auxio.music.info.Date
|
||||||
|
import org.oxycblt.auxio.music.info.Name
|
||||||
|
import org.oxycblt.auxio.util.positiveOrNull
|
||||||
|
import org.oxycblt.auxio.util.update
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library-backed implementation of [Song].
|
||||||
|
*
|
||||||
|
* @param linkedSong The completed [LinkedSong] all metadata van be inferred from
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class SongImpl(linkedSong: LinkedSong) : Song {
|
||||||
|
private val preSong = linkedSong.preSong
|
||||||
|
|
||||||
|
override val uid =
|
||||||
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
|
preSong.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
|
||||||
|
?: Music.UID.auxio(MusicType.SONGS) {
|
||||||
|
// Song UIDs are based on the raw data without parsing so that they remain
|
||||||
|
// consistent across music setting changes. Parents are not held up to the
|
||||||
|
// same standard since grouping is already inherently linked to settings.
|
||||||
|
update(preSong.rawName)
|
||||||
|
update(preSong.preAlbum.rawName)
|
||||||
|
update(preSong.date)
|
||||||
|
|
||||||
|
update(preSong.track)
|
||||||
|
update(preSong.disc?.number)
|
||||||
|
|
||||||
|
update(preSong.preArtists.map { it.rawName })
|
||||||
|
update(preSong.preAlbum.preArtists.map { it.rawName })
|
||||||
|
}
|
||||||
|
override val name = preSong.name
|
||||||
|
override val track = preSong.track
|
||||||
|
override val disc = preSong.disc
|
||||||
|
override val date = preSong.date
|
||||||
|
override val uri = preSong.uri
|
||||||
|
override val cover = preSong.cover
|
||||||
|
override val path = preSong.path
|
||||||
|
override val mimeType = preSong.mimeType
|
||||||
|
override val size = preSong.size
|
||||||
|
override val durationMs = preSong.durationMs
|
||||||
|
override val replayGainAdjustment = preSong.replayGainAdjustment
|
||||||
|
override val dateAdded = preSong.dateAdded
|
||||||
|
override val album = linkedSong.album.resolve(this)
|
||||||
|
override val artists = linkedSong.artists.resolve(this)
|
||||||
|
override val genres = linkedSong.genres.resolve(this)
|
||||||
|
|
||||||
|
private val hashCode = 31 * uid.hashCode() + preSong.hashCode()
|
||||||
|
|
||||||
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
|
override fun equals(other: Any?) =
|
||||||
|
other is SongImpl &&
|
||||||
|
uid == other.uid &&
|
||||||
|
preSong == other.preSong
|
||||||
|
|
||||||
|
override fun toString() = "Song(uid=$uid, name=$name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library-backed implementation of [Album].
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
|
||||||
|
private val preAlbum = linkedAlbum.preAlbum
|
||||||
|
|
||||||
|
override val uid =
|
||||||
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
|
preAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
|
||||||
|
?: Music.UID.auxio(MusicType.ALBUMS) {
|
||||||
|
// Hash based on only names despite the presence of a date to increase stability.
|
||||||
|
// I don't know if there is any situation where an artist will have two albums with
|
||||||
|
// the exact same name, but if there is, I would love to know.
|
||||||
|
update(preAlbum.rawName)
|
||||||
|
update(preAlbum.preArtists.map { it.rawName })
|
||||||
|
}
|
||||||
|
override val name = preAlbum.name
|
||||||
|
override val releaseType = preAlbum.releaseType
|
||||||
|
override var durationMs = 0L
|
||||||
|
override var dateAdded = 0L
|
||||||
|
override lateinit var cover: ParentCover
|
||||||
|
override var dates: Date.Range? = null
|
||||||
|
|
||||||
|
override val artists = linkedAlbum.artists.resolve(this)
|
||||||
|
override val songs = mutableSetOf<Song>()
|
||||||
|
|
||||||
|
private var hashCode = 31 * uid.hashCode() + preAlbum.hashCode()
|
||||||
|
|
||||||
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
|
// Since equality on public-facing music models is not identical to the tag equality,
|
||||||
|
// we just compare raw instances and how they are interpreted.
|
||||||
|
override fun equals(other: Any?) =
|
||||||
|
other is AlbumImpl &&
|
||||||
|
uid == other.uid &&
|
||||||
|
preAlbum == other.preAlbum &&
|
||||||
|
songs == other.songs
|
||||||
|
|
||||||
|
override fun toString() = "Album(uid=$uid, name=$name)"
|
||||||
|
|
||||||
|
fun link(song: SongImpl) {
|
||||||
|
songs.add(song)
|
||||||
|
hashCode = 31 * hashCode + song.hashCode()
|
||||||
|
durationMs += song.durationMs
|
||||||
|
dateAdded = min(dateAdded, song.dateAdded)
|
||||||
|
if (song.date != null) {
|
||||||
|
dates = dates?.let {
|
||||||
|
if (song.date < it.min) Date.Range(song.date, it.max)
|
||||||
|
else if (song.date > it.max) Date.Range(it.min, song.date)
|
||||||
|
else it
|
||||||
|
} ?: Date.Range(song.date, song.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform final validation and organization on this instance.
|
||||||
|
*
|
||||||
|
* @return This instance upcasted to [Album].
|
||||||
|
*/
|
||||||
|
fun finalize(): Album {
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library-backed implementation of [Artist].
|
||||||
|
*
|
||||||
|
* @param grouping [Grouping] to derive the member data from.
|
||||||
|
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class ArtistImpl(
|
||||||
|
grouping: Grouping<RawArtist, Music>,
|
||||||
|
private val nameFactory: Name.Known.Factory
|
||||||
|
) : Artist {
|
||||||
|
private val rawArtist = grouping.raw.inner
|
||||||
|
|
||||||
|
override val uid =
|
||||||
|
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
|
||||||
|
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
|
||||||
|
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
|
||||||
|
override val name =
|
||||||
|
rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
|
||||||
|
?: Name.Unknown(R.string.def_artist)
|
||||||
|
|
||||||
|
override val songs: Set<Song>
|
||||||
|
override val explicitAlbums: Set<Album>
|
||||||
|
override val implicitAlbums: Set<Album>
|
||||||
|
override val durationMs: Long?
|
||||||
|
override val cover: ParentCover
|
||||||
|
|
||||||
|
override lateinit var genres: List<Genre>
|
||||||
|
|
||||||
|
private var hashCode = uid.hashCode()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val distinctSongs = mutableSetOf<Song>()
|
||||||
|
val albumMap = mutableMapOf<Album, Boolean>()
|
||||||
|
|
||||||
|
for (music in grouping.music) {
|
||||||
|
when (music) {
|
||||||
|
is SongImpl -> {
|
||||||
|
music.link(this)
|
||||||
|
distinctSongs.add(music)
|
||||||
|
if (albumMap[music.album] == null) {
|
||||||
|
albumMap[music.album] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AlbumImpl -> {
|
||||||
|
music.link(this)
|
||||||
|
albumMap[music] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> error("Unexpected input music $music in $name ${music::class.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
songs = distinctSongs
|
||||||
|
val albums = albumMap.keys
|
||||||
|
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
|
||||||
|
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
|
||||||
|
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
|
||||||
|
|
||||||
|
val singleCover =
|
||||||
|
when (val src = grouping.raw.src) {
|
||||||
|
is SongImpl -> src.cover
|
||||||
|
is AlbumImpl -> src.cover.single
|
||||||
|
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
|
||||||
|
}
|
||||||
|
cover = ParentCover.from(singleCover, songs)
|
||||||
|
|
||||||
|
hashCode = 31 * hashCode + rawArtist.hashCode()
|
||||||
|
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Append song contents to MusicParent equality so that artists with
|
||||||
|
// the same UID but different songs are not equal.
|
||||||
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
|
// Since equality on public-facing music models is not identical to the tag equality,
|
||||||
|
// we just compare raw instances and how they are interpreted.
|
||||||
|
override fun equals(other: Any?) =
|
||||||
|
other is ArtistImpl &&
|
||||||
|
uid == other.uid &&
|
||||||
|
rawArtist == other.rawArtist &&
|
||||||
|
nameFactory == other.nameFactory &&
|
||||||
|
songs == other.songs
|
||||||
|
|
||||||
|
override fun toString() = "Artist(uid=$uid, name=$name)"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform final validation and organization on this instance.
|
||||||
|
*
|
||||||
|
* @return This instance upcasted to [Artist].
|
||||||
|
*/
|
||||||
|
fun finalize(): Artist {
|
||||||
|
// There are valid artist configurations:
|
||||||
|
// 1. No songs, no implicit albums, some explicit albums
|
||||||
|
// 2. Some songs, no implicit albums, some explicit albums
|
||||||
|
// 3. Some songs, some implicit albums, no implicit albums
|
||||||
|
// 4. Some songs, some implicit albums, some explicit albums
|
||||||
|
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
|
||||||
|
// but I can't be 100% certain.
|
||||||
|
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
|
||||||
|
"Malformed artist $name: Empty"
|
||||||
|
}
|
||||||
|
genres =
|
||||||
|
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||||
|
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
|
||||||
|
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library-backed implementation of [Genre].
|
||||||
|
*
|
||||||
|
* @param grouping [Grouping] to derive the member data from.
|
||||||
|
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
class GenreImpl(
|
||||||
|
grouping: Grouping<RawGenre, SongImpl>,
|
||||||
|
private val nameFactory: Name.Known.Factory
|
||||||
|
) : Genre {
|
||||||
|
private val rawGenre = grouping.raw.inner
|
||||||
|
|
||||||
|
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
|
||||||
|
override val name =
|
||||||
|
rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
|
||||||
|
?: Name.Unknown(R.string.def_genre)
|
||||||
|
|
||||||
|
override val songs: Set<Song>
|
||||||
|
override val artists: Set<Artist>
|
||||||
|
override val durationMs: Long
|
||||||
|
override val cover: ParentCover
|
||||||
|
|
||||||
|
private var hashCode = uid.hashCode()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val distinctArtists = mutableSetOf<Artist>()
|
||||||
|
var totalDuration = 0L
|
||||||
|
|
||||||
|
for (song in grouping.music) {
|
||||||
|
song.link(this)
|
||||||
|
distinctArtists.addAll(song.artists)
|
||||||
|
totalDuration += song.durationMs
|
||||||
|
}
|
||||||
|
|
||||||
|
songs = grouping.music
|
||||||
|
artists = distinctArtists
|
||||||
|
durationMs = totalDuration
|
||||||
|
|
||||||
|
cover = ParentCover.from(grouping.raw.src.cover, songs)
|
||||||
|
|
||||||
|
hashCode = 31 * hashCode + rawGenre.hashCode()
|
||||||
|
hashCode = 31 * hashCode + nameFactory.hashCode()
|
||||||
|
hashCode = 31 * hashCode + songs.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode() = hashCode
|
||||||
|
|
||||||
|
override fun equals(other: Any?) =
|
||||||
|
other is GenreImpl &&
|
||||||
|
uid == other.uid &&
|
||||||
|
rawGenre == other.rawGenre &&
|
||||||
|
nameFactory == other.nameFactory &&
|
||||||
|
songs == other.songs
|
||||||
|
|
||||||
|
override fun toString() = "Genre(uid=$uid, name=$name)"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform final validation and organization on this instance.
|
||||||
|
*
|
||||||
|
* @return This instance upcasted to [Genre].
|
||||||
|
*/
|
||||||
|
fun finalize(): Genre {
|
||||||
|
check(songs.isNotEmpty()) { "Malformed genre $name: Empty" }
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue