commit
b7bc0a6206
308 changed files with 16168 additions and 10144 deletions
5
.github/CONTRIBUTING.md
vendored
5
.github/CONTRIBUTING.md
vendored
|
|
@ -21,7 +21,7 @@ These should also be logged in the [Issues](https://github.com/OxygenCobalt/Auxi
|
|||
Please keep in mind when requesting a feature:
|
||||
- **Has it already been requested?** Make sure request for this feature is not already here.
|
||||
- **Has it been already added?** Make sure this feature has not already been added in the most recent release.
|
||||
- **Will it be accepted?** Read the [Accepted Additions and Requests](../info/ADDITIONS.md) in order to see the likelihood that your request will be implemented.
|
||||
- **Will it be accepted?** Read the [Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F) in order to see the likelihood that your request will be implemented.
|
||||
|
||||
If you do make a request, provide the following:
|
||||
- What is it that you want?
|
||||
|
|
@ -29,7 +29,7 @@ If you do make a request, provide the following:
|
|||
- Why do you think it will benefit everyone's usage of the app?
|
||||
|
||||
If you have the knowledge, you can also implement the feature yourself and create a [Pull Request](https://github.com/OxygenCobalt/Auxio/pulls), but its recommended that **you create an issue beforehand to give me a heads up.**
|
||||
Its also recommended that you read about [Auxio's Architecture](../info/ARCHITECTURE.md) as well to make changes better and more efficient.
|
||||
Its also recommended that you read about [Auxio's Architecture](https://github.com/OxygenCobalt/Auxio/wiki/Architecture) as well to make changes better and more efficient.
|
||||
|
||||
## Translations
|
||||
Go to Auxio's weblate project [here](https://hosted.weblate.org/engage/auxio/).
|
||||
|
|
@ -44,4 +44,3 @@ If you have knowledge of Android/Kotlin, feel free to to contribute to the proje
|
|||
- Please ***FULLY TEST*** your changes before creating a PR. Untested code will not be merged.
|
||||
- Java code will **NOT** be accepted. Kotlin only.
|
||||
- Keep your code up the date with the upstream and continue to maintain it after you create the PR. This makes it less of a hassle to merge.
|
||||
- Make sure you have read about the [Accepted Additions and Requests](../info/ADDITIONS.md) before working on your addition.
|
||||
|
|
|
|||
47
.github/ISSUE_TEMPLATE/bug-crash-report.md
vendored
47
.github/ISSUE_TEMPLATE/bug-crash-report.md
vendored
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
name: Bug/Crash Report
|
||||
about: Report an issue with Auxio
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
#### Describe the bug/crash:
|
||||
<!-- A clear and concise description of what the bug or crash is. -->
|
||||
|
||||
#### Expected behavior
|
||||
<!-- Include behavior of other android music players, if applicable. -->
|
||||
|
||||
#### Steps To Reproduce the bug/crash:
|
||||
<!--
|
||||
1. Go to X
|
||||
2. Click on Y
|
||||
3. Scroll down to Z
|
||||
4. See error
|
||||
-->
|
||||
|
||||
#### Logs/Stack Traces:
|
||||
<!--
|
||||
If possible, provide a stack trace or a Logcat. This can help identify the issue.
|
||||
To take a logcat, you must do the following:
|
||||
1. Use a desktop/laptop to download the android platform tools from https://developer.android.com/studio/releases/platform-tools.
|
||||
2. Extract the downloaded file to a folder.
|
||||
3. Enable USB debugging on your phone [See https://developer.android.com/studio/command-line/adb#Enabling], and then connect your
|
||||
phone to a laptop. You will get a prompt to "Allow USB debugging" when you run the logcat command. Accept this.
|
||||
4. Open up a terminal/command prompt in that folder and run:
|
||||
- `./adb -d logcat | grep -i "[DWE] Auxio"` in the case of a bug [may require some changes on windows]
|
||||
- `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash
|
||||
5. Copy and paste the output to this area of the issue.
|
||||
-->
|
||||
|
||||
#### Screenshots:
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
#### Phone Information:
|
||||
<!-- Please provide information about your phone's manufacturer, model, android version, and skin. -->
|
||||
|
||||
#### Due Diligence:
|
||||
- [ ] I have checked this issue for any duplicates.
|
||||
- [ ] I have checked for this issue in the [FAQ](https://github.com/OxygenCobalt/Auxio/blob/dev/info/FAQ.md).
|
||||
- [ ] I have read the [Contribution Guidelines](https://github.com/OxygenCobalt/Auxio/blob/dev/.github/CONTRIBUTING.md).
|
||||
88
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
Normal file
88
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: Bug/Crash Report
|
||||
description: Report a problem
|
||||
labels: ["bug"]
|
||||
assignees:
|
||||
- OxygenCobalt
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Welcome to Auxio's bug report form.
|
||||
Please note that not every reported issue can be fixed. Well-written bug reports are more likely to be resolved.
|
||||
- type: textarea
|
||||
id: desc
|
||||
attributes:
|
||||
label: Describe the Bug/Crash
|
||||
description: Provide a clear and concise description of the issue alongside steps to reproduce it.
|
||||
placeholder: |
|
||||
1. Go to X
|
||||
2. Click on Y
|
||||
3. Scroll down to Z
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: intended
|
||||
attributes:
|
||||
label: Describe the intended behavior
|
||||
description: Provide a clear and concise descripton of the correct behavior. Include examples from other music players if applicable.
|
||||
placeholder: Should do X.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: android-version
|
||||
attributes:
|
||||
label: What android version do you use?
|
||||
options:
|
||||
- Android 13
|
||||
- Android 12L
|
||||
- Android 12
|
||||
- Android 11
|
||||
- Android 10
|
||||
- Android 9 (Pie)
|
||||
- Android 8.1 (Oreo)
|
||||
- Android 8 (Oreo)
|
||||
- Android 7 (Nougat)
|
||||
- Android 6 (Marshmallow)
|
||||
- Android 5.1 (Lollipop)
|
||||
- Android 5 (Lollipop)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: devide-model
|
||||
attributes:
|
||||
label: What device model do you use?
|
||||
description: Include details on OEM Skin or Custom ROM if possible.
|
||||
placeholder: OnePlus 7T (LineageOS)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: |
|
||||
If possible, provide a stack trace or a Logcat. This can help identify the issue.
|
||||
To take a logcat, you must do the following:
|
||||
1. Use a desktop/laptop to download the android platform tools from [here](https://developer.android.com/studio/releases/platform-tools).
|
||||
2. Extract the downloaded file to a folder.
|
||||
3. Enable USB debugging on your phone [Instructions](https://developer.android.com/studio/command-line/adb#Enabling), and then connect your
|
||||
phone to a laptop. You will get a prompt to "Allow USB debugging" when you run the logcat command. Accept this.
|
||||
4. Open up a terminal/command prompt in that folder and run:
|
||||
- `./adb -d logcat | grep -i "[DWE] Auxio"` in the case of a bug (may require some changes on windows)
|
||||
- `./adb -d logcat AndroidRuntime:E *:S` in the case of a crash
|
||||
5. Copy and paste the output to this area of the issue.
|
||||
render: shell
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Duplicates
|
||||
description: By submitting this issue, you aknowledge the following
|
||||
options:
|
||||
- label: I have checked the [Troubleshooting](https://github.com/OxygenCobalt/Auxio/wiki/Troubleshooting) page.
|
||||
required: true
|
||||
- label: I have checked this issue for duplicates.
|
||||
required: true
|
||||
- label: I have checked that this issue occurs on the [lastest version](https://github.com/OxygenCobalt/Auxio/releases).
|
||||
required: true
|
||||
- label: I agree to the [Contribution Guidelines](https://github.com/OxygenCobalt/Auxio/blob/dev/.github/CONTRIBUTING.md).
|
||||
required: true
|
||||
33
.github/ISSUE_TEMPLATE/feature-request.md
vendored
33
.github/ISSUE_TEMPLATE/feature-request.md
vendored
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
name: Feature Request
|
||||
about: Propose an idea for Auxio
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
!!! PLEASE READ THIS BEFORE WRITING YOUR ISSUE !!!
|
||||
There have been several major features that Auxio has already rejected, due to either technical
|
||||
issues or due to it not being in scope for the app. To ensure that you are not requesting a
|
||||
feature that was already rejected, please go here:
|
||||
https://github.com/OxygenCobalt/Auxio/issues?q=label%3Awontadd-technical%2Cwontadd-out-of-scope+
|
||||
-->
|
||||
|
||||
#### Describe the feature you want to implement:
|
||||
<!-- A clear and concise description of what you want to be added. -->
|
||||
|
||||
#### Is your feature request related to a problem? Please describe:
|
||||
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when... -->
|
||||
|
||||
#### Do other music players handle this? If so, how?
|
||||
<!-- This is optional, but recommended. -->
|
||||
|
||||
#### Why do you think this will improve everyone's usage of Auxio?
|
||||
<!-- Providing a good argument may convince me to approve this feature. -->
|
||||
|
||||
#### Due Diligence:
|
||||
- [ ] I have read the [Contribution Guidelines](https://github.com/OxygenCobalt/Auxio/blob/dev/.github/CONTRIBUTING.md).
|
||||
- [ ] I have read the [Accepted Additions and Requests](https://github.com/OxygenCobalt/Auxio/blob/dev/info/ADDITIONS.md) document.
|
||||
- [ ] I have checked for this feature in the [FAQ](https://github.com/OxygenCobalt/Auxio/blob/dev/info/FAQ.md).
|
||||
57
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
57
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: Feature Request
|
||||
description: Propose new functionality
|
||||
labels: ["enhancement"]
|
||||
assignees:
|
||||
- OxygenCobalt
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Welcome to Auxio's feature request form.
|
||||
Please note that there have been several major features that Auxio has already **rejected**, due to either technical issues or due to it not being in scope for the app. To ensure that you are not requesting a feature that was already rejected, please read the [Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F) page.
|
||||
- type: textarea
|
||||
id: desc
|
||||
attributes:
|
||||
label: Description
|
||||
description: Provide a clear and concise description of the feature to implement.
|
||||
placeholder: I want...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem solved
|
||||
description: Is your feature request related to a problem? Please describe.
|
||||
placeholder: I'm always frustrated when...
|
||||
- type: textarea
|
||||
id: others
|
||||
attributes:
|
||||
label: Other implementations
|
||||
description: How do other music players handle this? Please describe.
|
||||
placeholder: This music player does...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Benefit
|
||||
description: >
|
||||
How will this addition benefit **everyone's** usage of Auxio? A convincing argument increases the likelihood
|
||||
that this feature is accepted.
|
||||
placeholder: This feature allows...
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Duplicates
|
||||
description: By submitting this issue, you aknowledge the following
|
||||
options:
|
||||
- label: I have checked this feature request for duplicates.
|
||||
required: true
|
||||
- label: I have checked that this feature is not implemented in the [lastest version](https://github.com/OxygenCobalt/Auxio/releases).
|
||||
required: true
|
||||
- label: I have checked the [Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F) page.
|
||||
required: true
|
||||
- label: I agree to the [Contribution Guidelines](https://github.com/OxygenCobalt/Auxio/blob/dev/.github/CONTRIBUTING.md).
|
||||
required: true
|
||||
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -3,7 +3,6 @@
|
|||
#### What is it?
|
||||
- [ ] Bugfix (user facing)
|
||||
- [ ] Feature (user facing)
|
||||
- [ ] Translation to: <!-- Include the language here. Note if this translation is for a regional dialect. --> (user facing)
|
||||
- [ ] Codebase improvement (dev facing)
|
||||
- [ ] Meta improvement to the project (dev facing)
|
||||
|
||||
|
|
@ -25,4 +24,4 @@ debug.zip
|
|||
|
||||
#### Due Diligence
|
||||
- [ ] I have read the [Contribution Guidelines](https://github.com/OxygenCobalt/Auxio/blob/dev/.github/CONTRIBUTING.md).
|
||||
- [ ] I have read the [Accepted additions & Requests](https://github.com/OxygenCobalt/Auxio/blob/dev/info/ADDITIONS.md) document.
|
||||
- [ ] I have read the [Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F) page.
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
|
|
@ -2,6 +2,57 @@
|
|||
|
||||
## dev
|
||||
|
||||
## 3.0.0
|
||||
|
||||
#### What's New
|
||||
- Added multi-value tags support
|
||||
- Added support for multiple artists
|
||||
- Added support for multiple genres
|
||||
- Artists and album artists are now both given UI entires
|
||||
- Added setting to hide "collaborator" artists
|
||||
- Upgraded music ID management:
|
||||
- Added support for MusicBrainz IDs (MBIDs)
|
||||
- Use the more unique MD5 hash of metadata when MBIDs can't be used
|
||||
- Genres now display a list of artists
|
||||
- Added toggle to load non-music (Such as podcasts)
|
||||
- Music loader now caches parsed metadata for faster load times
|
||||
- Redesigned icon
|
||||
- Added animated splash screen on Android 12+
|
||||
- Added support for MP4 ReplayGain (`----`) atoms
|
||||
|
||||
#### What's Improved
|
||||
- Sorting now takes accented characters into account
|
||||
- Added support for compilation sub-release-types like (DJ) Mix
|
||||
- Album dates now start from the earliest date instead of latest date
|
||||
- Reshuffling the queue will no longer drop any songs you have added/removed
|
||||
- Allowed light/dark theme to be customized on Android 12+
|
||||
- All information now scrolls in the playback view
|
||||
- A month is now shown for song/album dates when available
|
||||
- Added loading indicator to song properties view
|
||||
- List items have been made more compact
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed issue where the scroll popup would not display correctly in landscape mode [#230]
|
||||
- Fixed issue where the playback progress would continue in the notification when
|
||||
audio focus was lost
|
||||
- Fixed issue where the artist name would not be shown in the OS audio switcher menu
|
||||
- Fixed issue where the search view would not update if the library changed
|
||||
- Fixed visual bug with transitions in the black theme
|
||||
- Fixed toolbar flickering when fast-scrolling in the home UI
|
||||
|
||||
#### What's Changed
|
||||
- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed.
|
||||
- Removed the "Play from genre" option in the library/detail playback mode settings+
|
||||
- "Use alternate notification action" is now "Custom notification action"
|
||||
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"
|
||||
|
||||
#### Dev/Meta
|
||||
- Created new wiki with more information about app functionality
|
||||
- Switched to issue forms
|
||||
- Completed migration to reactive playback system
|
||||
- Refactor music backends into a unified chain of extractors
|
||||
- Add bluetooth connection receiver (No functionality in app yet)
|
||||
|
||||
## 2.6.4
|
||||
|
||||
#### What's Fixed
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v2.6.4">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v2.6.4&color=0D5AF5">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.0">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.0&color=0D5AF5">
|
||||
</a>
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
</a>
|
||||
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-32B5ED">
|
||||
</p>
|
||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="/info/FAQ.md">FAQ</a> | <a href="/info/LICENSES.md">Licenses</a> | <a href="/.github/CONTRIBUTING.md">Contributing</a> | <a href="/info/ARCHITECTURE.md">Architecture</a></h4>
|
||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
|
||||
<p align="center">
|
||||
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
|
||||
<a href="https://hosted.weblate.org/engage/auxio/"><img src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
## About
|
||||
|
||||
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of <a href="https://exoplayer.dev/">Exoplayer</a>, Auxio has a much better listening experience compared to other apps that use the native MediaPlayer API. In short, **It plays music.**
|
||||
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of <a href="https://exoplayer.dev/">Exoplayer</a>, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
|
||||
|
||||
I primarily built Auxio for myself, but you can use it too, I guess.
|
||||
|
||||
|
|
@ -46,32 +46,25 @@ I primarily built Auxio for myself, but you can use it too, I guess.
|
|||
- Snappy UI derived from the latest Material Design guidelines
|
||||
- Opinionated UX that prioritizes ease of use over edge cases
|
||||
- Customizable behavior
|
||||
- Advanced media indexer that prioritizes correct metadata
|
||||
- Precise/Original Dates, Sort Tags, and Release Type support (Experimental)
|
||||
- Support for disc numbers, multiple artists, release types,
|
||||
precise/original dates, sort tags, and more
|
||||
- Advanced artist system that unifies artists and album artists
|
||||
- SD Card-aware folder management
|
||||
- Reliable playback state persistence
|
||||
- Full ReplayGain support (On MP3, MP4, FLAC, OGG, and OPUS)
|
||||
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
|
||||
- External equalizer support (ex. Wavelet)
|
||||
- Edge-to-edge
|
||||
- Embedded covers support
|
||||
- Search Functionality
|
||||
- Search functionality
|
||||
- Headset autoplay
|
||||
- Stylish widgets that automatically adapt to their size
|
||||
- Completely private and offline
|
||||
- No rounded album covers (Unless you want them. Then you can.)
|
||||
|
||||
## To come in the future:
|
||||
|
||||
- Playlists
|
||||
- Liked songs
|
||||
- Artist Images
|
||||
- More customization options
|
||||
- Other things, probably
|
||||
|
||||
## Permissions
|
||||
|
||||
- Storage (`READ_EXTERNAL_STORAGE`): to read and play your media files
|
||||
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`): to keep the music playing even if the app itself is in background
|
||||
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your media files
|
||||
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background
|
||||
|
||||
## Building
|
||||
|
||||
|
|
@ -97,3 +90,5 @@ will. Specifically you can redistribute and/or modify it under the terms of the
|
|||
[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as
|
||||
published by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
More information can be found [here](https://github.com/OxygenCobalt/Auxio/wiki/Licenses).
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ plugins {
|
|||
id "kotlin-android"
|
||||
id "androidx.navigation.safeargs.kotlin"
|
||||
id "com.diffplug.spotless"
|
||||
id "kotlin-parcelize"
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -11,8 +12,8 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "2.6.4"
|
||||
versionCode 23
|
||||
versionName "3.0.0"
|
||||
versionCode 24
|
||||
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
|
|
@ -23,7 +24,6 @@ android {
|
|||
}
|
||||
|
||||
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
|
|
@ -53,10 +53,6 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
preDebugBuild.dependsOn spotlessApply
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Kotlin
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
|
|
@ -69,9 +65,9 @@ dependencies {
|
|||
// General
|
||||
// 1.4.0 is used in order to avoid a ripple bug in material components
|
||||
implementation "androidx.appcompat:appcompat:1.4.0"
|
||||
implementation "androidx.core:core-ktx:1.8.0"
|
||||
implementation "androidx.activity:activity-ktx:1.6.0-rc01"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.2"
|
||||
implementation "androidx.core:core-ktx:1.9.0"
|
||||
implementation "androidx.activity:activity-ktx:1.6.1"
|
||||
implementation "androidx.fragment:fragment-ktx:1.5.5"
|
||||
|
||||
// UI
|
||||
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
||||
|
|
@ -100,14 +96,18 @@ dependencies {
|
|||
// Exoplayer
|
||||
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE PRE-BUILD SCRIPT.
|
||||
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
|
||||
implementation "com.google.android.exoplayer:exoplayer-core:2.18.1"
|
||||
implementation("com.google.android.exoplayer:exoplayer-core:2.18.2") {
|
||||
exclude group: "com.google.android.exoplayer", module: "exoplayer-extractor"
|
||||
}
|
||||
|
||||
implementation fileTree(dir: "libs", include: ["library-*.aar"])
|
||||
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
|
||||
|
||||
// Image loading
|
||||
implementation "io.coil-kt:coil:2.1.0"
|
||||
|
||||
// Material
|
||||
// Locked below 1.7.0-alpha03 to avoid the same ripple bug
|
||||
implementation "com.google.android.material:material:1.7.0-alpha02"
|
||||
|
||||
// LeakCanary
|
||||
|
|
@ -117,7 +117,7 @@ dependencies {
|
|||
spotless {
|
||||
kotlin {
|
||||
target "src/**/*.kt"
|
||||
ktfmt("0.37").dropboxStyle()
|
||||
ktfmt().dropboxStyle()
|
||||
licenseHeaderFile("NOTICE")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
|
|
@ -20,8 +20,6 @@
|
|||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class org.oxycblt.auxio.AuxioApp
|
||||
-keep class org.oxycblt.auxio.settings.SettingsListFragment
|
||||
|
||||
# Free software does not obsfucate. Also it's easier to debug stack traces.
|
||||
# Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
|
||||
# Also it's easier to debug if the class names remain unmangled.
|
||||
-dontobfuscate
|
||||
|
|
@ -8,6 +8,11 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Bluetooth auto-connect functionality (Disabled until permission workflow can be made) -->
|
||||
<!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />-->
|
||||
<!-- <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:minSdkVersion="31" />-->
|
||||
<!-- <uses-feature android:name="android.hardware.bluetooth" android:required="false"/>-->
|
||||
|
||||
<!-- Work around ExoPlayer requiring network permissions we do not use -->
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_NETWORK_STATE"
|
||||
|
|
@ -70,8 +75,8 @@
|
|||
</activity>
|
||||
|
||||
<!--
|
||||
IndexerService handles querying the media database,
|
||||
extracting metadata, and constructing the music library.
|
||||
Service handling querying the media database, extracting metadata, and constructing
|
||||
the music library.
|
||||
-->
|
||||
<service
|
||||
android:name=".music.system.IndexerService"
|
||||
|
|
@ -81,7 +86,7 @@
|
|||
android:roundIcon="@mipmap/ic_launcher" />
|
||||
|
||||
<!--
|
||||
PlaybackService handles music playback, system components, and state saving.
|
||||
Service handling music playback, system components, and state saving.
|
||||
-->
|
||||
<service
|
||||
android:name=".playback.system.PlaybackService"
|
||||
|
|
@ -102,7 +107,16 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Auxio's one and only AppWidget. -->
|
||||
<!-- Receiver for bluetooth headset events -->
|
||||
<!-- <receiver-->
|
||||
<!-- android:name=".playback.system.BluetoothConnectReceiver"-->
|
||||
<!-- android:exported="true">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- </receiver>-->
|
||||
|
||||
<!-- "Now Playing" widget.. -->
|
||||
<receiver
|
||||
android:name=".widgets.WidgetProvider"
|
||||
android:exported="false"
|
||||
|
|
|
|||
|
|
@ -83,11 +83,11 @@ import java.util.Map;
|
|||
* window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
|
||||
* BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
|
||||
*
|
||||
* Modified at several points by OxygenCobalt to work around miscellaneous insanity.
|
||||
* Modified at several points by Alexander Capehart to work around miscellaneous issues.
|
||||
*/
|
||||
public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
|
||||
|
||||
/** Callback for monitoring events about bottom sheets. */
|
||||
/** Listener for monitoring events about bottom sheets. */
|
||||
public abstract static class BottomSheetCallback {
|
||||
|
||||
/**
|
||||
|
|
@ -1205,9 +1205,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
|||
}
|
||||
|
||||
/**
|
||||
* Sets a callback to be notified of bottom sheet events.
|
||||
* Sets a listener to be notified of bottom sheet events.
|
||||
*
|
||||
* @param callback The callback to notify when bottom sheet events occur.
|
||||
* @param callback The listener to notify when bottom sheet events occur.
|
||||
* @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
|
||||
* #removeBottomSheetCallback(BottomSheetCallback)} instead
|
||||
*/
|
||||
|
|
@ -1227,9 +1227,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
|||
}
|
||||
|
||||
/**
|
||||
* Adds a callback to be notified of bottom sheet events.
|
||||
* Adds a listener to be notified of bottom sheet events.
|
||||
*
|
||||
* @param callback The callback to notify when bottom sheet events occur.
|
||||
* @param callback The listener to notify when bottom sheet events occur.
|
||||
*/
|
||||
public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
|
||||
if (!callbacks.contains(callback)) {
|
||||
|
|
@ -1238,9 +1238,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
|
|||
}
|
||||
|
||||
/**
|
||||
* Removes a previously added callback.
|
||||
* Removes a previously added listener.
|
||||
*
|
||||
* @param callback The callback to remove.
|
||||
* @param callback The listener to remove.
|
||||
*/
|
||||
public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) {
|
||||
callbacks.remove(callback);
|
||||
|
|
|
|||
|
|
@ -25,16 +25,22 @@ import androidx.core.graphics.drawable.IconCompat
|
|||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.request.CachePolicy
|
||||
import org.oxycblt.auxio.image.AlbumCoverFetcher
|
||||
import org.oxycblt.auxio.image.ArtistImageFetcher
|
||||
import org.oxycblt.auxio.image.CrossfadeTransitionFactory
|
||||
import org.oxycblt.auxio.image.GenreImageFetcher
|
||||
import org.oxycblt.auxio.image.MusicKeyer
|
||||
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
|
||||
import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
|
||||
import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
|
||||
import org.oxycblt.auxio.image.extractor.GenreImageFetcher
|
||||
import org.oxycblt.auxio.image.extractor.MusicKeyer
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
|
||||
/**
|
||||
* Auxio: A simple, rational music player for android.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AuxioApp : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Migrate any settings that may have changed in an app update.
|
||||
Settings(this).migrate()
|
||||
// Adding static shortcuts in a dynamic manner is better than declaring them
|
||||
// manually, as it will properly handle the difference between debug and release
|
||||
// Auxio instances.
|
||||
|
|
@ -54,18 +60,23 @@ class AuxioApp : Application(), ImageLoaderFactory {
|
|||
override fun newImageLoader() =
|
||||
ImageLoader.Builder(applicationContext)
|
||||
.components {
|
||||
// Add fetchers for Music components to make them usable with ImageRequest
|
||||
add(MusicKeyer())
|
||||
add(AlbumCoverFetcher.SongFactory())
|
||||
add(AlbumCoverFetcher.AlbumFactory())
|
||||
add(ArtistImageFetcher.Factory())
|
||||
add(GenreImageFetcher.Factory())
|
||||
add(MusicKeyer())
|
||||
}
|
||||
.transitionFactory(CrossfadeTransitionFactory())
|
||||
.diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching
|
||||
// Use our own crossfade with error drawable support
|
||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
||||
// Not downloading anything, so no disk-caching
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
const val SHORTCUT_SHUFFLE_ID = "shortcut_shuffle"
|
||||
/** The [Intent] name for the "Shuffle All" shortcut. */
|
||||
const val INTENT_KEY_SHORTCUT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE_ALL"
|
||||
/** The ID of the "Shuffle All" shortcut. */
|
||||
private const val SHORTCUT_SHUFFLE_ID = "shortcut_shuffle"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@
|
|||
|
||||
package org.oxycblt.auxio
|
||||
|
||||
/** A table containing all unique integer codes that Auxio uses. */
|
||||
/**
|
||||
* A table containing all of the magic integer codes that the codebase has currently reserved. May
|
||||
* be non-contiguous.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
object IntegerTable {
|
||||
/** SongViewHolder */
|
||||
const val VIEW_TYPE_SONG = 0xA000
|
||||
|
|
@ -45,21 +49,18 @@ object IntegerTable {
|
|||
const val VIEW_TYPE_GENRE_DETAIL = 0xA00B
|
||||
/** DiscHeaderViewHolder */
|
||||
const val VIEW_TYPE_DISC_HEADER = 0xA00C
|
||||
|
||||
/** "Music playback" notification code */
|
||||
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
|
||||
/** "Music loading" notification code */
|
||||
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
|
||||
/** Intent request code */
|
||||
/** MainActivity Intent request code */
|
||||
const val REQUEST_CODE = 0xA0C0
|
||||
|
||||
/** RepeatMode.NONE */
|
||||
const val REPEAT_MODE_NONE = 0xA100
|
||||
/** RepeatMode.ALL */
|
||||
const val REPEAT_MODE_ALL = 0xA101
|
||||
/** RepeatMode.TRACK */
|
||||
const val REPEAT_MODE_TRACK = 0xA102
|
||||
|
||||
/** PlaybackMode.IN_GENRE */
|
||||
const val PLAYBACK_MODE_IN_GENRE = 0xA103
|
||||
/** PlaybackMode.IN_ARTIST */
|
||||
|
|
@ -68,21 +69,16 @@ object IntegerTable {
|
|||
const val PLAYBACK_MODE_IN_ALBUM = 0xA105
|
||||
/** PlaybackMode.ALL_SONGS */
|
||||
const val PLAYBACK_MODE_ALL_SONGS = 0xA106
|
||||
|
||||
/** DisplayMode.NONE (No Longer used but still reserved) */
|
||||
// const val DISPLAY_MODE_NONE = 0xA107
|
||||
/** DisplayMode.SHOW_GENRES */
|
||||
const val DISPLAY_MODE_SHOW_GENRES = 0xA108
|
||||
/** DisplayMode.SHOW_ARTISTS */
|
||||
const val DISPLAY_MODE_SHOW_ARTISTS = 0xA109
|
||||
/** DisplayMode.SHOW_ALBUMS */
|
||||
const val DISPLAY_MODE_SHOW_ALBUMS = 0xA10A
|
||||
/** DisplayMode.SHOW_SONGS */
|
||||
const val DISPLAY_MODE_SHOW_SONGS = 0xA10B
|
||||
|
||||
// Note: Sort integer codes are non-contiguous due to significant amounts of time
|
||||
// passing between the additions of new sort modes.
|
||||
|
||||
/** MusicMode._GENRES */
|
||||
const val MUSIC_MODE_GENRES = 0xA108
|
||||
/** MusicMode._ARTISTS */
|
||||
const val MUSIC_MODE_ARTISTS = 0xA109
|
||||
/** MusicMode._ALBUMS */
|
||||
const val MUSIC_MODE_ALBUMS = 0xA10A
|
||||
/** MusicMode.SONGS */
|
||||
const val MUSIC_MODE_SONGS = 0xA10B
|
||||
/** Sort.ByName */
|
||||
const val SORT_BY_NAME = 0xA10C
|
||||
/** Sort.ByArtist */
|
||||
|
|
@ -101,7 +97,6 @@ object IntegerTable {
|
|||
const val SORT_BY_TRACK = 0xA117
|
||||
/** Sort.ByDateAdded */
|
||||
const val SORT_BY_DATE_ADDED = 0xA118
|
||||
|
||||
/** ReplayGainMode.Off (No longer used but still reserved) */
|
||||
// const val REPLAY_GAIN_MODE_OFF = 0xA110
|
||||
/** ReplayGainMode.Track */
|
||||
|
|
@ -110,11 +105,16 @@ object IntegerTable {
|
|||
const val REPLAY_GAIN_MODE_ALBUM = 0xA112
|
||||
/** ReplayGainMode.Dynamic */
|
||||
const val REPLAY_GAIN_MODE_DYNAMIC = 0xA113
|
||||
|
||||
/** BarAction.Next */
|
||||
const val BAR_ACTION_NEXT = 0xA119
|
||||
/** BarAction.Repeat */
|
||||
const val BAR_ACTION_REPEAT = 0xA11A
|
||||
/** BarAction.Shuffle */
|
||||
const val BAR_ACTION_SHUFFLE = 0xA11B
|
||||
/** ActionMode.Next */
|
||||
const val ACTION_MODE_NEXT = 0xA119
|
||||
/** ActionMode.Repeat */
|
||||
const val ACTION_MODE_REPEAT = 0xA11A
|
||||
/** ActionMode.Shuffle */
|
||||
const val ACTION_MODE_SHUFFLE = 0xA11B
|
||||
/** CoverMode.Off */
|
||||
const val COVER_MODE_OFF = 0xA11C
|
||||
/** CoverMode.MediaStore */
|
||||
const val COVER_MODE_MEDIA_STORE = 0xA11D
|
||||
/** CoverMode.Quality */
|
||||
const val COVER_MODE_QUALITY = 0xA11E
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@
|
|||
package org.oxycblt.auxio
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
|
@ -37,7 +36,7 @@ import org.oxycblt.auxio.util.logD
|
|||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* The single [AppCompatActivity] for Auxio.
|
||||
* Auxio's single [AppCompatActivity].
|
||||
*
|
||||
* TODO: Add error screens
|
||||
*
|
||||
|
|
@ -45,22 +44,30 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
*
|
||||
* TODO: Add multi-select
|
||||
*
|
||||
* TODO: Remove asterisk imports
|
||||
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* TODO: Migrate to material animation system
|
||||
*
|
||||
* TODO: Unit testing
|
||||
*
|
||||
* TODO: Standardize from/new usage
|
||||
*
|
||||
* TODO: Standardize companion object usage
|
||||
*
|
||||
* TODO: Standardize callback/listener naming.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val playbackModel: PlaybackViewModel by androidViewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setupTheme()
|
||||
|
||||
// Inflate the views after setting up the theme so that the theme attributes are applied.
|
||||
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupEdgeToEdge(binding.root)
|
||||
|
||||
logD("Activity created")
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +78,7 @@ class MainActivity : AppCompatActivity() {
|
|||
startService(Intent(this, PlaybackService::class.java))
|
||||
|
||||
if (!startIntentAction(intent)) {
|
||||
// No intent action to do, just restore the previously saved state.
|
||||
playbackModel.startAction(InternalPlayer.Action.RestoreState)
|
||||
}
|
||||
}
|
||||
|
|
@ -80,46 +88,12 @@ class MainActivity : AppCompatActivity() {
|
|||
startIntentAction(intent)
|
||||
}
|
||||
|
||||
private fun startIntentAction(intent: Intent?): Boolean {
|
||||
if (intent == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (intent.getBooleanExtra(KEY_INTENT_USED, false)) {
|
||||
// Don't commit the action, but also return that the intent was applied.
|
||||
// This is because onStart can run multiple times, and thus we really don't
|
||||
// want to return false and override the original delayed action with a
|
||||
// RestoreState action.
|
||||
return true
|
||||
}
|
||||
|
||||
intent.putExtra(KEY_INTENT_USED, true)
|
||||
|
||||
val action =
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
||||
AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> {
|
||||
InternalPlayer.Action.ShuffleAll
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
|
||||
playbackModel.startAction(action)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupTheme() {
|
||||
val settings = Settings(this)
|
||||
|
||||
// Disable theme customization above Android 12, as it's far enough in as a version to
|
||||
// the point where most phones should have an option for light/dark theming.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
}
|
||||
|
||||
// The black theme has a completely separate set of styles since style attributes cannot
|
||||
// be modified at runtime.
|
||||
// Apply the theme configuration.
|
||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||
// Apply the color scheme. The black theme requires it's own set of themes since
|
||||
// it's not possible to modify the themes at run-time.
|
||||
if (isNight && settings.useBlackTheme) {
|
||||
logD("Applying black theme [accent ${settings.accent}]")
|
||||
setTheme(settings.accent.blackTheme)
|
||||
|
|
@ -131,14 +105,47 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private fun setupEdgeToEdge(contentView: View) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
contentView.setOnApplyWindowInsetsListener { view, insets ->
|
||||
// Automatically inset the view to the left/right, as component support for
|
||||
// these insets are highly lacking.
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
view.updatePadding(left = bars.left, right = bars.right)
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used
|
||||
* in the playback system.
|
||||
* @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent.
|
||||
* @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started,
|
||||
* false otherwise.
|
||||
*/
|
||||
private fun startIntentAction(intent: Intent?): Boolean {
|
||||
if (intent == null) {
|
||||
// Nothing to do.
|
||||
return false
|
||||
}
|
||||
|
||||
if (intent.getBooleanExtra(KEY_INTENT_USED, false)) {
|
||||
// Don't commit the action, but also return that the intent was applied.
|
||||
// This is because onStart can run multiple times, and thus we really don't
|
||||
// want to return false and override the original delayed action with a
|
||||
// RestoreState action.
|
||||
return true
|
||||
}
|
||||
intent.putExtra(KEY_INTENT_USED, true)
|
||||
|
||||
val action =
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
|
||||
AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
|
||||
else -> return false
|
||||
}
|
||||
playbackModel.startAction(action)
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import androidx.core.view.ViewCompat
|
|||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
|
||||
|
|
@ -34,28 +36,33 @@ import com.google.android.material.transition.MaterialFadeThrough
|
|||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import org.oxycblt.auxio.databinding.FragmentMainBinding
|
||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackSheetBehavior
|
||||
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.queue.QueueSheetBehavior
|
||||
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* A wrapper around the home fragment that shows the playback fragment and controls the more
|
||||
* high-level navigation features.
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MainFragment :
|
||||
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
|
||||
ViewBindingFragment<FragmentMainBinding>(),
|
||||
ViewTreeObserver.OnPreDrawListener,
|
||||
NavController.OnDestinationChangedListener {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
private val selectionModel: SelectionViewModel by activityViewModels()
|
||||
private val callback = DynamicBackPressedCallback()
|
||||
private var lastInsets: WindowInsets? = null
|
||||
|
||||
private var initialNavDestinationChange = true
|
||||
private val elevationNormal: Float by lifecycleObject { binding ->
|
||||
binding.context.getDimen(R.dimen.elevation_normal)
|
||||
}
|
||||
|
|
@ -69,9 +76,12 @@ class MainFragment :
|
|||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
val context = requireActivity()
|
||||
|
||||
// Override the back pressed listener so we can map back navigation to collapsing
|
||||
// navigation, navigation out of detail views, etc.
|
||||
context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
|
||||
|
||||
binding.root.setOnApplyWindowInsetsListener { _, insets ->
|
||||
|
|
@ -85,26 +95,29 @@ class MainFragment :
|
|||
ViewCompat.setAccessibilityPaneTitle(
|
||||
binding.queueSheet, context.getString(R.string.lbl_queue))
|
||||
|
||||
val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
if (queueSheetBehavior != null) {
|
||||
// Bottom sheet mode, set up click listeners.
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
|
||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED &&
|
||||
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) {
|
||||
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
|
||||
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dual-pane mode, color/pad the queue sheet manually.
|
||||
// Dual-pane mode, manually style the static queue sheet.
|
||||
binding.queueSheet.apply {
|
||||
// Emulate the elevated bottom sheet style.
|
||||
background =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
fillColor = context.getAttrColorCompat(R.attr.colorSurface)
|
||||
elevation = context.getDimen(R.dimen.elevation_normal)
|
||||
}
|
||||
|
||||
// Apply bar insets for the queue's RecyclerView to usee.
|
||||
setOnApplyWindowInsetsListener { v, insets ->
|
||||
v.updatePadding(top = insets.systemBarInsetsCompat.top)
|
||||
insets
|
||||
|
|
@ -113,54 +126,63 @@ class MainFragment :
|
|||
}
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
collect(navModel.mainNavigationAction, ::handleMainNavigation)
|
||||
collect(navModel.exploreNavigationItem, ::handleExploreNavigation)
|
||||
collect(navModel.exploreArtistNavigationItem, ::handleArtistNavigationPicker)
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collect(playbackModel.artistPickerSong, ::handlePlaybackArtistPicker)
|
||||
collect(playbackModel.genrePickerSong, ::handlePlaybackGenrePicker)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
// Callback could still reasonably fire even if we clear the binding, attach/detach
|
||||
val binding = requireBinding()
|
||||
// Once we add the destination change callback, we will receive another initialization call,
|
||||
// so handle that by resetting the flag.
|
||||
initialNavDestinationChange = false
|
||||
binding.exploreNavHost.findNavController().addOnDestinationChangedListener(this)
|
||||
// Listener could still reasonably fire even if we clear the binding, attach/detach
|
||||
// our pre-draw listener our listener in onStart/onStop respectively.
|
||||
requireBinding().playbackSheet.viewTreeObserver.addOnPreDrawListener(this)
|
||||
binding.playbackSheet.viewTreeObserver.addOnPreDrawListener(this@MainFragment)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
requireBinding().playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
val binding = requireBinding()
|
||||
binding.exploreNavHost.findNavController().removeOnDestinationChangedListener(this)
|
||||
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
}
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
// CoordinatorLayout is insane and thus makes bottom sheet callbacks insane. Do our
|
||||
// checks before every draw, which is not ideal in the slightest but also has minimal
|
||||
// performance impact since we are only mutating attributes used during drawing.
|
||||
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||
// listener functionality. Just update all transitions before every draw. Should
|
||||
// probably be cheap enough.
|
||||
val binding = requireBinding()
|
||||
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
|
||||
|
||||
val outPlaybackRatio = 1 - playbackRatio
|
||||
val halfOutRatio = min(playbackRatio * 2, 1f)
|
||||
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
|
||||
|
||||
val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
|
||||
|
||||
if (queueSheetBehavior != null) {
|
||||
// Queue sheet, take queue into account so the playback bar is shown and the playback
|
||||
// panel is hidden when the queue sheet is expanded.
|
||||
// Queue sheet available, the normal transition applies, but it now much be combined
|
||||
// with another transition where the playback panel disappears and the playback bar
|
||||
// appears as the queue sheet expands.
|
||||
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
|
||||
val halfOutQueueRatio = min(queueRatio * 2, 1f)
|
||||
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
|
||||
|
||||
binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio)
|
||||
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
|
||||
binding.queueFragment.alpha = queueRatio
|
||||
|
||||
if (playbackModel.song.value != null) {
|
||||
// Hack around the playback sheet intercepting swipe events on the queue bar
|
||||
// Playback sheet intercepts queue sheet touch events, prevent that from
|
||||
// occurring by disabling dragging whenever the queue sheet is expanded.
|
||||
playbackSheetBehavior.isDraggable =
|
||||
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
|
|
@ -170,62 +192,82 @@ class MainFragment :
|
|||
binding.playbackPanelFragment.alpha = halfInPlaybackRatio
|
||||
}
|
||||
|
||||
// Fade out the content as the playback panel expands.
|
||||
// TODO: Replace with shadow?
|
||||
binding.exploreNavHost.apply {
|
||||
alpha = outPlaybackRatio
|
||||
// Prevent interactions when the content fully fades out.
|
||||
isInvisible = alpha == 0f
|
||||
}
|
||||
|
||||
// Reduce playback sheet elevation as it expands. This involves both updating the
|
||||
// shadow elevation for older versions, and fading out the background drawable
|
||||
// containing the elevation overlay.
|
||||
binding.playbackSheet.translationZ = elevationNormal * outPlaybackRatio
|
||||
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt()
|
||||
|
||||
// Fade out the playback bar as the panel expands.
|
||||
binding.playbackBarFragment.apply {
|
||||
// Prevent interactions when the playback bar fully fades out.
|
||||
isInvisible = alpha == 0f
|
||||
// As the playback bar expands, we also want to subtly translate the bar to
|
||||
// align with the top inset. This results in both a smooth transition from the bar
|
||||
// to the playback panel's toolbar, but also a correctly positioned playback bar
|
||||
// for when the queue sheet expands.
|
||||
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
|
||||
}
|
||||
|
||||
// Prevent interactions when the playback panell fully fades out.
|
||||
binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
|
||||
|
||||
binding.queueSheet.apply {
|
||||
// Queue sheet (not queue content) should fade out with the playback panel.
|
||||
alpha = halfInPlaybackRatio
|
||||
// Prevent interactions when the queue sheet fully fades out.
|
||||
binding.queueSheet.isInvisible = alpha == 0f
|
||||
}
|
||||
|
||||
// Prevent interactions when the queue content fully fades out.
|
||||
binding.queueFragment.isInvisible = binding.queueFragment.alpha == 0f
|
||||
|
||||
if (playbackModel.song.value == null) {
|
||||
// Sometimes lingering drags can un-hide the playback sheet even when we intend to
|
||||
// hide it, make sure we keep it hidden.
|
||||
tryHideAll()
|
||||
tryHideAllSheets()
|
||||
}
|
||||
|
||||
// Since the callback is also reliant on the bottom sheets, we must also update it
|
||||
// Since the listener is also reliant on the bottom sheets, we must also update it
|
||||
// every frame.
|
||||
callback.updateEnabledState()
|
||||
callback.invalidateEnabled()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
if (song != null) {
|
||||
tryUnhideAll()
|
||||
} else {
|
||||
tryHideAll()
|
||||
override fun onDestinationChanged(
|
||||
controller: NavController,
|
||||
destination: NavDestination,
|
||||
arguments: Bundle?
|
||||
) {
|
||||
// Drop the initial call by NavController that simply provides us with the current
|
||||
// destination. This would cause the selection state to be lost every time the device
|
||||
// rotates.
|
||||
if (!initialNavDestinationChange) {
|
||||
initialNavDestinationChange = true
|
||||
return
|
||||
}
|
||||
selectionModel.consume()
|
||||
}
|
||||
|
||||
private fun handleMainNavigation(action: MainNavigationAction?) {
|
||||
if (action == null) return
|
||||
if (action == null) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
when (action) {
|
||||
is MainNavigationAction.Expand -> tryExpandAll()
|
||||
is MainNavigationAction.Collapse -> tryCollapseAll()
|
||||
is MainNavigationAction.Settings ->
|
||||
findNavController().navigate(MainFragmentDirections.actionShowSettings())
|
||||
is MainNavigationAction.About ->
|
||||
findNavController().navigate(MainFragmentDirections.actionShowAbout())
|
||||
is MainNavigationAction.SongDetails ->
|
||||
findNavController()
|
||||
.navigate(MainFragmentDirections.actionShowDetails(action.song.id))
|
||||
is MainNavigationAction.Expand -> tryExpandSheets()
|
||||
is MainNavigationAction.Collapse -> tryCollapseSheets()
|
||||
is MainNavigationAction.Directions -> findNavController().navigate(action.directions)
|
||||
}
|
||||
|
||||
navModel.finishMainNavigation()
|
||||
|
|
@ -233,44 +275,75 @@ class MainFragment :
|
|||
|
||||
private fun handleExploreNavigation(item: Music?) {
|
||||
if (item != null) {
|
||||
tryCollapseAll()
|
||||
tryCollapseSheets()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryExpandAll() {
|
||||
private fun handleArtistNavigationPicker(item: Music?) {
|
||||
if (item != null) {
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(
|
||||
MainFragmentDirections.actionPickNavigationArtist(item.uid)))
|
||||
navModel.finishExploreNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
if (song != null) {
|
||||
tryShowSheets()
|
||||
} else {
|
||||
tryHideAllSheets()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlaybackArtistPicker(song: Song?) {
|
||||
if (song != null) {
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(
|
||||
MainFragmentDirections.actionPickPlaybackArtist(song.uid)))
|
||||
playbackModel.finishPlaybackArtistPicker()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlaybackGenrePicker(song: Song?) {
|
||||
if (song != null) {
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(
|
||||
MainFragmentDirections.actionPickPlaybackGenre(song.uid)))
|
||||
playbackModel.finishPlaybackArtistPicker()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryExpandSheets() {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) {
|
||||
// State is collapsed and non-hidden, expand
|
||||
// Playback sheet is not expanded and not hidden, we can expand it.
|
||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryCollapseAll() {
|
||||
private fun tryCollapseSheets() {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Make sure the queue is also collapsed here.
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
|
||||
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryUnhideAll() {
|
||||
private fun tryShowSheets() {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) {
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
// Queue sheet behavior is either collapsed or expanded, no hiding needed
|
||||
queueSheetBehavior?.isDraggable = true
|
||||
|
|
@ -283,17 +356,15 @@ class MainFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun tryHideAll() {
|
||||
private fun tryHideAllSheets() {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
|
||||
|
||||
// Make these views non-draggable so the user can't halt the hiding event.
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
|
||||
queueSheetBehavior?.apply {
|
||||
isDraggable = false
|
||||
state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
|
|
@ -307,47 +378,56 @@ class MainFragment :
|
|||
}
|
||||
|
||||
/**
|
||||
* A back press callback that handles how to respond to backwards navigation in the detail
|
||||
* fragments and the playback panel.
|
||||
* A [OnBackPressedCallback] that overrides the back button to first navigate out of internal
|
||||
* app components, such as the Bottom Sheets or Explore Navigation.
|
||||
*/
|
||||
private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
|
||||
// If expanded, collapse the queue sheet first.
|
||||
if (queueSheetBehavior != null &&
|
||||
queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
|
||||
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
|
||||
// Collapse the queue first if it is expanded.
|
||||
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
return
|
||||
}
|
||||
|
||||
// If expanded, collapse the playback sheet next.
|
||||
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
|
||||
playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
|
||||
// Then collapse the playback sheet.
|
||||
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
|
||||
return
|
||||
}
|
||||
|
||||
// Then try to navigate out of the explore navigation fragments (i.e Detail Views)
|
||||
binding.exploreNavHost.findNavController().navigateUp()
|
||||
}
|
||||
|
||||
fun updateEnabledState() {
|
||||
/**
|
||||
* Force this instance to update whether it's enabled or not. If there are no app components
|
||||
* that the back button should close first, the instance is disabled and back navigation is
|
||||
* delegated to the system.
|
||||
*
|
||||
* Normally, this listener would have just called the [MainActivity.onBackPressed] if there
|
||||
* were no components to close, but that prevents adaptive back navigation from working on
|
||||
* Android 14+, so we must do it this way.
|
||||
*/
|
||||
fun invalidateEnabled() {
|
||||
val binding = requireBinding()
|
||||
val playbackSheetBehavior =
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
|
||||
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
|
||||
val queueSheetBehavior =
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
|
||||
|
||||
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
|
||||
val exploreNavController = binding.exploreNavHost.findNavController()
|
||||
|
||||
isEnabled =
|
||||
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
|
||||
queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
|
||||
queueSheetBehavior?.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
|
||||
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED ||
|
||||
exploreNavController.currentDestination?.id !=
|
||||
exploreNavController.graph.startDestinationId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,10 @@
|
|||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.children
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
|
|
@ -32,40 +29,38 @@ import com.google.android.material.transition.MaterialSharedAxis
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.PlaybackMode
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.fragment.MenuFragment
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.util.canScroll
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* A fragment that shows information for a particular [Album].
|
||||
* @author OxygenCobalt
|
||||
* A [ListFragment] that shows information about an [Album].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumDetailFragment :
|
||||
MenuFragment<FragmentDetailBinding>(),
|
||||
Toolbar.OnMenuItemClickListener,
|
||||
AlbumDetailAdapter.Listener {
|
||||
class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
// Information about what album to display is initially within the navigation arguments
|
||||
// as a UID, as that is the only safe way to parcel an album.
|
||||
private val args: AlbumDetailFragmentArgs by navArgs()
|
||||
private val detailAdapter = AlbumDetailAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Detail transitions are always on the X axis. Shared element transitions are more
|
||||
// semantically correct, but are also too buggy to be sensible.
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
|
|
@ -74,9 +69,13 @@ class AlbumDetailFragment :
|
|||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
detailModel.setAlbumId(args.albumId)
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP --
|
||||
binding.detailToolbar.apply {
|
||||
inflateMenu(R.menu.menu_album_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
|
@ -86,12 +85,14 @@ class AlbumDetailFragment :
|
|||
binding.detailRecycler.adapter = detailAdapter
|
||||
|
||||
// -- VIEWMODEL SETUP ---
|
||||
|
||||
collectImmediately(detailModel.currentAlbum, ::handleItemChange)
|
||||
collectImmediately(detailModel.albumData, detailAdapter::submitList)
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setAlbumUid(args.albumUid)
|
||||
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
|
||||
collectImmediately(detailModel.albumList, detailAdapter::submitList)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
|
|
@ -101,50 +102,58 @@ class AlbumDetailFragment :
|
|||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentAlbum = unlikelyToBeNull(detailModel.currentAlbum.value)
|
||||
return when (item.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
playbackModel.playNext(currentAlbum)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
playbackModel.addToQueue(currentAlbum)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artist)
|
||||
onNavigateToParentArtist()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
if (item is Song) {
|
||||
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_ALBUM)
|
||||
override fun onRealClick(music: Music) {
|
||||
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
|
||||
when (Settings(requireContext()).detailPlaybackMode) {
|
||||
// "Play from shown item" and "Play from album" functionally have the same
|
||||
// behavior since a song can only have one album.
|
||||
null,
|
||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
|
||||
MusicMode.SONGS -> playbackModel.playFromAll(music)
|
||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
|
||||
MusicMode.GENRES -> playbackModel.playFromGenre(music)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
if (item is Song) {
|
||||
musicMenu(anchor, R.menu.menu_album_song_actions, item)
|
||||
return
|
||||
}
|
||||
|
||||
error("Unexpected datatype when opening menu: ${item::class.java}")
|
||||
check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
openMusicMenu(anchor, R.menu.menu_album_song_actions, item)
|
||||
}
|
||||
|
||||
override fun onPlayParent() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), false)
|
||||
override fun onPlay() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
override fun onShuffleParent() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), true)
|
||||
override fun onShuffle() {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
override fun onShowSortMenu(anchor: View) {
|
||||
menu(anchor, R.menu.menu_album_sort) {
|
||||
override fun onOpenSortMenu(anchor: View) {
|
||||
openMenu(anchor, R.menu.menu_album_sort) {
|
||||
val sort = detailModel.albumSort
|
||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
||||
|
|
@ -161,50 +170,56 @@ class AlbumDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onNavigateToArtist() {
|
||||
findNavController()
|
||||
.navigate(
|
||||
AlbumDetailFragmentDirections.actionShowArtist(
|
||||
unlikelyToBeNull(detailModel.currentAlbum.value).artist.id))
|
||||
override fun onNavigateToParentArtist() {
|
||||
navModel.exploreNavigateToParentArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
|
||||
private fun handleItemChange(album: Album?) {
|
||||
private fun updateAlbum(album: Album?) {
|
||||
if (album == null) {
|
||||
// Album we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
||||
requireBinding().detailToolbar.title = album.resolveName(requireContext())
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
|
||||
detailAdapter.setPlayingItem(song, isPlaying)
|
||||
} else {
|
||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
||||
detailAdapter.setPlayingItem(null, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigation(item: Music?) {
|
||||
val binding = requireBinding()
|
||||
when (item) {
|
||||
// Songs should be scrolled to if the album matches, or a new detail
|
||||
// fragment should be launched otherwise.
|
||||
is Song -> {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value).id == item.album.id) {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == item.album) {
|
||||
logD("Navigating to a song in this album")
|
||||
scrollToItem(item.id)
|
||||
scrollToAlbumSong(item)
|
||||
navModel.finishExploreNavigation()
|
||||
} else {
|
||||
logD("Navigating to another album")
|
||||
findNavController()
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.id))
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.uid))
|
||||
}
|
||||
}
|
||||
|
||||
// If the album matches, no need to do anything. Otherwise launch a new
|
||||
// detail fragment.
|
||||
is Album -> {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value).id == item.id) {
|
||||
if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) {
|
||||
logD("Navigating to the top of this album")
|
||||
binding.detailRecycler.scrollToPosition(0)
|
||||
navModel.finishExploreNavigation()
|
||||
} else {
|
||||
logD("Navigating to another album")
|
||||
findNavController()
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.uid))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,24 +227,42 @@ class AlbumDetailFragment :
|
|||
is Artist -> {
|
||||
logD("Navigating to another artist")
|
||||
findNavController()
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id))
|
||||
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.uid))
|
||||
}
|
||||
null -> {}
|
||||
else -> error("Unexpected navigation item ${item::class.java}")
|
||||
else -> error("Unexpected datatype: ${item::class.java}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Scroll to an song using its [id]. */
|
||||
private fun scrollToItem(id: Long) {
|
||||
private fun scrollToAlbumSong(song: Song) {
|
||||
// Calculate where the item for the currently played song is
|
||||
val pos = detailModel.albumData.value.indexOfFirst { it.id == id && it is Song }
|
||||
val pos = detailModel.albumList.value.indexOf(song)
|
||||
|
||||
if (pos != -1) {
|
||||
// Only scroll if the song is within this album.
|
||||
val binding = requireBinding()
|
||||
binding.detailRecycler.post {
|
||||
// Use a custom smooth scroller that will settle the item in the middle of
|
||||
// the screen rather than the end.
|
||||
val centerSmoothScroller =
|
||||
object : LinearSmoothScroller(context) {
|
||||
init {
|
||||
targetPosition = pos
|
||||
}
|
||||
|
||||
override fun calculateDtToFit(
|
||||
viewStart: Int,
|
||||
viewEnd: Int,
|
||||
boxStart: Int,
|
||||
boxEnd: Int,
|
||||
snapPreference: Int
|
||||
): Int =
|
||||
(boxStart + (boxEnd - boxStart) / 2) -
|
||||
(viewStart + (viewEnd - viewStart) / 2)
|
||||
}
|
||||
|
||||
// Make sure to increment the position to make up for the detail header
|
||||
binding.detailRecycler.layoutManager?.startSmoothScroll(
|
||||
CenterSmoothScroller(requireContext(), pos))
|
||||
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
|
||||
|
||||
// If the recyclerview can scroll, its certain that it will have to scroll to
|
||||
// correctly center the playing item, so make sure that the Toolbar is lifted in
|
||||
|
|
@ -239,44 +272,8 @@ class AlbumDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
val binding = requireBinding()
|
||||
|
||||
for (item in binding.detailToolbar.menu.children) {
|
||||
// If there is no playback going in, any queue additions will be wiped as soon as
|
||||
// something is played. Disable these actions when playback is going on so that
|
||||
// it isn't possible to add anything during that time.
|
||||
if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) {
|
||||
item.isEnabled = song != null
|
||||
}
|
||||
}
|
||||
|
||||
if (parent is Album && parent.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) {
|
||||
detailAdapter.updateIndicator(song, isPlaying)
|
||||
} else {
|
||||
// Clear the ViewHolders if the mode isn't ALL_SONGS
|
||||
detailAdapter.updateIndicator(null, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [LinearSmoothScroller] subclass that centers the item on the screen instead of snapping to
|
||||
* the top or bottom.
|
||||
*/
|
||||
private class CenterSmoothScroller(context: Context, target: Int) :
|
||||
LinearSmoothScroller(context) {
|
||||
init {
|
||||
targetPosition = target
|
||||
}
|
||||
|
||||
override fun calculateDtToFit(
|
||||
viewStart: Int,
|
||||
viewEnd: Int,
|
||||
boxStart: Int,
|
||||
boxEnd: Int,
|
||||
snapPreference: Int
|
||||
): Int {
|
||||
return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2)
|
||||
}
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
|
|
@ -30,37 +29,37 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
|
||||
import org.oxycblt.auxio.detail.recycler.DetailAdapter
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.PlaybackMode
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.fragment.MenuFragment
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* A fragment that shows information for a particular [Artist].
|
||||
* @author OxygenCobalt
|
||||
* A [ListFragment] that shows information about an [Artist].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistDetailFragment :
|
||||
MenuFragment<FragmentDetailBinding>(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener {
|
||||
class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
// Information about what artist to display is initially within the navigation arguments
|
||||
// as a UID, as that is the only safe way to parcel an artist.
|
||||
private val args: ArtistDetailFragmentArgs by navArgs()
|
||||
private val detailAdapter = ArtistDetailAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Detail transitions are always on the X axis. Shared element transitions are more
|
||||
// semantically correct, but are also too buggy to be sensible.
|
||||
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||
|
|
@ -69,9 +68,13 @@ class ArtistDetailFragment :
|
|||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
detailModel.setArtistId(args.artistId)
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailToolbar.apply {
|
||||
inflateMenu(R.menu.menu_genre_artist_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
|
@ -81,12 +84,14 @@ class ArtistDetailFragment :
|
|||
binding.detailRecycler.adapter = detailAdapter
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
collectImmediately(detailModel.currentArtist, ::handleItemChange)
|
||||
collectImmediately(detailModel.artistData, detailAdapter::submitList)
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setArtistUid(args.artistUid)
|
||||
collectImmediately(detailModel.currentArtist, ::updateItem)
|
||||
collectImmediately(detailModel.artistList, detailAdapter::submitList)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
|
|
@ -96,14 +101,19 @@ class ArtistDetailFragment :
|
|||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
||||
return when (item.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
playbackModel.playNext(currentArtist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
playbackModel.addToQueue(currentArtist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
|
|
@ -111,32 +121,44 @@ class ArtistDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
when (item) {
|
||||
is Song ->
|
||||
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_ARTIST)
|
||||
is Album -> navModel.exploreNavigateTo(item)
|
||||
override fun onRealClick(music: Music) {
|
||||
when (music) {
|
||||
is Song -> {
|
||||
when (Settings(requireContext()).detailPlaybackMode) {
|
||||
// When configured to play from the selected item, we already have an Artist
|
||||
// to play from.
|
||||
null ->
|
||||
playbackModel.playFromArtist(
|
||||
music, unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
MusicMode.SONGS -> playbackModel.playFromAll(music)
|
||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
|
||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
|
||||
MusicMode.GENRES -> playbackModel.playFromGenre(music)
|
||||
}
|
||||
}
|
||||
is Album -> navModel.exploreNavigateTo(music)
|
||||
else -> error("Unexpected datatype: ${music::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
when (item) {
|
||||
is Song -> musicMenu(anchor, R.menu.menu_artist_song_actions, item)
|
||||
is Album -> musicMenu(anchor, R.menu.menu_artist_album_actions, item)
|
||||
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
|
||||
is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item)
|
||||
is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item)
|
||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayParent() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), false)
|
||||
override fun onPlay() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
|
||||
override fun onShuffleParent() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), true)
|
||||
override fun onShuffle() {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
|
||||
override fun onShowSortMenu(anchor: View) {
|
||||
menu(anchor, R.menu.menu_artist_sort) {
|
||||
override fun onOpenSortMenu(anchor: View) {
|
||||
openMenu(anchor, R.menu.menu_artist_sort) {
|
||||
val sort = detailModel.artistSort
|
||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
||||
|
|
@ -155,8 +177,9 @@ class ArtistDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleItemChange(artist: Artist?) {
|
||||
private fun updateItem(artist: Artist?) {
|
||||
if (artist == null) {
|
||||
// Artist we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
@ -164,47 +187,58 @@ class ArtistDetailFragment :
|
|||
requireBinding().detailToolbar.title = artist.resolveName(requireContext())
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
|
||||
val playingItem =
|
||||
when (parent) {
|
||||
// Always highlight a playing album if it's from this artist.
|
||||
is Album -> parent
|
||||
// If the parent is the artist itself, use the currently playing song.
|
||||
currentArtist -> song
|
||||
// Nothing is playing from this artist.
|
||||
else -> null
|
||||
}
|
||||
|
||||
detailAdapter.setPlayingItem(playingItem, isPlaying)
|
||||
}
|
||||
|
||||
private fun handleNavigation(item: Music?) {
|
||||
val binding = requireBinding()
|
||||
|
||||
when (item) {
|
||||
// Songs should be shown in their album, not in their artist.
|
||||
is Song -> {
|
||||
logD("Navigating to another album")
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.id))
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid))
|
||||
}
|
||||
// Launch a new detail view for an album, even if it is part of
|
||||
// this artist.
|
||||
is Album -> {
|
||||
logD("Navigating to another album")
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.uid))
|
||||
}
|
||||
// If the artist that should be navigated to is this artist, then
|
||||
// scroll back to the top. Otherwise launch a new detail view.
|
||||
is Artist -> {
|
||||
if (item.id == detailModel.currentArtist.value?.id) {
|
||||
if (item.uid == detailModel.currentArtist.value?.uid) {
|
||||
logD("Navigating to the top of this artist")
|
||||
binding.detailRecycler.scrollToPosition(0)
|
||||
navModel.finishExploreNavigation()
|
||||
} else {
|
||||
logD("Navigating to another artist")
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id))
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowArtist(item.uid))
|
||||
}
|
||||
}
|
||||
null -> {}
|
||||
else -> error("Unexpected navigation item ${item::class.java}")
|
||||
else -> error("Unexpected datatype: ${item::class.java}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
var item: Item? = null
|
||||
|
||||
if (parent is Album) {
|
||||
item = parent
|
||||
}
|
||||
|
||||
if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
|
||||
item = song
|
||||
}
|
||||
|
||||
detailAdapter.updateIndicator(item, isPlaying)
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
Normal file
55
app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
|
||||
/**
|
||||
* A header variation that displays a button to open a sort menu.
|
||||
* @param titleRes The string resource to use as the header title
|
||||
*/
|
||||
data class SortHeader(@StringRes val titleRes: Int) : Item
|
||||
|
||||
/**
|
||||
* A header variation that delimits between disc groups.
|
||||
* @param disc The disc number to be displayed on the header.
|
||||
*/
|
||||
data class DiscHeader(val disc: Int) : Item
|
||||
|
||||
/**
|
||||
* A [Song] extension that adds information about it's file properties.
|
||||
* @param song The internal song
|
||||
* @param properties The properties of the song file. Null if parsing is ongoing.
|
||||
*/
|
||||
data class DetailSong(val song: Song, val properties: Properties?) {
|
||||
/**
|
||||
* The properties of a [Song]'s file.
|
||||
* @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
|
||||
* @param sampleRateHz The sample rate, in hertz.
|
||||
* @param resolvedMimeType The known mime type of the [Song] after it's file format was
|
||||
* determined.
|
||||
*/
|
||||
data class Properties(
|
||||
val bitrateKbps: Int?,
|
||||
val sampleRateHz: Int?,
|
||||
val resolvedMimeType: MimeType
|
||||
)
|
||||
}
|
||||
|
|
@ -22,8 +22,8 @@ import android.content.Context
|
|||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
|
@ -32,20 +32,27 @@ import com.google.android.material.appbar.AppBarLayout
|
|||
import java.lang.reflect.Field
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.AuxioAppBarLayout
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
|
||||
/**
|
||||
* An [AuxioAppBarLayout] variant that also shows the name of the toolbar whenever the detail
|
||||
* recyclerview is scrolled beyond it's first item (a.k.a the header). This is used instead of
|
||||
* CollapsingToolbarLayout since that thing is a mess with crippling bugs and state issues. This
|
||||
* just works.
|
||||
* @author OxygenCobalt
|
||||
* An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
|
||||
* beyond it's first item.
|
||||
*
|
||||
* This is intended for the detail views, in which the first item is the album/artist/genre header,
|
||||
* and thus scrolling past them should make the toolbar show the name in order to give context on
|
||||
* where the user currently is.
|
||||
*
|
||||
* This task should nominally be accomplished with CollapsingToolbarLayout, but I have not figured
|
||||
* out how to get that working sensibly yet.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DetailAppBarLayout
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AuxioAppBarLayout(context, attrs, defStyleAttr) {
|
||||
private var titleView: AppCompatTextView? = null
|
||||
private var titleView: TextView? = null
|
||||
private var recycler: RecyclerView? = null
|
||||
|
||||
private var titleShown: Boolean? = null
|
||||
|
|
@ -56,18 +63,26 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
|
||||
}
|
||||
|
||||
private fun findTitleView(): AppCompatTextView {
|
||||
private fun findTitleView(): TextView {
|
||||
val titleView = titleView
|
||||
if (titleView != null) {
|
||||
return titleView
|
||||
}
|
||||
|
||||
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
|
||||
// used within the detail layouts.
|
||||
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
|
||||
|
||||
// Reflect to get the actual title view to do transformations on
|
||||
val newTitleView = TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as AppCompatTextView
|
||||
// The Toolbar's title view is actually hidden. To avoid having to create our own
|
||||
// title view, we just reflect into Toolbar and grab the hidden field.
|
||||
val newTitleView =
|
||||
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
|
||||
// We can never properly initialize the title view's state before draw time,
|
||||
// so we just set it's alpha to 0f to produce a less jarring initialization
|
||||
// animation..
|
||||
alpha = 0f
|
||||
}
|
||||
|
||||
newTitleView.alpha = 0f
|
||||
this.titleView = newTitleView
|
||||
return newTitleView
|
||||
}
|
||||
|
|
@ -78,6 +93,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
return recycler
|
||||
}
|
||||
|
||||
// Use the scrolling view in order to find a RecyclerView to use.
|
||||
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
|
||||
this.recycler = newRecycler
|
||||
return newRecycler
|
||||
|
|
@ -85,7 +101,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
private fun setTitleVisibility(visible: Boolean) {
|
||||
if (titleShown == visible) return
|
||||
|
||||
titleShown = visible
|
||||
|
||||
val titleAnimator = titleAnimator
|
||||
|
|
@ -94,6 +109,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
this.titleAnimator = null
|
||||
}
|
||||
|
||||
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
|
||||
// the title view's alpha instead of the AppBarLayout's elevation.
|
||||
val titleView = findTitleView()
|
||||
val from: Float
|
||||
val to: Float
|
||||
|
|
@ -106,12 +123,20 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
to = 0f
|
||||
}
|
||||
|
||||
if (titleView.alpha == to) return
|
||||
if (titleView.alpha == to) {
|
||||
// Nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
this.titleAnimator =
|
||||
ValueAnimator.ofFloat(from, to).apply {
|
||||
addUpdateListener { titleView.alpha = it.animatedValue as Float }
|
||||
duration = TOOLBAR_FADE_DURATION
|
||||
duration =
|
||||
if (titleShown == true) {
|
||||
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
|
@ -131,19 +156,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
) {
|
||||
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
|
||||
|
||||
val appBar = child as DetailAppBarLayout
|
||||
val recycler = appBar.findRecyclerView()
|
||||
val appBarLayout = child as DetailAppBarLayout
|
||||
val recycler = appBarLayout.findRecyclerView()
|
||||
|
||||
val showTitle =
|
||||
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0
|
||||
|
||||
appBar.setTitleVisibility(showTitle)
|
||||
// Title should be visible if we are no longer showing the top item
|
||||
// (i.e the header)
|
||||
appBarLayout.setTitleVisibility(
|
||||
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TOOLBAR_FADE_DURATION = 150L
|
||||
|
||||
private val TOOLBAR_TITLE_TEXT_FIELD: Field by
|
||||
lazyReflectedField(Toolbar::class, "mTitleTextView")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,168 +24,272 @@ import androidx.annotation.StringRes
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.recycler.Header
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.util.TaskGuard
|
||||
import org.oxycblt.auxio.util.application
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* ViewModel that stores data for the detail fragments. This includes:
|
||||
* - What item the fragment should be showing
|
||||
* - The RecyclerView data for each fragment
|
||||
* - The sorts for each type of data
|
||||
* @author OxygenCobalt
|
||||
*
|
||||
* TODO: Unify how detail items are indicated [When playlists are implemented]
|
||||
* [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of
|
||||
* the current item they are showing, sub-data to display, and configuration. Since this ViewModel
|
||||
* requires a context, it must be instantiated [AndroidViewModel]'s Factory.
|
||||
* @param application [Application] context required to initialize certain information.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DetailViewModel(application: Application) :
|
||||
AndroidViewModel(application), MusicStore.Callback {
|
||||
data class DetailSong(val song: Song, val info: SongInfo?)
|
||||
|
||||
data class SongInfo(
|
||||
val bitrateKbps: Int?,
|
||||
val sampleRate: Int?,
|
||||
val resolvedMimeType: MimeType
|
||||
)
|
||||
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val settings = Settings(application)
|
||||
|
||||
private var currentSongJob: Job? = null
|
||||
|
||||
// --- SONG ---
|
||||
|
||||
private val _currentSong = MutableStateFlow<DetailSong?>(null)
|
||||
/**
|
||||
* The current [DetailSong] to display. Null if there is nothing to show.
|
||||
*
|
||||
* TODO: De-couple Song and Properties?
|
||||
*/
|
||||
val currentSong: StateFlow<DetailSong?>
|
||||
get() = _currentSong
|
||||
|
||||
// --- ALBUM ---
|
||||
|
||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||
/** The current [Album] to display. Null if there is nothing to show. */
|
||||
val currentAlbum: StateFlow<Album?>
|
||||
get() = _currentAlbum
|
||||
|
||||
private val _albumData = MutableStateFlow(listOf<Item>())
|
||||
val albumData: StateFlow<List<Item>>
|
||||
get() = _albumData
|
||||
private val _albumList = MutableStateFlow(listOf<Item>())
|
||||
/** The current list data derived from [currentAlbum]. */
|
||||
val albumList: StateFlow<List<Item>>
|
||||
get() = _albumList
|
||||
|
||||
/** The current [Sort] used for [Song]s in [albumList]. */
|
||||
var albumSort: Sort
|
||||
get() = settings.detailAlbumSort
|
||||
set(value) {
|
||||
settings.detailAlbumSort = value
|
||||
currentAlbum.value?.let(::refreshAlbumData)
|
||||
// Refresh the album list to reflect the new sort.
|
||||
currentAlbum.value?.let(::refreshAlbumList)
|
||||
}
|
||||
|
||||
// --- ARTIST ---
|
||||
|
||||
private val _currentArtist = MutableStateFlow<Artist?>(null)
|
||||
/** The current [Artist] to display. Null if there is nothing to show. */
|
||||
val currentArtist: StateFlow<Artist?>
|
||||
get() = _currentArtist
|
||||
|
||||
private val _artistData = MutableStateFlow(listOf<Item>())
|
||||
val artistData: StateFlow<List<Item>> = _artistData
|
||||
private val _artistList = MutableStateFlow(listOf<Item>())
|
||||
/** The current list derived from [currentArtist]. */
|
||||
val artistList: StateFlow<List<Item>> = _artistList
|
||||
|
||||
/** The current [Sort] used for [Song]s in [artistList]. */
|
||||
var artistSort: Sort
|
||||
get() = settings.detailArtistSort
|
||||
set(value) {
|
||||
logD(value)
|
||||
settings.detailArtistSort = value
|
||||
currentArtist.value?.let(::refreshArtistData)
|
||||
// Refresh the artist list to reflect the new sort.
|
||||
currentArtist.value?.let(::refreshArtistList)
|
||||
}
|
||||
|
||||
// --- GENRE ---
|
||||
|
||||
private val _currentGenre = MutableStateFlow<Genre?>(null)
|
||||
/** The current [Genre] to display. Null if there is nothing to show. */
|
||||
val currentGenre: StateFlow<Genre?>
|
||||
get() = _currentGenre
|
||||
|
||||
private val _genreData = MutableStateFlow(listOf<Item>())
|
||||
val genreData: StateFlow<List<Item>> = _genreData
|
||||
private val _genreList = MutableStateFlow(listOf<Item>())
|
||||
/** The current list data derived from [currentGenre]. */
|
||||
val genreList: StateFlow<List<Item>> = _genreList
|
||||
|
||||
/** The current [Sort] used for [Song]s in [genreList]. */
|
||||
var genreSort: Sort
|
||||
get() = settings.detailGenreSort
|
||||
set(value) {
|
||||
settings.detailGenreSort = value
|
||||
currentGenre.value?.let(::refreshGenreData)
|
||||
// Refresh the genre list to reflect the new sort.
|
||||
currentGenre.value?.let(::refreshGenreList)
|
||||
}
|
||||
|
||||
private val songGuard = TaskGuard()
|
||||
|
||||
fun setSongId(id: Long) {
|
||||
if (_currentSong.value?.run { song.id } == id) return
|
||||
val library = unlikelyToBeNull(musicStore.library)
|
||||
val song = requireNotNull(library.findSongById(id)) { "Invalid song id provided" }
|
||||
generateDetailSong(song)
|
||||
}
|
||||
|
||||
fun clearSong() {
|
||||
songGuard.newHandle()
|
||||
_currentSong.value = null
|
||||
}
|
||||
|
||||
fun setAlbumId(id: Long) {
|
||||
if (_currentAlbum.value?.id == id) return
|
||||
val library = unlikelyToBeNull(musicStore.library)
|
||||
val album = requireNotNull(library.findAlbumById(id)) { "Invalid album id provided " }
|
||||
|
||||
_currentAlbum.value = album
|
||||
refreshAlbumData(album)
|
||||
}
|
||||
|
||||
fun setArtistId(id: Long) {
|
||||
if (_currentArtist.value?.id == id) return
|
||||
val library = unlikelyToBeNull(musicStore.library)
|
||||
val artist = requireNotNull(library.findArtistById(id)) { "Invalid artist id provided" }
|
||||
_currentArtist.value = artist
|
||||
refreshArtistData(artist)
|
||||
}
|
||||
|
||||
fun setGenreId(id: Long) {
|
||||
if (_currentGenre.value?.id == id) return
|
||||
val library = unlikelyToBeNull(musicStore.library)
|
||||
val genre = requireNotNull(library.findGenreById(id)) { "Invalid genre id provided" }
|
||||
_currentGenre.value = genre
|
||||
refreshGenreData(genre)
|
||||
}
|
||||
|
||||
init {
|
||||
musicStore.addCallback(this)
|
||||
}
|
||||
|
||||
private fun generateDetailSong(song: Song) {
|
||||
_currentSong.value = DetailSong(song, null)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val handle = songGuard.newHandle()
|
||||
val info = generateDetailSongInfo(song)
|
||||
songGuard.yield(handle)
|
||||
_currentSong.value = DetailSong(song, info)
|
||||
override fun onCleared() {
|
||||
musicStore.removeCallback(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
if (library == null) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
// If we are showing any item right now, we will need to refresh it (and any information
|
||||
// related to it) with the new library in order to prevent stale items from showing up
|
||||
// in the UI.
|
||||
|
||||
val song = currentSong.value
|
||||
if (song != null) {
|
||||
val newSong = library.sanitize(song.song)
|
||||
if (newSong != null) {
|
||||
loadDetailSong(newSong)
|
||||
} else {
|
||||
_currentSong.value = null
|
||||
}
|
||||
logD("Updated song to $newSong")
|
||||
}
|
||||
|
||||
val album = currentAlbum.value
|
||||
if (album != null) {
|
||||
_currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList)
|
||||
logD("Updated genre to ${currentAlbum.value}")
|
||||
}
|
||||
|
||||
val artist = currentArtist.value
|
||||
if (artist != null) {
|
||||
_currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList)
|
||||
logD("Updated genre to ${currentArtist.value}")
|
||||
}
|
||||
|
||||
val genre = currentGenre.value
|
||||
if (genre != null) {
|
||||
_currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList)
|
||||
logD("Updated genre to ${currentGenre.value}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateDetailSongInfo(song: Song): SongInfo {
|
||||
/**
|
||||
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, a new loading
|
||||
* process will begin and the newly-loaded [DetailSong] will be set to [currentSong].
|
||||
* @param uid The UID of the [Song] to load. Must be valid.
|
||||
*/
|
||||
fun setSongUid(uid: Music.UID) {
|
||||
if (_currentSong.value?.run { song.uid } == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Opening Song [uid: $uid]")
|
||||
loadDetailSong(requireMusic(uid))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentAlbum] from it's [Music.UID]. If the [Music.UID] differs, [currentAlbum]
|
||||
* and [albumList] will be updated to align with the new [Album].
|
||||
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
|
||||
*/
|
||||
fun setAlbumUid(uid: Music.UID) {
|
||||
if (_currentAlbum.value?.uid == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Opening Album [uid: $uid]")
|
||||
_currentAlbum.value = requireMusic<Album>(uid).also { refreshAlbumList(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentArtist] from it's [Music.UID]. If the [Music.UID] differs, [currentArtist]
|
||||
* and [artistList] will be updated to align with the new [Artist].
|
||||
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
|
||||
*/
|
||||
fun setArtistUid(uid: Music.UID) {
|
||||
if (_currentArtist.value?.uid == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Opening Artist [uid: $uid]")
|
||||
_currentArtist.value = requireMusic<Artist>(uid).also { refreshArtistList(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentGenre] from it's [Music.UID]. If the [Music.UID] differs, [currentGenre]
|
||||
* and [genreList] will be updated to align with the new album.
|
||||
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
|
||||
*/
|
||||
fun setGenreUid(uid: Music.UID) {
|
||||
if (_currentGenre.value?.uid == uid) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
logD("Opening Genre [uid: $uid]")
|
||||
_currentGenre.value = requireMusic<Genre>(uid).also { refreshGenreList(it) }
|
||||
}
|
||||
|
||||
private fun <T : Music> requireMusic(uid: Music.UID): T =
|
||||
requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" }
|
||||
|
||||
/**
|
||||
* Start a new job to load a [DetailSong] based on the properties of the given [Song]'s file.
|
||||
* @param song The song to load.
|
||||
*/
|
||||
private fun loadDetailSong(song: Song) {
|
||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||
currentSongJob?.cancel()
|
||||
_currentSong.value = DetailSong(song, null)
|
||||
currentSongJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val info = loadProperties(song)
|
||||
yield()
|
||||
_currentSong.value = DetailSong(song, info)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProperties(song: Song): DetailSong.Properties {
|
||||
// While we would use ExoPlayer to extract this information, it doesn't support
|
||||
// common data like bit rate in progressive data sources due to there being no
|
||||
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
|
||||
val extractor = MediaExtractor()
|
||||
|
||||
try {
|
||||
extractor.setDataSource(application, song.uri, emptyMap())
|
||||
extractor.setDataSource(context, song.uri, emptyMap())
|
||||
} catch (e: Exception) {
|
||||
// Can feasibly fail with invalid file formats. Note that this isn't considered
|
||||
// an error condition in the UI, as there is still plenty of other song information
|
||||
// that we can show.
|
||||
logW("Unable to extract song attributes.")
|
||||
logW(e.stackTraceToString())
|
||||
return SongInfo(null, null, song.mimeType)
|
||||
return DetailSong.Properties(null, null, song.mimeType)
|
||||
}
|
||||
|
||||
// Get the first track from the extractor (This is basically always the only
|
||||
// track we need to analyze).
|
||||
val format = extractor.getTrackFormat(0)
|
||||
|
||||
// Accessing fields can throw an exception if the fields are not present, and
|
||||
// the new method for using default values is not available on lower API levels.
|
||||
// So, we are forced to handle the exception and map it to a saner null value.
|
||||
val bitrate =
|
||||
try {
|
||||
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 // bps -> kbps
|
||||
} catch (e: Exception) {
|
||||
// Convert bytes-per-second to kilobytes-per-second.
|
||||
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000
|
||||
} catch (e: NullPointerException) {
|
||||
logD("Unable to extract bit rate field")
|
||||
null
|
||||
}
|
||||
|
||||
val sampleRate =
|
||||
try {
|
||||
format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract sample rate field")
|
||||
null
|
||||
}
|
||||
|
||||
|
|
@ -194,138 +298,113 @@ class DetailViewModel(application: Application) :
|
|||
// ExoPlayer was already able to populate the format.
|
||||
song.mimeType
|
||||
} else {
|
||||
// ExoPlayer couldn't populate the format somehow, populate it here.
|
||||
val formatMimeType =
|
||||
try {
|
||||
format.getString(MediaFormat.KEY_MIME)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: NullPointerException) {
|
||||
logE("Unable to extract mime type field")
|
||||
null
|
||||
}
|
||||
|
||||
MimeType(song.mimeType.fromExtension, formatMimeType)
|
||||
}
|
||||
|
||||
return SongInfo(bitrate, sampleRate, resolvedMimeType)
|
||||
return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType)
|
||||
}
|
||||
|
||||
private fun refreshAlbumData(album: Album) {
|
||||
private fun refreshAlbumList(album: Album) {
|
||||
logD("Refreshing album data")
|
||||
val data = mutableListOf<Item>(album)
|
||||
data.add(SortHeader(R.string.lbl_songs))
|
||||
|
||||
// To create a good user experience regarding disc numbers, we intersperse
|
||||
// items that show the disc number throughout the album's songs. In the case
|
||||
// that the album does not have distinct disc numbers, we omit such a header.
|
||||
// To create a good user experience regarding disc numbers, we group the album's
|
||||
// songs up by disc and then delimit the groups by a disc header.
|
||||
val songs = albumSort.songs(album.songs)
|
||||
// Songs without disc tags become part of Disc 1.
|
||||
val byDisc = songs.groupBy { it.disc ?: 1 }
|
||||
if (byDisc.size > 1) {
|
||||
logD("Album has more than one disc, interspersing headers")
|
||||
for (entry in byDisc.entries) {
|
||||
val disc = entry.key
|
||||
val discSongs = entry.value
|
||||
data.add(DiscHeader(disc)) // Ensure ID uniqueness
|
||||
data.addAll(discSongs)
|
||||
data.add(DiscHeader(entry.key))
|
||||
data.addAll(entry.value)
|
||||
}
|
||||
} else {
|
||||
// Album only has one disc, don't add any redundant headers
|
||||
data.addAll(songs)
|
||||
}
|
||||
|
||||
_albumData.value = data
|
||||
_albumList.value = data
|
||||
}
|
||||
|
||||
private fun refreshArtistData(artist: Artist) {
|
||||
private fun refreshArtistList(artist: Artist) {
|
||||
logD("Refreshing artist data")
|
||||
val data = mutableListOf<Item>(artist)
|
||||
val albums = Sort(Sort.Mode.ByYear, false).albums(artist.albums)
|
||||
val albums = Sort(Sort.Mode.ByDate, false).albums(artist.albums)
|
||||
|
||||
val byReleaseGroup =
|
||||
albums.groupBy {
|
||||
when (it.releaseType.refinement) {
|
||||
ReleaseType.Refinement.LIVE -> R.string.lbl_live_group
|
||||
ReleaseType.Refinement.REMIX -> R.string.lbl_remix_group
|
||||
// Remap the complicated Album.Type data structure into an easier
|
||||
// "AlbumGrouping" enum that will automatically group and sort
|
||||
// the artist's albums.
|
||||
when (it.type.refinement) {
|
||||
Album.Type.Refinement.LIVE -> AlbumGrouping.LIVE
|
||||
Album.Type.Refinement.REMIX -> AlbumGrouping.REMIXES
|
||||
null ->
|
||||
when (it.releaseType) {
|
||||
is ReleaseType.Album -> R.string.lbl_albums
|
||||
is ReleaseType.EP -> R.string.lbl_eps
|
||||
is ReleaseType.Single -> R.string.lbl_singles
|
||||
is ReleaseType.Compilation -> R.string.lbl_compilations
|
||||
is ReleaseType.Soundtrack -> R.string.lbl_soundtracks
|
||||
is ReleaseType.Mixtape -> R.string.lbl_mixtapes
|
||||
when (it.type) {
|
||||
is Album.Type.Album -> AlbumGrouping.ALBUMS
|
||||
is Album.Type.EP -> AlbumGrouping.EPS
|
||||
is Album.Type.Single -> AlbumGrouping.SINGLES
|
||||
is Album.Type.Compilation -> AlbumGrouping.COMPILATIONS
|
||||
is Album.Type.Soundtrack -> AlbumGrouping.SOUNDTRACKS
|
||||
is Album.Type.Mix -> AlbumGrouping.MIXES
|
||||
is Album.Type.Mixtape -> AlbumGrouping.MIXTAPES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logD("Release groups for this artist: ${byReleaseGroup.keys}")
|
||||
|
||||
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
|
||||
data.add(Header(entry.key))
|
||||
data.add(Header(entry.key.headerTitleRes))
|
||||
data.addAll(entry.value)
|
||||
}
|
||||
|
||||
data.add(SortHeader(R.string.lbl_songs))
|
||||
data.addAll(artistSort.songs(artist.songs))
|
||||
_artistData.value = data.toList()
|
||||
// Artists may not be linked to any songs, only include a header entry if we have any.
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
logD("Songs present in this artist, adding header")
|
||||
data.add(SortHeader(R.string.lbl_songs))
|
||||
data.addAll(artistSort.songs(artist.songs))
|
||||
}
|
||||
|
||||
_artistList.value = data.toList()
|
||||
}
|
||||
|
||||
private fun refreshGenreData(genre: Genre) {
|
||||
private fun refreshGenreList(genre: Genre) {
|
||||
logD("Refreshing genre data")
|
||||
val data = mutableListOf<Item>(genre)
|
||||
// Genre is guaranteed to always have artists and songs.
|
||||
data.add(Header(R.string.lbl_artists))
|
||||
data.addAll(genre.artists)
|
||||
data.add(SortHeader(R.string.lbl_songs))
|
||||
data.addAll(genreSort.songs(genre.songs))
|
||||
_genreData.value = data
|
||||
_genreList.value = data
|
||||
}
|
||||
|
||||
// --- CALLBACKS ---
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
if (library != null) {
|
||||
val song = currentSong.value
|
||||
if (song != null) {
|
||||
logD("Song changed, refreshing data")
|
||||
val newSong = library.sanitize(song.song)
|
||||
if (newSong != null) {
|
||||
generateDetailSong(newSong)
|
||||
} else {
|
||||
_currentSong.value = null
|
||||
}
|
||||
}
|
||||
|
||||
val album = currentAlbum.value
|
||||
if (album != null) {
|
||||
logD("Album changed, refreshing data")
|
||||
val newAlbum = library.sanitize(album).also { _currentAlbum.value = it }
|
||||
if (newAlbum != null) {
|
||||
refreshAlbumData(newAlbum)
|
||||
}
|
||||
}
|
||||
|
||||
val artist = currentArtist.value
|
||||
if (artist != null) {
|
||||
logD("Artist changed, refreshing data")
|
||||
val newArtist = library.sanitize(artist).also { _currentArtist.value = it }
|
||||
if (newArtist != null) {
|
||||
refreshArtistData(newArtist)
|
||||
}
|
||||
}
|
||||
|
||||
val genre = currentGenre.value
|
||||
if (genre != null) {
|
||||
logD("Genre changed, refreshing data")
|
||||
val newGenre = library.sanitize(genre).also { _currentGenre.value = it }
|
||||
if (newGenre != null) {
|
||||
refreshGenreData(newGenre)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
musicStore.removeCallback(this)
|
||||
/**
|
||||
* A simpler mapping of [Album.Type] used for grouping and sorting songs.
|
||||
* @param headerTitleRes The title string resource to use for a header created out of an
|
||||
* instance of this enum.
|
||||
*/
|
||||
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
|
||||
ALBUMS(R.string.lbl_albums),
|
||||
EPS(R.string.lbl_eps),
|
||||
SINGLES(R.string.lbl_singles),
|
||||
COMPILATIONS(R.string.lbl_compilations),
|
||||
SOUNDTRACKS(R.string.lbl_soundtracks),
|
||||
MIXES(R.string.lbl_mixes),
|
||||
MIXTAPES(R.string.lbl_mixtapes),
|
||||
LIVE(R.string.lbl_live_group),
|
||||
REMIXES(R.string.lbl_remix_group),
|
||||
}
|
||||
}
|
||||
|
||||
data class SortHeader(@StringRes val string: Int) : Item() {
|
||||
override val id: Long
|
||||
get() = string.toLong()
|
||||
}
|
||||
|
||||
data class DiscHeader(val disc: Int) : Item() {
|
||||
override val id: Long
|
||||
get() = disc.toLong()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
|
|
@ -30,35 +29,33 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.FragmentDetailBinding
|
||||
import org.oxycblt.auxio.detail.recycler.DetailAdapter
|
||||
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.PlaybackMode
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.fragment.MenuFragment
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* A fragment that shows information for a particular [Genre].
|
||||
* @author OxygenCobalt
|
||||
* A [ListFragment] that shows information for a particular [Genre].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreDetailFragment :
|
||||
MenuFragment<FragmentDetailBinding>(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener {
|
||||
class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
|
||||
// Information about what genre to display is initially within the navigation arguments
|
||||
// as a UID, as that is the only safe way to parcel an genre.
|
||||
private val args: GenreDetailFragmentArgs by navArgs()
|
||||
private val detailAdapter = GenreDetailAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
|
@ -70,9 +67,13 @@ class GenreDetailFragment :
|
|||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
detailModel.setGenreId(args.genreId)
|
||||
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
|
||||
binding.detailSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.detailToolbar.apply {
|
||||
inflateMenu(R.menu.menu_genre_artist_detail)
|
||||
setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
|
@ -82,12 +83,14 @@ class GenreDetailFragment :
|
|||
binding.detailRecycler.adapter = detailAdapter
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
collectImmediately(detailModel.currentGenre, ::handleItemChange)
|
||||
collectImmediately(detailModel.genreData, detailAdapter::submitList)
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setGenreUid(args.genreUid)
|
||||
collectImmediately(detailModel.currentGenre, ::updateItem)
|
||||
collectImmediately(detailModel.genreList, detailAdapter::submitList)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentDetailBinding) {
|
||||
|
|
@ -97,14 +100,19 @@ class GenreDetailFragment :
|
|||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
|
||||
return when (item.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
playbackModel.playNext(currentGenre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
playbackModel.addToQueue(currentGenre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
|
|
@ -112,32 +120,43 @@ class GenreDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
when (item) {
|
||||
override fun onRealClick(music: Music) {
|
||||
when (music) {
|
||||
is Artist -> navModel.exploreNavigateTo(music)
|
||||
is Song ->
|
||||
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_GENRE)
|
||||
is Album ->
|
||||
findNavController()
|
||||
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
when (Settings(requireContext()).detailPlaybackMode) {
|
||||
// When configured to play from the selected item, we already have a Genre
|
||||
// to play from.
|
||||
null ->
|
||||
playbackModel.playFromGenre(
|
||||
music, unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
MusicMode.SONGS -> playbackModel.playFromAll(music)
|
||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
|
||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
|
||||
MusicMode.GENRES -> playbackModel.playFromGenre(music)
|
||||
}
|
||||
else -> error("Unexpected datatype: ${music::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
if (item is Song) {
|
||||
musicMenu(anchor, R.menu.menu_song_actions, item)
|
||||
when (item) {
|
||||
is Artist -> openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
||||
is Song -> openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
||||
else -> error("Unexpected datatype: ${item::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayParent() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), false)
|
||||
override fun onPlay() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
|
||||
override fun onShuffleParent() {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), true)
|
||||
override fun onShuffle() {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
|
||||
override fun onShowSortMenu(anchor: View) {
|
||||
menu(anchor, R.menu.menu_genre_sort) {
|
||||
override fun onOpenSortMenu(anchor: View) {
|
||||
openMenu(anchor, R.menu.menu_genre_sort) {
|
||||
val sort = detailModel.genreSort
|
||||
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
|
||||
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
|
||||
|
|
@ -154,8 +173,9 @@ class GenreDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleItemChange(genre: Genre?) {
|
||||
private fun updateItem(genre: Genre?) {
|
||||
if (genre == null) {
|
||||
// Genre we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
|
@ -163,21 +183,36 @@ class GenreDetailFragment :
|
|||
requireBinding().detailToolbar.title = genre.resolveName(requireContext())
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
var item: Item? = null
|
||||
|
||||
if (parent is Artist) {
|
||||
item = parent
|
||||
}
|
||||
|
||||
if (parent is Genre && parent.uid == unlikelyToBeNull(detailModel.currentGenre.value).uid) {
|
||||
item = song
|
||||
}
|
||||
|
||||
detailAdapter.setPlayingItem(item, isPlaying)
|
||||
}
|
||||
|
||||
private fun handleNavigation(item: Music?) {
|
||||
when (item) {
|
||||
is Song -> {
|
||||
logD("Navigating to another song")
|
||||
findNavController()
|
||||
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.id))
|
||||
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid))
|
||||
}
|
||||
is Album -> {
|
||||
logD("Navigating to another album")
|
||||
findNavController().navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id))
|
||||
findNavController()
|
||||
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.uid))
|
||||
}
|
||||
is Artist -> {
|
||||
logD("Navigating to another artist")
|
||||
findNavController()
|
||||
.navigate(GenreDetailFragmentDirections.actionShowArtist(item.id))
|
||||
.navigate(GenreDetailFragmentDirections.actionShowArtist(item.uid))
|
||||
}
|
||||
is Genre -> {
|
||||
navModel.finishExploreNavigation()
|
||||
|
|
@ -186,12 +221,8 @@ class GenreDetailFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
if (parent is Genre && parent.id == unlikelyToBeNull(detailModel.currentGenre.value).id) {
|
||||
detailAdapter.updateIndicator(song, isPlaying)
|
||||
} else {
|
||||
// Ignore song playback not from the genre
|
||||
detailAdapter.updateIndicator(null, isPlaying)
|
||||
}
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
detailAdapter.setSelectedItems(selected)
|
||||
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ import com.google.android.material.textfield.TextInputEditText
|
|||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* A [TextInputEditText] that deliberately restricts all input except for selection. Yes, this is a
|
||||
* blatant abuse of Material Design Guidelines, but I also don't want to figure out how to plain
|
||||
* text selectable.
|
||||
* A [TextInputEditText] that deliberately restricts all input except for selection. This will work
|
||||
* just like a normal block of selectable/copyable text, but with nicer aesthetics.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ReadOnlyTextInput
|
||||
@JvmOverloads
|
||||
|
|
@ -38,17 +39,18 @@ constructor(
|
|||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.editTextStyle
|
||||
) : TextInputEditText(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
// Enable selection, but still disable focus (i.e Keyboard opening)
|
||||
setTextIsSelectable(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
focusable = View.FOCUSABLE_AUTO
|
||||
}
|
||||
}
|
||||
|
||||
// Make text immutable
|
||||
override fun getFreezesText() = false
|
||||
|
||||
// Prevent editing by default
|
||||
override fun getDefaultEditable() = false
|
||||
|
||||
// Remove the movement method that allows cursor scrolling
|
||||
override fun getDefaultMovementMethod() = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,23 +21,24 @@ import android.os.Bundle
|
|||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
|
||||
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
|
||||
/**
|
||||
* A dialog displayed when "View properties" is selected on a song, showing more information about
|
||||
* the properties of the audio file itself.
|
||||
* @author OxygenCobalt
|
||||
* A [ViewBindingDialogFragment] that shows information about a Song.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||
private val detailModel: DetailViewModel by androidActivityViewModels()
|
||||
// Information about what song to display is initially within the navigation arguments
|
||||
// as a UID, as that is the only safe way to parcel an song.
|
||||
private val args: SongDetailDialogArgs by navArgs()
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
|
|
@ -50,46 +51,48 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
|
||||
override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
detailModel.setSongId(args.songId)
|
||||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setSongUid(args.itemUid)
|
||||
collectImmediately(detailModel.currentSong, ::updateSong)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
detailModel.clearSong()
|
||||
}
|
||||
private fun updateSong(song: DetailSong?) {
|
||||
if (song == null) {
|
||||
// Song we were showing no longer exists.
|
||||
findNavController().navigateUp()
|
||||
return
|
||||
}
|
||||
|
||||
private fun updateSong(song: DetailViewModel.DetailSong?) {
|
||||
val binding = requireBinding()
|
||||
if (song.properties != null) {
|
||||
// Finished loading Song properties, populate and show the list of Song information.
|
||||
binding.detailLoading.isInvisible = true
|
||||
binding.detailContainer.isInvisible = false
|
||||
|
||||
if (song != null) {
|
||||
if (song.info != null) {
|
||||
val context = requireContext()
|
||||
binding.detailContainer.isGone = false
|
||||
binding.detailFileName.setText(song.song.path.name)
|
||||
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
|
||||
binding.detailFormat.setText(song.info.resolvedMimeType.resolveName(context))
|
||||
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
|
||||
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
|
||||
val context = requireContext()
|
||||
binding.detailFileName.setText(song.song.path.name)
|
||||
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
|
||||
binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context))
|
||||
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
|
||||
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
|
||||
|
||||
if (song.info.bitrateKbps != null) {
|
||||
binding.detailBitrate.setText(
|
||||
getString(R.string.fmt_bitrate, song.info.bitrateKbps))
|
||||
} else {
|
||||
binding.detailBitrate.setText(R.string.def_bitrate)
|
||||
}
|
||||
|
||||
if (song.info.sampleRate != null) {
|
||||
binding.detailSampleRate.setText(
|
||||
getString(R.string.fmt_sample_rate, song.info.sampleRate))
|
||||
} else {
|
||||
binding.detailSampleRate.setText(R.string.def_sample_rate)
|
||||
}
|
||||
if (song.properties.bitrateKbps != null) {
|
||||
binding.detailBitrate.setText(
|
||||
getString(R.string.fmt_bitrate, song.properties.bitrateKbps))
|
||||
} else {
|
||||
binding.detailContainer.isGone = true
|
||||
binding.detailBitrate.setText(R.string.def_bitrate)
|
||||
}
|
||||
|
||||
if (song.properties.sampleRateHz != null) {
|
||||
binding.detailSampleRate.setText(
|
||||
getString(R.string.fmt_sample_rate, song.properties.sampleRateHz))
|
||||
} else {
|
||||
binding.detailSampleRate.setText(R.string.def_sample_rate)
|
||||
}
|
||||
} else {
|
||||
findNavController().navigateUp()
|
||||
// Loading is still on-going, don't show anything yet.
|
||||
binding.detailLoading.isInvisible = false
|
||||
binding.detailContainer.isInvisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,26 +27,38 @@ import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
|
|||
import org.oxycblt.auxio.databinding.ItemDetailBinding
|
||||
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
|
||||
import org.oxycblt.auxio.detail.DiscHeader
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* An adapter for displaying [Album] information and it's children.
|
||||
* @author OxygenCobalt
|
||||
* An [DetailAdapter] implementing the header and sub-items for the [Album] detail view.
|
||||
* @param listener A [Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumDetailAdapter(private val listener: Listener) :
|
||||
DetailAdapter<AlbumDetailAdapter.Listener>(listener, DIFFER) {
|
||||
class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
|
||||
/**
|
||||
* An extension to [DetailAdapter.Listener] that enables interactions specific to the album
|
||||
* detail view.
|
||||
*/
|
||||
interface Listener : DetailAdapter.Listener {
|
||||
/**
|
||||
* Called when the artist name in the [Album] header was clicked, requesting navigation to
|
||||
* it's parent artist.
|
||||
*/
|
||||
fun onNavigateToParentArtist()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
when (differ.currentList[position]) {
|
||||
// Support the Album header, sub-headers for each disc, and special album songs.
|
||||
is Album -> AlbumDetailViewHolder.VIEW_TYPE
|
||||
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
|
||||
is Song -> AlbumSongViewHolder.VIEW_TYPE
|
||||
|
|
@ -61,162 +73,209 @@ class AlbumDetailAdapter(private val listener: Listener) :
|
|||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
when (val item = differ.currentList[position]) {
|
||||
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
|
||||
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
|
||||
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
super.onBindViewHolder(holder, position)
|
||||
when (val item = differ.currentList[position]) {
|
||||
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
|
||||
is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
|
||||
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isItemFullWidth(position: Int): Boolean {
|
||||
// The album and disc headers should be full-width in all configurations.
|
||||
val item = differ.currentList[position]
|
||||
return super.isItemFullWidth(position) || item is Album || item is DiscHeader
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
private val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is Album && newItem is Album ->
|
||||
AlbumDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is DiscHeader && newItem is DiscHeader ->
|
||||
DiscHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Song && newItem is Song ->
|
||||
AlbumSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
|
||||
// Fall back to DetailAdapter's differ to handle other headers.
|
||||
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Listener : DetailAdapter.Listener {
|
||||
fun onNavigateToArtist()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [new] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: Album, listener: AlbumDetailAdapter.Listener) {
|
||||
binding.detailCover.bind(item)
|
||||
binding.detailType.text = binding.context.getString(item.releaseType.stringRes)
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param album The new [Album] to bind.
|
||||
* @param listener A [AlbumDetailAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(album: Album, listener: AlbumDetailAdapter.Listener) {
|
||||
binding.detailCover.bind(album)
|
||||
|
||||
binding.detailName.text = item.resolveName(binding.context)
|
||||
// The type text depends on the release type (Album, EP, Single, etc.)
|
||||
binding.detailType.text = binding.context.getString(album.type.stringRes)
|
||||
|
||||
binding.detailName.text = album.resolveName(binding.context)
|
||||
|
||||
// Artist name maps to the subhead text
|
||||
binding.detailSubhead.apply {
|
||||
text = item.artist.resolveName(context)
|
||||
setOnClickListener { listener.onNavigateToArtist() }
|
||||
text = album.resolveArtistContents(context)
|
||||
|
||||
// Add a QoL behavior where navigation to the artist will occur if the artist
|
||||
// name is pressed.
|
||||
setOnClickListener { listener.onNavigateToParentArtist() }
|
||||
}
|
||||
|
||||
// Date, song count, and duration map to the info text
|
||||
binding.detailInfo.apply {
|
||||
val date =
|
||||
item.date?.let { context.getString(R.string.fmt_number, it.year) }
|
||||
?: context.getString(R.string.def_date)
|
||||
|
||||
val songCount = context.getPlural(R.plurals.fmt_song_count, item.songs.size)
|
||||
|
||||
val duration = item.durationMs.formatDurationMs(true)
|
||||
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
val date = album.date?.resolveDate(context) ?: context.getString(R.string.def_date)
|
||||
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
|
||||
val duration = album.durationMs.formatDurationMs(true)
|
||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||
}
|
||||
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A unique ID for this [RecyclerView.ViewHolder] type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_DETAIL
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Album>() {
|
||||
override fun areItemsTheSame(oldItem: Album, newItem: Album) =
|
||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.artist.rawName == newItem.artist.rawName &&
|
||||
oldItem.areArtistContentsTheSame(newItem) &&
|
||||
oldItem.date == newItem.date &&
|
||||
oldItem.songs.size == newItem.songs.size &&
|
||||
oldItem.durationMs == newItem.durationMs &&
|
||||
oldItem.releaseType == newItem.releaseType
|
||||
oldItem.type == newItem.type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
|
||||
* [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: DiscHeader) {
|
||||
binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, item.disc)
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param discHeader The new [DiscHeader] to bind.
|
||||
*/
|
||||
fun bind(discHeader: DiscHeader) {
|
||||
binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, discHeader.disc)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A unique ID for this [RecyclerView.ViewHolder] type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DISC_HEADER
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<DiscHeader>() {
|
||||
override fun areItemsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
|
||||
override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
|
||||
oldItem.disc == newItem.disc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [new] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
|
||||
IndicatorAdapter.ViewHolder(binding.root) {
|
||||
fun bind(item: Song, listener: MenuItemListener) {
|
||||
// Hide the track number view if the song does not have a track.
|
||||
if (item.track != null) {
|
||||
binding.songTrack.apply {
|
||||
text = context.getString(R.string.fmt_number, item.track)
|
||||
SelectionIndicatorAdapter.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param song The new [Song] to bind.
|
||||
* @param listener A [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(song: Song, listener: SelectableListListener) {
|
||||
listener.bind(this, song, binding.songMenu)
|
||||
|
||||
binding.songTrack.apply {
|
||||
if (song.track != null) {
|
||||
// Instead of an album cover, we show the track number, as the song list
|
||||
// within the album detail view would have homogeneous album covers otherwise.
|
||||
text = context.getString(R.string.fmt_number, song.track)
|
||||
isInvisible = false
|
||||
contentDescription = context.getString(R.string.desc_track_number, item.track)
|
||||
}
|
||||
} else {
|
||||
binding.songTrack.apply {
|
||||
contentDescription = context.getString(R.string.desc_track_number, song.track)
|
||||
} else {
|
||||
// No track, do not show a number, instead showing a generic icon.
|
||||
text = ""
|
||||
isInvisible = true
|
||||
contentDescription = context.getString(R.string.def_track)
|
||||
}
|
||||
}
|
||||
|
||||
binding.songName.text = item.resolveName(binding.context)
|
||||
binding.songDuration.text = item.durationMs.formatDurationMs(false)
|
||||
binding.songName.text = song.resolveName(binding.context)
|
||||
|
||||
// binding.songMenu.setOnClickListener { listener.onOpenMenu(item, it) }
|
||||
binding.root.setOnLongClickListener {
|
||||
listener.onOpenMenu(item, it)
|
||||
true
|
||||
}
|
||||
binding.root.setOnClickListener { listener.onItemClick(item) }
|
||||
// Use duration instead of album or artist for each song, as this text would
|
||||
// be homogenous otherwise.
|
||||
binding.songDuration.text = song.durationMs.formatDurationMs(false)
|
||||
}
|
||||
|
||||
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isActivated = isActive
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isSelected = isActive
|
||||
binding.songTrackBg.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||
binding.root.isActivated = isSelected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A unique ID for this [RecyclerView.ViewHolder] type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_SONG
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Song>() {
|
||||
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,35 +19,33 @@ package org.oxycblt.auxio.detail.recycler
|
|||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemDetailBinding
|
||||
import org.oxycblt.auxio.databinding.ItemParentBinding
|
||||
import org.oxycblt.auxio.databinding.ItemSongBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolveYear
|
||||
import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* An adapter for displaying [Artist] information and it's children. Unlike the other adapters, this
|
||||
* one actually contains both album information and song information.
|
||||
* @author OxygenCobalt
|
||||
* A [DetailAdapter] implementing the header and sub-items for the [Artist] detail view.
|
||||
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistDetailAdapter(private val listener: Listener) :
|
||||
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
|
||||
|
||||
class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
|
||||
override fun getItemViewType(position: Int) =
|
||||
when (differ.currentList[position]) {
|
||||
// Support an artist header, and special artist albums/songs.
|
||||
is Artist -> ArtistDetailViewHolder.VIEW_TYPE
|
||||
is Album -> ArtistAlbumViewHolder.VIEW_TYPE
|
||||
is Song -> ArtistSongViewHolder.VIEW_TYPE
|
||||
|
|
@ -62,148 +60,212 @@ class ArtistDetailAdapter(private val listener: Listener) :
|
|||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
when (val item = differ.currentList[position]) {
|
||||
is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
|
||||
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
|
||||
is Song -> (holder as ArtistSongViewHolder).bind(item, listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
super.onBindViewHolder(holder, position)
|
||||
// Re-binding an item with new data and not just a changed selection/playing state.
|
||||
when (val item = differ.currentList[position]) {
|
||||
is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
|
||||
is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
|
||||
is Song -> (holder as ArtistSongViewHolder).bind(item, listener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isItemFullWidth(position: Int): Boolean {
|
||||
// Artist headers should be full-width in all configurations.
|
||||
val item = differ.currentList[position]
|
||||
return super.isItemFullWidth(position) || item is Artist
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
private val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is Artist && newItem is Artist ->
|
||||
ArtistDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
ArtistDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(
|
||||
oldItem, newItem)
|
||||
oldItem is Album && newItem is Album ->
|
||||
ArtistAlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
ArtistAlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Song && newItem is Song ->
|
||||
ArtistSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
else -> DetailAdapter.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
ArtistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [new] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(item: Artist, listener: DetailAdapter.Listener) {
|
||||
binding.detailCover.bind(item)
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param artist The new [Artist] to bind.
|
||||
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(artist: Artist, listener: DetailAdapter.Listener) {
|
||||
binding.detailCover.bind(artist)
|
||||
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
|
||||
binding.detailName.text = item.resolveName(binding.context)
|
||||
binding.detailName.text = artist.resolveName(binding.context)
|
||||
|
||||
// Get the genre that corresponds to the most songs in this artist, which would be
|
||||
// the most "Prominent" genre.
|
||||
binding.detailSubhead.text =
|
||||
item.songs
|
||||
.groupBy { it.genre.resolveName(binding.context) }
|
||||
.entries
|
||||
.maxByOrNull { it.value.size }
|
||||
?.key
|
||||
?: binding.context.getString(R.string.def_genre)
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
// Information about the artist's genre(s) map to the sub-head text
|
||||
binding.detailSubhead.apply {
|
||||
isVisible = true
|
||||
text = artist.resolveGenreContents(binding.context)
|
||||
}
|
||||
|
||||
binding.detailInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size),
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size))
|
||||
// Song and album counts map to the info
|
||||
binding.detailInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
|
||||
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
||||
// In the case that this header used to he configured to have no songs,
|
||||
// we want to reset the visibility of all information that was hidden.
|
||||
binding.detailPlayButton.isVisible = true
|
||||
binding.detailShuffleButton.isVisible = true
|
||||
} else {
|
||||
// The artist does not have any songs, so hide functionality that makes no sense.
|
||||
// ex. Play and Shuffle, Song Counts, and Genre Information.
|
||||
// Artists are always guaranteed to have albums however, so continue to show those.
|
||||
binding.detailSubhead.isVisible = false
|
||||
binding.detailInfo.text =
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
|
||||
binding.detailPlayButton.isVisible = false
|
||||
binding.detailShuffleButton.isVisible = false
|
||||
}
|
||||
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A unique ID for this [RecyclerView.ViewHolder] type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_DETAIL
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER = ArtistViewHolder.DIFFER
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Artist>() {
|
||||
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.areGenreContentsTheSame(newItem) &&
|
||||
oldItem.albums.size == newItem.albums.size &&
|
||||
oldItem.songs.size == newItem.songs.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ArtistAlbumViewHolder
|
||||
private constructor(
|
||||
private val binding: ItemParentBinding,
|
||||
) : IndicatorAdapter.ViewHolder(binding.root) {
|
||||
fun bind(item: Album, listener: MenuItemListener) {
|
||||
binding.parentImage.bind(item)
|
||||
binding.parentName.text = item.resolveName(binding.context)
|
||||
binding.parentInfo.text = item.date.resolveYear(binding.context)
|
||||
// binding.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) }
|
||||
binding.root.setOnLongClickListener {
|
||||
listener.onOpenMenu(item, it)
|
||||
true
|
||||
}
|
||||
binding.root.setOnClickListener { listener.onItemClick(item) }
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [new] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class ArtistAlbumViewHolder private constructor(private val binding: ItemParentBinding) :
|
||||
SelectionIndicatorAdapter.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param album The new [Album] to bind.
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(album: Album, listener: SelectableListListener) {
|
||||
listener.bind(this, album, binding.parentMenu)
|
||||
binding.parentImage.bind(album)
|
||||
binding.parentName.text = album.resolveName(binding.context)
|
||||
binding.parentInfo.text =
|
||||
// Fall back to a friendlier "No date" text if the album doesn't have date information
|
||||
album.date?.resolveDate(binding.context) ?: binding.context.getString(R.string.def_date)
|
||||
}
|
||||
|
||||
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isActivated = isActive
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isSelected = isActive
|
||||
binding.parentImage.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||
binding.root.isActivated = isSelected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A unique ID for this [RecyclerView.ViewHolder] type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_ALBUM
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Album>() {
|
||||
override fun areItemsTheSame(oldItem: Album, newItem: Album) =
|
||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||
oldItem.rawName == newItem.rawName && oldItem.date == newItem.date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ArtistSongViewHolder
|
||||
private constructor(
|
||||
private val binding: ItemSongBinding,
|
||||
) : IndicatorAdapter.ViewHolder(binding.root) {
|
||||
fun bind(item: Song, listener: MenuItemListener) {
|
||||
binding.songAlbumCover.bind(item)
|
||||
binding.songName.text = item.resolveName(binding.context)
|
||||
binding.songInfo.text = item.album.resolveName(binding.context)
|
||||
// binding.songMenu.setOnClickListener { listener.onOpenMenu(item, it) }
|
||||
binding.root.setOnLongClickListener {
|
||||
listener.onOpenMenu(item, it)
|
||||
true
|
||||
}
|
||||
binding.root.setOnClickListener { listener.onItemClick(item) }
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [new] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class ArtistSongViewHolder private constructor(private val binding: ItemSongBinding) :
|
||||
SelectionIndicatorAdapter.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param song The new [Song] to bind.
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(song: Song, listener: SelectableListListener) {
|
||||
listener.bind(this, song, binding.songMenu)
|
||||
binding.songAlbumCover.bind(song)
|
||||
binding.songName.text = song.resolveName(binding.context)
|
||||
binding.songInfo.text = song.album.resolveName(binding.context)
|
||||
}
|
||||
|
||||
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isActivated = isActive
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isSelected = isActive
|
||||
binding.songAlbumCover.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||
binding.root.isActivated = isSelected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_SONG
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Song>() {
|
||||
override fun areItemsTheSame(oldItem: Song, newItem: Song) =
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.album.rawName == newItem.album.rawName
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,26 +26,30 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
|
||||
import org.oxycblt.auxio.detail.SortHeader
|
||||
import org.oxycblt.auxio.ui.recycler.AuxioRecyclerView
|
||||
import org.oxycblt.auxio.ui.recycler.Header
|
||||
import org.oxycblt.auxio.ui.recycler.HeaderViewHolder
|
||||
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.recycler.*
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
abstract class DetailAdapter<L : DetailAdapter.Listener>(
|
||||
private val listener: L,
|
||||
diffCallback: DiffUtil.ItemCallback<Item>
|
||||
) : IndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
||||
private var isPlaying = false
|
||||
|
||||
@Suppress("LeakingThis") override fun getItemCount() = differ.currentList.size
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
|
||||
* @param listener A [Listener] to bind interactions to.
|
||||
* @param itemCallback A [DiffUtil.ItemCallback] to use with [AsyncListDiffer] when updating the
|
||||
* internal list.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class DetailAdapter(
|
||||
private val listener: Listener,
|
||||
itemCallback: DiffUtil.ItemCallback<Item>
|
||||
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
|
||||
// Safe to leak this since the listener will not fire during initialization
|
||||
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
when (differ.currentList[position]) {
|
||||
// Implement support for headers and sort headers
|
||||
is Header -> HeaderViewHolder.VIEW_TYPE
|
||||
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
|
|
@ -58,82 +62,109 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
|
|||
else -> error("Invalid item type $viewType")
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) =
|
||||
throw IllegalStateException()
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
val item = differ.currentList[position]
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
when (item) {
|
||||
is Header -> (holder as HeaderViewHolder).bind(item)
|
||||
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = differ.currentList[position]) {
|
||||
is Header -> (holder as HeaderViewHolder).bind(item)
|
||||
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
|
||||
}
|
||||
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
}
|
||||
|
||||
override fun isItemFullWidth(position: Int): Boolean {
|
||||
// Headers should be full-width in all configurations.
|
||||
val item = differ.currentList[position]
|
||||
return item is Header || item is SortHeader
|
||||
}
|
||||
|
||||
protected val differ = AsyncListDiffer(this, diffCallback)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
fun submitList(list: List<Item>) {
|
||||
differ.submitList(list)
|
||||
/**
|
||||
* Asynchronously update the list with new items. Assumes that the list only contains data
|
||||
* supported by the concrete [DetailAdapter] implementation.
|
||||
* @param newList The new [Item]s for the adapter to display.
|
||||
*/
|
||||
fun submitList(newList: List<Item>) {
|
||||
differ.submitList(newList)
|
||||
}
|
||||
|
||||
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
|
||||
interface Listener : SelectableListListener {
|
||||
// TODO: Split off into sub-listeners if a collapsing toolbar is implemented.
|
||||
/**
|
||||
* Called when the play button in a detail header is pressed, requesting that the current
|
||||
* item should be played.
|
||||
*/
|
||||
fun onPlay()
|
||||
|
||||
/**
|
||||
* Called when the shuffle button in a detail header is pressed, requesting that the current
|
||||
* item should be shuffled
|
||||
*/
|
||||
fun onShuffle()
|
||||
|
||||
/**
|
||||
* Called when the button in a [SortHeader] item is pressed, requesting that the sort menu
|
||||
* should be opened.
|
||||
*/
|
||||
fun onOpenSortMenu(anchor: View)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is Header && newItem is Header ->
|
||||
HeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is SortHeader && newItem is SortHeader ->
|
||||
SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Listener : MenuItemListener {
|
||||
fun onPlayParent()
|
||||
fun onShuffleParent()
|
||||
fun onShowSortMenu(anchor: View)
|
||||
}
|
||||
}
|
||||
|
||||
class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a
|
||||
* button opening a menu for sorting. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(item: SortHeader, listener: DetailAdapter.Listener) {
|
||||
binding.headerTitle.text = binding.context.getString(item.string)
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param sortHeader The new [SortHeader] to bind.
|
||||
* @param listener An [DetailAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener) {
|
||||
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
|
||||
binding.headerButton.apply {
|
||||
// Add a Tooltip based on the content description so that the purpose of this
|
||||
// button can be clear.
|
||||
TooltipCompat.setTooltipText(this, contentDescription)
|
||||
setOnClickListener(listener::onShowSortMenu)
|
||||
setOnClickListener(listener::onOpenSortMenu)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A unique ID for this [RecyclerView.ViewHolder] type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SORT_HEADER
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<SortHeader>() {
|
||||
override fun areItemsTheSame(oldItem: SortHeader, newItem: SortHeader) =
|
||||
oldItem.string == newItem.string
|
||||
override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,32 +19,35 @@ package org.oxycblt.auxio.detail.recycler
|
|||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemDetailBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
|
||||
import org.oxycblt.auxio.ui.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* An adapter for displaying genre information and it's children.
|
||||
* @author OxygenCobalt
|
||||
* An [DetailAdapter] implementing the header and sub-items for the [Genre] detail view.
|
||||
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreDetailAdapter(private val listener: Listener) :
|
||||
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
|
||||
private var currentSong: Song? = null
|
||||
private var isPlaying = false
|
||||
|
||||
class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
|
||||
override fun getItemViewType(position: Int) =
|
||||
when (differ.currentList[position]) {
|
||||
// Support the Genre header and generic Artist/Song items. There's nothing about
|
||||
// a genre that will make the artists/songs homogeneous, so it doesn't matter what we
|
||||
// use for their ViewHolders.
|
||||
is Genre -> GenreDetailViewHolder.VIEW_TYPE
|
||||
is Artist -> ArtistViewHolder.VIEW_TYPE
|
||||
is Song -> SongViewHolder.VIEW_TYPE
|
||||
else -> super.getItemViewType(position)
|
||||
}
|
||||
|
|
@ -52,68 +55,88 @@ class GenreDetailAdapter(private val listener: Listener) :
|
|||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
when (viewType) {
|
||||
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.new(parent)
|
||||
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
|
||||
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
|
||||
else -> super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: RecyclerView.ViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
when (val item = differ.currentList[position]) {
|
||||
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
|
||||
is Song -> (holder as SongViewHolder).bind(item, listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
super.onBindViewHolder(holder, position)
|
||||
when (val item = differ.currentList[position]) {
|
||||
is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
|
||||
is Artist -> (holder as ArtistViewHolder).bind(item, listener)
|
||||
is Song -> (holder as SongViewHolder).bind(item, listener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isItemFullWidth(position: Int): Boolean {
|
||||
// Genre headers should be full-width in all configurations
|
||||
val item = differ.currentList[position]
|
||||
return super.isItemFullWidth(position) || item is Genre
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DIFFER =
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Item>() {
|
||||
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
|
||||
return when {
|
||||
oldItem is Genre && newItem is Genre ->
|
||||
GenreDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
GenreDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Artist && newItem is Artist ->
|
||||
ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
oldItem is Song && newItem is Song ->
|
||||
SongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
|
||||
else -> DetailAdapter.DIFFER.areContentsTheSame(oldItem, newItem)
|
||||
SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
else -> DetailAdapter.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [new] to
|
||||
* create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(item: Genre, listener: DetailAdapter.Listener) {
|
||||
binding.detailCover.bind(item)
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param genre The new [Song] to bind.
|
||||
* @param listener A [DetailAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(genre: Genre, listener: DetailAdapter.Listener) {
|
||||
binding.detailCover.bind(genre)
|
||||
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
|
||||
binding.detailName.text = item.resolveName(binding.context)
|
||||
binding.detailSubhead.text =
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)
|
||||
binding.detailInfo.text = item.durationMs.formatDurationMs(false)
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() }
|
||||
binding.detailName.text = genre.resolveName(binding.context)
|
||||
// Nothing about a genre is applicable to the sub-head text.
|
||||
binding.detailSubhead.isVisible = false
|
||||
// The song count of the genre maps to the info text.
|
||||
binding.detailInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
|
||||
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
|
||||
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A unique ID for this [RecyclerView.ViewHolder] type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE_DETAIL
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
|
||||
|
||||
val DIFFER =
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Genre>() {
|
||||
override fun areItemsTheSame(oldItem: Genre, newItem: Genre) =
|
||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.songs.size == newItem.songs.size &&
|
||||
oldItem.durationMs == newItem.durationMs
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A tag configuration strategy that automatically adapts the tab layout to the screen size.
|
||||
* - On small screens, use only an icon
|
||||
* - On medium screens, use only text
|
||||
* - On large screens, use text and an icon
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class AdaptiveTabStrategy(context: Context, private val homeModel: HomeViewModel) :
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val tabMode = homeModel.tabs[position]
|
||||
|
||||
when {
|
||||
width < 370 -> {
|
||||
logD("Using icon-only configuration")
|
||||
tab.setIcon(tabMode.icon).setContentDescription(tabMode.string)
|
||||
}
|
||||
width < 600 -> {
|
||||
logD("Using text-only configuration")
|
||||
tab.setText(tabMode.string)
|
||||
}
|
||||
else -> {
|
||||
logD("Using icon-and-text configuration")
|
||||
tab.setIcon(tabMode.icon).setText(tabMode.string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
|
||||
/**
|
||||
* A [FrameLayout] that automatically applies bottom insets.
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class EdgeFrameLayout
|
||||
@JvmOverloads
|
||||
|
|
@ -37,7 +37,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
// Save a layout by simply moving the view bounds upwards
|
||||
// Prevent excessive layouts by using translation instead of padding.
|
||||
translationY = -insets.systemBarInsetsCompat.bottom.toFloat()
|
||||
return insets
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,13 @@ import android.view.MenuItem
|
|||
import android.view.View
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.iterator
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
|
@ -39,37 +40,43 @@ import com.google.android.material.transition.MaterialSharedAxis
|
|||
import java.lang.reflect.Field
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.MainFragmentDirections
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeBinding
|
||||
import org.oxycblt.auxio.home.list.AlbumListFragment
|
||||
import org.oxycblt.auxio.home.list.ArtistListFragment
|
||||
import org.oxycblt.auxio.home.list.GenreListFragment
|
||||
import org.oxycblt.auxio.home.list.SongListFragment
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
|
||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.system.Indexer
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* The main "Launching Point" fragment of Auxio, allowing navigation to the detail views for each
|
||||
* respective item.
|
||||
* @author OxygenCobalt
|
||||
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
|
||||
* to other views.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuItemClickListener {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
class HomeFragment :
|
||||
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
|
||||
private val homeModel: HomeViewModel by androidActivityViewModels()
|
||||
private val musicModel: MusicViewModel by activityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
|
||||
// lifecycleObject builds this in the creation step, so doing this is okay.
|
||||
private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject {
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
musicModel.reindex()
|
||||
musicModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,28 +93,21 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
// our transitions.
|
||||
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1)
|
||||
if (axis > -1) {
|
||||
initAxisTransitions(axis)
|
||||
setupAxisTransitions(axis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
|
||||
|
||||
override fun getSelectionToolbar(binding: FragmentHomeBinding) = binding.homeSelectionToolbar
|
||||
|
||||
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
|
||||
binding.homeAppbar.apply {
|
||||
addOnOffsetChangedListener { _, offset ->
|
||||
val range = binding.homeAppbar.totalScrollRange
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
binding.homeToolbar.alpha = 1f - (abs(offset.toFloat()) / (range.toFloat() / 2))
|
||||
|
||||
binding.homeContent.updatePadding(
|
||||
bottom = binding.homeAppbar.totalScrollRange + offset)
|
||||
}
|
||||
}
|
||||
|
||||
binding.homeToolbar.setOnMenuItemClickListener(this@HomeFragment)
|
||||
|
||||
updateTabConfiguration()
|
||||
// --- UI SETUP ---
|
||||
binding.homeAppbar.addOnOffsetChangedListener(this)
|
||||
binding.homeToolbar.setOnMenuItemClickListener(this)
|
||||
|
||||
// Load the track color in manually as it's unclear whether the track actually supports
|
||||
// using a ColorStateList in the resources
|
||||
|
|
@ -115,39 +115,49 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
requireContext().getColorCompat(R.color.sel_track).defaultColor
|
||||
|
||||
binding.homePager.apply {
|
||||
adapter = HomePagerAdapter()
|
||||
// Update HomeViewModel whenever the user swipes through the ViewPager.
|
||||
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
|
||||
// is an object for some reason.
|
||||
registerOnPageChangeCallback(
|
||||
object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
homeModel.synchronizeTabPosition(position)
|
||||
}
|
||||
})
|
||||
|
||||
// ViewPager2 will nominally consume window insets, which will then break the window
|
||||
// insets applied to the indexing view before API 30. Fix this by overriding the
|
||||
// listener with a non-consuming listener.
|
||||
setOnApplyWindowInsetsListener { _, insets -> insets }
|
||||
|
||||
// We know that there will only be a fixed amount of tabs, so we manually set this
|
||||
// limit to that. This also prevents the appbar lift state from being confused during
|
||||
// page transitions.
|
||||
offscreenPageLimit = homeModel.tabs.size
|
||||
offscreenPageLimit = homeModel.currentTabModes.size
|
||||
|
||||
reduceSensitivity(3)
|
||||
|
||||
registerOnPageChangeCallback(
|
||||
object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) =
|
||||
homeModel.updateCurrentTab(position)
|
||||
})
|
||||
|
||||
TabLayoutMediator(binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel))
|
||||
.attach()
|
||||
|
||||
// ViewPager2 will nominally consume window insets, which will then break the window
|
||||
// insets applied to the indexing view before API 30. Fix this by overriding the
|
||||
// callback with a non-consuming listener.
|
||||
setOnApplyWindowInsetsListener { _, insets -> insets }
|
||||
// By default, ViewPager2's sensitivity is high enough to result in vertical scroll
|
||||
// events being registered as horizontal scroll events. Reflect into the internal
|
||||
// RecyclerView and change the touch slope so that touch actions will act more as a
|
||||
// scroll than as a swipe. Derived from:
|
||||
// https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
|
||||
val recycler = VP_RECYCLER_FIELD.get(this@apply)
|
||||
val slop = RV_TOUCH_SLOP_FIELD.get(recycler) as Int
|
||||
RV_TOUCH_SLOP_FIELD.set(recycler, slop * 3)
|
||||
}
|
||||
|
||||
// Further initialization must be done in the function that also handles
|
||||
// re-creating the ViewPager.
|
||||
setupPager(binding)
|
||||
|
||||
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
|
||||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
|
||||
collect(homeModel.recreateTabs, ::handleRecreateTabs)
|
||||
collectImmediately(homeModel.currentTab, ::updateCurrentTab)
|
||||
collectImmediately(musicModel.libraryExists, homeModel.isFastScrolling, ::updateFab)
|
||||
collectImmediately(musicModel.indexerState, ::handleIndexerState)
|
||||
collect(homeModel.shouldRecreate, ::handleRecreate)
|
||||
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
||||
collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab)
|
||||
collectImmediately(musicModel.indexerState, ::updateIndexerState)
|
||||
collect(navModel.exploreNavigationItem, ::handleNavigation)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
|
@ -161,111 +171,77 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
|
||||
override fun onDestroyBinding(binding: FragmentHomeBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.homeAppbar.removeOnOffsetChangedListener(this)
|
||||
binding.homeToolbar.setOnMenuItemClickListener(null)
|
||||
}
|
||||
|
||||
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
|
||||
val binding = requireBinding()
|
||||
val range = appBarLayout.totalScrollRange
|
||||
// Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap,
|
||||
// the alpha transition is shifted such that the Toolbar becomes fully transparent
|
||||
// when the AppBarLayout is only at half-collapsed.
|
||||
binding.homeSelectionToolbar.alpha =
|
||||
1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
|
||||
binding.homeContent.updatePadding(
|
||||
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
if (super.onMenuItemClick(item)) {
|
||||
return true
|
||||
}
|
||||
|
||||
when (item.itemId) {
|
||||
// Handle main actions (Search, Settings, About)
|
||||
R.id.action_search -> {
|
||||
logD("Navigating to search")
|
||||
initAxisTransitions(MaterialSharedAxis.Z)
|
||||
setupAxisTransitions(MaterialSharedAxis.Z)
|
||||
findNavController().navigate(HomeFragmentDirections.actionShowSearch())
|
||||
}
|
||||
R.id.action_settings -> {
|
||||
logD("Navigating to settings")
|
||||
navModel.mainNavigateTo(MainNavigationAction.Settings)
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
|
||||
}
|
||||
R.id.action_about -> {
|
||||
logD("Navigating to about")
|
||||
navModel.mainNavigateTo(MainNavigationAction.About)
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
|
||||
}
|
||||
|
||||
// Handle sort menu
|
||||
R.id.submenu_sorting -> {
|
||||
// Junk click event when opening the menu
|
||||
}
|
||||
R.id.option_sort_asc -> {
|
||||
item.isChecked = !item.isChecked
|
||||
homeModel.updateCurrentSort(
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel
|
||||
.getSortForDisplay(homeModel.currentTab.value)
|
||||
.getSortForTab(homeModel.currentTabMode.value)
|
||||
.withAscending(item.isChecked))
|
||||
}
|
||||
else -> {
|
||||
// Sorting option was selected, mark it as selected and update the mode
|
||||
item.isChecked = true
|
||||
homeModel.updateCurrentSort(
|
||||
homeModel.setSortForCurrentTab(
|
||||
homeModel
|
||||
.getSortForDisplay(homeModel.currentTab.value)
|
||||
.getSortForTab(homeModel.currentTabMode.value)
|
||||
.withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId))))
|
||||
}
|
||||
}
|
||||
|
||||
// Always handling it one way or another, so always return true
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateCurrentTab(tab: DisplayMode) {
|
||||
// Make sure that we update the scrolling view and allowed menu items whenever
|
||||
// the tab changes.
|
||||
val binding = requireBinding()
|
||||
when (tab) {
|
||||
DisplayMode.SHOW_SONGS -> {
|
||||
updateSortMenu(tab) { id -> id != R.id.option_sort_count }
|
||||
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list
|
||||
}
|
||||
DisplayMode.SHOW_ALBUMS -> {
|
||||
updateSortMenu(tab) { id -> id != R.id.option_sort_album }
|
||||
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list
|
||||
}
|
||||
DisplayMode.SHOW_ARTISTS -> {
|
||||
updateSortMenu(tab) { id ->
|
||||
id == R.id.option_sort_asc ||
|
||||
id == R.id.option_sort_name ||
|
||||
id == R.id.option_sort_count ||
|
||||
id == R.id.option_sort_duration
|
||||
}
|
||||
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_artist_list
|
||||
}
|
||||
DisplayMode.SHOW_GENRES -> {
|
||||
updateSortMenu(tab) { id ->
|
||||
id == R.id.option_sort_asc ||
|
||||
id == R.id.option_sort_name ||
|
||||
id == R.id.option_sort_count ||
|
||||
id == R.id.option_sort_duration
|
||||
}
|
||||
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_genre_list
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun setupPager(binding: FragmentHomeBinding) {
|
||||
binding.homePager.adapter =
|
||||
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
|
||||
|
||||
private fun updateSortMenu(displayMode: DisplayMode, isVisible: (Int) -> Boolean) {
|
||||
val sortMenu = requireNotNull(sortItem.subMenu)
|
||||
val toHighlight = homeModel.getSortForDisplay(displayMode)
|
||||
|
||||
for (option in sortMenu) {
|
||||
if (option.itemId == toHighlight.mode.itemId) {
|
||||
option.isChecked = true
|
||||
}
|
||||
|
||||
if (option.itemId == R.id.option_sort_asc) {
|
||||
option.isChecked = toHighlight.isAscending
|
||||
}
|
||||
|
||||
option.isVisible = isVisible(option.itemId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRecreateTabs(recreate: Boolean) {
|
||||
if (recreate) {
|
||||
requireBinding().homePager.recreate()
|
||||
updateTabConfiguration()
|
||||
homeModel.finishRecreateTabs()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTabConfiguration() {
|
||||
val binding = requireBinding()
|
||||
val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams
|
||||
if (homeModel.tabs.size == 1) {
|
||||
// A single tag makes the tab layout redundant, hide it and disable the collapsing
|
||||
val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams
|
||||
if (homeModel.currentTabModes.size == 1) {
|
||||
// A single tab makes the tab layout redundant, hide it and disable the collapsing
|
||||
// behavior.
|
||||
binding.homeTabs.isVisible = false
|
||||
binding.homeAppbar.setExpanded(true, false)
|
||||
|
|
@ -276,13 +252,79 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or
|
||||
AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
|
||||
}
|
||||
|
||||
// Set up the mapping between the ViewPager and TabLayout.
|
||||
TabLayoutMediator(
|
||||
binding.homeTabs,
|
||||
binding.homePager,
|
||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabModes))
|
||||
.attach()
|
||||
}
|
||||
|
||||
private fun handleIndexerState(state: Indexer.State?) {
|
||||
private fun updateCurrentTab(tabMode: MusicMode) {
|
||||
// Update the sort options to align with those allowed by the tab
|
||||
val isVisible: (Int) -> Boolean =
|
||||
when (tabMode) {
|
||||
// Disallow sorting by count for songs
|
||||
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
|
||||
// Disallow sorting by album for albums
|
||||
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
|
||||
// Only allow sorting by name, count, and duration for artists
|
||||
MusicMode.ARTISTS -> { id ->
|
||||
id == R.id.option_sort_asc ||
|
||||
id == R.id.option_sort_name ||
|
||||
id == R.id.option_sort_count ||
|
||||
id == R.id.option_sort_duration
|
||||
}
|
||||
// Only allow sorting by name, count, and duration for genres
|
||||
MusicMode.GENRES -> { id ->
|
||||
id == R.id.option_sort_asc ||
|
||||
id == R.id.option_sort_name ||
|
||||
id == R.id.option_sort_count ||
|
||||
id == R.id.option_sort_duration
|
||||
}
|
||||
}
|
||||
|
||||
val sortMenu = requireNotNull(sortItem.subMenu)
|
||||
val toHighlight = homeModel.getSortForTab(tabMode)
|
||||
|
||||
for (option in sortMenu) {
|
||||
// Check the ascending option and corresponding sort option to align with
|
||||
// the current sort of the tab.
|
||||
if (option.itemId == toHighlight.mode.itemId ||
|
||||
(option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
|
||||
option.isChecked = true
|
||||
}
|
||||
|
||||
// Disable options that are not allowed by the isVisible lambda
|
||||
option.isVisible = isVisible(option.itemId)
|
||||
}
|
||||
|
||||
// Update the scrolling view in AppBarLayout to align with the current tab's
|
||||
// scrolling state. This prevents the lift state from being confused as one
|
||||
// goes between different tabs.
|
||||
requireBinding().homeAppbar.liftOnScrollTargetViewId = getTabRecyclerId(tabMode)
|
||||
}
|
||||
|
||||
private fun handleRecreate(recreate: Boolean) {
|
||||
if (!recreate) {
|
||||
// Nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
val binding = requireBinding()
|
||||
// Move back to position zero, as there must be a tab there.
|
||||
binding.homePager.currentItem = 0
|
||||
// Make sure tabs are set up to also follow the new ViewPager configuration.
|
||||
setupPager(binding)
|
||||
homeModel.finishRecreate()
|
||||
}
|
||||
|
||||
private fun updateIndexerState(state: Indexer.State?) {
|
||||
val binding = requireBinding()
|
||||
when (state) {
|
||||
is Indexer.State.Complete -> handleIndexerResponse(binding, state.response)
|
||||
is Indexer.State.Indexing -> handleIndexingState(binding, state.indexing)
|
||||
is Indexer.State.Complete -> setupCompleteState(binding, state.response)
|
||||
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
|
||||
null -> {
|
||||
logD("Indexer is in indeterminate state")
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
|
|
@ -290,39 +332,44 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleIndexerResponse(binding: FragmentHomeBinding, response: Indexer.Response) {
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) {
|
||||
if (response is Indexer.Response.Ok) {
|
||||
logD("Received ok response")
|
||||
binding.homeFab.show()
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
} else {
|
||||
logD("Received non-ok response")
|
||||
val context = requireContext()
|
||||
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
|
||||
logD("Received non-ok response $response")
|
||||
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
when (response) {
|
||||
is Indexer.Response.Err -> {
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
logD("Updating UI to Response.Err state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.reindex() }
|
||||
setOnClickListener { musicModel.refresh() }
|
||||
}
|
||||
}
|
||||
is Indexer.Response.NoMusic -> {
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
logD("Updating UI to Response.NoMusic state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||
|
||||
// Configure the action to act as a reload trigger.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_retry)
|
||||
setOnClickListener { musicModel.reindex() }
|
||||
setOnClickListener { musicModel.refresh() }
|
||||
}
|
||||
}
|
||||
is Indexer.Response.NoPerms -> {
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
logD("Updating UI to Response.NoPerms state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||
|
||||
// Configure the action to act as a permission launcher.
|
||||
binding.homeIndexingAction.apply {
|
||||
visibility = View.VISIBLE
|
||||
text = context.getString(R.string.lbl_grant)
|
||||
|
|
@ -336,21 +383,22 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
|
||||
private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
|
||||
// Remove all content except for the progress indicator.
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
||||
binding.homeIndexingAction.visibility = View.INVISIBLE
|
||||
|
||||
val context = requireContext()
|
||||
|
||||
when (indexing) {
|
||||
is Indexer.Indexing.Indeterminate -> {
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.lng_indexing)
|
||||
// In a query/initialization state, show a generic loading status.
|
||||
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
||||
binding.homeIndexingProgress.isIndeterminate = true
|
||||
}
|
||||
is Indexer.Indexing.Songs -> {
|
||||
// Actively loading songs, show the current progress.
|
||||
binding.homeIndexingStatus.text =
|
||||
context.getString(R.string.fmt_indexing, indexing.current, indexing.total)
|
||||
getString(R.string.fmt_indexing, indexing.current, indexing.total)
|
||||
binding.homeIndexingProgress.apply {
|
||||
isIndeterminate = false
|
||||
max = indexing.total
|
||||
|
|
@ -360,9 +408,12 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
}
|
||||
}
|
||||
|
||||
private fun updateFab(hasLoaded: Boolean, isFastScrolling: Boolean) {
|
||||
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
|
||||
val binding = requireBinding()
|
||||
if (!hasLoaded || isFastScrolling) {
|
||||
// If there are no songs, it's likely that the library has not been loaded, so
|
||||
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
|
||||
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
|
||||
if (songs.isEmpty() || isFastScrolling) {
|
||||
binding.homeFab.hide()
|
||||
} else {
|
||||
binding.homeFab.show()
|
||||
|
|
@ -372,23 +423,32 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
private fun handleNavigation(item: Music?) {
|
||||
val action =
|
||||
when (item) {
|
||||
is Song -> HomeFragmentDirections.actionShowAlbum(item.album.id)
|
||||
is Album -> HomeFragmentDirections.actionShowAlbum(item.id)
|
||||
is Artist -> HomeFragmentDirections.actionShowArtist(item.id)
|
||||
is Genre -> HomeFragmentDirections.actionShowGenre(item.id)
|
||||
is Song -> HomeFragmentDirections.actionShowAlbum(item.album.uid)
|
||||
is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
|
||||
is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
|
||||
is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
|
||||
else -> return
|
||||
}
|
||||
|
||||
initAxisTransitions(MaterialSharedAxis.X)
|
||||
|
||||
setupAxisTransitions(MaterialSharedAxis.X)
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
|
||||
private fun initAxisTransitions(axis: Int) {
|
||||
// Sanity check
|
||||
if (axis != MaterialSharedAxis.X && axis != MaterialSharedAxis.Z) {
|
||||
logW("Invalid axis provided")
|
||||
return
|
||||
private fun updateSelection(selected: List<Music>) {
|
||||
val binding = requireBinding()
|
||||
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
|
||||
selected.isNotEmpty()) {
|
||||
logD("Significant selection occurred, expanding AppBar")
|
||||
// Significant enough change where we want to expand the RecyclerView
|
||||
binding.homeAppbar.expandWithRecycler(
|
||||
binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupAxisTransitions(axis: Int) {
|
||||
// Sanity check to avoid in-correct axis transitions
|
||||
check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) {
|
||||
"Not expecting Y axis transition"
|
||||
}
|
||||
|
||||
enterTransition = MaterialSharedAxis(axis, true)
|
||||
|
|
@ -398,42 +458,45 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
|
|||
}
|
||||
|
||||
/**
|
||||
* By default, ViewPager2's sensitivity is high enough to result in vertical scroll events being
|
||||
* registered as horizontal scroll events. Reflect into the internal recyclerview and change the
|
||||
* touch slope so that touch actions will act more as a scroll than as a swipe. Derived from:
|
||||
* https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
|
||||
* Get the ID of the RecyclerView contained by [ViewPager2] tab represented with the given
|
||||
* [MusicMode].
|
||||
* @param tabMode The [MusicMode] of the tab.
|
||||
* @return The ID of the RecyclerView contained by the given tab.
|
||||
*/
|
||||
private fun ViewPager2.reduceSensitivity(by: Int) {
|
||||
val recycler = VIEW_PAGER_RECYCLER_FIELD.get(this@reduceSensitivity)
|
||||
val slop = VIEW_PAGER_TOUCH_SLOP_FIELD.get(recycler) as Int
|
||||
VIEW_PAGER_TOUCH_SLOP_FIELD.set(recycler, slop * by)
|
||||
}
|
||||
|
||||
/** Forces the view to recreate all fragments contained within it. */
|
||||
private fun ViewPager2.recreate() {
|
||||
currentItem = 0
|
||||
adapter = HomePagerAdapter()
|
||||
}
|
||||
|
||||
private inner class HomePagerAdapter :
|
||||
FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) {
|
||||
|
||||
override fun getItemCount(): Int = homeModel.tabs.size
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (homeModel.tabs[position]) {
|
||||
DisplayMode.SHOW_SONGS -> SongListFragment()
|
||||
DisplayMode.SHOW_ALBUMS -> AlbumListFragment()
|
||||
DisplayMode.SHOW_ARTISTS -> ArtistListFragment()
|
||||
DisplayMode.SHOW_GENRES -> GenreListFragment()
|
||||
}
|
||||
private fun getTabRecyclerId(tabMode: MusicMode) =
|
||||
when (tabMode) {
|
||||
MusicMode.SONGS -> R.id.home_song_recycler
|
||||
MusicMode.ALBUMS -> R.id.home_album_recycler
|
||||
MusicMode.ARTISTS -> R.id.home_artist_recycler
|
||||
MusicMode.GENRES -> R.id.home_genre_recycler
|
||||
}
|
||||
|
||||
/**
|
||||
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
|
||||
* @param tabs The current tab configuration. This will define the [Fragment]s created.
|
||||
* @param fragmentManager The [FragmentManager] required by [FragmentStateAdapter].
|
||||
* @param lifecycleOwner The [LifecycleOwner], whose Lifecycle is required by
|
||||
* [FragmentStateAdapter].
|
||||
*/
|
||||
private class HomePagerAdapter(
|
||||
private val tabs: List<MusicMode>,
|
||||
fragmentManager: FragmentManager,
|
||||
lifecycleOwner: LifecycleOwner
|
||||
) : FragmentStateAdapter(fragmentManager, lifecycleOwner.lifecycle) {
|
||||
override fun getItemCount() = tabs.size
|
||||
override fun createFragment(position: Int): Fragment =
|
||||
when (tabs[position]) {
|
||||
MusicMode.SONGS -> SongListFragment()
|
||||
MusicMode.ALBUMS -> AlbumListFragment()
|
||||
MusicMode.ARTISTS -> ArtistListFragment()
|
||||
MusicMode.GENRES -> GenreListFragment()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val VIEW_PAGER_RECYCLER_FIELD: Field by
|
||||
private val VP_RECYCLER_FIELD: Field by
|
||||
lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||
private val VIEW_PAGER_TOUCH_SLOP_FIELD: Field by
|
||||
private val RV_TOUCH_SLOP_FIELD: Field by
|
||||
lazyReflectedField(RecyclerView::class, "mTouchSlop")
|
||||
private const val KEY_LAST_TRANSITION_AXIS =
|
||||
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
|
||||
|
|
|
|||
|
|
@ -26,135 +26,184 @@ import org.oxycblt.auxio.home.tabs.Tab
|
|||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.util.application
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
|
||||
* @author OxygenCobalt
|
||||
* The ViewModel for managing the tab data and lists of the home view.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class HomeViewModel(application: Application) :
|
||||
AndroidViewModel(application), Settings.Callback, MusicStore.Callback {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
private val settings = Settings(application, this)
|
||||
|
||||
private val _songs = MutableStateFlow(listOf<Song>())
|
||||
val songs: StateFlow<List<Song>>
|
||||
get() = _songs
|
||||
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val songLists: StateFlow<List<Song>>
|
||||
get() = _songsList
|
||||
|
||||
private val _albums = MutableStateFlow(listOf<Album>())
|
||||
val albums: StateFlow<List<Album>>
|
||||
get() = _albums
|
||||
private val _albumsLists = MutableStateFlow(listOf<Album>())
|
||||
/** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val albumsList: StateFlow<List<Album>>
|
||||
get() = _albumsLists
|
||||
|
||||
private val _artists = MutableStateFlow(listOf<Artist>())
|
||||
val artists: MutableStateFlow<List<Artist>>
|
||||
get() = _artists
|
||||
private val _artistsList = MutableStateFlow(listOf<Artist>())
|
||||
/**
|
||||
* A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that
|
||||
* if "Hide collaborators" is on, this list will not include [Artist]s where
|
||||
* [Artist.isCollaborator] is true.
|
||||
*/
|
||||
val artistsList: MutableStateFlow<List<Artist>>
|
||||
get() = _artistsList
|
||||
|
||||
private val _genres = MutableStateFlow(listOf<Genre>())
|
||||
val genres: StateFlow<List<Genre>>
|
||||
get() = _genres
|
||||
|
||||
var tabs: List<DisplayMode> = visibleTabs
|
||||
private set
|
||||
|
||||
/** Internal getter for getting the visible library tabs */
|
||||
private val visibleTabs: List<DisplayMode>
|
||||
get() = settings.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
|
||||
private val _currentTab = MutableStateFlow(tabs[0])
|
||||
val currentTab: StateFlow<DisplayMode> = _currentTab
|
||||
private val _genresList = MutableStateFlow(listOf<Genre>())
|
||||
/** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||
val genresList: StateFlow<List<Genre>>
|
||||
get() = _genresList
|
||||
|
||||
/**
|
||||
* Marker to recreate all library tabs, usually initiated by a settings change. When this flag
|
||||
* is set, all tabs (and their respective viewpager fragments) will be recreated from scratch.
|
||||
* A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible
|
||||
* [Tab]s.
|
||||
*/
|
||||
private val _shouldRecreateTabs = MutableStateFlow(false)
|
||||
val recreateTabs: StateFlow<Boolean> = _shouldRecreateTabs
|
||||
var currentTabModes: List<MusicMode> = makeTabModes()
|
||||
private set
|
||||
|
||||
private val _currentTabMode = MutableStateFlow(currentTabModes[0])
|
||||
/** The [MusicMode] of the currently shown [Tab]. */
|
||||
val currentTabMode: StateFlow<MusicMode> = _currentTabMode
|
||||
|
||||
private val _shouldRecreate = MutableStateFlow(false)
|
||||
/**
|
||||
* A marker to re-create all library tabs, usually initiated by a settings change. When this
|
||||
* flag is true, all tabs (and their respective ViewPager2 fragments) will be re-created from
|
||||
* scratch.
|
||||
*/
|
||||
val shouldRecreate: StateFlow<Boolean> = _shouldRecreate
|
||||
|
||||
private val _isFastScrolling = MutableStateFlow(false)
|
||||
/** A marker for whether the user is fast-scrolling in the home view or not. */
|
||||
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||
|
||||
init {
|
||||
musicStore.addCallback(this)
|
||||
}
|
||||
|
||||
/** Update the current tab based off of the new ViewPager position. */
|
||||
fun updateCurrentTab(pos: Int) {
|
||||
logD("Updating current tab to ${tabs[pos]}")
|
||||
_currentTab.value = tabs[pos]
|
||||
}
|
||||
|
||||
fun finishRecreateTabs() {
|
||||
_shouldRecreateTabs.value = false
|
||||
}
|
||||
|
||||
/** Get the specific sort for the given [DisplayMode]. */
|
||||
fun getSortForDisplay(displayMode: DisplayMode) =
|
||||
when (displayMode) {
|
||||
DisplayMode.SHOW_SONGS -> settings.libSongSort
|
||||
DisplayMode.SHOW_ALBUMS -> settings.libAlbumSort
|
||||
DisplayMode.SHOW_ARTISTS -> settings.libArtistSort
|
||||
DisplayMode.SHOW_GENRES -> settings.libGenreSort
|
||||
}
|
||||
|
||||
/** Update the currently displayed item's [Sort]. */
|
||||
fun updateCurrentSort(sort: Sort) {
|
||||
logD("Updating ${_currentTab.value} sort to $sort")
|
||||
when (_currentTab.value) {
|
||||
DisplayMode.SHOW_SONGS -> {
|
||||
settings.libSongSort = sort
|
||||
_songs.value = sort.songs(_songs.value)
|
||||
}
|
||||
DisplayMode.SHOW_ALBUMS -> {
|
||||
settings.libAlbumSort = sort
|
||||
_albums.value = sort.albums(_albums.value)
|
||||
}
|
||||
DisplayMode.SHOW_ARTISTS -> {
|
||||
settings.libArtistSort = sort
|
||||
_artists.value = sort.artists(_artists.value)
|
||||
}
|
||||
DisplayMode.SHOW_GENRES -> {
|
||||
settings.libGenreSort = sort
|
||||
_genres.value = sort.genres(_genres.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the fast scroll state. This is used to control the FAB visibility whenever the user
|
||||
* begins to fast scroll.
|
||||
*/
|
||||
fun updateFastScrolling(scrolling: Boolean) {
|
||||
logD("Updating fast scrolling state: $scrolling")
|
||||
_isFastScrolling.value = scrolling
|
||||
}
|
||||
|
||||
// --- OVERRIDES ---
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
if (library != null) {
|
||||
_songs.value = settings.libSongSort.songs(library.songs)
|
||||
_albums.value = settings.libAlbumSort.albums(library.albums)
|
||||
_artists.value = settings.libArtistSort.artists(library.artists)
|
||||
_genres.value = settings.libGenreSort.genres(library.genres)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String) {
|
||||
if (key == application.getString(R.string.set_key_lib_tabs)) {
|
||||
tabs = visibleTabs
|
||||
_shouldRecreateTabs.value = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicStore.removeCallback(this)
|
||||
settings.release()
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
if (library != null) {
|
||||
logD("Library changed, refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
_songsList.value = settings.libSongSort.songs(library.songs)
|
||||
_albumsLists.value = settings.libAlbumSort.albums(library.albums)
|
||||
_artistsList.value =
|
||||
settings.libArtistSort.artists(
|
||||
if (settings.shouldHideCollaborators) {
|
||||
// Hide Collaborators is enabled, filter out collaborators.
|
||||
library.artists.filter { !it.isCollaborator }
|
||||
} else {
|
||||
library.artists
|
||||
})
|
||||
_genresList.value = settings.libGenreSort.genres(library.genres)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String) {
|
||||
when (key) {
|
||||
context.getString(R.string.set_key_lib_tabs) -> {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
currentTabModes = makeTabModes()
|
||||
_shouldRecreate.value = true
|
||||
}
|
||||
context.getString(R.string.set_key_hide_collaborators) -> {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
onLibraryChanged(musicStore.library)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update [currentTabMode] to reflect a new ViewPager2 position
|
||||
* @param pagerPos The new position of the ViewPager2 instance.
|
||||
*/
|
||||
fun synchronizeTabPosition(pagerPos: Int) {
|
||||
logD("Updating current tab to ${currentTabModes[pagerPos]}")
|
||||
_currentTabMode.value = currentTabModes[pagerPos]
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the recreation process as complete.
|
||||
* @see shouldRecreate
|
||||
*/
|
||||
fun finishRecreate() {
|
||||
_shouldRecreate.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preferred [Sort] for a given [Tab].
|
||||
* @param tabMode The [MusicMode] of the [Tab] desired.
|
||||
* @return The [Sort] preferred for that [Tab]
|
||||
*/
|
||||
fun getSortForTab(tabMode: MusicMode) =
|
||||
when (tabMode) {
|
||||
MusicMode.SONGS -> settings.libSongSort
|
||||
MusicMode.ALBUMS -> settings.libAlbumSort
|
||||
MusicMode.ARTISTS -> settings.libArtistSort
|
||||
MusicMode.GENRES -> settings.libGenreSort
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preferred [Sort] for the current [Tab]. Will update corresponding list.
|
||||
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
|
||||
*/
|
||||
fun setSortForCurrentTab(sort: Sort) {
|
||||
logD("Updating ${_currentTabMode.value} sort to $sort")
|
||||
// Can simply re-sort the current list of items without having to access the library.
|
||||
when (_currentTabMode.value) {
|
||||
MusicMode.SONGS -> {
|
||||
settings.libSongSort = sort
|
||||
_songsList.value = sort.songs(_songsList.value)
|
||||
}
|
||||
MusicMode.ALBUMS -> {
|
||||
settings.libAlbumSort = sort
|
||||
_albumsLists.value = sort.albums(_albumsLists.value)
|
||||
}
|
||||
MusicMode.ARTISTS -> {
|
||||
settings.libArtistSort = sort
|
||||
_artistsList.value = sort.artists(_artistsList.value)
|
||||
}
|
||||
MusicMode.GENRES -> {
|
||||
settings.libGenreSort = sort
|
||||
_genresList.value = sort.genres(_genresList.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update whether the user is fast scrolling or not in the home view.
|
||||
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
||||
*/
|
||||
fun setFastScrolling(isFastScrolling: Boolean) {
|
||||
logD("Updating fast scrolling state: $isFastScrolling")
|
||||
_isFastScrolling.value = isFastScrolling
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list of [MusicMode]s representing a simpler version of the [Tab] configuration.
|
||||
* @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in
|
||||
* the same way as the configuration.
|
||||
*/
|
||||
private fun makeTabModes() = settings.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui.fastscroll
|
||||
package org.oxycblt.auxio.home.fastscroll
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
|
|
@ -35,20 +35,20 @@ import androidx.core.widget.TextViewCompat
|
|||
import com.google.android.material.textview.MaterialTextView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenSize
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.isRtl
|
||||
|
||||
/**
|
||||
* Internal view responsible for the fast scroller popup.
|
||||
* @author OxygenCobalt, Hai Zhang
|
||||
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
|
||||
* @author Alexander Capehart (OxygenCobalt), Hai Zhang
|
||||
*/
|
||||
class FastScrollPopupView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
|
||||
MaterialTextView(context, attrs, defStyleRes) {
|
||||
init {
|
||||
minimumWidth = context.getDimenSize(R.dimen.fast_scroll_popup_min_width)
|
||||
minimumHeight = context.getDimenSize(R.dimen.fast_scroll_popup_min_height)
|
||||
minimumWidth = context.getDimenPixels(R.dimen.fast_scroll_popup_min_width)
|
||||
minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
|
||||
|
||||
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
|
||||
setTextColor(context.getAttrColorCompat(R.attr.colorOnSecondary))
|
||||
|
|
@ -57,7 +57,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
|
|||
includeFontPadding = false
|
||||
|
||||
alpha = 0f
|
||||
elevation = context.getDimenSize(R.dimen.elevation_normal).toFloat()
|
||||
elevation = context.getDimenPixels(R.dimen.elevation_normal).toFloat()
|
||||
background = FastScrollPopupDrawable(context)
|
||||
}
|
||||
|
||||
|
|
@ -72,8 +72,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
|
|||
private val path = Path()
|
||||
private val matrix = Matrix()
|
||||
|
||||
private val paddingStart = context.getDimenSize(R.dimen.fast_scroll_popup_padding_start)
|
||||
private val paddingEnd = context.getDimenSize(R.dimen.fast_scroll_popup_padding_end)
|
||||
private val paddingStart = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_start)
|
||||
private val paddingEnd = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_end)
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
canvas.drawPath(path, paint)
|
||||
|
|
@ -170,7 +170,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
|
|||
}
|
||||
|
||||
companion object {
|
||||
// Pre-calculate sqrt(2) for faster drawing
|
||||
// Pre-calculate sqrt(2)
|
||||
private const val SQRT2 = 1.4142135f
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui.fastscroll
|
||||
package org.oxycblt.auxio.home.fastscroll
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
|
|
@ -35,12 +35,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.recycler.AuxioRecyclerView
|
||||
import org.oxycblt.auxio.util.getDimenSize
|
||||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
import org.oxycblt.auxio.util.isRtl
|
||||
import org.oxycblt.auxio.util.isUnder
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of
|
||||
|
|
@ -65,12 +61,36 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
* - Added drag listener
|
||||
* - Added documentation
|
||||
*
|
||||
* @author Hai Zhang, OxygenCobalt
|
||||
* TODO: Add vibration when popup changes
|
||||
*
|
||||
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
|
||||
*
|
||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class FastScrollRecyclerView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
||||
/** An interface to provide text to use in the popup when fast-scrolling. */
|
||||
interface PopupProvider {
|
||||
/**
|
||||
* Get text to use in the popup at the specified position.
|
||||
* @param pos The position in the list.
|
||||
* @return A [String] to use in the popup. Null if there is no applicable text for the popup
|
||||
* at [pos].
|
||||
*/
|
||||
fun getPopup(pos: Int): String?
|
||||
}
|
||||
|
||||
/** A listener for fast scroller interactions. */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the fast scrolling state changes.
|
||||
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
|
||||
*/
|
||||
fun onFastScrollingChanged(isFastScrolling: Boolean)
|
||||
}
|
||||
|
||||
// Thumb
|
||||
private val thumbView =
|
||||
View(context).apply {
|
||||
|
|
@ -98,7 +118,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
.apply {
|
||||
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
|
||||
marginEnd = context.getDimenSize(R.dimen.spacing_small)
|
||||
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +126,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
// Touch
|
||||
private val minTouchTargetSize =
|
||||
context.getDimenSize(R.dimen.fast_scroll_thumb_touch_target_size)
|
||||
context.getDimenPixels(R.dimen.fast_scroll_thumb_touch_target_size)
|
||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
|
||||
private var downX = 0f
|
||||
|
|
@ -133,33 +153,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
removeCallbacks(hideThumbRunnable)
|
||||
showScrollbar()
|
||||
showPopup()
|
||||
listener?.onFastScrollStart()
|
||||
} else {
|
||||
postAutoHideScrollbar()
|
||||
hidePopup()
|
||||
listener?.onFastScrollStop()
|
||||
}
|
||||
|
||||
listener?.onFastScrollingChanged(field)
|
||||
}
|
||||
|
||||
private val tRect = Rect()
|
||||
|
||||
interface PopupProvider {
|
||||
fun getPopup(pos: Int): String?
|
||||
}
|
||||
|
||||
/** Callback to provide a string to be shown on the popup when an item is passed */
|
||||
var popupProvider: PopupProvider? = null
|
||||
|
||||
interface OnFastScrollListener {
|
||||
fun onFastScrollStart()
|
||||
fun onFastScrollStop()
|
||||
}
|
||||
|
||||
/**
|
||||
* A listener for when a drag event occurs. The value will be true if a drag has begun, and
|
||||
* false if a drag ended.
|
||||
*/
|
||||
var listener: OnFastScrollListener? = null
|
||||
var listener: Listener? = null
|
||||
|
||||
init {
|
||||
overlay.add(thumbView)
|
||||
|
|
@ -208,13 +213,20 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
||||
|
||||
val firstPos = firstAdapterPos
|
||||
val child = getChildAt(0)
|
||||
val firstAdapterPos =
|
||||
if (child != null) {
|
||||
layoutManager?.getPosition(child) ?: NO_POSITION
|
||||
} else {
|
||||
NO_POSITION
|
||||
}
|
||||
|
||||
val popupText: String
|
||||
val provider = popupProvider
|
||||
if (firstPos != NO_POSITION && provider != null) {
|
||||
if (firstAdapterPos != NO_POSITION && provider != null) {
|
||||
popupView.isInvisible = false
|
||||
// Get the popup text. If there is none, we default to "?".
|
||||
popupText = provider.getPopup(firstPos) ?: "?"
|
||||
popupText = provider.getPopup(firstAdapterPos) ?: "?"
|
||||
} else {
|
||||
// No valid position or provider, do not show the popup.
|
||||
popupView.isInvisible = true
|
||||
|
|
@ -298,6 +310,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
// Combine the previous item dimensions with the current item top to find our scroll
|
||||
// position
|
||||
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
|
||||
val child = getChildAt(0)
|
||||
val firstAdapterPos =
|
||||
when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
|
||||
is LinearLayoutManager -> mgr.getPosition(child)
|
||||
else -> 0
|
||||
}
|
||||
|
||||
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
|
||||
|
||||
// Then calculate the thumb position, which is just:
|
||||
|
|
@ -332,7 +352,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
if (!dragging &&
|
||||
thumbView.isUnder(downX, thumbView.top.toFloat(), minTouchTargetSize) &&
|
||||
abs(eventY - downY) > touchSlop) {
|
||||
|
||||
if (thumbView.isUnder(downX, downY, minTouchTargetSize)) {
|
||||
dragStartY = lastY
|
||||
dragStartThumbOffset = thumbOffset
|
||||
|
|
@ -413,7 +432,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
showingThumb = true
|
||||
animateView(thumbView, 1f)
|
||||
animateViewIn(thumbView)
|
||||
}
|
||||
|
||||
private fun hideScrollbar() {
|
||||
|
|
@ -422,7 +441,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
showingThumb = false
|
||||
animateView(thumbView, 0f)
|
||||
animateViewOut(thumbView)
|
||||
}
|
||||
|
||||
private fun showPopup() {
|
||||
|
|
@ -431,7 +450,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
showingPopup = true
|
||||
animateView(popupView, 1f)
|
||||
animateViewIn(popupView)
|
||||
}
|
||||
|
||||
private fun hidePopup() {
|
||||
|
|
@ -440,11 +459,23 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
showingPopup = false
|
||||
animateView(popupView, 0f)
|
||||
animateViewOut(popupView)
|
||||
}
|
||||
|
||||
private fun animateView(view: View, alpha: Float) {
|
||||
view.animate().alpha(alpha).setDuration(ANIM_MILLIS).start()
|
||||
private fun animateViewIn(view: View) {
|
||||
view
|
||||
.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(context.getInteger(R.integer.anim_fade_enter_duration).toLong())
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun animateViewOut(view: View) {
|
||||
view
|
||||
.animate()
|
||||
.alpha(0f)
|
||||
.setDuration(context.getInteger(R.integer.anim_fade_exit_duration).toLong())
|
||||
.start()
|
||||
}
|
||||
|
||||
// --- LAYOUT STATE ---
|
||||
|
|
@ -474,21 +505,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
private val scrollOffsetRange: Int
|
||||
get() = scrollRange - height
|
||||
|
||||
private val firstAdapterPos: Int
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
return NO_POSITION
|
||||
}
|
||||
|
||||
val child = getChildAt(0)
|
||||
|
||||
return when (val mgr = layoutManager) {
|
||||
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
|
||||
is LinearLayoutManager -> mgr.getPosition(child)
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private val itemHeight: Int
|
||||
get() {
|
||||
if (childCount == 0) {
|
||||
|
|
@ -509,7 +525,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
companion object {
|
||||
private const val ANIM_MILLIS = 150L
|
||||
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
|
||||
}
|
||||
}
|
||||
|
|
@ -19,59 +19,83 @@ package org.oxycblt.auxio.home.list
|
|||
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import java.util.*
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import java.util.Formatter
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.recycler.AlbumViewHolder
|
||||
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.util.secsToMs
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Album]s.
|
||||
* @author OxygenCobalt
|
||||
* A [ListFragment] that shows a list of [Album]s.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumListFragment : HomeListFragment<Album>() {
|
||||
private val homeAdapter = AlbumAdapter(this)
|
||||
private val formatterSb = StringBuilder(32)
|
||||
class AlbumListFragment :
|
||||
ListFragment<FragmentHomeListBinding>(),
|
||||
FastScrollRecyclerView.Listener,
|
||||
FastScrollRecyclerView.PopupProvider {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val albumAdapter = AlbumAdapter(this)
|
||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
||||
private val formatterSb = StringBuilder(64)
|
||||
private val formatter = Formatter(formatterSb)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
FragmentHomeListBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
binding.homeRecycler.apply {
|
||||
id = R.id.home_album_list
|
||||
adapter = homeAdapter
|
||||
id = R.id.home_album_recycler
|
||||
adapter = albumAdapter
|
||||
popupProvider = this@AlbumListFragment
|
||||
listener = this@AlbumListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.albums, homeAdapter::replaceList)
|
||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent)
|
||||
collectImmediately(homeModel.albumsList, albumAdapter::replaceList)
|
||||
collectImmediately(selectionModel.selected, albumAdapter::setSelectedItems)
|
||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.homeRecycler.apply {
|
||||
adapter = null
|
||||
popupProvider = null
|
||||
listener = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val album = homeModel.albums.value[pos]
|
||||
|
||||
// Change how we display the popup depending on the mode.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS).mode) {
|
||||
val album = homeModel.albumsList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> album.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> album.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// By Artist -> Use Artist Name
|
||||
is Sort.Mode.ByArtist -> album.artist.sortName?.run { first().uppercase() }
|
||||
// By Artist -> Use name of first artist
|
||||
is Sort.Mode.ByArtist ->
|
||||
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext())
|
||||
is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext())
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
|
||||
|
|
@ -97,47 +121,47 @@ class AlbumListFragment : HomeListFragment<Album>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
check(item is Music)
|
||||
navModel.exploreNavigateTo(item)
|
||||
override fun onFastScrollingChanged(isFastScrolling: Boolean) {
|
||||
homeModel.setFastScrolling(isFastScrolling)
|
||||
}
|
||||
|
||||
override fun onRealClick(music: Music) {
|
||||
check(music is Album) { "Unexpected datatype: ${music::class.java}" }
|
||||
navModel.exploreNavigateTo(music)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
when (item) {
|
||||
is Album -> musicMenu(anchor, R.menu.menu_album_actions, item)
|
||||
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
|
||||
}
|
||||
check(item is Album) { "Unexpected datatype: ${item::class.java}" }
|
||||
openMusicMenu(anchor, R.menu.menu_album_actions, item)
|
||||
}
|
||||
|
||||
private fun handleParent(parent: MusicParent?, isPlaying: Boolean) {
|
||||
if (parent is Album) {
|
||||
homeAdapter.updateIndicator(parent, isPlaying)
|
||||
} else {
|
||||
// Ignore playback not from albums
|
||||
homeAdapter.updateIndicator(null, isPlaying)
|
||||
}
|
||||
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
||||
// If an album is playing, highlight it within this adapter.
|
||||
albumAdapter.setPlayingItem(parent as? Album, isPlaying)
|
||||
}
|
||||
|
||||
private class AlbumAdapter(private val listener: MenuItemListener) :
|
||||
IndicatorAdapter<AlbumViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFFER)
|
||||
/**
|
||||
* A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder].
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
private class AlbumAdapter(private val listener: SelectableListListener) :
|
||||
SelectionIndicatorAdapter<AlbumViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
AlbumViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int, payloads: List<Any>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously update the list with new [Album]s.
|
||||
* @param newList The new [Album]s for the adapter to display.
|
||||
*/
|
||||
fun replaceList(newList: List<Album>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,106 +18,125 @@
|
|||
package org.oxycblt.auxio.home.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Artist]s.
|
||||
* @author OxygenCobalt
|
||||
* A [ListFragment] that shows a list of [Artist]s.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistListFragment : HomeListFragment<Artist>() {
|
||||
class ArtistListFragment :
|
||||
ListFragment<FragmentHomeListBinding>(),
|
||||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.Listener {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val homeAdapter = ArtistAdapter(this)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
FragmentHomeListBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
binding.homeRecycler.apply {
|
||||
id = R.id.home_artist_list
|
||||
id = R.id.home_artist_recycler
|
||||
adapter = homeAdapter
|
||||
popupProvider = this@ArtistListFragment
|
||||
listener = this@ArtistListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.artists, homeAdapter::replaceList)
|
||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent)
|
||||
collectImmediately(homeModel.artistsList, homeAdapter::replaceList)
|
||||
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
|
||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.homeRecycler.apply {
|
||||
adapter = null
|
||||
popupProvider = null
|
||||
listener = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val artist = homeModel.artists.value[pos]
|
||||
|
||||
// Change how we display the popup depending on the mode.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ARTISTS).mode) {
|
||||
val artist = homeModel.artistsList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> artist.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> artist.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> artist.durationMs.formatDurationMs(false)
|
||||
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||
|
||||
// Count -> Use song count
|
||||
is Sort.Mode.ByCount -> artist.songs.size.toString()
|
||||
is Sort.Mode.ByCount -> artist.songs.size.nonZeroOrNull()?.toString()
|
||||
|
||||
// Unsupported sort, error gracefully
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
check(item is Music)
|
||||
navModel.exploreNavigateTo(item)
|
||||
override fun onFastScrollingChanged(isFastScrolling: Boolean) {
|
||||
homeModel.setFastScrolling(isFastScrolling)
|
||||
}
|
||||
|
||||
override fun onRealClick(music: Music) {
|
||||
check(music is Artist) { "Unexpected datatype: ${music::class.java}" }
|
||||
navModel.exploreNavigateTo(music)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
when (item) {
|
||||
is Artist -> musicMenu(anchor, R.menu.menu_genre_artist_actions, item)
|
||||
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
|
||||
}
|
||||
check(item is Artist) { "Unexpected datatype: ${item::class.java}" }
|
||||
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
||||
}
|
||||
|
||||
private fun handleParent(parent: MusicParent?, isPlaying: Boolean) {
|
||||
if (parent is Artist) {
|
||||
homeAdapter.updateIndicator(parent, isPlaying)
|
||||
} else {
|
||||
// Ignore playback not from artists
|
||||
homeAdapter.updateIndicator(null, isPlaying)
|
||||
}
|
||||
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
||||
// If an artist is playing, highlight it within this adapter.
|
||||
homeAdapter.setPlayingItem(parent as? Artist, isPlaying)
|
||||
}
|
||||
|
||||
private class ArtistAdapter(private val listener: MenuItemListener) :
|
||||
IndicatorAdapter<ArtistViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFFER)
|
||||
/**
|
||||
* A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder].
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
private class ArtistAdapter(private val listener: SelectableListListener) :
|
||||
SelectionIndicatorAdapter<ArtistViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
ArtistViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(
|
||||
holder: ArtistViewHolder,
|
||||
position: Int,
|
||||
payloads: List<Any>
|
||||
) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously update the list with new [Artist]s.
|
||||
* @param newList The new [Artist]s for the adapter to display.
|
||||
*/
|
||||
fun replaceList(newList: List<Artist>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,49 +18,71 @@
|
|||
package org.oxycblt.auxio.home.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.recycler.GenreViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.recycler.GenreViewHolder
|
||||
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Genre]s.
|
||||
* @author OxygenCobalt
|
||||
* A [ListFragment] that shows a list of [Genre]s.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreListFragment : HomeListFragment<Genre>() {
|
||||
class GenreListFragment :
|
||||
ListFragment<FragmentHomeListBinding>(),
|
||||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.Listener {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val homeAdapter = GenreAdapter(this)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
FragmentHomeListBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
binding.homeRecycler.apply {
|
||||
id = R.id.home_genre_list
|
||||
id = R.id.home_genre_recycler
|
||||
adapter = homeAdapter
|
||||
popupProvider = this@GenreListFragment
|
||||
listener = this@GenreListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.genres, homeAdapter::replaceList)
|
||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
|
||||
collectImmediately(homeModel.genresList, homeAdapter::replaceList)
|
||||
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
|
||||
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.homeRecycler.apply {
|
||||
adapter = null
|
||||
popupProvider = null
|
||||
listener = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val genre = homeModel.genres.value[pos]
|
||||
|
||||
// Change how we display the popup depending on the mode.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_GENRES).mode) {
|
||||
val genre = homeModel.genresList.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> genre.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> genre.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
||||
|
|
@ -73,47 +95,47 @@ class GenreListFragment : HomeListFragment<Genre>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
check(item is Music)
|
||||
navModel.exploreNavigateTo(item)
|
||||
override fun onFastScrollingChanged(isFastScrolling: Boolean) {
|
||||
homeModel.setFastScrolling(isFastScrolling)
|
||||
}
|
||||
|
||||
override fun onRealClick(music: Music) {
|
||||
check(music is Genre) { "Unexpected datatype: ${music::class.java}" }
|
||||
navModel.exploreNavigateTo(music)
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
when (item) {
|
||||
is Genre -> musicMenu(anchor, R.menu.menu_genre_artist_actions, item)
|
||||
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
|
||||
}
|
||||
check(item is Genre) { "Unexpected datatype: ${item::class.java}" }
|
||||
openMusicMenu(anchor, R.menu.menu_artist_actions, item)
|
||||
}
|
||||
|
||||
private fun handlePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
||||
if (parent is Genre) {
|
||||
homeAdapter.updateIndicator(parent, isPlaying)
|
||||
} else {
|
||||
// Ignore playback not from genres
|
||||
homeAdapter.updateIndicator(null, isPlaying)
|
||||
}
|
||||
private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
|
||||
// If a genre is playing, highlight it within this adapter.
|
||||
homeAdapter.setPlayingItem(parent as? Genre, isPlaying)
|
||||
}
|
||||
|
||||
private class GenreAdapter(private val listener: MenuItemListener) :
|
||||
IndicatorAdapter<GenreViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, GenreViewHolder.DIFFER)
|
||||
/**
|
||||
* A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder].
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
private class GenreAdapter(private val listener: SelectableListListener) :
|
||||
SelectionIndicatorAdapter<GenreViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
GenreViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: GenreViewHolder, position: Int, payloads: List<Any>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously update the list with new [Genre]s.
|
||||
* @param newList The new [Genre]s for the adapter to display.
|
||||
*/
|
||||
fun replaceList(newList: List<Genre>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.home.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.ui.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.ui.fragment.MenuFragment
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
|
||||
/**
|
||||
* A Base [Fragment] implementing the base features shared across all list fragments in the home UI.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
abstract class HomeListFragment<T : Item> :
|
||||
MenuFragment<FragmentHomeListBinding>(),
|
||||
MenuItemListener,
|
||||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.OnFastScrollListener {
|
||||
protected val homeModel: HomeViewModel by androidActivityViewModels()
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
FragmentHomeListBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
|
||||
binding.homeRecycler.popupProvider = this
|
||||
binding.homeRecycler.listener = this
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||
homeModel.updateFastScrolling(false)
|
||||
binding.homeRecycler.apply {
|
||||
adapter = null
|
||||
popupProvider = null
|
||||
listener = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFastScrollStart() {
|
||||
homeModel.updateFastScrolling(true)
|
||||
}
|
||||
|
||||
override fun onFastScrollStop() {
|
||||
homeModel.updateFastScrolling(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -19,67 +19,91 @@ package org.oxycblt.auxio.home.list
|
|||
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import java.util.Formatter
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
|
||||
import org.oxycblt.auxio.home.HomeViewModel
|
||||
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
|
||||
import org.oxycblt.auxio.list.*
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
|
||||
import org.oxycblt.auxio.ui.recycler.Item
|
||||
import org.oxycblt.auxio.ui.recycler.MenuItemListener
|
||||
import org.oxycblt.auxio.ui.recycler.SongViewHolder
|
||||
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.formatDurationMs
|
||||
import org.oxycblt.auxio.util.secsToMs
|
||||
|
||||
/**
|
||||
* A [HomeListFragment] for showing a list of [Song]s.
|
||||
* @author OxygenCobalt
|
||||
* A [ListFragment] that shows a list of [Song]s.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SongListFragment : HomeListFragment<Song>() {
|
||||
class SongListFragment :
|
||||
ListFragment<FragmentHomeListBinding>(),
|
||||
FastScrollRecyclerView.PopupProvider,
|
||||
FastScrollRecyclerView.Listener {
|
||||
private val homeModel: HomeViewModel by activityViewModels()
|
||||
private val homeAdapter = SongAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
private val formatterSb = StringBuilder(50)
|
||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
||||
private val formatterSb = StringBuilder(64)
|
||||
private val formatter = Formatter(formatterSb)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
FragmentHomeListBinding.inflate(inflater)
|
||||
|
||||
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
binding.homeRecycler.apply {
|
||||
id = R.id.home_song_list
|
||||
id = R.id.home_song_recycler
|
||||
adapter = homeAdapter
|
||||
popupProvider = this@SongListFragment
|
||||
listener = this@SongListFragment
|
||||
}
|
||||
|
||||
collectImmediately(homeModel.songs, homeAdapter::replaceList)
|
||||
collectImmediately(homeModel.songLists, homeAdapter::replaceList)
|
||||
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
|
||||
collectImmediately(
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
|
||||
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.homeRecycler.apply {
|
||||
adapter = null
|
||||
popupProvider = null
|
||||
listener = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPopup(pos: Int): String? {
|
||||
val song = homeModel.songs.value[pos]
|
||||
|
||||
// Change how we display the popup depending on the mode.
|
||||
val song = homeModel.songLists.value[pos]
|
||||
// Change how we display the popup depending on the current sort mode.
|
||||
// Note: We don't use the more correct individual artist name here, as sorts are largely
|
||||
// based off the names of the parent objects and not the child objects.
|
||||
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS).mode) {
|
||||
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
|
||||
// Name -> Use name
|
||||
is Sort.Mode.ByName -> song.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Artist -> Use Artist Name
|
||||
is Sort.Mode.ByArtist -> song.album.artist.sortName?.run { first().uppercase() }
|
||||
// Artist -> Use name of first artist
|
||||
is Sort.Mode.ByArtist ->
|
||||
song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Album -> Use Album Name
|
||||
is Sort.Mode.ByAlbum -> song.album.sortName?.run { first().uppercase() }
|
||||
is Sort.Mode.ByAlbum ->
|
||||
song.album.collationKey?.run { sourceString.first().uppercase() }
|
||||
|
||||
// Year -> Use Full Year
|
||||
is Sort.Mode.ByYear -> song.album.date?.resolveYear(requireContext())
|
||||
is Sort.Mode.ByDate -> song.album.date?.resolveDate(requireContext())
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||
|
|
@ -102,47 +126,56 @@ class SongListFragment : HomeListFragment<Song>() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Item) {
|
||||
check(item is Song)
|
||||
playbackModel.play(item, settings.libPlaybackMode)
|
||||
override fun onFastScrollingChanged(isFastScrolling: Boolean) {
|
||||
homeModel.setFastScrolling(isFastScrolling)
|
||||
}
|
||||
|
||||
override fun onRealClick(music: Music) {
|
||||
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
|
||||
when (Settings(requireContext()).libPlaybackMode) {
|
||||
MusicMode.SONGS -> playbackModel.playFromAll(music)
|
||||
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
|
||||
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
|
||||
MusicMode.GENRES -> playbackModel.playFromGenre(music)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOpenMenu(item: Item, anchor: View) {
|
||||
when (item) {
|
||||
is Song -> musicMenu(anchor, R.menu.menu_song_actions, item)
|
||||
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
|
||||
}
|
||||
check(item is Song) { "Unexpected datatype: ${item::class.java}" }
|
||||
openMusicMenu(anchor, R.menu.menu_song_actions, item)
|
||||
}
|
||||
|
||||
private fun handlePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
|
||||
if (parent == null) {
|
||||
homeAdapter.updateIndicator(song, isPlaying)
|
||||
homeAdapter.setPlayingItem(song, isPlaying)
|
||||
} else {
|
||||
// Ignore playback that is not from all songs
|
||||
homeAdapter.updateIndicator(null, isPlaying)
|
||||
homeAdapter.setPlayingItem(null, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
private class SongAdapter(private val listener: MenuItemListener) :
|
||||
IndicatorAdapter<SongViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, SongViewHolder.DIFFER)
|
||||
/**
|
||||
* A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
private class SongAdapter(private val listener: SelectableListListener) :
|
||||
SelectionIndicatorAdapter<SongViewHolder>() {
|
||||
private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK)
|
||||
|
||||
override val currentList: List<Item>
|
||||
get() = differ.currentList
|
||||
|
||||
override fun getItemCount() = differ.currentList.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
SongViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: SongViewHolder, position: Int, payloads: List<Any>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
|
||||
holder.bind(differ.currentList[position], listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously update the list with new [Song]s.
|
||||
* @param newList The new [Song]s for the adapter to display.
|
||||
*/
|
||||
fun replaceList(newList: List<Song>) {
|
||||
differ.replaceList(newList)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.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.MusicMode
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* 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<MusicMode>) :
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
private val width = context.resources.configuration.smallestScreenWidthDp
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
val icon: Int
|
||||
val string: Int
|
||||
|
||||
when (tabs[position]) {
|
||||
MusicMode.SONGS -> {
|
||||
icon = R.drawable.ic_song_24
|
||||
string = R.string.lbl_songs
|
||||
}
|
||||
MusicMode.ALBUMS -> {
|
||||
icon = R.drawable.ic_album_24
|
||||
string = R.string.lbl_albums
|
||||
}
|
||||
MusicMode.ARTISTS -> {
|
||||
icon = R.drawable.ic_artist_24
|
||||
string = R.string.lbl_artists
|
||||
}
|
||||
MusicMode.GENRES -> {
|
||||
icon = R.drawable.ic_genre_24
|
||||
string = R.string.lbl_genres
|
||||
}
|
||||
}
|
||||
|
||||
// Use expected sw* size thresholds when choosing a configuration.
|
||||
when {
|
||||
// On small screens, only display an icon.
|
||||
width < 370 -> {
|
||||
logD("Using icon-only configuration")
|
||||
tab.setIcon(icon).setContentDescription(string)
|
||||
}
|
||||
// On large screens, display an icon and text.
|
||||
width < 600 -> {
|
||||
logD("Using text-only configuration")
|
||||
tab.setText(string)
|
||||
}
|
||||
// On medium-size screens, display text.
|
||||
else -> {
|
||||
logD("Using icon-and-text configuration")
|
||||
tab.setIcon(icon).setText(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,60 +17,66 @@
|
|||
|
||||
package org.oxycblt.auxio.home.tabs
|
||||
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.util.logE
|
||||
|
||||
/**
|
||||
* A data representation of a library tab. A tab can come in two moves, [Visible] or [Invisible].
|
||||
* Invisibility means that the tab will still be present in the customization menu, but will not be
|
||||
* shown on the home UI.
|
||||
*
|
||||
* Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs cannot
|
||||
* be serialized on their own. Instead, they are saved as a sequence of tabs as shown below:
|
||||
*
|
||||
* 0bTAB1_TAB2_TAB3_TAB4_TAB5
|
||||
*
|
||||
* Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists. Each
|
||||
* chunk in a sequence is represented as:
|
||||
*
|
||||
* VTTT
|
||||
*
|
||||
* Where V is a bit representing the visibility and T is a 3-bit integer representing the
|
||||
* [DisplayMode] ordinal for this tab.
|
||||
*
|
||||
* To serialize and deserialize a tab sequence, [toSequence] and [fromSequence] can be used
|
||||
* respectively.
|
||||
*
|
||||
* By default, the tab order will be SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS
|
||||
* A representation of a library tab suitable for configuration.
|
||||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed class Tab(open val mode: DisplayMode) {
|
||||
data class Visible(override val mode: DisplayMode) : Tab(mode)
|
||||
data class Invisible(override val mode: DisplayMode) : Tab(mode)
|
||||
sealed class Tab(open val mode: MusicMode) {
|
||||
/**
|
||||
* A visible tab. This will be visible in the home and tab configuration views.
|
||||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
*/
|
||||
data class Visible(override val mode: MusicMode) : Tab(mode)
|
||||
|
||||
/**
|
||||
* A visible tab. This will be visible in the tab configuration view, but not in the home view.
|
||||
* @param mode The type of list in the home view this instance corresponds to.
|
||||
*/
|
||||
data class Invisible(override val mode: MusicMode) : Tab(mode)
|
||||
|
||||
companion object {
|
||||
/** The length a well-formed tab sequence should be */
|
||||
// Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs
|
||||
// cannot be serialized on their own. Instead, they are saved as a sequence of tabs as shown
|
||||
// below:
|
||||
//
|
||||
// 0bTAB1_TAB2_TAB3_TAB4_TAB5
|
||||
//
|
||||
// Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists.
|
||||
// Each chunk in a sequence is represented as:
|
||||
//
|
||||
// VTTT
|
||||
//
|
||||
// Where V is a bit representing the visibility and T is a 3-bit integer representing the
|
||||
// MusicMode for this tab.
|
||||
|
||||
/** The length a well-formed tab sequence should be. */
|
||||
private const val SEQUENCE_LEN = 4
|
||||
/** The default tab sequence, represented in integer form */
|
||||
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
|
||||
|
||||
/**
|
||||
* Maps between the integer code in the tab sequence and the actual [DisplayMode] instance.
|
||||
* The default tab sequence, in integer form. This represents a set of four visible tabs
|
||||
* ordered as "Song", "Album", "Artist", and "Genre".
|
||||
*/
|
||||
private val MODE_TABLE =
|
||||
arrayOf(
|
||||
DisplayMode.SHOW_SONGS,
|
||||
DisplayMode.SHOW_ALBUMS,
|
||||
DisplayMode.SHOW_ARTISTS,
|
||||
DisplayMode.SHOW_GENRES)
|
||||
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
|
||||
|
||||
/** Convert an array [tabs] into a sequence of tabs. */
|
||||
fun toSequence(tabs: Array<Tab>): Int {
|
||||
/** Maps between the integer code in the tab sequence and it's [MusicMode]. */
|
||||
private val MODE_TABLE =
|
||||
arrayOf(MusicMode.SONGS, MusicMode.ALBUMS, MusicMode.ARTISTS, MusicMode.GENRES)
|
||||
|
||||
/**
|
||||
* Convert an array of [Tab]s into it's integer representation.
|
||||
* @param tabs The array of [Tab]s to convert
|
||||
* @return An integer representation of the [Tab] array
|
||||
*/
|
||||
fun toIntCode(tabs: Array<Tab>): Int {
|
||||
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
|
||||
val distinct = tabs.distinctBy { it.mode }
|
||||
|
||||
var sequence = 0b0100
|
||||
var shift = SEQUENCE_LEN * 4
|
||||
|
||||
for (tab in distinct) {
|
||||
val bin =
|
||||
when (tab) {
|
||||
|
|
@ -85,14 +91,18 @@ sealed class Tab(open val mode: DisplayMode) {
|
|||
return sequence
|
||||
}
|
||||
|
||||
/** Convert a [sequence] into an array of tabs. */
|
||||
fun fromSequence(sequence: Int): Array<Tab>? {
|
||||
/**
|
||||
* Convert a [Tab] integer representation into it's corresponding array of [Tab]s.
|
||||
* @param intCode The integer representation of the [Tab]s.
|
||||
* @return An array of [Tab]s corresponding to the sequence.
|
||||
*/
|
||||
fun fromIntCode(intCode: Int): Array<Tab>? {
|
||||
val tabs = mutableListOf<Tab>()
|
||||
|
||||
// Try to parse a mode for each chunk in the sequence.
|
||||
// If we can't parse one, just skip it.
|
||||
for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
|
||||
val chunk = sequence.shr(shift) and 0b1111
|
||||
val chunk = intCode.shr(shift) and 0b1111
|
||||
|
||||
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue
|
||||
|
||||
|
|
|
|||
|
|
@ -22,74 +22,127 @@ import android.view.MotionEvent
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemTabBinding
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.recycler.DialogViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
|
||||
* @param listener A [Listener] for tab interactions.
|
||||
*/
|
||||
class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewHolder>() {
|
||||
/** The current array of [Tab]s. */
|
||||
var tabs = arrayOf<Tab>()
|
||||
private set
|
||||
|
||||
override fun getItemCount() = tabs.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
|
||||
holder.bind(tabs[position], listener)
|
||||
}
|
||||
|
||||
@Suppress("NotifyDatasetChanged")
|
||||
/**
|
||||
* Immediately update the tab array. This should be used when initializing the list.
|
||||
* @param newTabs The new array of tabs to show.
|
||||
*/
|
||||
fun submitTabs(newTabs: Array<Tab>) {
|
||||
tabs = newTabs
|
||||
notifyDataSetChanged()
|
||||
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific tab to the given value.
|
||||
* @param at The position of the tab to update.
|
||||
* @param tab The new tab.
|
||||
*/
|
||||
fun setTab(at: Int, tab: Tab) {
|
||||
tabs[at] = tab
|
||||
// Use a payload to avoid an item change animation.
|
||||
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
|
||||
}
|
||||
|
||||
fun moveItems(from: Int, to: Int) {
|
||||
val t = tabs[to]
|
||||
val f = tabs[from]
|
||||
tabs[from] = t
|
||||
tabs[to] = f
|
||||
notifyItemMoved(from, to)
|
||||
/**
|
||||
* Swap two tabs with each other.
|
||||
* @param a The position of the first tab to swap.
|
||||
* @param b The position of the second tab to swap.
|
||||
*/
|
||||
fun swapTabs(a: Int, b: Int) {
|
||||
val tmp = tabs[b]
|
||||
tabs[b] = tabs[a]
|
||||
tabs[a] = tmp
|
||||
notifyItemMoved(a, b)
|
||||
}
|
||||
|
||||
/** A listener for interactions specific to tab configuration. */
|
||||
interface Listener {
|
||||
fun onVisibilityToggled(displayMode: DisplayMode)
|
||||
fun onPickUpTab(viewHolder: RecyclerView.ViewHolder)
|
||||
/**
|
||||
* Called when a tab is clicked, requesting that the visibility should be inverted (i.e
|
||||
* Visible -> Invisible and vice versa).
|
||||
* @param tabMode The [MusicMode] of the tab clicked.
|
||||
*/
|
||||
fun onToggleVisibility(tabMode: MusicMode)
|
||||
|
||||
/**
|
||||
* Called when the drag handle on a [RecyclerView.ViewHolder] is clicked, requesting that a
|
||||
* drag should be started.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] to start dragging.
|
||||
*/
|
||||
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val PAYLOAD_TAB_CHANGED = Any()
|
||||
private val PAYLOAD_TAB_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Tab]. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
|
||||
DialogViewHolder(binding.root) {
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param tab The new [Tab] to bind.
|
||||
* @param listener A [TabAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
fun bind(item: Tab, listener: TabAdapter.Listener) {
|
||||
binding.root.setOnClickListener { listener.onVisibilityToggled(item.mode) }
|
||||
fun bind(tab: Tab, listener: TabAdapter.Listener) {
|
||||
binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) }
|
||||
|
||||
binding.tabIcon.apply {
|
||||
setText(item.mode.string)
|
||||
isChecked = item is Tab.Visible
|
||||
binding.tabCheckBox.apply {
|
||||
// Update the CheckBox name to align with the mode
|
||||
setText(
|
||||
when (tab.mode) {
|
||||
MusicMode.SONGS -> R.string.lbl_songs
|
||||
MusicMode.ALBUMS -> R.string.lbl_albums
|
||||
MusicMode.ARTISTS -> R.string.lbl_artists
|
||||
MusicMode.GENRES -> R.string.lbl_genres
|
||||
})
|
||||
|
||||
// Unlike in other adapters, we update the checked state alongside
|
||||
// the tab data since they are in the same data structure (Tab)
|
||||
isChecked = tab is Tab.Visible
|
||||
}
|
||||
|
||||
// Roll our own drag handlers as the default ones suck
|
||||
// Set up the drag handle to start a drag whenever it is touched.
|
||||
binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
|
||||
binding.tabDragHandle.performClick()
|
||||
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
listener.onPickUpTab(this)
|
||||
listener.onPickUp(this)
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,19 +25,20 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogTabsBinding
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.DisplayMode
|
||||
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* The dialog for customizing library tabs.
|
||||
* @author OxygenCobalt
|
||||
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener {
|
||||
private val tabAdapter = TabAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
private val tabAdapter = TabAdapter(this)
|
||||
private val touchHelper: ItemTouchHelper by lifecycleObject {
|
||||
ItemTouchHelper(TabDragCallback(tabAdapter))
|
||||
}
|
||||
|
|
@ -55,14 +56,17 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
|||
}
|
||||
|
||||
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
|
||||
val savedTabs = findSavedTabState(savedInstanceState)
|
||||
if (savedTabs != null) {
|
||||
logD("Found saved tab state")
|
||||
tabAdapter.submitTabs(savedTabs)
|
||||
} else {
|
||||
tabAdapter.submitTabs(settings.libTabs)
|
||||
var tabs = settings.libTabs
|
||||
// Try to restore a pending tab configuration that was saved prior.
|
||||
if (savedInstanceState != null) {
|
||||
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
|
||||
if (savedTabs != null) {
|
||||
tabs = savedTabs
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the tab RecyclerView
|
||||
tabAdapter.submitTabs(tabs)
|
||||
binding.tabRecycler.apply {
|
||||
adapter = tabAdapter
|
||||
touchHelper.attachToRecyclerView(this)
|
||||
|
|
@ -71,7 +75,8 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
|||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putInt(KEY_TABS, Tab.toSequence(tabAdapter.tabs))
|
||||
// Save any pending tab configurations to restore if this dialog is re-created.
|
||||
outState.putInt(KEY_TABS, Tab.toIntCode(tabAdapter.tabs))
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogTabsBinding) {
|
||||
|
|
@ -79,40 +84,31 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
|
|||
binding.tabRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onVisibilityToggled(displayMode: DisplayMode) {
|
||||
// Tab viewholders bind with the initial tab state, which will drift from the actual
|
||||
// state of the tabs over editing. So, this callback simply provides the displayMode
|
||||
// for us to locate within the data and then update.
|
||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == displayMode }
|
||||
if (index > -1) {
|
||||
val tab = tabAdapter.tabs[index]
|
||||
tabAdapter.setTab(
|
||||
index,
|
||||
when (tab) {
|
||||
is Tab.Visible -> Tab.Invisible(tab.mode)
|
||||
is Tab.Invisible -> Tab.Visible(tab.mode)
|
||||
})
|
||||
}
|
||||
override fun onToggleVisibility(tabMode: MusicMode) {
|
||||
logD("Toggling tab $tabMode")
|
||||
|
||||
// We will need the exact index of the tab to update on in order to
|
||||
// notify the adapter of the change.
|
||||
val index = tabAdapter.tabs.indexOfFirst { it.mode == tabMode }
|
||||
val tab = tabAdapter.tabs[index]
|
||||
tabAdapter.setTab(
|
||||
index,
|
||||
when (tab) {
|
||||
// Invert the visibility of the tab
|
||||
is Tab.Visible -> Tab.Invisible(tab.mode)
|
||||
is Tab.Invisible -> Tab.Visible(tab.mode)
|
||||
})
|
||||
|
||||
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
|
||||
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
|
||||
tabAdapter.tabs.filterIsInstance<Tab.Visible>().isNotEmpty()
|
||||
}
|
||||
|
||||
override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) {
|
||||
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
|
||||
touchHelper.startDrag(viewHolder)
|
||||
}
|
||||
|
||||
private fun findSavedTabState(savedInstanceState: Bundle?): Array<Tab>? {
|
||||
if (savedInstanceState != null) {
|
||||
// Restore any pending tab configurations
|
||||
return Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = BuildConfig.APPLICATION_ID + ".tag.TAB_CUSTOMIZE"
|
||||
const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
|
||||
private const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,14 +22,15 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu.
|
||||
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple.
|
||||
* An [ItemTouchHelper.Callback] that implements dragging in the [TabAdapter].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() {
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int = makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN)
|
||||
) = // Allow dragging up and down only
|
||||
makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN)
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas,
|
||||
|
|
@ -40,8 +41,6 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
|
|||
actionState: Int,
|
||||
isCurrentlyActive: Boolean
|
||||
) {
|
||||
// No fancy UI magic here. This is a dialog, we don't need to give it as much attention.
|
||||
// Just make sure the built-in androidx code doesn't get in our way.
|
||||
viewHolder.itemView.translationX = dX
|
||||
viewHolder.itemView.translationY = dY
|
||||
}
|
||||
|
|
@ -56,12 +55,14 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
|
|||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
adapter.moveItems(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
|
||||
// I don't think it's possible to jump more than one position at a time, so a swap
|
||||
// will work just fine.
|
||||
adapter.swapTabs(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
|
||||
|
||||
// We use a custom drag handle, so disable the long press action.
|
||||
override fun isLongPressDragEnabled(): Boolean = false
|
||||
override fun isLongPressDragEnabled() = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,79 +24,99 @@ import coil.imageLoader
|
|||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.TaskGuard
|
||||
|
||||
/**
|
||||
* A utility to provide bitmaps in a manner less prone to race conditions.
|
||||
* A utility to provide bitmaps in a race-less manner.
|
||||
*
|
||||
* Pretty much each service component needs to load bitmaps of some kind, but doing a blind image
|
||||
* request with some target callbacks could result in overlapping requests causing incorrect
|
||||
* updates. This class (to an extent) resolves this by adding several guards
|
||||
* When it comes to components that load images manually as [Bitmap] instances, queued
|
||||
* [ImageRequest]s may cause a race condition that results in the incorrect image being drawn. This
|
||||
* utility resolves this by keeping track of the current request, and disposing it as soon as a new
|
||||
* request is queued or if another, competing request is newer.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* @param context [Context] required to load images.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class BitmapProvider(private val context: Context) {
|
||||
/**
|
||||
* An extension of [Disposable] with an additional [Target] to deliver the final [Bitmap] to.
|
||||
*/
|
||||
private data class Request(val disposable: Disposable, val callback: Target)
|
||||
|
||||
/** The target that will receive the requested [Bitmap]. */
|
||||
interface Target {
|
||||
/**
|
||||
* Configure the [ImageRequest.Builder] to enable [Target]-specific configuration.
|
||||
* @param builder The [ImageRequest.Builder] that will be used to request the desired
|
||||
* [Bitmap].
|
||||
* @return The same [ImageRequest.Builder] in order to easily chain configuration methods.
|
||||
*/
|
||||
fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
|
||||
|
||||
/**
|
||||
* Called when the loading process is completed.
|
||||
* @param bitmap The loaded bitmap, or null if the bitmap could not be loaded.
|
||||
*/
|
||||
fun onCompleted(bitmap: Bitmap?)
|
||||
}
|
||||
|
||||
private var currentRequest: Request? = null
|
||||
private var guard = TaskGuard()
|
||||
private var currentHandle = 0L
|
||||
|
||||
/** If this provider is currently attempting to load something. */
|
||||
val isBusy: Boolean
|
||||
get() = currentRequest?.run { !disposable.isDisposed } ?: false
|
||||
|
||||
/**
|
||||
* Load a bitmap from [song]. [target] should be a new object, not a reference to an existing
|
||||
* callback.
|
||||
* Load the Album cover [Bitmap] from a [Song].
|
||||
* @param song The song to load a [Bitmap] of it's album cover from.
|
||||
* @param target The [Target] to deliver the [Bitmap] to asynchronously.
|
||||
*/
|
||||
@Synchronized
|
||||
fun load(song: Song, target: Target) {
|
||||
val handle = guard.newHandle()
|
||||
|
||||
// Increment the handle, indicating a newer request has been created
|
||||
val handle = ++currentHandle
|
||||
currentRequest?.run { disposable.dispose() }
|
||||
currentRequest = null
|
||||
|
||||
val request =
|
||||
target.onConfigRequest(
|
||||
ImageRequest.Builder(context)
|
||||
.data(song)
|
||||
.size(Size.ORIGINAL)
|
||||
.target(
|
||||
onSuccess = {
|
||||
if (guard.check(handle)) {
|
||||
val imageRequest =
|
||||
target
|
||||
.onConfigRequest(
|
||||
ImageRequest.Builder(context)
|
||||
.data(song)
|
||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||
.size(Size.ORIGINAL)
|
||||
.transformations(SquareFrameTransform.INSTANCE))
|
||||
// Override the target in order to deliver the bitmap to the given
|
||||
// listener.
|
||||
.target(
|
||||
onSuccess = {
|
||||
synchronized(this) {
|
||||
if (currentHandle == handle) {
|
||||
// Has not been superceded by a new request, can deliver
|
||||
// this result.
|
||||
target.onCompleted(it.toBitmap())
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
if (guard.check(handle)) {
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
synchronized(this) {
|
||||
if (currentHandle == handle) {
|
||||
// Has not been superceded by a new request, can deliver
|
||||
// this result.
|
||||
target.onCompleted(null)
|
||||
}
|
||||
})
|
||||
.transformations(SquareFrameTransform.INSTANCE))
|
||||
|
||||
currentRequest = Request(context.imageLoader.enqueue(request.build()), target)
|
||||
}
|
||||
})
|
||||
currentRequest = Request(context.imageLoader.enqueue(imageRequest.build()), target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, canceling all image load jobs. This should be ran when the object is
|
||||
* no longer used.
|
||||
*/
|
||||
/** Release this instance, cancelling any currently running operations. */
|
||||
@Synchronized
|
||||
fun release() {
|
||||
++currentHandle
|
||||
currentRequest?.run { disposable.dispose() }
|
||||
currentRequest = null
|
||||
}
|
||||
|
||||
private data class Request(val disposable: Disposable, val callback: Target)
|
||||
|
||||
/** Represents the target for a request. */
|
||||
interface Target {
|
||||
/** Modify the default request with custom attributes. */
|
||||
fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
|
||||
|
||||
/**
|
||||
* Called when the loading process is completed. [bitmap] will be null if there was an
|
||||
* error.
|
||||
*/
|
||||
fun onCompleted(bitmap: Bitmap?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt
Normal file
61
app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image
|
||||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
|
||||
/**
|
||||
* Represents the options available for album cover loading.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class CoverMode {
|
||||
/** Do not load album covers ("Off"). */
|
||||
OFF,
|
||||
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
|
||||
MEDIA_STORE,
|
||||
/** Load high-quality covers directly from music files ("Quality"). */
|
||||
QUALITY;
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
* @see fromIntCode
|
||||
*/
|
||||
val intCode: Int
|
||||
get() =
|
||||
when (this) {
|
||||
OFF -> IntegerTable.COVER_MODE_OFF
|
||||
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
|
||||
QUALITY -> IntegerTable.COVER_MODE_QUALITY
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a [CoverMode] integer representation into an instance.
|
||||
* @param intCode An integer representation of a [CoverMode]
|
||||
* @return The corresponding [CoverMode], or null if the [CoverMode] is invalid.
|
||||
* @see CoverMode.intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.COVER_MODE_OFF -> OFF
|
||||
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
|
||||
IntegerTable.COVER_MODE_QUALITY -> QUALITY
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,63 +17,85 @@
|
|||
|
||||
package org.oxycblt.auxio.image
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.view.updateMarginsRelative
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
|
||||
/**
|
||||
* Effectively a super-charged [StyledImageView].
|
||||
*
|
||||
* This class enables the following features alongside the base features pf [StyledImageView]:
|
||||
* - Activation indicator
|
||||
* - (Eventually) selection indicator
|
||||
* A super-charged [StyledImageView]. This class enables the following features in addition to
|
||||
* [StyledImageView]:
|
||||
* - A selection indicator
|
||||
* - An activation (playback) indicator
|
||||
* - Support for ONE custom view
|
||||
*
|
||||
* This class is primarily intended for list items. For most uses, the simpler [StyledImageView] is
|
||||
* more efficient and suitable.
|
||||
* This class is primarily intended for list items. For other uses, [StyledImageView] is more
|
||||
* suitable.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* TODO: Rework content descriptions here
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ImageGroup
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
FrameLayout(context, attrs, defStyleAttr) {
|
||||
private val cornerRadius: Float
|
||||
private val inner: StyledImageView
|
||||
private val innerImageView: StyledImageView
|
||||
private var customView: View? = null
|
||||
private val indicator: IndicatorView
|
||||
private val playbackIndicatorView: PlaybackIndicatorView
|
||||
private val selectionIndicatorView: ImageView
|
||||
|
||||
private var fadeAnimator: ValueAnimator? = null
|
||||
private val cornerRadius: Float
|
||||
|
||||
init {
|
||||
// Android wants you to make separate attributes for each view type, but will
|
||||
// then throw an error if you do because of duplicate attribute names.
|
||||
// Obtain some StyledImageView attributes to use later when theming the cusotm view.
|
||||
@SuppressLint("CustomViewStyleable")
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
|
||||
// Keep track of our corner radius so that we can apply the same attributes to the custom
|
||||
// view.
|
||||
cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
|
||||
styledAttrs.recycle()
|
||||
|
||||
inner = StyledImageView(context, attrs)
|
||||
indicator = IndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius }
|
||||
// Initialize what views we can here.
|
||||
innerImageView = StyledImageView(context, attrs)
|
||||
playbackIndicatorView =
|
||||
PlaybackIndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius }
|
||||
selectionIndicatorView =
|
||||
ImageView(context).apply {
|
||||
imageTintList = context.getAttrColorCompat(R.attr.colorOnPrimary)
|
||||
setImageResource(R.drawable.ic_check_20)
|
||||
setBackgroundResource(R.drawable.ui_selection_badge_bg)
|
||||
}
|
||||
|
||||
addView(inner)
|
||||
// The inner StyledImageView should be at the bottom and hidden by any other elements
|
||||
// if they become visible.
|
||||
addView(innerImageView)
|
||||
}
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
// Due to innerImageView, the max child count is actually 2 and not 1.
|
||||
check(childCount < 3) { "Only one custom view is allowed" }
|
||||
|
||||
if (childCount > 2) {
|
||||
error("Only one custom view is allowed")
|
||||
}
|
||||
|
||||
// Get the second inflated child, making sure we customize it to align with
|
||||
// the rest of this view.
|
||||
customView =
|
||||
getChildAt(1)?.apply {
|
||||
background =
|
||||
|
|
@ -83,65 +105,144 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
}
|
||||
|
||||
addView(indicator)
|
||||
// Playback indicator should sit above the inner StyledImageView and custom view/
|
||||
addView(playbackIndicatorView)
|
||||
// Selction indicator should never be obscured, so place it at the top.
|
||||
addView(
|
||||
selectionIndicatorView,
|
||||
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
|
||||
// Override the layout params of the indicator so that it's in the
|
||||
// bottom left corner.
|
||||
gravity = Gravity.BOTTOM or Gravity.END
|
||||
val spacing = context.getDimenPixels(R.dimen.spacing_tiny)
|
||||
updateMarginsRelative(bottom = spacing, end = spacing)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
invalidateIndicator()
|
||||
// Initialize each component before this view is drawn.
|
||||
invalidateImageAlpha()
|
||||
invalidatePlayingIndicator()
|
||||
invalidateSelectionIndicator()
|
||||
}
|
||||
|
||||
override fun setActivated(activated: Boolean) {
|
||||
super.setActivated(activated)
|
||||
invalidateIndicator()
|
||||
invalidateSelectionIndicator()
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
invalidateIndicator()
|
||||
invalidateImageAlpha()
|
||||
invalidatePlayingIndicator()
|
||||
}
|
||||
|
||||
override fun setSelected(selected: Boolean) {
|
||||
super.setSelected(selected)
|
||||
invalidateImageAlpha()
|
||||
invalidatePlayingIndicator()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a [Song] to the internal [StyledImageView].
|
||||
* @param song The [Song] to bind to the view.
|
||||
* @see StyledImageView.bind
|
||||
*/
|
||||
fun bind(song: Song) = innerImageView.bind(song)
|
||||
|
||||
/**
|
||||
* Bind a [Album] to the internal [StyledImageView].
|
||||
* @param album The [Album] to bind to the view.
|
||||
* @see StyledImageView.bind
|
||||
*/
|
||||
fun bind(album: Album) = innerImageView.bind(album)
|
||||
|
||||
/**
|
||||
* Bind a [Genre] to the internal [StyledImageView].
|
||||
* @param artist The [Artist] to bind to the view.
|
||||
* @see StyledImageView.bind
|
||||
*/
|
||||
fun bind(artist: Artist) = innerImageView.bind(artist)
|
||||
|
||||
/**
|
||||
* Bind a [Genre] to the internal [StyledImageView].
|
||||
* @param genre The [Genre] to bind to the view.
|
||||
* @see StyledImageView.bind
|
||||
*/
|
||||
fun bind(genre: Genre) = innerImageView.bind(genre)
|
||||
|
||||
/**
|
||||
* Whether this view should be indicated to have ongoing playback or not. See
|
||||
* PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this
|
||||
* view to already be marked as playing with setSelected (not the same thing) before this is set
|
||||
* to true.
|
||||
*/
|
||||
var isPlaying: Boolean
|
||||
get() = indicator.isPlaying
|
||||
get() = playbackIndicatorView.isPlaying
|
||||
set(value) {
|
||||
indicator.isPlaying = value
|
||||
playbackIndicatorView.isPlaying = value
|
||||
}
|
||||
|
||||
private fun invalidateIndicator() {
|
||||
if (isActivated) {
|
||||
alpha = 1f
|
||||
private fun invalidateImageAlpha() {
|
||||
// If this view is disabled, show it at half-opacity, *unless* it is also marked
|
||||
// as playing, in which we still want to show it at full-opacity.
|
||||
alpha = if (isSelected || isEnabled) 1f else 0.5f
|
||||
}
|
||||
|
||||
private fun invalidatePlayingIndicator() {
|
||||
if (isSelected) {
|
||||
// View is "selected" (actually marked as playing), so show the playing indicator
|
||||
// and hide all other elements except for the selection indicator.
|
||||
// TODO: Animate the other indicators?
|
||||
customView?.alpha = 0f
|
||||
inner.alpha = 0f
|
||||
indicator.alpha = 1f
|
||||
innerImageView.alpha = 0f
|
||||
playbackIndicatorView.alpha = 1f
|
||||
} else {
|
||||
alpha = if (isEnabled) 1f else 0.5f
|
||||
// View is not "selected", hide the playing indicator.
|
||||
customView?.alpha = 1f
|
||||
inner.alpha = 1f
|
||||
indicator.alpha = 0f
|
||||
innerImageView.alpha = 1f
|
||||
playbackIndicatorView.alpha = 0f
|
||||
}
|
||||
}
|
||||
|
||||
fun bind(song: Song) {
|
||||
inner.bind(song)
|
||||
contentDescription =
|
||||
context.getString(R.string.desc_album_cover, song.album.resolveName(context))
|
||||
}
|
||||
private fun invalidateSelectionIndicator() {
|
||||
// Set up a target transition for the selection indicator.
|
||||
val targetAlpha: Float
|
||||
val targetDuration: Long
|
||||
|
||||
fun bind(album: Album) {
|
||||
inner.bind(album)
|
||||
contentDescription =
|
||||
context.getString(R.string.desc_album_cover, album.resolveName(context))
|
||||
}
|
||||
if (isActivated) {
|
||||
// View is "activated" (i.e marked as selected), so show the selection indicator.
|
||||
targetAlpha = 1f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
// View is not "activated", hide the selection indicator.
|
||||
targetAlpha = 0f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
|
||||
fun bind(artist: Artist) {
|
||||
inner.bind(artist)
|
||||
contentDescription =
|
||||
context.getString(R.string.desc_artist_image, artist.resolveName(context))
|
||||
}
|
||||
if (selectionIndicatorView.alpha == targetAlpha) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
fun bind(genre: Genre) {
|
||||
inner.bind(genre)
|
||||
contentDescription =
|
||||
context.getString(R.string.desc_genre_image, genre.resolveName(context))
|
||||
if (!isLaidOut) {
|
||||
// Not laid out, initialize it without animation before drawing.
|
||||
selectionIndicatorView.alpha = targetAlpha
|
||||
return
|
||||
}
|
||||
|
||||
if (fadeAnimator != null) {
|
||||
// Cancel any previous animation.
|
||||
fadeAnimator?.cancel()
|
||||
fadeAnimator = null
|
||||
}
|
||||
|
||||
fadeAnimator =
|
||||
ValueAnimator.ofFloat(selectionIndicatorView.alpha, targetAlpha).apply {
|
||||
duration = targetDuration
|
||||
addUpdateListener { selectionIndicatorView.alpha = it.animatedValue as Float }
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,27 +33,31 @@ import org.oxycblt.auxio.util.getColorCompat
|
|||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
|
||||
/**
|
||||
* View that displays the playback indicator. Nominally emulates [StyledImageView], but is much
|
||||
* different internally as an animated icon can't be wrapped within StyledDrawable without causing
|
||||
* insane issues.
|
||||
* @author OxygenCobalt
|
||||
* A view that displays an activation (i.e playback) indicator, with an accented styling and an
|
||||
* animated equalizer icon.
|
||||
*
|
||||
* This is only meant for use with [ImageGroup]. Due to limitations with [AnimationDrawable]
|
||||
* instances within custom views, this cannot be merged with [ImageGroup].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class IndicatorView
|
||||
class PlaybackIndicatorView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
private val playingIndicatorDrawable =
|
||||
context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable
|
||||
|
||||
private val pausedIndicatorDrawable =
|
||||
context.getDrawableCompat(R.drawable.ic_paused_indicator_24)
|
||||
|
||||
private val indicatorMatrix = Matrix()
|
||||
private val indicatorMatrixSrc = RectF()
|
||||
private val indicatorMatrixDst = RectF()
|
||||
|
||||
private val settings = Settings(context)
|
||||
|
||||
/**
|
||||
* The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius
|
||||
* to this view without any attribute hacks.
|
||||
*/
|
||||
var cornerRadius = 0f
|
||||
set(value) {
|
||||
field = value
|
||||
|
|
@ -66,7 +70,29 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this view should be indicated to have ongoing playback or not. If true, the animated
|
||||
* playing icon will be shown. If false, the static paused icon will be shown.
|
||||
*/
|
||||
var isPlaying: Boolean
|
||||
get() = drawable == playingIndicatorDrawable
|
||||
set(value) {
|
||||
if (value) {
|
||||
playingIndicatorDrawable.start()
|
||||
setImageDrawable(playingIndicatorDrawable)
|
||||
} else {
|
||||
playingIndicatorDrawable.stop()
|
||||
setImageDrawable(pausedIndicatorDrawable)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// We will need to manually re-scale the playing/paused drawables to align with
|
||||
// StyledDrawable, so use the matrix scale type.
|
||||
scaleType = ScaleType.MATRIX
|
||||
// Tint the playing/paused drawables so they are harmonious with the background.
|
||||
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||
|
||||
// Use clipToOutline and a background drawable to crop images. While Coil's transformation
|
||||
// could theoretically be used to round corners, the corner radius is dependent on the
|
||||
// dimensions of the image, which will result in inconsistent corners across different
|
||||
|
|
@ -79,23 +105,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
||||
setCornerSize(cornerRadius)
|
||||
}
|
||||
|
||||
scaleType = ScaleType.MATRIX
|
||||
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
|
||||
// Emulate StyledDrawable scaling with matrix scaling.
|
||||
val iconSize = max(measuredWidth, measuredHeight) / 2
|
||||
|
||||
imageMatrix =
|
||||
indicatorMatrix.apply {
|
||||
reset()
|
||||
drawable?.let { drawable ->
|
||||
// Android is too good to allow us to set a fixed image size, so we instead need
|
||||
// to define a matrix to scale an image directly.
|
||||
|
||||
// First scale the icon up to the desired size.
|
||||
indicatorMatrixSrc.set(
|
||||
0f,
|
||||
|
|
@ -106,23 +126,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
indicatorMatrix.setRectToRect(
|
||||
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
|
||||
|
||||
// Then actually center it into the icon, which the previous call does not
|
||||
// actually do.
|
||||
// Then actually center it into the icon.
|
||||
indicatorMatrix.postTranslate(
|
||||
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isPlaying: Boolean
|
||||
get() = drawable == playingIndicatorDrawable
|
||||
set(value) {
|
||||
if (value) {
|
||||
playingIndicatorDrawable.start()
|
||||
setImageDrawable(playingIndicatorDrawable)
|
||||
} else {
|
||||
playingIndicatorDrawable.stop()
|
||||
setImageDrawable(pausedIndicatorDrawable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import coil.dispose
|
|||
import coil.load
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
|
|
@ -43,41 +44,33 @@ import org.oxycblt.auxio.util.getColorCompat
|
|||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
|
||||
/**
|
||||
* An [AppCompatImageView] that applies many of the stylistic choices that Auxio uses regarding
|
||||
* images.
|
||||
* An [AppCompatImageView] with some additional styling, including:
|
||||
*
|
||||
* Default behavior includes the addition of a tonal background, automatic sizing of icons to half
|
||||
* of the view size, and corner radius application depending on user preference.
|
||||
* - Tonal background
|
||||
* - Rounded corners based on user preferences
|
||||
* - Built-in support for binding image data or using a static icon with the same styling as
|
||||
* placeholder drawables.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class StyledImageView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
private val settings = Settings(context)
|
||||
|
||||
var cornerRadius = 0f
|
||||
set(value) {
|
||||
field = value
|
||||
(background as? MaterialShapeDrawable)?.let { bg ->
|
||||
if (settings.roundMode) {
|
||||
bg.setCornerSize(value)
|
||||
} else {
|
||||
bg.setCornerSize(0f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var staticIcon: Drawable? = null
|
||||
set(value) {
|
||||
field = value?.let { StyledDrawable(context, it) }
|
||||
setImageDrawable(field)
|
||||
}
|
||||
|
||||
private var useLargeIcon: Boolean = false
|
||||
|
||||
init {
|
||||
// Load view attributes
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
|
||||
val staticIcon =
|
||||
styledAttrs.getResourceId(
|
||||
R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL)
|
||||
val cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
|
||||
styledAttrs.recycle()
|
||||
|
||||
if (staticIcon != ResourcesCompat.ID_NULL) {
|
||||
// Use the static icon if specified for this image.
|
||||
setImageDrawable(StyledDrawable(context, context.getDrawableCompat(staticIcon)))
|
||||
}
|
||||
|
||||
// Use clipToOutline and a background drawable to crop images. While Coil's transformation
|
||||
// could theoretically be used to round corners, the corner radius is dependent on the
|
||||
// dimensions of the image, which will result in inconsistent corners across different
|
||||
|
|
@ -88,71 +81,90 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
background =
|
||||
MaterialShapeDrawable().apply {
|
||||
fillColor = context.getColorCompat(R.color.sel_cover_bg)
|
||||
setCornerSize(cornerRadius)
|
||||
if (Settings(context).roundMode) {
|
||||
// Only use the specified corner radius when round mode is enabled.
|
||||
setCornerSize(cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
|
||||
val staticIcon =
|
||||
styledAttrs.getResourceId(
|
||||
R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL)
|
||||
if (staticIcon != ResourcesCompat.ID_NULL) {
|
||||
this.staticIcon = context.getDrawableCompat(staticIcon)
|
||||
}
|
||||
|
||||
useLargeIcon = styledAttrs.getBoolean(R.styleable.StyledImageView_useLargeIcon, false)
|
||||
|
||||
cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
|
||||
styledAttrs.recycle()
|
||||
}
|
||||
|
||||
/** Bind the album cover for a [song]. */
|
||||
fun bind(song: Song) = loadImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
|
||||
/**
|
||||
* Bind a [Song]'s album cover to this view, also updating the content description.
|
||||
* @param song The [Song] to bind.
|
||||
*/
|
||||
fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
|
||||
|
||||
/** Bind the album cover for an [album]. */
|
||||
fun bind(album: Album) = loadImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
|
||||
/**
|
||||
* Bind an [Album]'s cover to this view, also updating the content description.
|
||||
* @param album the [Album] to bind.
|
||||
*/
|
||||
fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
|
||||
|
||||
/** Bind the image for an [artist] */
|
||||
fun bind(artist: Artist) = loadImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
|
||||
/**
|
||||
* Bind an [Artist]'s image to this view, also updating the content description.
|
||||
* @param artist the [Artist] to bind.
|
||||
*/
|
||||
fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
|
||||
|
||||
/** Bind the image for a [genre] */
|
||||
fun bind(genre: Genre) = loadImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
|
||||
|
||||
private fun <T : Music> loadImpl(music: T, @DrawableRes error: Int, @StringRes desc: Int) {
|
||||
if (staticIcon != null) {
|
||||
error("Static StyledImageViews cannot bind new images")
|
||||
}
|
||||
|
||||
contentDescription = context.getString(desc, music.resolveName(context))
|
||||
/**
|
||||
* Bind an [Genre]'s image to this view, also updating the content description.
|
||||
* @param genre the [Genre] to bind.
|
||||
*/
|
||||
fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
|
||||
|
||||
/**
|
||||
* Internally bind a [Music]'s image to this view.
|
||||
* @param music The music to find.
|
||||
* @param errorRes The error drawable resource to use if the music cannot be loaded.
|
||||
* @param descRes The content description string resource to use. The resource must have one
|
||||
* field for the name of the [Music].
|
||||
*/
|
||||
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
|
||||
// Dispose of any previous image request and load a new image.
|
||||
dispose()
|
||||
load(music) {
|
||||
error(StyledDrawable(context, context.getDrawableCompat(error)))
|
||||
error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
||||
transformations(SquareFrameTransform.INSTANCE)
|
||||
}
|
||||
|
||||
// Update the content description to the specified resource.
|
||||
contentDescription = context.getString(descRes, music.resolveName(context))
|
||||
}
|
||||
|
||||
private class StyledDrawable(context: Context, private val src: Drawable) : Drawable() {
|
||||
/**
|
||||
* A [Drawable] wrapper that re-styles the drawable to better align with the style of
|
||||
* [StyledImageView].
|
||||
* @param context [Context] required for initialization.
|
||||
* @param inner The [Drawable] to wrap.
|
||||
*/
|
||||
private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() {
|
||||
init {
|
||||
DrawableCompat.setTintList(src, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||
// Re-tint the drawable to use the analogous "on surface" color for
|
||||
// StyledImageView.
|
||||
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
// Resize the drawable such that it's always 1/4 the size of the image and
|
||||
// centered in the middle of the canvas.
|
||||
val adjustWidth = bounds.width() / 4
|
||||
val adjustHeight = bounds.height() / 4
|
||||
src.bounds.set(
|
||||
inner.bounds.set(
|
||||
adjustWidth,
|
||||
adjustHeight,
|
||||
bounds.width() - adjustWidth,
|
||||
bounds.height() - adjustHeight)
|
||||
src.draw(canvas)
|
||||
inner.draw(canvas)
|
||||
}
|
||||
|
||||
// Required drawable overrides. Just forward to the wrapped drawable.
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
src.alpha = alpha
|
||||
inner.alpha = alpha
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
src.colorFilter = colorFilter
|
||||
inner.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.content.Context
|
||||
import coil.ImageLoader
|
||||
|
|
@ -35,41 +35,44 @@ import org.oxycblt.auxio.music.Artist
|
|||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
|
||||
/** A basic keyer for music data. */
|
||||
/**
|
||||
* A [Keyer] implementation for [Music] data.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicKeyer : Keyer<Music> {
|
||||
override fun key(data: Music, options: Options): String {
|
||||
return if (data is Song) {
|
||||
override fun key(data: Music, options: Options) =
|
||||
if (data is Song) {
|
||||
// Group up song covers with album covers for better caching
|
||||
key(data.album, options)
|
||||
data.album.uid.toString()
|
||||
} else {
|
||||
"${data::class.simpleName}: ${data.id}"
|
||||
data.uid.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetcher that returns the album cover for a given [Album] or [Song], depending on the factory
|
||||
* used.
|
||||
* @author OxygenCobalt
|
||||
* Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
|
||||
* [AlbumFactory] for instantiation.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumCoverFetcher
|
||||
private constructor(private val context: Context, private val album: Album) : BaseFetcher() {
|
||||
private constructor(private val context: Context, private val album: Album) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? =
|
||||
fetchArt(context, album)?.let { stream ->
|
||||
Covers.fetch(context, album)?.run {
|
||||
SourceResult(
|
||||
source = ImageSource(stream.source().buffer(), context),
|
||||
source = ImageSource(source().buffer(), context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
|
||||
/** A [Fetcher.Factory] implementation that works with [Song]s. */
|
||||
class SongFactory : Fetcher.Factory<Song> {
|
||||
override fun create(data: Song, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return AlbumCoverFetcher(options.context, data.album)
|
||||
}
|
||||
override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
|
||||
AlbumCoverFetcher(options.context, data.album)
|
||||
}
|
||||
|
||||
/** A [Fetcher.Factory] implementation that works with [Album]s. */
|
||||
class AlbumFactory : Fetcher.Factory<Album> {
|
||||
override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
|
||||
AlbumCoverFetcher(options.context, data)
|
||||
|
|
@ -77,21 +80,23 @@ private constructor(private val context: Context, private val album: Album) : Ba
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetcher that fetches the image for an [Artist]
|
||||
* @author OxygenCobalt
|
||||
* [Fetcher] for [Artist] images. Use [Factory] for instantiation.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistImageFetcher
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val size: Size,
|
||||
private val artist: Artist,
|
||||
) : BaseFetcher() {
|
||||
private val artist: Artist
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
val albums = Sort(Sort.Mode.ByName, true).albums(artist.albums)
|
||||
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
|
||||
return createMosaic(context, results, size)
|
||||
// Pick the "most prominent" albums (i.e albums with the most songs) to show in the image.
|
||||
val albums = Sort(Sort.Mode.ByCount, false).albums(artist.albums)
|
||||
val results = albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, album) }
|
||||
return Images.createMosaic(context, results, size)
|
||||
}
|
||||
|
||||
/** [Fetcher.Factory] implementation. */
|
||||
class Factory : Fetcher.Factory<Artist> {
|
||||
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
|
||||
ArtistImageFetcher(options.context, options.size, data)
|
||||
|
|
@ -99,35 +104,21 @@ private constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetcher that fetches the image for a [Genre]
|
||||
* @author OxygenCobalt
|
||||
* [Fetcher] for [Genre] images. Use [Factory] for instantiation.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreImageFetcher
|
||||
private constructor(
|
||||
private val context: Context,
|
||||
private val size: Size,
|
||||
private val genre: Genre,
|
||||
) : BaseFetcher() {
|
||||
private val genre: Genre
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
// Genre logic is the most complicated, as we want to ensure album cover variation (i.e
|
||||
// all four covers shouldn't be from the same artist) while also still leveraging mosaics
|
||||
// whenever possible. So, if there are more than four distinct artists in a genre, make
|
||||
// it so that one artist only adds one album cover to the mosaic. Otherwise, use order
|
||||
// albums normally.
|
||||
val artists = genre.songs.groupBy { it.album.artist.id }.keys
|
||||
val albums =
|
||||
Sort(Sort.Mode.ByName, true).albums(genre.songs.groupBy { it.album }.keys).run {
|
||||
if (artists.size > 4) {
|
||||
distinctBy { it.artist.rawName }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
|
||||
return createMosaic(context, results, size)
|
||||
val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, it) }
|
||||
return Images.createMosaic(context, results, size)
|
||||
}
|
||||
|
||||
/** [Fetcher.Factory] implementation. */
|
||||
class Factory : Fetcher.Factory<Genre> {
|
||||
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
|
||||
GenreImageFetcher(options.context, options.size, data)
|
||||
|
|
@ -135,10 +126,14 @@ private constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Map at most [n] items from a collection. [transform] is called for each item that is eligible. If
|
||||
* null is returned, then that item will be skipped.
|
||||
* Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be
|
||||
* transformed into [R].
|
||||
* @param n The maximum amount of items to map.
|
||||
* @param transform The function that transforms data [T] from the original list into data [R] in
|
||||
* the new list. Can return null if the [T] cannot be transformed into an [R].
|
||||
* @return A new list of at most N non-null [R] items.
|
||||
*/
|
||||
private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(
|
||||
private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull(
|
||||
n: Int,
|
||||
transform: (T) -> R?
|
||||
): List<R> {
|
||||
|
|
@ -146,11 +141,12 @@ private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(
|
|||
val out = mutableListOf<R>()
|
||||
|
||||
for (item in this) {
|
||||
if (out.size < until) {
|
||||
transform(item)?.let(out::add)
|
||||
} else {
|
||||
if (out.size >= until) {
|
||||
break
|
||||
}
|
||||
|
||||
// Still have more data we can transform.
|
||||
transform(item)?.let(out::add)
|
||||
}
|
||||
|
||||
return out
|
||||
|
|
@ -15,24 +15,10 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.util.Size as AndroidSize
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.size.Dimension
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
|
|
@ -42,36 +28,32 @@ import java.io.ByteArrayInputStream
|
|||
import java.io.InputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* The base implementation for all image fetchers in Auxio.
|
||||
* @author OxygenCobalt
|
||||
*
|
||||
* TODO: File-system derived images [cover.jpg, Artist Images]
|
||||
* Internal utilities for loading album covers.
|
||||
* @author Alexander Capehart (OxygenCobalt).
|
||||
*/
|
||||
abstract class BaseFetcher : Fetcher {
|
||||
object Covers {
|
||||
/**
|
||||
* Fetch the artwork of an [album]. This call respects user configuration and has proper
|
||||
* redundancy in the case that metadata fails to load.
|
||||
* Fetch an album cover, respecting the current cover configuration.
|
||||
* @param context [Context] required to load the image.
|
||||
* @param album [Album] to load the cover from.
|
||||
* @return An [InputStream] of image data if the cover loading was successful, null if the cover
|
||||
* loading failed or should not occur.
|
||||
*/
|
||||
protected suspend fun fetchArt(context: Context, album: Album): InputStream? {
|
||||
suspend fun fetch(context: Context, album: Album): InputStream? {
|
||||
val settings = Settings(context)
|
||||
|
||||
if (!settings.showCovers) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
if (settings.useQualityCovers) {
|
||||
fetchQualityCovers(context, album)
|
||||
} else {
|
||||
fetchMediaStoreCovers(context, album)
|
||||
when (settings.coverMode) {
|
||||
CoverMode.OFF -> null
|
||||
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
|
||||
CoverMode.QUALITY -> fetchQualityCovers(context, album)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logW("Unable to extract album cover due to an error: $e")
|
||||
|
|
@ -79,20 +61,28 @@ abstract class BaseFetcher : Fetcher {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an [Album] cover directly from one of it's Song files. This attempts the following in
|
||||
* order:
|
||||
* - [MediaMetadataRetriever], as it has the best support and speed.
|
||||
* - ExoPlayer's [MetadataRetriever], as some devices (notably Samsung) can have broken
|
||||
* [MediaMetadataRetriever] implementations.
|
||||
* - MediaStore, as a last-ditch fallback if the format is really obscure.
|
||||
*
|
||||
* @param context [Context] required to load the image.
|
||||
* @param album [Album] to load the cover from.
|
||||
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
|
||||
*/
|
||||
private suspend fun fetchQualityCovers(context: Context, album: Album) =
|
||||
// Loading quality covers basically means to parse the file metadata ourselves
|
||||
// and then extract the cover.
|
||||
|
||||
// First try MediaMetadataRetriever. We will always do this first, as it supports
|
||||
// a variety of formats, has multiple levels of fault tolerance, and is pretty fast
|
||||
// for a manual parser.
|
||||
// However, Samsung seems to cripple this class as to force people to use their ad-infested
|
||||
// music app which relies on proprietary OneUI extensions instead of AOSP. That means
|
||||
// we have to add even more layers of redundancy to make sure we can extract a cover.
|
||||
// Thanks Samsung. Prick.
|
||||
fetchAospMetadataCovers(context, album)
|
||||
?: fetchExoplayerCover(context, album) ?: fetchMediaStoreCovers(context, album)
|
||||
|
||||
/**
|
||||
* Loads an album cover with [MediaMetadataRetriever].
|
||||
* @param context [Context] required to load the image.
|
||||
* @param album [Album] to load the cover from.
|
||||
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
|
||||
*/
|
||||
private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
|
||||
MediaMetadataRetriever().apply {
|
||||
// This call is time-consuming but it also doesn't seem to hold up the main thread,
|
||||
|
|
@ -106,18 +96,24 @@ abstract class BaseFetcher : Fetcher {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an [Album] cover with ExoPlayer's [MetadataRetriever].
|
||||
* @param context [Context] required to load the image.
|
||||
* @param album [Album] to load the cover from.
|
||||
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
|
||||
*/
|
||||
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
|
||||
val uri = album.songs[0].uri
|
||||
val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri))
|
||||
|
||||
// future.get is a blocking call that makes us spin until the future is done.
|
||||
// This is bad for a co-routine, as it prevents cancellation and by extension
|
||||
// messes with the image loading process and causes frustrating bugs.
|
||||
// messes with the image loading process and causes annoying bugs.
|
||||
// To fix this we wrap this around in a withContext call to make it suspend and make
|
||||
// sure that the runner can do other coroutines.
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val tracks =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
future.get()
|
||||
} catch (e: Exception) {
|
||||
|
|
@ -172,80 +168,15 @@ abstract class BaseFetcher : Fetcher {
|
|||
return stream
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? {
|
||||
val uri = data.coverUri
|
||||
|
||||
// Eliminate any chance that this blocking call might mess up the loading process
|
||||
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
|
||||
* https://github.com/kabouzeid/Phonograph
|
||||
* Loads an [Album] cover from MediaStore.
|
||||
* @param context [Context] required to load the image.
|
||||
* @param album [Album] to load the cover from.
|
||||
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
|
||||
*/
|
||||
protected suspend fun createMosaic(
|
||||
context: Context,
|
||||
streams: List<InputStream>,
|
||||
size: Size
|
||||
): FetchResult? {
|
||||
if (streams.size < 4) {
|
||||
return streams.firstOrNull()?.let { stream ->
|
||||
return SourceResult(
|
||||
source = ImageSource(stream.source().buffer(), context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
}
|
||||
|
||||
// Use whatever size coil gives us to create the mosaic, rounding it to even so that we
|
||||
// get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a
|
||||
// 512x512 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
|
||||
}
|
||||
|
||||
// Run the bitmap through a transform to make sure it's a square of the desired
|
||||
// resolution.
|
||||
val bitmap =
|
||||
SquareFrameTransform.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 {
|
||||
val size = pxOrElse { 512 }
|
||||
return if (size.mod(2) > 0) size + 1 else size
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
|
||||
// Eliminate any chance that this blocking call might mess up the loading process
|
||||
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import coil.decode.DataSource
|
||||
import coil.drawable.CrossfadeDrawable
|
||||
|
|
@ -26,11 +26,10 @@ import coil.transition.Transition
|
|||
import coil.transition.TransitionTarget
|
||||
|
||||
/**
|
||||
* A copy of [CrossfadeTransition.Factory] that applies a transition to error results. You know.
|
||||
* Like they used to.
|
||||
* @author Coil Team
|
||||
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
|
||||
* @author Coil Team, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class CrossfadeTransitionFactory : Transition.Factory {
|
||||
class ErrorCrossfadeTransitionFactory : Transition.Factory {
|
||||
override fun create(target: TransitionTarget, result: ImageResult): Transition {
|
||||
// Don't animate if the request was fulfilled by the memory cache.
|
||||
if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) {
|
||||
114
app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt
Normal file
114
app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.util.Size as AndroidSize
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
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 java.io.InputStream
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
|
||||
/**
|
||||
* Utilities for constructing Artist and Genre images.
|
||||
* @author Alexander Capehart (OxygenCobalt), Karim Abou Zeid
|
||||
*/
|
||||
object Images {
|
||||
/**
|
||||
* Create a mosaic image from the given image [InputStream]s. Derived from phonograph:
|
||||
* https://github.com/kabouzeid/Phonograph
|
||||
* @param context [Context] required to generate the mosaic.
|
||||
* @param streams [InputStream]s of image data to create the mosaic out of.
|
||||
* @param size [Size] of the Mosaic to generate.
|
||||
*/
|
||||
suspend fun createMosaic(
|
||||
context: Context,
|
||||
streams: List<InputStream>,
|
||||
size: Size
|
||||
): FetchResult? {
|
||||
if (streams.size < 4) {
|
||||
return streams.firstOrNull()?.let { stream ->
|
||||
SourceResult(
|
||||
source = ImageSource(stream.source().buffer(), context),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.DISK)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Run the bitmap through a transform to reflect the configuration of other images.
|
||||
val bitmap =
|
||||
SquareFrameTransform.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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an image dimension suitable to create a mosaic with.
|
||||
* @return A pixel dimension derived from the given [Dimension] that will always be even,
|
||||
* allowing it to be sub-divided.
|
||||
*/
|
||||
private fun Dimension.mosaicSize(): Int {
|
||||
val size = pxOrElse { 512 }
|
||||
return if (size.mod(2) > 0) size + 1 else size
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import coil.size.Size
|
||||
|
|
@ -24,9 +24,9 @@ import coil.transform.Transformation
|
|||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* A transformation that performs a center crop-style transformation on an image, however unlike the
|
||||
* actual ScaleType, this isn't affected by any hacks we do with ImageView itself.
|
||||
* @author OxygenCobalt
|
||||
* A transformation that performs a center crop-style transformation on an image. Allowing this
|
||||
* behavior to be intrinsic without any view configuration.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SquareFrameTransform : Transformation {
|
||||
override val cacheKey: String
|
||||
|
|
@ -38,20 +38,19 @@ class SquareFrameTransform : Transformation {
|
|||
val dstSize = min(input.width, input.height)
|
||||
val x = (input.width - dstSize) / 2
|
||||
val y = (input.height - dstSize) / 2
|
||||
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
|
||||
|
||||
val desiredWidth = size.width.pxOrElse { dstSize }
|
||||
val desiredHeight = size.height.pxOrElse { dstSize }
|
||||
|
||||
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
|
||||
|
||||
if (dstSize != desiredWidth || dstSize != desiredHeight) {
|
||||
// Image is not the desired size, upscale it.
|
||||
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A re-usable instance. */
|
||||
val INSTANCE = SquareFrameTransform()
|
||||
}
|
||||
}
|
||||
|
|
@ -15,9 +15,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.dirs
|
||||
package org.oxycblt.auxio.list
|
||||
|
||||
import org.oxycblt.auxio.music.Directory
|
||||
import androidx.annotation.StringRes
|
||||
|
||||
/** Represents a the configuration for the "Folder Management" setting */
|
||||
data class MusicDirs(val dirs: List<Directory>, val shouldInclude: Boolean)
|
||||
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
|
||||
interface Item
|
||||
|
||||
/**
|
||||
* A "header" used for delimiting groups of data.
|
||||
* @param titleRes The string resource used for the header's title.
|
||||
*/
|
||||
data class Header(@StringRes val titleRes: Int) : Item
|
||||
246
app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
Normal file
246
app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list
|
||||
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.oxycblt.auxio.MainFragmentDirections
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.selection.SelectionFragment
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
* A Fragment containing a selectable list.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class ListFragment<VB : ViewBinding> : SelectionFragment<VB>(), SelectableListListener {
|
||||
protected val navModel: NavigationViewModel by activityViewModels()
|
||||
private var currentMenu: PopupMenu? = null
|
||||
|
||||
override fun onDestroyBinding(binding: VB) {
|
||||
super.onDestroyBinding(binding)
|
||||
currentMenu?.dismiss()
|
||||
currentMenu = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when [onClick] is called, but does not result in the item being selected. This more or
|
||||
* less corresponds to an [onClick] implementation in a non-[ListFragment].
|
||||
* @param music The [Music] item that was clicked.
|
||||
*/
|
||||
abstract fun onRealClick(music: Music)
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
if (selectionModel.selected.value.isNotEmpty()) {
|
||||
// Map clicking an item to selecting an item when items are already selected.
|
||||
selectionModel.select(item)
|
||||
} else {
|
||||
// Delegate to the concrete implementation when we don't select the item.
|
||||
onRealClick(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSelect(item: Item) {
|
||||
check(item is Music) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
selectionModel.select(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Song]. This menu will be managed by the Fragment and closed
|
||||
* when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param song The [Song] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, song: Song) {
|
||||
logD("Launching new song menu: ${song.rawName}")
|
||||
|
||||
openMusicMenuImpl(anchor, menuRes) {
|
||||
when (it.itemId) {
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(song)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
navModel.exploreNavigateToParentArtist(song)
|
||||
}
|
||||
R.id.action_go_album -> {
|
||||
navModel.exploreNavigateTo(song.album)
|
||||
}
|
||||
R.id.action_song_detail -> {
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(
|
||||
MainFragmentDirections.actionShowDetails(song.uid)))
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Album]. This menu will be managed by the Fragment and
|
||||
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param album The [Album] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, album: Album) {
|
||||
logD("Launching new album menu: ${album.rawName}")
|
||||
|
||||
openMusicMenuImpl(anchor, menuRes) {
|
||||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(album)
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(album)
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(album)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
navModel.exploreNavigateToParentArtist(album)
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Artist]. This menu will be managed by the Fragment and
|
||||
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param artist The [Artist] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, artist: Artist) {
|
||||
logD("Launching new artist menu: ${artist.rawName}")
|
||||
|
||||
openMusicMenuImpl(anchor, menuRes) {
|
||||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(artist)
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(artist)
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(artist)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a menu in the context of a [Genre]. This menu will be managed by the Fragment and
|
||||
* closed when the view is destroyed. If a menu is already opened, this call is ignored.
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param genre The [Genre] to create the menu for.
|
||||
*/
|
||||
protected fun openMusicMenu(anchor: View, @MenuRes menuRes: Int, genre: Genre) {
|
||||
logD("Launching new genre menu: ${genre.rawName}")
|
||||
|
||||
openMusicMenuImpl(anchor, menuRes) {
|
||||
when (it.itemId) {
|
||||
R.id.action_play -> {
|
||||
playbackModel.play(genre)
|
||||
}
|
||||
R.id.action_shuffle -> {
|
||||
playbackModel.shuffle(genre)
|
||||
}
|
||||
R.id.action_play_next -> {
|
||||
playbackModel.playNext(genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
R.id.action_queue_add -> {
|
||||
playbackModel.addToQueue(genre)
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
}
|
||||
else -> {
|
||||
error("Unexpected menu item selected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openMusicMenuImpl(
|
||||
anchor: View,
|
||||
@MenuRes menuRes: Int,
|
||||
onMenuItemClick: (MenuItem) -> Unit
|
||||
) {
|
||||
openMenu(anchor, menuRes) {
|
||||
setOnMenuItemClickListener { item ->
|
||||
onMenuItemClick(item)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu. This menu will be managed by the Fragment and closed when the view is destroyed.
|
||||
* If a menu is already opened, this call is ignored.
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
* @param menuRes The resource of the menu to load.
|
||||
* @param block A block that is ran within [PopupMenu] that allows further configuration.
|
||||
*/
|
||||
protected fun openMenu(anchor: View, @MenuRes menuRes: Int, block: PopupMenu.() -> Unit) {
|
||||
if (currentMenu != null) {
|
||||
logD("Menu already present, not launching")
|
||||
return
|
||||
}
|
||||
|
||||
currentMenu =
|
||||
PopupMenu(requireContext(), anchor).apply {
|
||||
inflate(menuRes)
|
||||
block()
|
||||
setOnDismissListener { currentMenu = null }
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
Normal file
75
app/src/main/java/org/oxycblt/auxio/list/Listeners.kt
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list
|
||||
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* A basic listener for list interactions.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface ClickableListListener {
|
||||
// TODO: Supply a ViewHolder on clicks
|
||||
// (allows editable lists to be standardized into a listener.)
|
||||
/**
|
||||
* Called when an [Item] in the list is clicked.
|
||||
* @param item The [Item] that was clicked.
|
||||
*/
|
||||
fun onClick(item: Item)
|
||||
}
|
||||
|
||||
/**
|
||||
* An extension of [ClickableListListener] that enables menu and selection functionality.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface SelectableListListener : ClickableListListener {
|
||||
/**
|
||||
* Called when an [Item] in the list requests that a menu related to it should be opened.
|
||||
* @param item The [Item] to show a menu for.
|
||||
* @param anchor The [View] to anchor the menu to.
|
||||
*/
|
||||
fun onOpenMenu(item: Item, anchor: View)
|
||||
|
||||
/**
|
||||
* Called when an [Item] in the list requests that it be selected.
|
||||
* @param item The [Item] to select.
|
||||
*/
|
||||
fun onSelect(item: Item)
|
||||
|
||||
/**
|
||||
* Binds this instance to a list item.
|
||||
* @param viewHolder The [RecyclerView.ViewHolder] to bind.
|
||||
* @param item The [Item] that this list entry is bound to.
|
||||
* @param menuButton A [Button] that opens a menu.
|
||||
*/
|
||||
fun bind(viewHolder: RecyclerView.ViewHolder, item: Item, menuButton: Button) {
|
||||
viewHolder.itemView.apply {
|
||||
// Map clicks to the click listener.
|
||||
setOnClickListener { onClick(item) }
|
||||
// Map long clicks to the selection listener.
|
||||
setOnLongClickListener {
|
||||
onSelect(item)
|
||||
true
|
||||
}
|
||||
}
|
||||
// Map the menu button to the menu opening listener.
|
||||
menuButton.setOnClickListener { onOpenMenu(item, it) }
|
||||
}
|
||||
}
|
||||
|
|
@ -15,10 +15,9 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui.recycler
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.WindowInsets
|
||||
import androidx.annotation.AttrRes
|
||||
|
|
@ -27,30 +26,36 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/** A [RecyclerView] that enables some extra functionality for Auxio's use-case. */
|
||||
/**
|
||||
* A [RecyclerView] with a few QoL extensions, such as:
|
||||
* - Automatic edge-to-edge support
|
||||
* - Adapter-based [SpanSizeLookup] implementation
|
||||
* - Automatic [setHasFixedSize] setup
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
open class AuxioRecyclerView
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
RecyclerView(context, attrs, defStyleAttr) {
|
||||
private val initialPadding = Rect(paddingLeft, paddingTop, paddingRight, paddingBottom)
|
||||
private val initialPaddingBottom = paddingBottom
|
||||
|
||||
init {
|
||||
// Prevent children from being clipped by window insets
|
||||
clipToPadding = false
|
||||
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
|
||||
// so we can enable fixed-size optimizations.
|
||||
setHasFixedSize(true)
|
||||
}
|
||||
|
||||
final override fun setHasFixedSize(hasFixedSize: Boolean) {
|
||||
// Prevent a this leak by marking setHasFixedSize as final.
|
||||
super.setHasFixedSize(hasFixedSize)
|
||||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
updatePadding(
|
||||
initialPadding.left,
|
||||
initialPadding.top,
|
||||
initialPadding.right,
|
||||
initialPadding.bottom + insets.systemBarInsetsCompat.bottom)
|
||||
|
||||
// Update the RecyclerView's padding such that the bottom insets are applied
|
||||
// while still preserving bottom padding.
|
||||
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
|
||||
return insets
|
||||
}
|
||||
|
||||
|
|
@ -58,16 +63,28 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
super.setAdapter(adapter)
|
||||
|
||||
if (adapter is SpanSizeLookup) {
|
||||
// This adapter has support for special span sizes, hook it up to the
|
||||
// GridLayoutManager.
|
||||
val glm = (layoutManager as GridLayoutManager)
|
||||
val fullWidthSpanCount = glm.spanCount
|
||||
glm.spanSizeLookup =
|
||||
object : GridLayoutManager.SpanSizeLookup() {
|
||||
// Using the adapter implementation, if the adapter specifies that
|
||||
// an item is full width, it will take up all of the spans, using a
|
||||
// single span otherwise.
|
||||
override fun getSpanSize(position: Int) =
|
||||
if (adapter.isItemFullWidth(position)) glm.spanCount else 1
|
||||
if (adapter.isItemFullWidth(position)) fullWidthSpanCount else 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */
|
||||
interface SpanSizeLookup {
|
||||
/**
|
||||
* Get if the item at a position takes up the whole width of the [RecyclerView] or not.
|
||||
* @param position The position of the item.
|
||||
* @return true if the item is full-width, false otherwise.
|
||||
*/
|
||||
fun isItemFullWidth(position: Int): Boolean
|
||||
}
|
||||
}
|
||||
|
|
@ -15,10 +15,11 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui.recycler
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.view.isInvisible
|
||||
|
|
@ -27,12 +28,13 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDivider
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getDimenSize
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
|
||||
/**
|
||||
* A RecyclerView that enables something resembling the android:scrollIndicators attribute. Only
|
||||
* used in dialogs.
|
||||
* @author OxygenCobalt
|
||||
* A [RecyclerView] intended for use in Dialogs, adding features such as:
|
||||
* - NestedScrollView scrollIndicators behavior emulation
|
||||
* - Dialog-specific [ViewHolder] that automatically resolves certain issues.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class DialogRecyclerView
|
||||
@JvmOverloads
|
||||
|
|
@ -40,56 +42,70 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
RecyclerView(context, attrs, defStyleAttr) {
|
||||
private val topDivider = MaterialDivider(context)
|
||||
private val bottomDivider = MaterialDivider(context)
|
||||
private val spacingMedium = context.getDimenSize(R.dimen.spacing_medium)
|
||||
private val spacingMedium = context.getDimenPixels(R.dimen.spacing_medium)
|
||||
|
||||
init {
|
||||
// Apply top padding to give enough room to the dialog title, assuming that this view
|
||||
// is at the top of the dialog.
|
||||
updatePadding(top = spacingMedium)
|
||||
// Disable over-scrolling, the top and bottom dividers have the same purpose.
|
||||
overScrollMode = OVER_SCROLL_NEVER
|
||||
|
||||
// Safer to use the overlay than the actual RecyclerView children.
|
||||
overlay.apply {
|
||||
add(topDivider)
|
||||
add(bottomDivider)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScrolled(dx: Int, dy: Int) {
|
||||
super.onScrolled(dx, dy)
|
||||
invalidateDividers()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||
super.onMeasure(widthSpec, heightSpec)
|
||||
measureDivider(topDivider)
|
||||
measureDivider(bottomDivider)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
super.onLayout(changed, l, t, r, b)
|
||||
topDivider.layout(l, spacingMedium, r, spacingMedium + topDivider.measuredHeight)
|
||||
bottomDivider.layout(l, measuredHeight - bottomDivider.measuredHeight, r, b)
|
||||
// Make sure we initialize the dividers here before we start drawing.
|
||||
invalidateDividers()
|
||||
}
|
||||
|
||||
override fun onScrolled(dx: Int, dy: Int) {
|
||||
super.onScrolled(dx, dy)
|
||||
// Scroll event occurred, need to update the dividers.
|
||||
invalidateDividers()
|
||||
}
|
||||
|
||||
private fun measureDivider(divider: MaterialDivider) {
|
||||
val widthMeasureSpec =
|
||||
ViewGroup.getChildMeasureSpec(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
0,
|
||||
divider.layoutParams.width)
|
||||
|
||||
val heightMeasureSpec =
|
||||
ViewGroup.getChildMeasureSpec(
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
||||
0,
|
||||
divider.layoutParams.height)
|
||||
|
||||
divider.measure(widthMeasureSpec, heightMeasureSpec)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
super.onLayout(changed, l, t, r, b)
|
||||
topDivider.layout(l, spacingMedium, r, spacingMedium + topDivider.measuredHeight)
|
||||
bottomDivider.layout(l, measuredHeight - bottomDivider.measuredHeight, r, b)
|
||||
invalidateDividers()
|
||||
private fun invalidateDividers() {
|
||||
val lmm = layoutManager as LinearLayoutManager
|
||||
// Top divider should only be visible when the first item has gone off-screen.
|
||||
topDivider.isInvisible = lmm.findFirstCompletelyVisibleItemPosition() < 1
|
||||
// Bottom divider should only be visible when the lsat item is completely on-screen.
|
||||
bottomDivider.isInvisible =
|
||||
lmm.findLastCompletelyVisibleItemPosition() == (lmm.itemCount - 1)
|
||||
}
|
||||
|
||||
private fun invalidateDividers() {
|
||||
val manager = layoutManager as LinearLayoutManager
|
||||
topDivider.isInvisible = manager.findFirstCompletelyVisibleItemPosition() < 1
|
||||
bottomDivider.isInvisible =
|
||||
manager.findLastCompletelyVisibleItemPosition() == (manager.itemCount - 1)
|
||||
/** A [RecyclerView.ViewHolder] that implements dialog-specific fixes. */
|
||||
abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
|
||||
init {
|
||||
// ViewHolders are not automatically full-width in dialogs, manually resize
|
||||
// them to be as such.
|
||||
root.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class PlayingIndicatorAdapter<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||
// There are actually two states for this adapter:
|
||||
// - The currently playing item, which is usually marked as "selected" and becomes accented.
|
||||
// - Whether playback is ongoing, which corresponds to whether the item's ImageGroup is
|
||||
// marked as "playing" or not.
|
||||
private var currentItem: Item? = null
|
||||
private var isPlaying = false
|
||||
|
||||
/**
|
||||
* The current list of the adapter. This is used to update items if the indicator state changes.
|
||||
*/
|
||||
abstract val currentList: List<Item>
|
||||
|
||||
override fun getItemCount() = currentList.size
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||
// Only try to update the playing indicator if the ViewHolder supports it
|
||||
if (holder is ViewHolder) {
|
||||
holder.updatePlayingIndicator(currentList[position] == currentItem, isPlaying)
|
||||
}
|
||||
|
||||
if (payloads.isEmpty()) {
|
||||
// Not updating any indicator-specific attributes, so delegate to the concrete
|
||||
// adapter (actually bind the item)
|
||||
onBindViewHolder(holder, position)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Update the currently playing item in the list.
|
||||
* @param item The item currently being played, or null if it is not being played.
|
||||
* @param isPlaying Whether playback is ongoing or paused.
|
||||
*/
|
||||
fun setPlayingItem(item: Item?, isPlaying: Boolean) {
|
||||
var updatedItem = false
|
||||
if (currentItem != item) {
|
||||
val oldItem = currentItem
|
||||
currentItem = item
|
||||
|
||||
// Remove the playing indicator from the old item
|
||||
if (oldItem != null) {
|
||||
val pos = currentList.indexOfFirst { it == oldItem }
|
||||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
logD("oldItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the playing indicator on the new item
|
||||
if (item != null) {
|
||||
val pos = currentList.indexOfFirst { it == item }
|
||||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
logD("newItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
|
||||
updatedItem = true
|
||||
}
|
||||
|
||||
if (this.isPlaying != isPlaying) {
|
||||
this.isPlaying = isPlaying
|
||||
|
||||
// We may have already called notifyItemChanged before when checking
|
||||
// if the item was being played, so in that case we don't need to
|
||||
// update again here.
|
||||
if (!updatedItem && item != null) {
|
||||
val pos = currentList.indexOfFirst { it == item }
|
||||
if (pos > -1) {
|
||||
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
|
||||
} else {
|
||||
logD("newItem was not in adapter data")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A [RecyclerView.ViewHolder] that can display a playing indicator. */
|
||||
abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
|
||||
/**
|
||||
* Update the playing indicator within this [RecyclerView.ViewHolder].
|
||||
* @param isActive True if this item is playing, false otherwise.
|
||||
* @param isPlaying True if playback is ongoing, false if paused. If this is true,
|
||||
* [isActive] will also be true.
|
||||
*/
|
||||
abstract fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAYLOAD_PLAYING_INDICATOR_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.music.Music
|
||||
|
||||
/**
|
||||
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
|
||||
* items.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class SelectionIndicatorAdapter<VH : RecyclerView.ViewHolder> :
|
||||
PlayingIndicatorAdapter<VH>() {
|
||||
private var selectedItems = setOf<Music>()
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int, payloads: List<Any>) {
|
||||
super.onBindViewHolder(holder, position, payloads)
|
||||
if (holder is ViewHolder) {
|
||||
holder.updateSelectionIndicator(selectedItems.contains(currentList[position]))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the list of selected items.
|
||||
* @param items A list of selected [Music].
|
||||
*/
|
||||
fun setSelectedItems(items: List<Music>) {
|
||||
val oldSelectedItems = selectedItems
|
||||
val newSelectedItems = items.toSet()
|
||||
if (newSelectedItems == oldSelectedItems) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
selectedItems = newSelectedItems
|
||||
for (i in currentList.indices) {
|
||||
// TODO: Perhaps add an optimization that allows me to avoid the O(n) iteration
|
||||
// assuming all list items are unique?
|
||||
val item = currentList[i]
|
||||
if (item !is Music) {
|
||||
// Not applicable.
|
||||
continue
|
||||
}
|
||||
|
||||
// Only update items that were added or removed from the list.
|
||||
val added = !oldSelectedItems.contains(item) && newSelectedItems.contains(item)
|
||||
val removed = oldSelectedItems.contains(item) && !newSelectedItems.contains(item)
|
||||
if (added || removed) {
|
||||
notifyItemChanged(i, PAYLOAD_SELECTION_INDICATOR_CHANGED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A [PlayingIndicatorAdapter.ViewHolder] that can display a selection indicator. */
|
||||
abstract class ViewHolder(root: View) : PlayingIndicatorAdapter.ViewHolder(root) {
|
||||
/**
|
||||
* Update the selection indicator within this [PlayingIndicatorAdapter.ViewHolder].
|
||||
* @param isSelected Whether this [PlayingIndicatorAdapter.ViewHolder] is selected.
|
||||
*/
|
||||
abstract fun updateSelectionIndicator(isSelected: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PAYLOAD_SELECTION_INDICATOR_CHANGED = Any()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import org.oxycblt.auxio.list.Item
|
||||
|
||||
/**
|
||||
* A [DiffUtil.ItemCallback] that automatically implements the [areItemsTheSame] method. Use this
|
||||
* whenever creating [DiffUtil.ItemCallback] implementations with an [Item] subclass.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class SimpleItemCallback<T : Item> : DiffUtil.ItemCallback<T>() {
|
||||
final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem
|
||||
}
|
||||
|
|
@ -15,48 +15,17 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.ui.recycler
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.recyclerview.widget.AdapterListUpdateCallback
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* The base for all items in Auxio. Any datatype can derive this type and gain some behavior not
|
||||
* provided for free by the normal adapter implementations, such as certain types of diffing.
|
||||
*/
|
||||
abstract class Item {
|
||||
/** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
|
||||
abstract val id: Long
|
||||
}
|
||||
|
||||
/** A data object used solely for the "Header" UI element. */
|
||||
data class Header(
|
||||
/** The string resource used for the header. */
|
||||
@StringRes val string: Int
|
||||
) : Item() {
|
||||
override val id: Long
|
||||
get() = string.toLong()
|
||||
}
|
||||
|
||||
/** An interface for detecting if an item has been clicked once. */
|
||||
interface ItemClickListener {
|
||||
/** Called when an item is clicked once. */
|
||||
fun onItemClick(item: Item)
|
||||
}
|
||||
|
||||
/** An interface for detecting if an item has had it's menu opened. */
|
||||
interface MenuItemListener : ItemClickListener {
|
||||
/** Called when an item desires to open a menu relating to it. */
|
||||
fun onOpenMenu(item: Item, anchor: View)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [AsyncListDiffer], but synchronous. This may seem like it would be inefficient, but in
|
||||
* practice Auxio's lists tend to be small enough to the point where this does not matter, and
|
||||
* situations that would be inefficient are ruled out with [replaceList].
|
||||
* A list differ that operates synchronously. This can help resolve some shortcomings with
|
||||
* AsyncListDiffer, at the cost of performance. Derived from Material Files:
|
||||
* https://github.com/zhanghai/MaterialFiles
|
||||
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SyncListDiffer<T>(
|
||||
adapter: RecyclerView.Adapter<*>,
|
||||
|
|
@ -141,17 +110,28 @@ class SyncListDiffer<T>(
|
|||
result.dispatchUpdatesTo(updateCallback)
|
||||
}
|
||||
|
||||
/** Submit a list normally, doing a diff synchronously. Only use this for trivial changes. */
|
||||
/**
|
||||
* Submit a list like AsyncListDiffer. This is exceedingly slow for large diffs, so only use it
|
||||
* if the changes are trivial.
|
||||
* @param newList The list to update to.
|
||||
*/
|
||||
fun submitList(newList: List<T>) {
|
||||
if (newList == currentList) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
currentList = newList
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this list with a new list. This is useful for very large list diffs that would
|
||||
* generally be too chaotic and slow to provide a good UX.
|
||||
* Replace this list with a new list. This is good for large diffs that are too slow to update
|
||||
* synchronously, but too chaotic to update asynchronously.
|
||||
* @param newList The list to update to.
|
||||
*/
|
||||
fun replaceList(newList: List<T>) {
|
||||
if (newList == currentList) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -159,14 +139,3 @@ class SyncListDiffer<T>(
|
|||
currentList = newList
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A base [DiffUtil.ItemCallback] that automatically provides an implementation of
|
||||
* [areContentsTheSame] any object that is derived from [Item].
|
||||
*/
|
||||
abstract class SimpleItemCallback<T : Item> : DiffUtil.ItemCallback<T>() {
|
||||
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
|
||||
if (oldItem.javaClass != newItem.javaClass) return false
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
}
|
||||
274
app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
Normal file
274
app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.ItemHeaderBinding
|
||||
import org.oxycblt.auxio.databinding.ItemParentBinding
|
||||
import org.oxycblt.auxio.databinding.ItemSongBinding
|
||||
import org.oxycblt.auxio.list.Header
|
||||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SongViewHolder private constructor(private val binding: ItemSongBinding) :
|
||||
SelectionIndicatorAdapter.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param song The new [Song] to bind.
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(song: Song, listener: SelectableListListener) {
|
||||
listener.bind(this, song, binding.songMenu)
|
||||
binding.songAlbumCover.bind(song)
|
||||
binding.songName.text = song.resolveName(binding.context)
|
||||
binding.songInfo.text = song.resolveArtistContents(binding.context)
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isSelected = isActive
|
||||
binding.songAlbumCover.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||
binding.root.isActivated = isSelected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SONG
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = SongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Song>() {
|
||||
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
|
||||
oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Album]. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class AlbumViewHolder private constructor(private val binding: ItemParentBinding) :
|
||||
SelectionIndicatorAdapter.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param album The new [Album] to bind.
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(album: Album, listener: SelectableListListener) {
|
||||
listener.bind(this, album, binding.parentMenu)
|
||||
binding.parentImage.bind(album)
|
||||
binding.parentName.text = album.resolveName(binding.context)
|
||||
binding.parentInfo.text = album.resolveArtistContents(binding.context)
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isSelected = isActive
|
||||
binding.parentImage.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||
binding.root.isActivated = isSelected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = AlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Album>() {
|
||||
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.areArtistContentsTheSame(newItem) &&
|
||||
oldItem.type == newItem.type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Artist]. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistViewHolder private constructor(private val binding: ItemParentBinding) :
|
||||
SelectionIndicatorAdapter.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param artist The new [Artist] to bind.
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(artist: Artist, listener: SelectableListListener) {
|
||||
listener.bind(this, artist, binding.parentMenu)
|
||||
binding.parentImage.bind(artist)
|
||||
binding.parentName.text = artist.resolveName(binding.context)
|
||||
binding.parentInfo.text =
|
||||
if (artist.songs.isNotEmpty()) {
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size),
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size))
|
||||
} else {
|
||||
// Artist has no songs, only display an album count.
|
||||
binding.context.getPlural(R.plurals.fmt_album_count, artist.albums.size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isSelected = isActive
|
||||
binding.parentImage.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||
binding.root.isActivated = isSelected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = ArtistViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Artist>() {
|
||||
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
|
||||
oldItem.rawName == newItem.rawName &&
|
||||
oldItem.albums.size == newItem.albums.size &&
|
||||
oldItem.songs.size == newItem.songs.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Genre]. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenreViewHolder private constructor(private val binding: ItemParentBinding) :
|
||||
SelectionIndicatorAdapter.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param genre The new [Genre] to bind.
|
||||
* @param listener An [SelectableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(genre: Genre, listener: SelectableListListener) {
|
||||
listener.bind(this, genre, binding.parentMenu)
|
||||
binding.parentImage.bind(genre)
|
||||
binding.parentName.text = genre.resolveName(binding.context)
|
||||
binding.parentInfo.text =
|
||||
binding.context.getString(
|
||||
R.string.fmt_two,
|
||||
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
|
||||
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
|
||||
}
|
||||
|
||||
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
|
||||
binding.root.isSelected = isActive
|
||||
binding.parentImage.isPlaying = isPlaying
|
||||
}
|
||||
|
||||
override fun updateSelectionIndicator(isSelected: Boolean) {
|
||||
binding.root.isActivated = isSelected
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = GenreViewHolder(ItemParentBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Genre>() {
|
||||
override fun areContentsTheSame(oldItem: Genre, newItem: Genre): Boolean =
|
||||
oldItem.rawName == newItem.rawName && oldItem.songs.size == newItem.songs.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [RecyclerView.ViewHolder] that displays a [Header]. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param header The new [Header] to bind.
|
||||
*/
|
||||
fun bind(header: Header) {
|
||||
binding.title.text = binding.context.getString(header.titleRes)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Unique ID for this ViewHolder type. */
|
||||
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_HEADER
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) = HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
|
||||
|
||||
/** A comparator that can be used with DiffUtil. */
|
||||
val DIFF_CALLBACK =
|
||||
object : SimpleItemCallback<Header>() {
|
||||
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.selection
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
|
||||
/**
|
||||
* A subset of ListFragment that implements aspects of the selection UI.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class SelectionFragment<VB : ViewBinding> :
|
||||
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
|
||||
protected val selectionModel: SelectionViewModel by activityViewModels()
|
||||
protected val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
|
||||
/**
|
||||
* Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by
|
||||
* [SelectionFragment].
|
||||
* @return The [SelectionToolbarOverlay] of the concrete [SelectionFragment]'s [VB], or null if
|
||||
* there is not one.
|
||||
*/
|
||||
open fun getSelectionToolbar(binding: VB): SelectionToolbarOverlay? = null
|
||||
|
||||
override fun onBindingCreated(binding: VB, savedInstanceState: Bundle?) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
getSelectionToolbar(binding)?.apply {
|
||||
// Add cancel and menu item listeners to manage what occurs with the selection.
|
||||
setOnSelectionCancelListener { selectionModel.consume() }
|
||||
setOnMenuItemClickListener(this@SelectionFragment)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: VB) {
|
||||
super.onDestroyBinding(binding)
|
||||
getSelectionToolbar(binding)?.setOnMenuItemClickListener(null)
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem) =
|
||||
when (item.itemId) {
|
||||
R.id.action_selection_play_next -> {
|
||||
playbackModel.playNext(selectionModel.consume())
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
R.id.action_selection_queue_add -> {
|
||||
playbackModel.addToQueue(selectionModel.consume())
|
||||
requireContext().showToast(R.string.lng_queue_added)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.selection
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.Toolbar.OnMenuItemClickListener
|
||||
import androidx.core.view.isInvisible
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getInteger
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A wrapper around a [MaterialToolbar] that adds an additional [MaterialToolbar] showing the
|
||||
* current selection state.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SelectionToolbarOverlay
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
FrameLayout(context, attrs, defStyleAttr) {
|
||||
private lateinit var innerToolbar: MaterialToolbar
|
||||
private val selectionToolbar =
|
||||
MaterialToolbar(context).apply {
|
||||
setNavigationIcon(R.drawable.ic_close_24)
|
||||
inflateMenu(R.menu.menu_selection_actions)
|
||||
|
||||
if (isInEditMode) {
|
||||
isInvisible = true
|
||||
}
|
||||
}
|
||||
private var fadeThroughAnimator: ValueAnimator? = null
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
// Sanity check: Avoid incorrect views from being included in this layout.
|
||||
check(childCount == 1 && getChildAt(0) is MaterialToolbar) {
|
||||
"SelectionToolbarOverlay Must have only one MaterialToolbar child"
|
||||
}
|
||||
// The inner toolbar should be the first child.
|
||||
innerToolbar = getChildAt(0) as MaterialToolbar
|
||||
// Selection toolbar should appear on top of the inner toolbar.
|
||||
addView(selectionToolbar)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an OnClickListener for when the "cancel" button in the selection [MaterialToolbar] is
|
||||
* pressed.
|
||||
* @param listener The OnClickListener to respond to this interaction.
|
||||
* @see MaterialToolbar.setNavigationOnClickListener
|
||||
*/
|
||||
fun setOnSelectionCancelListener(listener: OnClickListener) {
|
||||
selectionToolbar.setNavigationOnClickListener(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an [OnMenuItemClickListener] for when a MenuItem is selected from the selection
|
||||
* [MaterialToolbar].
|
||||
* @param listener The [OnMenuItemClickListener] to respond to this interaction.
|
||||
* @see MaterialToolbar.setOnMenuItemClickListener
|
||||
*/
|
||||
fun setOnMenuItemClickListener(listener: OnMenuItemClickListener?) {
|
||||
selectionToolbar.setOnMenuItemClickListener(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selection [MaterialToolbar] to reflect the current selection amount.
|
||||
* @param amount The amount of items that are currently selected.
|
||||
* @return true if the selection [MaterialToolbar] changes, false otherwise.
|
||||
*/
|
||||
fun updateSelectionAmount(amount: Int): Boolean {
|
||||
logD("Updating selection amount to $amount")
|
||||
return if (amount > 0) {
|
||||
// Only update the selected amount when it's non-zero to prevent a strange
|
||||
// title text.
|
||||
selectionToolbar.title = context.getString(R.string.fmt_selected, amount)
|
||||
animateToolbarsVisibility(true)
|
||||
} else {
|
||||
animateToolbarsVisibility(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the visibility of the inner and selection [MaterialToolbar]s to the given state.
|
||||
* @param selectionVisible Whether the selection [MaterialToolbar] should be visible or not.
|
||||
* @return true if the toolbars have changed, false otherwise.
|
||||
*/
|
||||
private fun animateToolbarsVisibility(selectionVisible: Boolean): Boolean {
|
||||
// TODO: Animate nicer Material Fade transitions using animators (Normal transitions
|
||||
// don't work due to translation)
|
||||
// Set up the target transitions for both the inner and selection toolbars.
|
||||
val targetInnerAlpha: Float
|
||||
val targetSelectionAlpha: Float
|
||||
val targetDuration: Long
|
||||
|
||||
if (selectionVisible) {
|
||||
targetInnerAlpha = 0f
|
||||
targetSelectionAlpha = 1f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
|
||||
} else {
|
||||
targetInnerAlpha = 1f
|
||||
targetSelectionAlpha = 0f
|
||||
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
|
||||
}
|
||||
|
||||
if (innerToolbar.alpha == targetInnerAlpha &&
|
||||
selectionToolbar.alpha == targetSelectionAlpha) {
|
||||
// Nothing to do.
|
||||
return false
|
||||
}
|
||||
|
||||
if (!isLaidOut) {
|
||||
// Not laid out, just change it immediately while are not shown to the user.
|
||||
// This is an initialization, so we return false despite changing.
|
||||
setToolbarsAlpha(targetInnerAlpha)
|
||||
return false
|
||||
}
|
||||
|
||||
if (fadeThroughAnimator != null) {
|
||||
fadeThroughAnimator?.cancel()
|
||||
fadeThroughAnimator = null
|
||||
}
|
||||
|
||||
fadeThroughAnimator =
|
||||
ValueAnimator.ofFloat(innerToolbar.alpha, targetInnerAlpha).apply {
|
||||
duration = targetDuration
|
||||
addUpdateListener { setToolbarsAlpha(it.animatedValue as Float) }
|
||||
start()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the alpha of the inner and selection [MaterialToolbar]s.
|
||||
* @param innerAlpha The opacity of the inner [MaterialToolbar]. This will map to the inverse
|
||||
* opacity of the selection [MaterialToolbar].
|
||||
*/
|
||||
private fun setToolbarsAlpha(innerAlpha: Float) {
|
||||
innerToolbar.apply {
|
||||
alpha = innerAlpha
|
||||
isInvisible = innerAlpha == 0f
|
||||
}
|
||||
|
||||
selectionToolbar.apply {
|
||||
alpha = 1 - innerAlpha
|
||||
isInvisible = innerAlpha == 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.list.selection
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.*
|
||||
|
||||
/**
|
||||
* A [ViewModel] that manages the current selection.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SelectionViewModel : ViewModel(), MusicStore.Callback {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
|
||||
private val _selected = MutableStateFlow(listOf<Music>())
|
||||
/** the currently selected items. These are ordered in earliest selected and latest selected. */
|
||||
val selected: StateFlow<List<Music>>
|
||||
get() = _selected
|
||||
|
||||
init {
|
||||
musicStore.addCallback(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
if (library == null) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize the selection to remove items that no longer exist and thus
|
||||
// won't appear in any list.
|
||||
_selected.value =
|
||||
_selected.value.mapNotNull {
|
||||
when (it) {
|
||||
is Song -> library.sanitize(it)
|
||||
is Album -> library.sanitize(it)
|
||||
is Artist -> library.sanitize(it)
|
||||
is Genre -> library.sanitize(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicStore.removeCallback(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a new [Music] item. If this item is already within the selected items, the item will
|
||||
* be removed. Otherwise, it will be added.
|
||||
* @param music The [Music] item to select.
|
||||
*/
|
||||
fun select(music: Music) {
|
||||
val selected = _selected.value.toMutableList()
|
||||
if (!selected.remove(music)) {
|
||||
selected.add(music)
|
||||
}
|
||||
_selected.value = selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume the current selection. This will clear any items that were selected prior.
|
||||
* @return The list of selected items before it was cleared.
|
||||
*/
|
||||
fun consume() = _selected.value.also { _selected.value = listOf() }
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
65
app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt
Normal file
65
app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
|
||||
/**
|
||||
* Represents a data configuration corresponding to a specific type of [Music],
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class MusicMode {
|
||||
/** Configure with respect to [Song] instances. */
|
||||
SONGS,
|
||||
/** Configure with respect to [Album] instances. */
|
||||
ALBUMS,
|
||||
/** Configure with respect to [Artist] instances. */
|
||||
ARTISTS,
|
||||
/** Configure with respect to [Genre] instances. */
|
||||
GENRES;
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
* @see fromIntCode
|
||||
*/
|
||||
val intCode: Int
|
||||
get() =
|
||||
when (this) {
|
||||
SONGS -> IntegerTable.MUSIC_MODE_SONGS
|
||||
ALBUMS -> IntegerTable.MUSIC_MODE_ALBUMS
|
||||
ARTISTS -> IntegerTable.MUSIC_MODE_ARTISTS
|
||||
GENRES -> IntegerTable.MUSIC_MODE_GENRES
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a [MusicMode] integer representation into an instance.
|
||||
* @param intCode An integer representation of a [MusicMode]
|
||||
* @return The corresponding [MusicMode], or null if the [MusicMode] is invalid.
|
||||
* @see MusicMode.intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.MUSIC_MODE_SONGS -> SONGS
|
||||
IntegerTable.MUSIC_MODE_ALBUMS -> ALBUMS
|
||||
IntegerTable.MUSIC_MODE_ARTISTS -> ARTISTS
|
||||
IntegerTable.MUSIC_MODE_GENRES -> GENRES
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,115 +20,170 @@ package org.oxycblt.auxio.music
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import org.oxycblt.auxio.util.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.storage.useQuery
|
||||
|
||||
/**
|
||||
* The main storage for music items.
|
||||
* A repository granting access to the music library..
|
||||
*
|
||||
* Whereas other apps load music from MediaStore as it is shown, Auxio does not do that, as it
|
||||
* cripples any kind of advanced metadata functionality. Instead, Auxio loads all music into a
|
||||
* in-memory relational data-structure called [Library]. This costs more memory-wise, but is also
|
||||
* much more sensible.
|
||||
* This can be used to obtain certain music items, or await changes to the music library. It is
|
||||
* generally recommended to use this over Indexer to keep track of the library state, as the
|
||||
* interface will be less volatile.
|
||||
*
|
||||
* The only other, memory-efficient option is to create our own hybrid database that leverages both
|
||||
* a typical DB and a mem-cache, like Vinyl. But why would we do that when I've encountered no real
|
||||
* issues with the current system.
|
||||
*
|
||||
* [Library] may not be available at all times, so leveraging [Callback] is recommended. Consumers
|
||||
* should also be aware that [Library] may change while they are running, and design their work
|
||||
* accordingly.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicStore private constructor() {
|
||||
private val callbacks = mutableListOf<Callback>()
|
||||
|
||||
/**
|
||||
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
|
||||
* can change, so it's highly recommended to not access this directly and instead rely on
|
||||
* [Callback].
|
||||
*/
|
||||
var library: Library? = null
|
||||
private set
|
||||
set(value) {
|
||||
field = value
|
||||
for (callback in callbacks) {
|
||||
callback.onLibraryChanged(library)
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a callback to this instance. Make sure to remove it when done. */
|
||||
/**
|
||||
* Add a [Callback] to this instance. This can be used to receive changes in the music library.
|
||||
* Will invoke all [Callback] methods to initialize the instance with the current state.
|
||||
* @param callback The [Callback] to add.
|
||||
* @see Callback
|
||||
*/
|
||||
@Synchronized
|
||||
fun addCallback(callback: Callback) {
|
||||
callback.onLibraryChanged(library)
|
||||
callbacks.add(callback)
|
||||
}
|
||||
|
||||
/** Remove a callback from this instance. */
|
||||
/**
|
||||
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
|
||||
* @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
|
||||
* the first place.
|
||||
* @see Callback
|
||||
*/
|
||||
@Synchronized
|
||||
fun removeCallback(callback: Callback) {
|
||||
callbacks.remove(callback)
|
||||
}
|
||||
|
||||
/** Update the library in this instance. This is only meant for use by the internal indexer. */
|
||||
@Synchronized
|
||||
fun updateLibrary(newLibrary: Library?) {
|
||||
library = newLibrary
|
||||
for (callback in callbacks) {
|
||||
callback.onLibraryChanged(library)
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents a library of music owned by [MusicStore]. */
|
||||
/**
|
||||
* A library of [Music] instances.
|
||||
* @param songs All [Song]s loaded from the device.
|
||||
* @param albums All [Album]s that could be created.
|
||||
* @param artists All [Artist]s that could be created.
|
||||
* @param genres All [Genre]s that could be created.
|
||||
*/
|
||||
data class Library(
|
||||
val genres: List<Genre>,
|
||||
val artists: List<Artist>,
|
||||
val songs: List<Song>,
|
||||
val albums: List<Album>,
|
||||
val songs: List<Song>
|
||||
val artists: List<Artist>,
|
||||
val genres: List<Genre>,
|
||||
) {
|
||||
private val genreIdMap = HashMap<Long, Genre>().apply { genres.forEach { put(it.id, it) } }
|
||||
private val artistIdMap =
|
||||
HashMap<Long, Artist>().apply { artists.forEach { put(it.id, it) } }
|
||||
private val albumIdMap = HashMap<Long, Album>().apply { albums.forEach { put(it.id, it) } }
|
||||
private val songIdMap = HashMap<Long, Song>().apply { songs.forEach { put(it.id, it) } }
|
||||
private val uidMap = HashMap<Music.UID, Music>()
|
||||
|
||||
/** Find a [Song] by it's ID. Null if no song exists with that ID. */
|
||||
fun findSongById(songId: Long) = songIdMap[songId]
|
||||
init {
|
||||
// The data passed to Library initially are complete, but are still volitaile.
|
||||
// Finalize them to ensure they are well-formed. Also initialize the UID map in
|
||||
// the same loop for efficiency.
|
||||
for (song in songs) {
|
||||
song._finalize()
|
||||
uidMap[song.uid] = song
|
||||
}
|
||||
|
||||
/** Find a [Album] by it's ID. Null if no album exists with that ID. */
|
||||
fun findAlbumById(albumId: Long) = albumIdMap[albumId]
|
||||
for (album in albums) {
|
||||
album._finalize()
|
||||
uidMap[album.uid] = album
|
||||
}
|
||||
|
||||
/** Find a [Artist] by it's ID. Null if no artist exists with that ID. */
|
||||
fun findArtistById(artistId: Long) = artistIdMap[artistId]
|
||||
for (artist in artists) {
|
||||
artist._finalize()
|
||||
uidMap[artist.uid] = artist
|
||||
}
|
||||
|
||||
/** Find a [Genre] by it's ID. Null if no genre exists with that ID. */
|
||||
fun findGenreById(genreId: Long) = genreIdMap[genreId]
|
||||
for (genre in genres) {
|
||||
genre._finalize()
|
||||
uidMap[genre.uid] = genre
|
||||
}
|
||||
}
|
||||
|
||||
/** Sanitize an old item to find the corresponding item in a new library. */
|
||||
fun sanitize(song: Song) = findSongById(song.id)
|
||||
/** Sanitize an old item to find the corresponding item in a new library. */
|
||||
fun sanitize(songs: List<Song>) = songs.mapNotNull { sanitize(it) }
|
||||
/** Sanitize an old item to find the corresponding item in a new library. */
|
||||
fun sanitize(album: Album) = findAlbumById(album.id)
|
||||
/** Sanitize an old item to find the corresponding item in a new library. */
|
||||
fun sanitize(artist: Artist) = findArtistById(artist.id)
|
||||
/** Sanitize an old item to find the corresponding item in a new library. */
|
||||
fun sanitize(genre: Genre) = findGenreById(genre.id)
|
||||
/**
|
||||
* Finds a [Music] item [T] in the library by it's [Music.UID].
|
||||
* @param uid The [Music.UID] to search for.
|
||||
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found
|
||||
* or the [Music.UID] did not correspond to a [T].
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST") fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
|
||||
|
||||
/** Find a song for a [uri]. */
|
||||
/**
|
||||
* Convert a [Song] from an another library into a [Song] in this [Library].
|
||||
* @param song The [Song] to convert.
|
||||
* @return The analogous [Song] in this [Library], or null if it does not exist.
|
||||
*/
|
||||
fun sanitize(song: Song) = find<Song>(song.uid)
|
||||
|
||||
/**
|
||||
* Convert a [Album] from an another library into a [Album] in this [Library].
|
||||
* @param album The [Album] to convert.
|
||||
* @return The analogous [Album] in this [Library], or null if it does not exist.
|
||||
*/
|
||||
fun sanitize(album: Album) = find<Album>(album.uid)
|
||||
|
||||
/**
|
||||
* Convert a [Artist] from an another library into a [Artist] in this [Library].
|
||||
* @param artist The [Artist] to convert.
|
||||
* @return The analogous [Artist] in this [Library], or null if it does not exist.
|
||||
*/
|
||||
fun sanitize(artist: Artist) = find<Artist>(artist.uid)
|
||||
|
||||
/**
|
||||
* Convert a [Genre] from an another library into a [Genre] in this [Library].
|
||||
* @param genre The [Genre] to convert.
|
||||
* @return The analogous [Genre] in this [Library], or null if it does not exist.
|
||||
*/
|
||||
fun sanitize(genre: Genre) = find<Genre>(genre.uid)
|
||||
|
||||
/**
|
||||
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
|
||||
* @param context [Context] required to analyze the [Uri].
|
||||
* @param uri [Uri] to search for.
|
||||
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
|
||||
*/
|
||||
fun findSongForUri(context: Context, uri: Uri) =
|
||||
context.contentResolverSafe.useQuery(uri, arrayOf(OpenableColumns.DISPLAY_NAME)) {
|
||||
cursor ->
|
||||
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))
|
||||
|
||||
songs.find { it.path.name == displayName }
|
||||
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
|
||||
songs.find { it.path.name == displayName && it.size == size }
|
||||
}
|
||||
}
|
||||
|
||||
/** A callback for awaiting the loading of music. */
|
||||
/** A listener for changes in the music library. */
|
||||
interface Callback {
|
||||
/**
|
||||
* Called when the current [Library] has changed.
|
||||
* @param library The new [Library], or null if no [Library] has been loaded yet.
|
||||
*/
|
||||
fun onLibraryChanged(library: Library?)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MusicStore? = null
|
||||
|
||||
/** Get the process-level instance of [MusicStore] */
|
||||
/**
|
||||
* Get a singleton instance.
|
||||
* @return The (possibly newly-created) singleton instance.
|
||||
*/
|
||||
fun getInstance(): MusicStore {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
if (currentInstance != null) {
|
||||
return currentInstance
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,370 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
|
||||
fun ContentResolver.queryCursor(
|
||||
uri: Uri,
|
||||
projection: Array<out String>,
|
||||
selector: String? = null,
|
||||
args: Array<String>? = null
|
||||
) = query(uri, projection, selector, args, null)
|
||||
|
||||
/** Shortcut for making a [ContentResolver] query and using the particular cursor with [use]. */
|
||||
inline fun <reified R> ContentResolver.useQuery(
|
||||
uri: Uri,
|
||||
projection: Array<out String>,
|
||||
selector: String? = null,
|
||||
args: Array<String>? = null,
|
||||
block: (Cursor) -> R
|
||||
) = queryCursor(uri, projection, selector, args)?.use(block)
|
||||
|
||||
/**
|
||||
* For some reason the album cover URI namespace does not have a member in [MediaStore], but it
|
||||
* still works since at least API 21.
|
||||
*/
|
||||
private val EXTERNAL_ALBUM_ART_URI = Uri.parse("content://media/external/audio/albumart")
|
||||
|
||||
/** Converts a [Long] Audio ID into a URI to that particular audio file. */
|
||||
val Long.audioUri: Uri
|
||||
get() = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this)
|
||||
|
||||
/** Converts a [Long] Album ID into a URI pointing to MediaStore-cached album art. */
|
||||
val Long.albumCoverUri: Uri
|
||||
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)
|
||||
|
||||
/**
|
||||
* Parse out the track number field as if the given Int is formatted as DTTT, where D Is the disc
|
||||
* and T is the track number. Values of zero will be ignored under the assumption that they are
|
||||
* invalid.
|
||||
*/
|
||||
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse out the disc number field as if the given Int is formatted as DTTT, where D Is the disc and
|
||||
* T is the track number. Values of zero will be ignored under the assumption that they are invalid.
|
||||
*/
|
||||
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
|
||||
* CD_TRACK_NUMBER. Values of zero will be ignored under the assumption that they are invalid.
|
||||
*/
|
||||
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
|
||||
|
||||
/** Parse a plain year from the field into a [Date]. */
|
||||
fun String.parseYear() = toIntOrNull()?.let(Date::from)
|
||||
|
||||
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */
|
||||
fun String.parseTimestamp() = Date.from(this)
|
||||
|
||||
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
|
||||
fun Date?.resolveYear(context: Context) =
|
||||
this?.resolveYear(context) ?: context.getString(R.string.def_date)
|
||||
|
||||
/**
|
||||
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
|
||||
* anglo-centric, but it's also a bit of an expected feature in music players, so we implement it
|
||||
* anyway.
|
||||
*/
|
||||
fun String.parseSortName() =
|
||||
when {
|
||||
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
|
||||
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
|
||||
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
|
||||
else -> this
|
||||
}
|
||||
|
||||
/** Shortcut to parse an [ReleaseType] from a string */
|
||||
fun String.parseReleaseType() = ReleaseType.parse(this)
|
||||
|
||||
/** Shortcut to parse a [ReleaseType] from a list of strings */
|
||||
fun List<String>.parseReleaseType() = ReleaseType.parse(this)
|
||||
|
||||
/**
|
||||
* Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
|
||||
* that Auxio uses.
|
||||
*/
|
||||
fun String.parseId3GenreName() = parseId3v1Genre() ?: parseId3v2Genre() ?: this
|
||||
|
||||
private fun String.parseId3v1Genre(): String? =
|
||||
when {
|
||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
||||
// try to index the genre table with such.
|
||||
isDigitsOnly() -> GENRE_TABLE.getOrNull(toInt())
|
||||
|
||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
||||
this == "CR" -> "Cover"
|
||||
this == "RX" -> "Remix"
|
||||
|
||||
// Current name is fine.
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun String.parseId3v2Genre(): String? {
|
||||
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
|
||||
val genres = mutableSetOf<String>()
|
||||
|
||||
// ID3v2 genres are far more complex and require string grokking to properly implement.
|
||||
// You can read the spec for it here: https://id3.org/id3v2.3.0#TCON
|
||||
// This implementation in particular is based off Mutagen's genre parser.
|
||||
|
||||
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
|
||||
// ID3v1 tags.
|
||||
val genreIds = groups.getOrNull(1)
|
||||
if (genreIds != null && genreIds.isNotEmpty()) {
|
||||
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
||||
for (id in ids) {
|
||||
id.parseId3v1Genre()?.let(genres::add)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Genre names as a normal string. The only case we have to look out for are
|
||||
// escaped strings formatted as ((genre).
|
||||
val genreName = groups.getOrNull(3)
|
||||
if (genreName != null && genreName.isNotEmpty()) {
|
||||
if (genreName.startsWith("((")) {
|
||||
genres.add(genreName.substring(1))
|
||||
} else {
|
||||
genres.add(genreName)
|
||||
}
|
||||
}
|
||||
|
||||
return genres.joinToString(separator = ", ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/** Regex that implements matching for ID3v2's genre format. */
|
||||
private val GENRE_RE = Regex("((?:\\(([0-9]+|RX|CR)\\))*)(.+)?")
|
||||
|
||||
/**
|
||||
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
|
||||
* Note that we do not translate these, as that greatly increases technical complexity.
|
||||
*/
|
||||
private val GENRE_TABLE =
|
||||
arrayOf(
|
||||
// ID3 Standard
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
"Alternative",
|
||||
"Ska",
|
||||
"Death Metal",
|
||||
"Pranks",
|
||||
"Soundtrack",
|
||||
"Euro-Techno",
|
||||
"Ambient",
|
||||
"Trip-Hop",
|
||||
"Vocal",
|
||||
"Jazz+Funk",
|
||||
"Fusion",
|
||||
"Trance",
|
||||
"Classical",
|
||||
"Instrumental",
|
||||
"Acid",
|
||||
"House",
|
||||
"Game",
|
||||
"Sound Clip",
|
||||
"Gospel",
|
||||
"Noise",
|
||||
"AlternRock",
|
||||
"Bass",
|
||||
"Soul",
|
||||
"Punk",
|
||||
"Space",
|
||||
"Meditative",
|
||||
"Instrumental Pop",
|
||||
"Instrumental Rock",
|
||||
"Ethnic",
|
||||
"Gothic",
|
||||
"Darkwave",
|
||||
"Techno-Industrial",
|
||||
"Electronic",
|
||||
"Pop-Folk",
|
||||
"Eurodance",
|
||||
"Dream",
|
||||
"Southern Rock",
|
||||
"Comedy",
|
||||
"Cult",
|
||||
"Gangsta",
|
||||
"Top 40",
|
||||
"Christian Rap",
|
||||
"Pop/Funk",
|
||||
"Jungle",
|
||||
"Native American",
|
||||
"Cabaret",
|
||||
"New Wave",
|
||||
"Psychadelic",
|
||||
"Rave",
|
||||
"Showtunes",
|
||||
"Trailer",
|
||||
"Lo-Fi",
|
||||
"Tribal",
|
||||
"Acid Punk",
|
||||
"Acid Jazz",
|
||||
"Polka",
|
||||
"Retro",
|
||||
"Musical",
|
||||
"Rock & Roll",
|
||||
"Hard Rock",
|
||||
|
||||
// Winamp extensions, more or less a de-facto standard
|
||||
"Folk",
|
||||
"Folk-Rock",
|
||||
"National Folk",
|
||||
"Swing",
|
||||
"Fast Fusion",
|
||||
"Bebob",
|
||||
"Latin",
|
||||
"Revival",
|
||||
"Celtic",
|
||||
"Bluegrass",
|
||||
"Avantgarde",
|
||||
"Gothic Rock",
|
||||
"Progressive Rock",
|
||||
"Psychedelic Rock",
|
||||
"Symphonic Rock",
|
||||
"Slow Rock",
|
||||
"Big Band",
|
||||
"Chorus",
|
||||
"Easy Listening",
|
||||
"Acoustic",
|
||||
"Humour",
|
||||
"Speech",
|
||||
"Chanson",
|
||||
"Opera",
|
||||
"Chamber Music",
|
||||
"Sonata",
|
||||
"Symphony",
|
||||
"Booty Bass",
|
||||
"Primus",
|
||||
"Porn Groove",
|
||||
"Satire",
|
||||
"Slow Jam",
|
||||
"Club",
|
||||
"Tango",
|
||||
"Samba",
|
||||
"Folklore",
|
||||
"Ballad",
|
||||
"Power Ballad",
|
||||
"Rhythmic Soul",
|
||||
"Freestyle",
|
||||
"Duet",
|
||||
"Punk Rock",
|
||||
"Drum Solo",
|
||||
"A capella",
|
||||
"Euro-House",
|
||||
"Dance Hall",
|
||||
"Goa",
|
||||
"Drum & Bass",
|
||||
"Club-House",
|
||||
"Hardcore",
|
||||
"Terror",
|
||||
"Indie",
|
||||
"Britpop",
|
||||
"Negerpunk",
|
||||
"Polsk Punk",
|
||||
"Beat",
|
||||
"Christian Gangsta",
|
||||
"Heavy Metal",
|
||||
"Black Metal",
|
||||
"Crossover",
|
||||
"Contemporary Christian",
|
||||
"Christian Rock",
|
||||
"Merengue",
|
||||
"Salsa",
|
||||
"Thrash Metal",
|
||||
"Anime",
|
||||
"JPop",
|
||||
"Synthpop",
|
||||
|
||||
// Winamp 5.6+ extensions, also used by EasyTAG.
|
||||
// I only include this because post-rock is a based genre and deserves a slot.
|
||||
"Abstract",
|
||||
"Art Rock",
|
||||
"Baroque",
|
||||
"Bhangra",
|
||||
"Big Beat",
|
||||
"Breakbeat",
|
||||
"Chillout",
|
||||
"Downtempo",
|
||||
"Dub",
|
||||
"EBM",
|
||||
"Eclectic",
|
||||
"Electro",
|
||||
"Electroclash",
|
||||
"Emo",
|
||||
"Experimental",
|
||||
"Garage",
|
||||
"Global",
|
||||
"IDM",
|
||||
"Illbient",
|
||||
"Industro-Goth",
|
||||
"Jam Band",
|
||||
"Krautrock",
|
||||
"Leftfield",
|
||||
"Lounge",
|
||||
"Math Rock",
|
||||
"New Romantic",
|
||||
"Nu-Breakz",
|
||||
"Post-Punk",
|
||||
"Post-Rock",
|
||||
"Psytrance",
|
||||
"Shoegaze",
|
||||
"Space Rock",
|
||||
"Trop Rock",
|
||||
"World Music",
|
||||
"Neoclassical",
|
||||
"Audiobook",
|
||||
"Audio Theatre",
|
||||
"Neue Deutsche Welle",
|
||||
"Podcast",
|
||||
"Indie Rock",
|
||||
"G-Funk",
|
||||
"Dubstep",
|
||||
"Garage Rock",
|
||||
"Psybient",
|
||||
|
||||
// Auxio's extensions (Future garage is also based and deserves a slot)
|
||||
"Future Garage")
|
||||
|
|
@ -23,36 +23,67 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import org.oxycblt.auxio.music.system.Indexer
|
||||
|
||||
/**
|
||||
* A ViewModel representing the current indexing state.
|
||||
* @author OxygenCobalt
|
||||
* A [ViewModel] providing data specific to the music loading process.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicViewModel : ViewModel(), Indexer.Callback {
|
||||
private val indexer = Indexer.getInstance()
|
||||
|
||||
private val _indexerState = MutableStateFlow<Indexer.State?>(null)
|
||||
/** The current music indexing state. */
|
||||
/** The current music loading state, or null if no loading is going on. */
|
||||
val indexerState: StateFlow<Indexer.State?> = _indexerState
|
||||
|
||||
private val _libraryExists = MutableStateFlow(false)
|
||||
/** Whether a music library has successfully been loaded. */
|
||||
val libraryExists: StateFlow<Boolean> = _libraryExists
|
||||
private val _statistics = MutableStateFlow<Statistics?>(null)
|
||||
/** [Statistics] about the last completed music load. */
|
||||
val statistics: StateFlow<Statistics?>
|
||||
get() = _statistics
|
||||
|
||||
init {
|
||||
indexer.registerCallback(this)
|
||||
}
|
||||
|
||||
fun reindex() {
|
||||
indexer.requestReindex()
|
||||
override fun onCleared() {
|
||||
indexer.unregisterCallback(this)
|
||||
}
|
||||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
_indexerState.value = state
|
||||
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
|
||||
_libraryExists.value = true
|
||||
// New state is a completed library, update the statistics values.
|
||||
val library = state.response.library
|
||||
_statistics.value =
|
||||
Statistics(
|
||||
library.songs.size,
|
||||
library.albums.size,
|
||||
library.artists.size,
|
||||
library.genres.size,
|
||||
library.songs.sumOf { it.durationMs })
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
indexer.unregisterCallback(this)
|
||||
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
||||
fun refresh() {
|
||||
indexer.requestReindex(true)
|
||||
}
|
||||
|
||||
/** Requests that the music library be re-loaded without the cache. */
|
||||
fun rescan() {
|
||||
indexer.requestReindex(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-manipulated statistics bound the last successful music load.
|
||||
* @param songs The amount of [Song]s that were loaded.
|
||||
* @param albums The amount of [Album]s that were created.
|
||||
* @param artists The amount of [Artist]s that were created.
|
||||
* @param genres The amount of [Genre]s that were created.
|
||||
* @param durationMs The total duration of all songs in the library, in milliseconds.
|
||||
*/
|
||||
data class Statistics(
|
||||
val songs: Int,
|
||||
val albums: Int,
|
||||
val artists: Int,
|
||||
val genres: Int,
|
||||
val durationMs: Long
|
||||
)
|
||||
}
|
||||
|
|
|
|||
610
app/src/main/java/org/oxycblt/auxio/music/Sort.kt
Normal file
610
app/src/main/java/org/oxycblt/auxio/music/Sort.kt
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
/*
|
||||
* Copyright (c) 2021 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import androidx.annotation.IdRes
|
||||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Sort.Mode
|
||||
|
||||
/**
|
||||
* A sorting method.
|
||||
*
|
||||
* This can be used not only to sort items, but also represent a sorting mode within the UI.
|
||||
*
|
||||
* @param mode A [Mode] dictating how to sort the list.
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class Sort(val mode: Mode, val isAscending: Boolean) {
|
||||
/**
|
||||
* Create a new [Sort] with the same [mode], but different [isAscending] value.
|
||||
* @param isAscending Whether the new sort should be in ascending order or not.
|
||||
* @return A new sort with the same mode, but with the new [isAscending] value applied.
|
||||
*/
|
||||
fun withAscending(isAscending: Boolean) = Sort(mode, isAscending)
|
||||
|
||||
/**
|
||||
* Create a new [Sort] with the same [isAscending] value, but different [mode] value.
|
||||
* @param mode Tbe new mode to use for the Sort.
|
||||
* @return A new sort with the same [isAscending] value, but with the new [mode] applied.
|
||||
*/
|
||||
fun withMode(mode: Mode) = Sort(mode, isAscending)
|
||||
|
||||
/**
|
||||
* Sort a list of [Song]s.
|
||||
* @param songs The list of [Song]s.
|
||||
* @return A new list of [Song]s sorted by this [Sort]'s configuration.
|
||||
*/
|
||||
fun songs(songs: Collection<Song>): List<Song> {
|
||||
val mutable = songs.toMutableList()
|
||||
songsInPlace(mutable)
|
||||
return mutable
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list of [Album]s.
|
||||
* @param albums The list of [Album]s.
|
||||
* @return A new list of [Album]s sorted by this [Sort]'s configuration.
|
||||
*/
|
||||
fun albums(albums: Collection<Album>): List<Album> {
|
||||
val mutable = albums.toMutableList()
|
||||
albumsInPlace(mutable)
|
||||
return mutable
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list of [Artist]s.
|
||||
* @param artists The list of [Artist]s.
|
||||
* @return A new list of [Artist]s sorted by this [Sort]'s configuration.
|
||||
*/
|
||||
fun artists(artists: Collection<Artist>): List<Artist> {
|
||||
val mutable = artists.toMutableList()
|
||||
artistsInPlace(mutable)
|
||||
return mutable
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a list of [Genre]s.
|
||||
* @param genres The list of [Genre]s.
|
||||
* @return A new list of [Genre]s sorted by this [Sort]'s configuration.
|
||||
*/
|
||||
fun genres(genres: Collection<Genre>): List<Genre> {
|
||||
val mutable = genres.toMutableList()
|
||||
genresInPlace(mutable)
|
||||
return mutable
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
|
||||
* @param songs The [Song]s to sort.
|
||||
*/
|
||||
fun songsInPlace(songs: MutableList<Song>) {
|
||||
songs.sortWith(mode.getSongComparator(isAscending))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration.
|
||||
* @param albums The [Album]s to sort.
|
||||
*/
|
||||
private fun albumsInPlace(albums: MutableList<Album>) {
|
||||
albums.sortWith(mode.getAlbumComparator(isAscending))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration.
|
||||
* @param artists The [Album]s to sort.
|
||||
*/
|
||||
private fun artistsInPlace(artists: MutableList<Artist>) {
|
||||
artists.sortWith(mode.getArtistComparator(isAscending))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration.
|
||||
* @param genres The [Genre]s to sort.
|
||||
*/
|
||||
private fun genresInPlace(genres: MutableList<Genre>) {
|
||||
genres.sortWith(mode.getGenreComparator(isAscending))
|
||||
}
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
* @see fromIntCode
|
||||
*/
|
||||
val intCode: Int
|
||||
// Sort's integer representation is formatted as AMMMM, where A is a bitflag
|
||||
// representing if the sort is in ascending or descending order, and M is the
|
||||
// integer representation of the sort mode.
|
||||
get() = mode.intCode.shl(1) or if (isAscending) 1 else 0
|
||||
|
||||
sealed class Mode {
|
||||
/** The integer representation of this sort mode. */
|
||||
abstract val intCode: Int
|
||||
/** The item ID of this sort mode in menu resources. */
|
||||
abstract val itemId: Int
|
||||
|
||||
/**
|
||||
* Get a [Comparator] that sorts [Song]s according to this [Mode].
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode].
|
||||
*/
|
||||
open fun getSongComparator(isAscending: Boolean): Comparator<Song> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a [Comparator] that sorts [Album]s according to this [Mode].
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode].
|
||||
*/
|
||||
open fun getAlbumComparator(isAscending: Boolean): Comparator<Album> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a [Comparator] that sorts [Artist]s according to this [Mode].
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
|
||||
*/
|
||||
open fun getArtistComparator(isAscending: Boolean): Comparator<Artist> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a [Comparator] that sorts [Genre]s according to this [Mode].
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
|
||||
*/
|
||||
open fun getGenreComparator(isAscending: Boolean): Comparator<Genre> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the item's name.
|
||||
* @see Music.collationKey
|
||||
*/
|
||||
object ByName : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_NAME
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_name
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean) =
|
||||
compareByDynamic(isAscending, BasicComparator.SONG)
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean) =
|
||||
compareByDynamic(isAscending, BasicComparator.ALBUM)
|
||||
|
||||
override fun getArtistComparator(isAscending: Boolean) =
|
||||
compareByDynamic(isAscending, BasicComparator.ARTIST)
|
||||
|
||||
override fun getGenreComparator(isAscending: Boolean) =
|
||||
compareByDynamic(isAscending, BasicComparator.GENRE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the [Album] of an item. Only available for [Song]s.
|
||||
* @see Album.collationKey
|
||||
*/
|
||||
object ByAlbum : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_ALBUM
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_album
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, BasicComparator.ALBUM) { it.album },
|
||||
compareBy(NullableComparator.INT) { it.disc },
|
||||
compareBy(NullableComparator.INT) { it.track },
|
||||
compareBy(BasicComparator.SONG))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the [Artist] name of an item. Only available for [Song] and [Album].
|
||||
* @see Artist.collationKey
|
||||
*/
|
||||
object ByArtist : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_ARTIST
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_artist
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||
compareByDescending(NullableComparator.DATE) { it.album.date },
|
||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||
compareBy(NullableComparator.INT) { it.disc },
|
||||
compareBy(NullableComparator.INT) { it.track },
|
||||
compareBy(BasicComparator.SONG))
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
|
||||
compareByDescending(NullableComparator.DATE) { it.date },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the [Date] of an item. Only available for [Song] and [Album].
|
||||
* @see Song.date
|
||||
* @see Album.date
|
||||
*/
|
||||
object ByDate : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_YEAR
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_year
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.album.date },
|
||||
compareByDescending(BasicComparator.ALBUM) { it.album },
|
||||
compareBy(NullableComparator.INT) { it.disc },
|
||||
compareBy(NullableComparator.INT) { it.track },
|
||||
compareBy(BasicComparator.SONG))
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.DATE) { it.date },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
}
|
||||
|
||||
/** Sort by the duration of an item. */
|
||||
object ByDuration : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_DURATION
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_duration
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.durationMs },
|
||||
compareBy(BasicComparator.SONG))
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.durationMs },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
|
||||
override fun getArtistComparator(isAscending: Boolean): Comparator<Artist> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.LONG) { it.durationMs },
|
||||
compareBy(BasicComparator.ARTIST))
|
||||
|
||||
override fun getGenreComparator(isAscending: Boolean): Comparator<Genre> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.durationMs },
|
||||
compareBy(BasicComparator.GENRE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the amount of songs an item contains. Only available for [MusicParent]s.
|
||||
* @see MusicParent.songs
|
||||
*/
|
||||
object ByCount : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_COUNT
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_count
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.songs.size },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
|
||||
override fun getArtistComparator(isAscending: Boolean): Comparator<Artist> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.INT) { it.songs.size },
|
||||
compareBy(BasicComparator.ARTIST))
|
||||
|
||||
override fun getGenreComparator(isAscending: Boolean): Comparator<Genre> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.songs.size },
|
||||
compareBy(BasicComparator.GENRE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the disc number of an item. Only available for [Song]s.
|
||||
* @see Song.disc
|
||||
*/
|
||||
object ByDisc : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_DISC
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_disc
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.INT) { it.disc },
|
||||
compareBy(NullableComparator.INT) { it.track },
|
||||
compareBy(BasicComparator.SONG))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the track number of an item. Only available for [Song]s.
|
||||
* @see Song.track
|
||||
*/
|
||||
object ByTrack : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_TRACK
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_track
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareBy(NullableComparator.INT) { it.disc },
|
||||
compareByDynamic(isAscending, NullableComparator.INT) { it.track },
|
||||
compareBy(BasicComparator.SONG))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort by the date an item was added. Only supported by [Song]s and [Album]s.
|
||||
* @see Song.dateAdded
|
||||
* @see Album.date
|
||||
*/
|
||||
object ByDateAdded : Mode() {
|
||||
override val intCode: Int
|
||||
get() = IntegerTable.SORT_BY_DATE_ADDED
|
||||
|
||||
override val itemId: Int
|
||||
get() = R.id.option_sort_date_added
|
||||
|
||||
override fun getSongComparator(isAscending: Boolean): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.dateAdded }, compareBy(BasicComparator.SONG))
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { album -> album.dateAdded },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create a [Comparator] in a dynamic way determined by [isAscending].
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @see compareBy
|
||||
* @see compareByDescending
|
||||
*/
|
||||
protected inline fun <T : Music, K : Comparable<K>> compareByDynamic(
|
||||
isAscending: Boolean,
|
||||
crossinline selector: (T) -> K
|
||||
) =
|
||||
if (isAscending) {
|
||||
compareBy(selector)
|
||||
} else {
|
||||
compareByDescending(selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create a [Comparator] in a dynamic way determined by [isAscending]
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @param comparator A [Comparator] to wrap.
|
||||
* @return A new [Comparator] with the specified configuration.
|
||||
* @see compareBy
|
||||
* @see compareByDescending
|
||||
*/
|
||||
protected fun <T : Music> compareByDynamic(
|
||||
isAscending: Boolean,
|
||||
comparator: Comparator<in T>
|
||||
): Comparator<T> = compareByDynamic(isAscending, comparator) { it }
|
||||
|
||||
/**
|
||||
* Utility function to create a [Comparator] a dynamic way determined by [isAscending]
|
||||
* @param isAscending Whether to sort in ascending or descending order.
|
||||
* @param comparator A [Comparator] to wrap.
|
||||
* @param selector Called to obtain a specific attribute to sort by.
|
||||
* @return A new [Comparator] with the specified configuration.
|
||||
* @see compareBy
|
||||
* @see compareByDescending
|
||||
*/
|
||||
protected inline fun <T : Music, K> compareByDynamic(
|
||||
isAscending: Boolean,
|
||||
comparator: Comparator<in K>,
|
||||
crossinline selector: (T) -> K
|
||||
) =
|
||||
if (isAscending) {
|
||||
compareBy(comparator, selector)
|
||||
} else {
|
||||
compareByDescending(comparator, selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create a [Comparator] that sorts in ascending order based on the
|
||||
* given [Comparator], with a selector based on the item itself.
|
||||
* @param comparator The [Comparator] to wrap.
|
||||
* @return A new [Comparator] with the specified configuration.
|
||||
* @see compareBy
|
||||
*/
|
||||
protected fun <T : Music> compareBy(comparator: Comparator<T>): Comparator<T> =
|
||||
compareBy(comparator) { it }
|
||||
|
||||
/**
|
||||
* A [Comparator] that chains several other [Comparator]s together to form one comparison.
|
||||
* @param comparators The [Comparator]s to chain. These will be iterated through in order
|
||||
* during a comparison, with the first non-equal result becoming the result.
|
||||
*/
|
||||
private class MultiComparator<T>(vararg comparators: Comparator<T>) : Comparator<T> {
|
||||
private val _comparators = comparators
|
||||
|
||||
override fun compare(a: T?, b: T?): Int {
|
||||
for (comparator in _comparators) {
|
||||
val result = comparator.compare(a, b)
|
||||
if (result != 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a [Comparator], extending it to compare two lists.
|
||||
* @param inner The [Comparator] to use.
|
||||
*/
|
||||
private class ListComparator<T>(private val inner: Comparator<T>) : Comparator<List<T>> {
|
||||
override fun compare(a: List<T>, b: List<T>): Int {
|
||||
for (i in 0 until max(a.size, b.size)) {
|
||||
val ai = a.getOrNull(i)
|
||||
val bi = b.getOrNull(i)
|
||||
when {
|
||||
ai != null && bi != null -> {
|
||||
val result = inner.compare(ai, bi)
|
||||
if (result != 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
ai == null && bi != null -> return -1 // a < b
|
||||
ai == null && bi == null -> return 0 // a = b
|
||||
else -> return 1 // a < b
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A re-usable configured for [Artist]s.. */
|
||||
val ARTISTS: Comparator<List<Artist>> = ListComparator(BasicComparator.ARTIST)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Comparator] that compares abstract [Music] values. Internally, this is similar to
|
||||
* [NullableComparator], however comparing [Music.collationKey] instead of [Comparable].
|
||||
* @see NullableComparator
|
||||
* @see Music.collationKey
|
||||
*/
|
||||
private class BasicComparator<T : Music> private constructor() : Comparator<T> {
|
||||
override fun compare(a: T, b: T): Int {
|
||||
val aKey = a.collationKey
|
||||
val bKey = b.collationKey
|
||||
return when {
|
||||
aKey != null && bKey != null -> aKey.compareTo(bKey)
|
||||
aKey == null && bKey != null -> -1 // a < b
|
||||
aKey == null && bKey == null -> 0 // a = b
|
||||
else -> 1 // a < b
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A re-usable instance configured for [Song]s. */
|
||||
val SONG: Comparator<Song> = BasicComparator()
|
||||
/** A re-usable instance configured for [Album]s. */
|
||||
val ALBUM: Comparator<Album> = BasicComparator()
|
||||
/** A re-usable instance configured for [Artist]s. */
|
||||
val ARTIST: Comparator<Artist> = BasicComparator()
|
||||
/** A re-usable instance configured for [Genre]s. */
|
||||
val GENRE: Comparator<Genre> = BasicComparator()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Comparator] that compares two possibly null values. Values will be considered lesser
|
||||
* if they are null, and greater if they are non-null.
|
||||
*/
|
||||
private class NullableComparator<T : Comparable<T>> private constructor() : Comparator<T?> {
|
||||
override fun compare(a: T?, b: T?) =
|
||||
when {
|
||||
a != null && b != null -> a.compareTo(b)
|
||||
a == null && b != null -> -1 // a < b
|
||||
a == null && b == null -> 0 // a = b
|
||||
else -> 1 // a < b
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A re-usable instance configured for [Int]s. */
|
||||
val INT = NullableComparator<Int>()
|
||||
/** A re-usable instance configured for [Long]s. */
|
||||
val LONG = NullableComparator<Long>()
|
||||
/** A re-usable instance configured for [Date]s. */
|
||||
val DATE = NullableComparator<Date>()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a [Mode] integer representation into an instance.
|
||||
* @param intCode An integer representation of a [Mode]
|
||||
* @return The corresponding [Mode], or null if the [Mode] is invalid.
|
||||
* @see intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
ByName.intCode -> ByName
|
||||
ByArtist.intCode -> ByArtist
|
||||
ByAlbum.intCode -> ByAlbum
|
||||
ByDate.intCode -> ByDate
|
||||
ByDuration.intCode -> ByDuration
|
||||
ByCount.intCode -> ByCount
|
||||
ByDisc.intCode -> ByDisc
|
||||
ByTrack.intCode -> ByTrack
|
||||
ByDateAdded.intCode -> ByDateAdded
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a menu item ID into a [Mode].
|
||||
* @param itemId The menu resource ID to convert
|
||||
* @return A [Mode] corresponding to the given ID, or null if the ID is invalid.
|
||||
* @see itemId
|
||||
*/
|
||||
fun fromItemId(@IdRes itemId: Int) =
|
||||
when (itemId) {
|
||||
ByName.itemId -> ByName
|
||||
ByAlbum.itemId -> ByAlbum
|
||||
ByArtist.itemId -> ByArtist
|
||||
ByDate.itemId -> ByDate
|
||||
ByDuration.itemId -> ByDuration
|
||||
ByCount.itemId -> ByCount
|
||||
ByDisc.itemId -> ByDisc
|
||||
ByTrack.itemId -> ByTrack
|
||||
ByDateAdded.itemId -> ByDateAdded
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a [Sort] integer representation into an instance.
|
||||
* @param intCode An integer representation of a [Sort]
|
||||
* @return The corresponding [Sort], or null if the [Sort] is invalid.
|
||||
* @see intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int): Sort? {
|
||||
// Sort's integer representation is formatted as AMMMM, where A is a bitflag
|
||||
// representing on if the mode is ascending or descending, and M is the integer
|
||||
// representation of the sort mode.
|
||||
val isAscending = (intCode and 1) == 1
|
||||
val mode = Mode.fromIntCode(intCode.shr(1)) ?: return null
|
||||
return Sort(mode, isAscending)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||
|
||||
/** A path to a file. [name] is the stripped file name, [parent] is the parent path. */
|
||||
data class Path(val name: String, val parent: Directory)
|
||||
|
||||
/**
|
||||
* A path to a directory. [volume] is the volume the directory resides in, and [relativePath] is the
|
||||
* path from the volume's root to the directory itself.
|
||||
*/
|
||||
class Directory private constructor(val volume: StorageVolume, val relativePath: String) {
|
||||
fun resolveName(context: Context) =
|
||||
context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), relativePath)
|
||||
|
||||
/** Converts this dir into an opaque document URI in the form of VOLUME:PATH. */
|
||||
fun toDocumentUri() =
|
||||
// "primary" actually corresponds to the internal storage, not the primary volume.
|
||||
// Removable storage is represented with the UUID.
|
||||
if (volume.isInternalCompat) {
|
||||
"${DOCUMENT_URI_PRIMARY_NAME}:${relativePath}"
|
||||
} else {
|
||||
volume.uuidCompat?.let { uuid -> "${uuid}:${relativePath}" }
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = volume.hashCode()
|
||||
result = 31 * result + relativePath.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
other is Directory && other.volume == volume && other.relativePath == relativePath
|
||||
|
||||
companion object {
|
||||
private const val DOCUMENT_URI_PRIMARY_NAME = "primary"
|
||||
|
||||
fun from(volume: StorageVolume, relativePath: String) =
|
||||
Directory(
|
||||
volume, relativePath.removePrefix(File.separator).removeSuffix(File.separator))
|
||||
|
||||
/**
|
||||
* Converts an opaque document uri in the form of VOLUME:PATH into a [Directory]. This is a
|
||||
* flagrant violation of the API convention, but since we never really write to the URI I
|
||||
* really doubt it matters.
|
||||
*/
|
||||
fun fromDocumentUri(storageManager: StorageManager, uri: String): Directory? {
|
||||
val split = uri.split(File.pathSeparator, limit = 2)
|
||||
|
||||
val volume =
|
||||
when (split[0]) {
|
||||
DOCUMENT_URI_PRIMARY_NAME -> storageManager.primaryStorageVolumeCompat
|
||||
else -> storageManager.storageVolumesCompat.find { it.uuidCompat == split[0] }
|
||||
}
|
||||
|
||||
val relativePath = split.getOrNull(1)
|
||||
|
||||
return from(volume ?: return null, relativePath ?: return null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NewApi")
|
||||
private val SM_API21_GET_VOLUME_LIST_METHOD: Method by
|
||||
lazyReflectedMethod(StorageManager::class, "getVolumeList")
|
||||
|
||||
@Suppress("NewApi")
|
||||
private val SV_API21_GET_PATH_METHOD: Method by lazyReflectedMethod(StorageVolume::class, "getPath")
|
||||
|
||||
/** The "primary" storage volume containing the OS. May be an SD Card. */
|
||||
val StorageManager.primaryStorageVolumeCompat: StorageVolume
|
||||
@Suppress("NewApi") get() = primaryStorageVolume
|
||||
|
||||
/**
|
||||
* A list of recognized volumes, retrieved in a compatible manner. Note that these volumes may be
|
||||
* mounted or unmounted.
|
||||
*/
|
||||
val StorageManager.storageVolumesCompat: List<StorageVolume>
|
||||
get() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
storageVolumes.toList()
|
||||
} else {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(SM_API21_GET_VOLUME_LIST_METHOD.invoke(this) as Array<StorageVolume>).toList()
|
||||
}
|
||||
|
||||
/** Returns the absolute path to a particular volume in a compatible manner. */
|
||||
val StorageVolume.directoryCompat: String?
|
||||
@SuppressLint("NewApi")
|
||||
get() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
directory?.absolutePath
|
||||
} else {
|
||||
// Replicate API: getPath if mounted, null if not
|
||||
when (stateCompat) {
|
||||
Environment.MEDIA_MOUNTED,
|
||||
Environment.MEDIA_MOUNTED_READ_ONLY ->
|
||||
SV_API21_GET_PATH_METHOD.invoke(this) as String
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the readable description of the volume in a compatible manner. */
|
||||
@SuppressLint("NewApi")
|
||||
fun StorageVolume.getDescriptionCompat(context: Context): String = getDescription(context)
|
||||
|
||||
/** If this volume is the primary volume. May still be removable storage. */
|
||||
val StorageVolume.isPrimaryCompat: Boolean
|
||||
@SuppressLint("NewApi") get() = isPrimary
|
||||
|
||||
/** If this volume is emulated. */
|
||||
val StorageVolume.isEmulatedCompat: Boolean
|
||||
@SuppressLint("NewApi") get() = isEmulated
|
||||
|
||||
/**
|
||||
* If this volume corresponds to "Internal shared storage", represented in document URIs as
|
||||
* "primary". These volumes are primary volumes, but are also non-removable and emulated.
|
||||
*/
|
||||
val StorageVolume.isInternalCompat: Boolean
|
||||
get() = isPrimaryCompat && isEmulatedCompat
|
||||
|
||||
/** Returns the UUID of the volume in a compatible manner. */
|
||||
val StorageVolume.uuidCompat: String?
|
||||
@SuppressLint("NewApi") get() = uuid
|
||||
|
||||
/** Returns the state of the volume in a compatible manner. */
|
||||
val StorageVolume.stateCompat: String
|
||||
@SuppressLint("NewApi") get() = state
|
||||
|
||||
/**
|
||||
* Returns the name of this volume as it is used in [MediaStore]. This will be
|
||||
* [MediaStore.VOLUME_EXTERNAL_PRIMARY] if it is the primary volume, and the lowercase UUID of the
|
||||
* volume otherwise.
|
||||
*/
|
||||
val StorageVolume.mediaStoreVolumeNameCompat: String?
|
||||
get() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
mediaStoreVolumeName
|
||||
} else {
|
||||
// Replicate API: primary_external if primary storage, lowercase uuid otherwise
|
||||
if (isPrimaryCompat) {
|
||||
@Suppress("NewApi") // Inlined constant
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
} else {
|
||||
uuidCompat?.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a mime type as it is loaded by Auxio. [fromExtension] is based on the file extension
|
||||
* should always exist, while [fromFormat] is based on the file itself and may not be available.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
data class MimeType(val fromExtension: String, val fromFormat: String?) {
|
||||
fun resolveName(context: Context): String {
|
||||
// We try our best to produce a more readable name for the common audio formats.
|
||||
val formatName =
|
||||
when (fromFormat) {
|
||||
// We start with the extracted mime types, as they are more consistent. Note that
|
||||
// we do not include container formats at all with these names. It is only the
|
||||
// inner codec that we show.
|
||||
MimeTypes.AUDIO_MPEG,
|
||||
MimeTypes.AUDIO_MPEG_L1,
|
||||
MimeTypes.AUDIO_MPEG_L2 -> R.string.cdc_mp3
|
||||
MimeTypes.AUDIO_AAC -> R.string.cdc_aac
|
||||
MimeTypes.AUDIO_VORBIS -> R.string.cdc_vorbis
|
||||
MimeTypes.AUDIO_OPUS -> R.string.cdc_opus
|
||||
MimeTypes.AUDIO_FLAC -> R.string.cdc_flac
|
||||
MimeTypes.AUDIO_WAV -> R.string.cdc_wav
|
||||
|
||||
// We don't give a name to more unpopular formats.
|
||||
|
||||
else -> -1
|
||||
}
|
||||
|
||||
if (formatName > -1) {
|
||||
return context.getString(formatName)
|
||||
}
|
||||
|
||||
// Fall back to the file extension in the case that we have no mime type or
|
||||
// a useless "audio/raw" mime type. Here:
|
||||
// - We return names for container formats instead of the inner format, as we
|
||||
// cannot parse the file.
|
||||
// - We are at the mercy of the Android OS, hence we check for every possible mime
|
||||
// type for a particular format.
|
||||
val extensionName =
|
||||
when (fromExtension) {
|
||||
"audio/mpeg",
|
||||
"audio/mp3" -> R.string.cdc_mp3
|
||||
"audio/mp4",
|
||||
"audio/mp4a-latm",
|
||||
"audio/mpeg4-generic" -> R.string.cdc_mp4
|
||||
"audio/aac",
|
||||
"audio/aacp",
|
||||
"audio/3gpp",
|
||||
"audio/3gpp2" -> R.string.cdc_aac
|
||||
"audio/ogg",
|
||||
"application/ogg",
|
||||
"application/x-ogg" -> R.string.cdc_ogg
|
||||
"audio/flac" -> R.string.cdc_flac
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/wave",
|
||||
"audio/vnd.wave" -> R.string.cdc_wav
|
||||
"audio/x-matroska" -> R.string.cdc_mka
|
||||
else -> -1
|
||||
}
|
||||
|
||||
return if (extensionName > -1) {
|
||||
context.getString(extensionName)
|
||||
} else {
|
||||
// Fall back to the extension if we can't find a special name for this format.
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(fromExtension)?.uppercase()
|
||||
?: context.getString(R.string.def_codec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
/**
|
||||
* Defines an Extractor that can load cached music. This is the first step in the music extraction
|
||||
* process and is an optimization to avoid the slow [MediaStoreExtractor] and [MetadataExtractor]
|
||||
* extraction process.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface CacheExtractor {
|
||||
/** Initialize the Extractor by reading the cache data into memory. */
|
||||
fun init()
|
||||
|
||||
/**
|
||||
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
|
||||
* freeing up memory.
|
||||
* @param rawSongs The songs to write into the cache.
|
||||
*/
|
||||
fun finalize(rawSongs: List<Song.Raw>)
|
||||
|
||||
/**
|
||||
* Use the cache to populate the given [Song.Raw].
|
||||
* @param rawSong The [Song.Raw] to attempt to populate. Note that this [Song.Raw] will only
|
||||
* contain the bare minimum information required to load a cache entry.
|
||||
* @return An [ExtractionResult] representing the result of the operation.
|
||||
* [ExtractionResult.PARSED] is not returned.
|
||||
*/
|
||||
fun populate(rawSong: Song.Raw): ExtractionResult
|
||||
}
|
||||
|
||||
/**
|
||||
* A [CacheExtractor] only capable of writing to the cache. This can be used to load music with
|
||||
* without the cache if the user desires.
|
||||
* @param context [Context] required to read the cache database.
|
||||
* @see CacheExtractor
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtractor {
|
||||
override fun init() {
|
||||
// Nothing to do.
|
||||
}
|
||||
|
||||
override fun finalize(rawSongs: List<Song.Raw>) {
|
||||
try {
|
||||
// Still write out whatever data was extracted.
|
||||
CacheDatabase.getInstance(context).write(rawSongs)
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to save cache database.")
|
||||
logE(e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun populate(rawSong: Song.Raw) =
|
||||
// Nothing to do.
|
||||
ExtractionResult.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* A [CacheExtractor] that supports reading from and writing to the cache.
|
||||
* @param context [Context] required to load
|
||||
* @see CacheExtractor
|
||||
* @author Alexander Capehart
|
||||
*/
|
||||
class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtractor(context) {
|
||||
private var cacheMap: Map<Long, Song.Raw>? = null
|
||||
private var invalidate = false
|
||||
|
||||
override fun init() {
|
||||
try {
|
||||
// Faster to load the whole database into memory than do a query on each
|
||||
// populate call.
|
||||
cacheMap = CacheDatabase.getInstance(context).read()
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to load cache database.")
|
||||
logE(e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun finalize(rawSongs: List<Song.Raw>) {
|
||||
cacheMap = null
|
||||
// Same some time by not re-writing the cache if we were able to create the entire
|
||||
// library from it. If there is even just one song we could not populate from the
|
||||
// cache, then we will re-write it.
|
||||
if (invalidate) {
|
||||
logD("Cache was invalidated during loading, rewriting")
|
||||
super.finalize(rawSongs)
|
||||
}
|
||||
}
|
||||
|
||||
override fun populate(rawSong: Song.Raw): ExtractionResult {
|
||||
val map = cacheMap ?: return ExtractionResult.NONE
|
||||
|
||||
// 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 cachedRawSong = map[rawSong.mediaStoreId]
|
||||
if (cachedRawSong != null &&
|
||||
cachedRawSong.dateAdded == rawSong.dateAdded &&
|
||||
cachedRawSong.dateModified == rawSong.dateModified) {
|
||||
// No built-in "copy from" method for data classes, just have to assign
|
||||
// the data ourselves.
|
||||
rawSong.musicBrainzId = cachedRawSong.musicBrainzId
|
||||
rawSong.name = cachedRawSong.name
|
||||
rawSong.sortName = cachedRawSong.sortName
|
||||
|
||||
rawSong.size = cachedRawSong.size
|
||||
rawSong.durationMs = cachedRawSong.durationMs
|
||||
|
||||
rawSong.track = cachedRawSong.track
|
||||
rawSong.disc = cachedRawSong.disc
|
||||
rawSong.date = cachedRawSong.date
|
||||
|
||||
rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId
|
||||
rawSong.albumName = cachedRawSong.albumName
|
||||
rawSong.albumSortName = cachedRawSong.albumSortName
|
||||
rawSong.albumTypes = cachedRawSong.albumTypes
|
||||
|
||||
rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds
|
||||
rawSong.artistNames = cachedRawSong.artistNames
|
||||
rawSong.artistSortNames = cachedRawSong.artistSortNames
|
||||
|
||||
rawSong.albumArtistMusicBrainzIds = cachedRawSong.albumArtistMusicBrainzIds
|
||||
rawSong.albumArtistNames = cachedRawSong.albumArtistNames
|
||||
rawSong.albumArtistSortNames = cachedRawSong.albumArtistSortNames
|
||||
|
||||
rawSong.genreNames = cachedRawSong.genreNames
|
||||
|
||||
return ExtractionResult.CACHED
|
||||
}
|
||||
|
||||
// We could not populate this song. This means our cache is stale and should be
|
||||
// re-written with newly-loaded music.
|
||||
invalidate = true
|
||||
return ExtractionResult.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal [Song.Raw] cache database.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
* @see [CacheExtractor]
|
||||
*/
|
||||
private class CacheDatabase(context: Context) :
|
||||
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
// Map the cacheable raw song fields to database fields. Cache-able in this context
|
||||
// means information independent of the file-system, excluding IDs and timestamps required
|
||||
// to retrieve items from the cache.
|
||||
db.createTable(TABLE_RAW_SONGS) {
|
||||
append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
|
||||
append("${Columns.DATE_ADDED} LONG NOT NULL,")
|
||||
append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
|
||||
append("${Columns.SIZE} LONG NOT NULL,")
|
||||
append("${Columns.DURATION} LONG NOT NULL,")
|
||||
append("${Columns.MUSIC_BRAINZ_ID} STRING,")
|
||||
append("${Columns.NAME} STRING NOT NULL,")
|
||||
append("${Columns.SORT_NAME} STRING,")
|
||||
append("${Columns.TRACK} INT,")
|
||||
append("${Columns.DISC} INT,")
|
||||
append("${Columns.DATE} STRING,")
|
||||
append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
|
||||
append("${Columns.ALBUM_NAME} STRING NOT NULL,")
|
||||
append("${Columns.ALBUM_SORT_NAME} STRING,")
|
||||
append("${Columns.ALBUM_TYPES} STRING,")
|
||||
append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
|
||||
append("${Columns.ARTIST_NAMES} STRING,")
|
||||
append("${Columns.ARTIST_SORT_NAMES} STRING,")
|
||||
append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
|
||||
append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
|
||||
append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
|
||||
append("${Columns.GENRE_NAMES} STRING")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
|
||||
|
||||
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
|
||||
|
||||
private fun nuke(db: SQLiteDatabase) {
|
||||
// No cost to nuking this database, only causes higher loading times.
|
||||
logD("Nuking database")
|
||||
db.apply {
|
||||
execSQL("DROP TABLE IF EXISTS $TABLE_RAW_SONGS")
|
||||
onCreate(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read out this database into memory.
|
||||
* @return A mapping between the MediaStore IDs of the cache entries and a [Song.Raw] containing
|
||||
* the cacheable data for the entry. Note that any filesystem-dependent information (excluding
|
||||
* IDs and timestamps) is not cached.
|
||||
*/
|
||||
fun read(): Map<Long, Song.Raw> {
|
||||
requireBackgroundThread()
|
||||
val start = System.currentTimeMillis()
|
||||
val map = mutableMapOf<Long, Song.Raw>()
|
||||
readableDatabase.queryAll(TABLE_RAW_SONGS) { cursor ->
|
||||
if (cursor.count == 0) {
|
||||
// Nothing to do.
|
||||
return@queryAll
|
||||
}
|
||||
|
||||
val idIndex = cursor.getColumnIndexOrThrow(Columns.MEDIA_STORE_ID)
|
||||
val dateAddedIndex = cursor.getColumnIndexOrThrow(Columns.DATE_ADDED)
|
||||
val dateModifiedIndex = cursor.getColumnIndexOrThrow(Columns.DATE_MODIFIED)
|
||||
|
||||
val sizeIndex = cursor.getColumnIndexOrThrow(Columns.SIZE)
|
||||
val durationIndex = cursor.getColumnIndexOrThrow(Columns.DURATION)
|
||||
|
||||
val musicBrainzIdIndex = cursor.getColumnIndexOrThrow(Columns.MUSIC_BRAINZ_ID)
|
||||
val nameIndex = cursor.getColumnIndexOrThrow(Columns.NAME)
|
||||
val sortNameIndex = cursor.getColumnIndexOrThrow(Columns.SORT_NAME)
|
||||
|
||||
val trackIndex = cursor.getColumnIndexOrThrow(Columns.TRACK)
|
||||
val discIndex = cursor.getColumnIndexOrThrow(Columns.DISC)
|
||||
val dateIndex = cursor.getColumnIndexOrThrow(Columns.DATE)
|
||||
|
||||
val albumMusicBrainzIdIndex =
|
||||
cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID)
|
||||
val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME)
|
||||
val albumSortNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_SORT_NAME)
|
||||
val albumTypesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_TYPES)
|
||||
|
||||
val artistMusicBrainzIdsIndex =
|
||||
cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS)
|
||||
val artistNamesIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_NAMES)
|
||||
val artistSortNamesIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_SORT_NAMES)
|
||||
|
||||
val albumArtistMusicBrainzIdsIndex =
|
||||
cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS)
|
||||
val albumArtistNamesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_NAMES)
|
||||
val albumArtistSortNamesIndex =
|
||||
cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_SORT_NAMES)
|
||||
|
||||
val genresIndex = cursor.getColumnIndexOrThrow(Columns.GENRE_NAMES)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val raw = Song.Raw()
|
||||
val id = cursor.getLong(idIndex)
|
||||
|
||||
raw.mediaStoreId = id
|
||||
raw.dateAdded = cursor.getLong(dateAddedIndex)
|
||||
raw.dateModified = cursor.getLong(dateModifiedIndex)
|
||||
|
||||
raw.size = cursor.getLong(sizeIndex)
|
||||
raw.durationMs = cursor.getLong(durationIndex)
|
||||
|
||||
raw.musicBrainzId = cursor.getStringOrNull(musicBrainzIdIndex)
|
||||
raw.name = cursor.getString(nameIndex)
|
||||
raw.sortName = cursor.getStringOrNull(sortNameIndex)
|
||||
|
||||
raw.track = cursor.getIntOrNull(trackIndex)
|
||||
raw.disc = cursor.getIntOrNull(discIndex)
|
||||
raw.date = cursor.getStringOrNull(dateIndex)?.parseTimestamp()
|
||||
|
||||
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
|
||||
raw.albumName = cursor.getString(albumNameIndex)
|
||||
raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex)
|
||||
cursor.getStringOrNull(albumTypesIndex)?.let {
|
||||
raw.albumTypes = it.parseSQLMultiValue()
|
||||
}
|
||||
|
||||
cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let {
|
||||
raw.artistMusicBrainzIds = it.parseSQLMultiValue()
|
||||
}
|
||||
cursor.getStringOrNull(artistNamesIndex)?.let {
|
||||
raw.artistNames = it.parseSQLMultiValue()
|
||||
}
|
||||
cursor.getStringOrNull(artistSortNamesIndex)?.let {
|
||||
raw.artistSortNames = it.parseSQLMultiValue()
|
||||
}
|
||||
|
||||
cursor.getStringOrNull(albumArtistMusicBrainzIdsIndex)?.let {
|
||||
raw.albumArtistMusicBrainzIds = it.parseSQLMultiValue()
|
||||
}
|
||||
cursor.getStringOrNull(albumArtistNamesIndex)?.let {
|
||||
raw.albumArtistNames = it.parseSQLMultiValue()
|
||||
}
|
||||
cursor.getStringOrNull(albumArtistSortNamesIndex)?.let {
|
||||
raw.albumArtistSortNames = it.parseSQLMultiValue()
|
||||
}
|
||||
|
||||
cursor.getStringOrNull(genresIndex)?.let {
|
||||
raw.genreNames = it.parseSQLMultiValue()
|
||||
}
|
||||
|
||||
map[id] = raw
|
||||
}
|
||||
}
|
||||
|
||||
logD("Read cache in ${System.currentTimeMillis() - start}ms")
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a new list of [Song.Raw] to this database.
|
||||
* @param rawSongs The new [Song.Raw] instances to cache. Note that any filesystem-dependent
|
||||
* information (excluding IDs and timestamps) is not cached.
|
||||
*/
|
||||
fun write(rawSongs: List<Song.Raw>) {
|
||||
val start = System.currentTimeMillis()
|
||||
|
||||
writableDatabase.writeList(rawSongs, TABLE_RAW_SONGS) { _, rawSong ->
|
||||
ContentValues(22).apply {
|
||||
put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId)
|
||||
put(Columns.DATE_ADDED, rawSong.dateAdded)
|
||||
put(Columns.DATE_MODIFIED, rawSong.dateModified)
|
||||
|
||||
put(Columns.SIZE, rawSong.size)
|
||||
put(Columns.DURATION, rawSong.durationMs)
|
||||
|
||||
put(Columns.MUSIC_BRAINZ_ID, rawSong.musicBrainzId)
|
||||
put(Columns.NAME, rawSong.name)
|
||||
put(Columns.SORT_NAME, rawSong.sortName)
|
||||
|
||||
put(Columns.TRACK, rawSong.track)
|
||||
put(Columns.DISC, rawSong.disc)
|
||||
put(Columns.DATE, rawSong.date?.toString())
|
||||
|
||||
put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
|
||||
put(Columns.ALBUM_NAME, rawSong.albumName)
|
||||
put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
|
||||
put(Columns.ALBUM_TYPES, rawSong.albumTypes.toSQLMultiValue())
|
||||
|
||||
put(Columns.ARTIST_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toSQLMultiValue())
|
||||
put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
|
||||
put(Columns.ARTIST_SORT_NAMES, rawSong.artistSortNames.toSQLMultiValue())
|
||||
|
||||
put(
|
||||
Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
|
||||
rawSong.albumArtistMusicBrainzIds.toSQLMultiValue())
|
||||
put(Columns.ALBUM_ARTIST_NAMES, rawSong.albumArtistNames.toSQLMultiValue())
|
||||
put(Columns.ALBUM_ARTIST_SORT_NAMES, rawSong.albumArtistSortNames.toSQLMultiValue())
|
||||
|
||||
put(Columns.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
|
||||
}
|
||||
}
|
||||
|
||||
logD("Wrote cache in ${System.currentTimeMillis() - start}ms")
|
||||
}
|
||||
|
||||
// SQLite does not natively support multiple values, so we have to serialize multi-value
|
||||
// tags with separators. Not ideal, but nothing we can do.
|
||||
|
||||
/**
|
||||
* Transforms the multi-string list into a SQL-safe multi-string value.
|
||||
* @return A single string containing all values within the multi-string list, delimited by a
|
||||
* ";". Pre-existing ";" characters will be escaped.
|
||||
*/
|
||||
private fun List<String>.toSQLMultiValue() =
|
||||
if (isNotEmpty()) {
|
||||
joinToString(";") { it.replace(";", "\\;") }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the SQL-safe multi-string value into a multi-string list.
|
||||
* @return A list of strings corresponding to the delimited values present within the original
|
||||
* string. Escaped delimiters are converted back into their normal forms.
|
||||
*/
|
||||
private fun String.parseSQLMultiValue() =
|
||||
splitEscaped { it == ';' }
|
||||
|
||||
/** Defines the columns used in this database. */
|
||||
private object Columns {
|
||||
/** @see Song.Raw.mediaStoreId */
|
||||
const val MEDIA_STORE_ID = "msid"
|
||||
/** @see Song.Raw.dateAdded */
|
||||
const val DATE_ADDED = "date_added"
|
||||
/** @see Song.Raw.dateModified */
|
||||
const val DATE_MODIFIED = "date_modified"
|
||||
/** @see Song.Raw.size */
|
||||
const val SIZE = "size"
|
||||
/** @see Song.Raw.durationMs */
|
||||
const val DURATION = "duration"
|
||||
/** @see Song.Raw.musicBrainzId */
|
||||
const val MUSIC_BRAINZ_ID = "mbid"
|
||||
/** @see Song.Raw.name */
|
||||
const val NAME = "name"
|
||||
/** @see Song.Raw.sortName */
|
||||
const val SORT_NAME = "sort_name"
|
||||
/** @see Song.Raw.track */
|
||||
const val TRACK = "track"
|
||||
/** @see Song.Raw.disc */
|
||||
const val DISC = "disc"
|
||||
/** @see Song.Raw.date */
|
||||
const val DATE = "date"
|
||||
/** @see Song.Raw.albumMusicBrainzId */
|
||||
const val ALBUM_MUSIC_BRAINZ_ID = "album_mbid"
|
||||
/** @see Song.Raw.albumName */
|
||||
const val ALBUM_NAME = "album"
|
||||
/** @see Song.Raw.albumSortName */
|
||||
const val ALBUM_SORT_NAME = "album_sort"
|
||||
/** @see Song.Raw.albumTypes */
|
||||
const val ALBUM_TYPES = "album_types"
|
||||
/** @see Song.Raw.artistMusicBrainzIds */
|
||||
const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid"
|
||||
/** @see Song.Raw.artistNames */
|
||||
const val ARTIST_NAMES = "artists"
|
||||
/** @see Song.Raw.artistSortNames */
|
||||
const val ARTIST_SORT_NAMES = "artists_sort"
|
||||
/** @see Song.Raw.albumArtistMusicBrainzIds */
|
||||
const val ALBUM_ARTIST_MUSIC_BRAINZ_IDS = "album_artists_mbid"
|
||||
/** @see Song.Raw.albumArtistNames */
|
||||
const val ALBUM_ARTIST_NAMES = "album_artists"
|
||||
/** @see Song.Raw.albumArtistSortNames */
|
||||
const val ALBUM_ARTIST_SORT_NAMES = "album_artists_sort"
|
||||
/** @see Song.Raw.genreNames */
|
||||
const val GENRE_NAMES = "genres"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DB_NAME = "auxio_music_cache.db"
|
||||
private const val DB_VERSION = 1
|
||||
private const val TABLE_RAW_SONGS = "raw_songs"
|
||||
|
||||
@Volatile private var INSTANCE: CacheDatabase? = null
|
||||
|
||||
/**
|
||||
* Get a singleton instance.
|
||||
* @return The (possibly newly-created) singleton instance.
|
||||
*/
|
||||
fun getInstance(context: Context): CacheDatabase {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
if (currentInstance != null) {
|
||||
return currentInstance
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
val newInstance = CacheDatabase(context.applicationContext)
|
||||
INSTANCE = newInstance
|
||||
return newInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
|
||||
/**
|
||||
* Represents the result of an extraction operation.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class ExtractionResult {
|
||||
/** A raw song was successfully extracted from the cache. */
|
||||
CACHED,
|
||||
|
||||
/** A raw song was successfully extracted from parsing it's file. */
|
||||
PARSED,
|
||||
|
||||
/** A raw song could not be parsed. */
|
||||
NONE
|
||||
}
|
||||
|
|
@ -0,0 +1,567 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.Directory
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.storage.directoryCompat
|
||||
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
|
||||
import org.oxycblt.auxio.music.storage.safeQuery
|
||||
import org.oxycblt.auxio.music.storage.storageVolumesCompat
|
||||
import org.oxycblt.auxio.music.storage.useQuery
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* The layer that loads music from the [MediaStore] database. This is an intermediate step in the
|
||||
* music extraction process and primarily intended for redundancy for files not natively supported
|
||||
* by [MetadataExtractor]. Solely relying on this is not recommended, as it often produces bad
|
||||
* metadata.
|
||||
* @param context [Context] required to query the media database.
|
||||
* @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class MediaStoreExtractor(
|
||||
private val context: Context,
|
||||
private val cacheExtractor: CacheExtractor
|
||||
) {
|
||||
private var cursor: Cursor? = null
|
||||
private var idIndex = -1
|
||||
private var titleIndex = -1
|
||||
private var displayNameIndex = -1
|
||||
private var mimeTypeIndex = -1
|
||||
private var sizeIndex = -1
|
||||
private var dateAddedIndex = -1
|
||||
private var dateModifiedIndex = -1
|
||||
private var durationIndex = -1
|
||||
private var yearIndex = -1
|
||||
private var albumIndex = -1
|
||||
private var albumIdIndex = -1
|
||||
private var artistIndex = -1
|
||||
private var albumArtistIndex = -1
|
||||
private val genreNamesMap = mutableMapOf<Long, String>()
|
||||
|
||||
/**
|
||||
* The [StorageVolume]s currently scanned by [MediaStore]. This should be used to transform path
|
||||
* information from the database into volume-aware paths.
|
||||
*/
|
||||
protected var volumes = listOf<StorageVolume>()
|
||||
private set
|
||||
|
||||
/**
|
||||
* Initialize this instance. This involves setting up the required sub-extractors and querying
|
||||
* the media database for music files.
|
||||
* @return A [Cursor] of the music data returned from the database.
|
||||
*/
|
||||
open fun init(): Cursor {
|
||||
val start = System.currentTimeMillis()
|
||||
cacheExtractor.init()
|
||||
val settings = Settings(context)
|
||||
val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||
|
||||
val args = mutableListOf<String>()
|
||||
var selector = BASE_SELECTOR
|
||||
|
||||
// Filter out audio that is not music, if enabled.
|
||||
if (settings.excludeNonMusic) {
|
||||
logD("Excluding non-music")
|
||||
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
|
||||
}
|
||||
|
||||
// Set up the projection to follow the music directory configuration.
|
||||
val dirs = settings.getMusicDirs(storageManager)
|
||||
if (dirs.dirs.isNotEmpty()) {
|
||||
selector += " AND "
|
||||
if (!dirs.shouldInclude) {
|
||||
// Without a NOT, the query will be restricted to the specified paths, resulting
|
||||
// in the "Include" mode. With a NOT, the specified paths will not be included,
|
||||
// resulting in the "Exclude" mode.
|
||||
selector += "NOT "
|
||||
}
|
||||
selector += " ("
|
||||
|
||||
// Specifying the paths to filter is version-specific, delegate to the concrete
|
||||
// implementations.
|
||||
for (i in dirs.dirs.indices) {
|
||||
if (addDirToSelector(dirs.dirs[i], args)) {
|
||||
selector +=
|
||||
if (i < dirs.dirs.lastIndex) {
|
||||
"$dirSelectorTemplate OR "
|
||||
} else {
|
||||
dirSelectorTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selector += ')'
|
||||
}
|
||||
|
||||
// Now we can actually query MediaStore.
|
||||
logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]")
|
||||
val cursor =
|
||||
context.contentResolverSafe
|
||||
.safeQuery(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selector,
|
||||
args.toTypedArray())
|
||||
.also { cursor = it }
|
||||
logD("Song query succeeded [Projected total: ${cursor.count}]")
|
||||
|
||||
// Set up cursor indices for later use.
|
||||
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
|
||||
displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||
mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
|
||||
sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
|
||||
dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
|
||||
dateModifiedIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED)
|
||||
durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
||||
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
|
||||
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
|
||||
albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
|
||||
artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
||||
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
|
||||
// Since we can't obtain the genre tag from a song query, we must construct our own
|
||||
// equivalent from genre database queries. Theoretically, this isn't needed since
|
||||
// MetadataLayer will fill this in for us, but I'd imagine there are some obscure
|
||||
// formats where genre support is only really covered by this, so we are forced to
|
||||
// bite the O(n^2) complexity here.
|
||||
context.contentResolverSafe.useQuery(
|
||||
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
|
||||
val idIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
|
||||
val nameIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
|
||||
|
||||
while (genreCursor.moveToNext()) {
|
||||
val id = genreCursor.getLong(idIndex)
|
||||
val name = genreCursor.getStringOrNull(nameIndex) ?: continue
|
||||
|
||||
context.contentResolverSafe.useQuery(
|
||||
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
|
||||
arrayOf(MediaStore.Audio.Genres.Members._ID)) { cursor ->
|
||||
val songIdIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
// Assume that a song can't inhabit multiple genre entries, as I doubt
|
||||
// MediaStore is actually aware that songs can have multiple genres.
|
||||
genreNamesMap[cursor.getLong(songIdIndex)] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
volumes = storageManager.storageVolumesCompat
|
||||
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
||||
|
||||
return cursor
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
|
||||
* freeing up memory.
|
||||
* @param rawSongs The songs to write into the cache.
|
||||
*/
|
||||
fun finalize(rawSongs: List<Song.Raw>) {
|
||||
// Free the cursor (and it's resources)
|
||||
cursor?.close()
|
||||
cursor = null
|
||||
cacheExtractor.finalize(rawSongs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a [Song.Raw] with the next [Cursor] value provided by [MediaStore].
|
||||
* @param raw The [Song.Raw] to populate.
|
||||
* @return An [ExtractionResult] signifying the result of the operation. Will return
|
||||
* [ExtractionResult.CACHED] if [CacheExtractor] returned it.
|
||||
*/
|
||||
fun populate(raw: Song.Raw): ExtractionResult {
|
||||
val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
|
||||
// Move to the next cursor, stopping if we have exhausted it.
|
||||
if (!cursor.moveToNext()) {
|
||||
logD("Cursor is exhausted")
|
||||
return ExtractionResult.NONE
|
||||
}
|
||||
|
||||
// Populate the minimum required columns to maybe obtain a cache entry.
|
||||
populateFileData(cursor, raw)
|
||||
if (cacheExtractor.populate(raw) == ExtractionResult.CACHED) {
|
||||
// We found a valid cache entry, no need to fully read the entry.
|
||||
return ExtractionResult.CACHED
|
||||
}
|
||||
|
||||
// Could not load entry from cache, we have to read the rest of the metadata.
|
||||
populateMetadata(cursor, raw)
|
||||
return ExtractionResult.PARSED
|
||||
}
|
||||
|
||||
/**
|
||||
* The database columns available to all android versions supported by Auxio. Concrete
|
||||
* implementations can extend this projection to add version-specific columns.
|
||||
*/
|
||||
protected open val projection: Array<String>
|
||||
get() =
|
||||
arrayOf(
|
||||
// These columns are guaranteed to work on all versions of android
|
||||
MediaStore.Audio.AudioColumns._ID,
|
||||
MediaStore.Audio.AudioColumns.DATE_ADDED,
|
||||
MediaStore.Audio.AudioColumns.DATE_MODIFIED,
|
||||
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
||||
MediaStore.Audio.AudioColumns.SIZE,
|
||||
MediaStore.Audio.AudioColumns.DURATION,
|
||||
MediaStore.Audio.AudioColumns.MIME_TYPE,
|
||||
MediaStore.Audio.AudioColumns.TITLE,
|
||||
MediaStore.Audio.AudioColumns.YEAR,
|
||||
MediaStore.Audio.AudioColumns.ALBUM,
|
||||
MediaStore.Audio.AudioColumns.ALBUM_ID,
|
||||
MediaStore.Audio.AudioColumns.ARTIST,
|
||||
AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
|
||||
/**
|
||||
* The companion template to add to the projection's selector whenever arguments are added by
|
||||
* [addDirToSelector].
|
||||
* @see addDirToSelector
|
||||
*/
|
||||
protected abstract val dirSelectorTemplate: String
|
||||
|
||||
/**
|
||||
* Add a [Directory] to the given list of projection selector arguments.
|
||||
* @param dir The [Directory] to add.
|
||||
* @param args The destination list to append selector arguments to that are analogous to the
|
||||
* given [Directory].
|
||||
* @return true if the [Directory] was added, false otherwise.
|
||||
* @see dirSelectorTemplate
|
||||
*/
|
||||
protected abstract fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean
|
||||
|
||||
/**
|
||||
* Populate a [Song.Raw] with the "File Data" of the given [MediaStore] [Cursor], which is the
|
||||
* data that cannot be cached. This includes any information not intrinsic to the file and
|
||||
* instead dependent on the file-system, which could change without invalidating the cache due
|
||||
* to volume additions or removals.
|
||||
* @param cursor The [Cursor] to read from.
|
||||
* @param raw The [Song.Raw] to populate.
|
||||
* @see populateMetadata
|
||||
*/
|
||||
protected open fun populateFileData(cursor: Cursor, raw: Song.Raw) {
|
||||
raw.mediaStoreId = cursor.getLong(idIndex)
|
||||
raw.dateAdded = cursor.getLong(dateAddedIndex)
|
||||
raw.dateModified = cursor.getLong(dateAddedIndex)
|
||||
// Try to use the DISPLAY_NAME column to obtain a (probably sane) file name
|
||||
// from the android system.
|
||||
raw.fileName = cursor.getStringOrNull(displayNameIndex)
|
||||
raw.extensionMimeType = cursor.getString(mimeTypeIndex)
|
||||
raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate a [Song.Raw] with the Metadata of the given [MediaStore] [Cursor], which is the data
|
||||
* about a [Song.Raw] that can be cached. This includes any information intrinsic to the file or
|
||||
* it's file format, such as music tags.
|
||||
* @param cursor The [Cursor] to read from.
|
||||
* @param raw The [Song.Raw] to populate.
|
||||
* @see populateFileData
|
||||
*/
|
||||
protected open fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
|
||||
// Song title
|
||||
raw.name = cursor.getString(titleIndex)
|
||||
// Size (in bytes)
|
||||
raw.size = cursor.getLong(sizeIndex)
|
||||
// Duration (in milliseconds)
|
||||
raw.durationMs = cursor.getLong(durationIndex)
|
||||
// MediaStore only exposes the year value of a file. This is actually worse than it
|
||||
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
|
||||
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
|
||||
raw.date = cursor.getIntOrNull(yearIndex)?.toDate()
|
||||
// A non-existent album name should theoretically be the name of the folder it contained
|
||||
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
|
||||
// file is not actually in the root internal storage directory. We can't do anything to
|
||||
// fix this, really.
|
||||
raw.albumName = cursor.getString(albumIndex)
|
||||
// Android does not make a non-existent artist tag null, it instead fills it in
|
||||
// as <unknown>, which makes absolutely no sense given how other columns default
|
||||
// to null if they are not present. If this column is such, null it so that
|
||||
// it's easier to handle later.
|
||||
val artist = cursor.getString(artistIndex)
|
||||
if (artist != MediaStore.UNKNOWN_STRING) {
|
||||
raw.artistNames = listOf(artist)
|
||||
}
|
||||
// The album artist column is nullable and never has placeholder values.
|
||||
cursor.getStringOrNull(albumArtistIndex)?.let { raw.albumArtistNames = listOf(it) }
|
||||
// Get the genre value we had to query for in initialization
|
||||
genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The base selector that works across all versions of android. Does not exclude
|
||||
* directories.
|
||||
*/
|
||||
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
|
||||
|
||||
/**
|
||||
* The album artist of a song. This column has existed since at least API 21, but until API
|
||||
* 30 it was an undocumented extension for Google Play Music. This column will work on all
|
||||
* versions that Auxio supports.
|
||||
*/
|
||||
@Suppress("InlinedApi")
|
||||
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
|
||||
|
||||
/**
|
||||
* The external volume. This naming has existed since API 21, but no constant existed for it
|
||||
* until API 29. This will work on all versions that Auxio supports.
|
||||
*/
|
||||
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The separation between version-specific backends may not be the cleanest. To preserve
|
||||
// speed, we only want to add redundancy on known issues, not with possible issues.
|
||||
|
||||
/**
|
||||
* A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 21
|
||||
* onwards to API 28.
|
||||
* @param context [Context] required to query the media database.
|
||||
* @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
|
||||
MediaStoreExtractor(context, cacheExtractor) {
|
||||
private var trackIndex = -1
|
||||
private var dataIndex = -1
|
||||
|
||||
override fun init(): Cursor {
|
||||
val cursor = super.init()
|
||||
// Set up cursor indices for later use.
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(
|
||||
MediaStore.Audio.AudioColumns.TRACK,
|
||||
// Below API 29, we are restricted to the absolute path (Called DATA by
|
||||
// MedaStore) when working with audio files.
|
||||
MediaStore.Audio.AudioColumns.DATA)
|
||||
|
||||
// The selector should be configured to convert the given directories instances to their
|
||||
// absolute paths and then compare them to DATA.
|
||||
|
||||
override val dirSelectorTemplate: String
|
||||
get() = "${MediaStore.Audio.Media.DATA} LIKE ?"
|
||||
|
||||
override fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean {
|
||||
// "%" signifies to accept any DATA value that begins with the Directory's path,
|
||||
// thus recursively filtering all files in the directory.
|
||||
args.add("${dir.volume.directoryCompat ?: return false}/${dir.relativePath}%")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
|
||||
super.populateFileData(cursor, raw)
|
||||
|
||||
val data = cursor.getString(dataIndex)
|
||||
|
||||
// On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
|
||||
// that this only applies to below API 29, as beyond API 29, this column not being
|
||||
// present would completely break the scoped storage system. Fill it in with DATA
|
||||
// if it's not available.
|
||||
if (raw.fileName == null) {
|
||||
raw.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
|
||||
}
|
||||
|
||||
// Find the volume that transforms the DATA column into a relative path. This is
|
||||
// the Directory we will use.
|
||||
val rawPath = data.substringBeforeLast(File.separatorChar)
|
||||
for (volume in volumes) {
|
||||
val volumePath = volume.directoryCompat ?: continue
|
||||
val strippedPath = rawPath.removePrefix(volumePath)
|
||||
if (strippedPath != rawPath) {
|
||||
raw.directory = Directory.from(volume, strippedPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
|
||||
super.populateMetadata(cursor, raw)
|
||||
// See unpackTrackNo/unpackDiscNo for an explanation
|
||||
// of how this column is set up.
|
||||
val rawTrack = cursor.getIntOrNull(trackIndex)
|
||||
if (rawTrack != null) {
|
||||
rawTrack.unpackTrackNo()?.let { raw.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { raw.disc = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MediaStoreExtractor] that implements common behavior supported from API 29 onwards.
|
||||
* @param context [Context] required to query the media database.
|
||||
* @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
|
||||
MediaStoreExtractor(context, cacheExtractor) {
|
||||
private var volumeIndex = -1
|
||||
private var relativePathIndex = -1
|
||||
|
||||
override fun init(): Cursor {
|
||||
val cursor = super.init()
|
||||
// Set up cursor indices for later use.
|
||||
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
|
||||
relativePathIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(
|
||||
// After API 29, we now have access to the volume name and relative
|
||||
// path, which simplifies working with Paths significantly.
|
||||
MediaStore.Audio.AudioColumns.VOLUME_NAME,
|
||||
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||
|
||||
// The selector should be configured to compare both the volume name and relative path
|
||||
// of the given directories, albeit with some conversion to the analogous MediaStore
|
||||
// column values.
|
||||
|
||||
override val dirSelectorTemplate: String
|
||||
get() =
|
||||
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
||||
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
||||
|
||||
override fun addDirToSelector(dir: Directory, args: MutableList<String>): Boolean {
|
||||
// MediaStore uses a different naming scheme for it's volume column convert this
|
||||
// directory's volume to it.
|
||||
args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false)
|
||||
// "%" signifies to accept any DATA value that begins with the Directory's path,
|
||||
// thus recursively filtering all files in the directory.
|
||||
args.add("${dir.relativePath}%")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
|
||||
super.populateFileData(cursor, raw)
|
||||
// Find the StorageVolume whose MediaStore name corresponds to this song.
|
||||
// This is combined with the plain relative path column to create the directory.
|
||||
val volumeName = cursor.getString(volumeIndex)
|
||||
val relativePath = cursor.getString(relativePathIndex)
|
||||
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
|
||||
if (volume != null) {
|
||||
raw.directory = Directory.from(volume, relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MediaStoreExtractor] that completes the music loading process in a way compatible with at API
|
||||
* 29.
|
||||
* @param context [Context] required to query the media database.
|
||||
* @param cacheExtractor [CacheExtractor] implementation for cache functionality.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
|
||||
BaseApi29MediaStoreExtractor(context, cacheExtractor) {
|
||||
private var trackIndex = -1
|
||||
|
||||
override fun init(): Cursor {
|
||||
val cursor = super.init()
|
||||
// Set up cursor indices for later use.
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override val projection: Array<String>
|
||||
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
||||
override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
|
||||
super.populateMetadata(cursor, raw)
|
||||
// This extractor is volume-aware, but does not support the modern track columns.
|
||||
// Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation
|
||||
// of how this column is set up.
|
||||
val rawTrack = cursor.getIntOrNull(trackIndex)
|
||||
if (rawTrack != null) {
|
||||
rawTrack.unpackTrackNo()?.let { raw.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { raw.disc = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 30
|
||||
* onwards.
|
||||
* @param context [Context] required to query the media database.
|
||||
* @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
|
||||
BaseApi29MediaStoreExtractor(context, cacheExtractor) {
|
||||
private var trackIndex: Int = -1
|
||||
private var discIndex: Int = -1
|
||||
|
||||
override fun init(): Cursor {
|
||||
val cursor = super.init()
|
||||
// Set up cursor indices for later use.
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
|
||||
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||
return cursor
|
||||
}
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(
|
||||
// API 30 grant us access to the superior CD_TRACK_NUMBER and DISC_NUMBER
|
||||
// fields, which take the place of TRACK.
|
||||
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
|
||||
MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||
|
||||
override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
|
||||
super.populateMetadata(cursor, raw)
|
||||
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
|
||||
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
|
||||
// N is the number and T is the total. Parse the number while ignoring the
|
||||
// total, as we have no use for it.
|
||||
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
|
||||
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.toAudioUri
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the
|
||||
* last step in the music extraction process and is mostly responsible for papering over the bad
|
||||
* metadata that [MediaStoreExtractor] produces.
|
||||
*
|
||||
* @param context [Context] required for reading audio files.
|
||||
* @param mediaStoreExtractor [MediaStoreExtractor] implementation for cache optimizations and
|
||||
* redundancy.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MetadataExtractor(
|
||||
private val context: Context,
|
||||
private val mediaStoreExtractor: MediaStoreExtractor
|
||||
) {
|
||||
// We can parallelize MetadataRetriever Futures to work around it's speed issues,
|
||||
// producing similar throughput's to other kinds of manual metadata extraction.
|
||||
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
|
||||
|
||||
/**
|
||||
* Initialize this extractor. This actually initializes the sub-extractors that this instance
|
||||
* relies on.
|
||||
* @return The amount of music that is expected to be loaded.
|
||||
*/
|
||||
fun init() = mediaStoreExtractor.init().count
|
||||
|
||||
/**
|
||||
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
|
||||
* freeing up memory.
|
||||
* @param rawSongs The songs to write into the cache.
|
||||
*/
|
||||
fun finalize(rawSongs: List<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
|
||||
|
||||
/**
|
||||
* Parse all [Song.Raw] instances queued by the sub-extractors. This will first delegate to the
|
||||
* sub-extractors before parsing the metadata itself.
|
||||
* @param emit A listener that will be invoked with every new [Song.Raw] instance when they are
|
||||
* successfully loaded.
|
||||
*/
|
||||
suspend fun parse(emit: suspend (Song.Raw) -> Unit) {
|
||||
while (true) {
|
||||
val raw = Song.Raw()
|
||||
when (mediaStoreExtractor.populate(raw)) {
|
||||
ExtractionResult.NONE -> break
|
||||
ExtractionResult.PARSED -> {}
|
||||
ExtractionResult.CACHED -> {
|
||||
// Avoid running the expensive parsing process on songs we can already
|
||||
// restore from the cache.
|
||||
emit(raw)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Spin until there is an open slot we can insert a task in.
|
||||
spin@ while (true) {
|
||||
for (i in taskPool.indices) {
|
||||
val task = taskPool[i]
|
||||
if (task != null) {
|
||||
val finishedRaw = task.get()
|
||||
if (finishedRaw != null) {
|
||||
emit(finishedRaw)
|
||||
taskPool[i] = Task(context, raw)
|
||||
break@spin
|
||||
}
|
||||
} else {
|
||||
taskPool[i] = Task(context, raw)
|
||||
break@spin
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spin@ while (true) {
|
||||
// Spin until all of the remaining tasks are complete.
|
||||
for (i in taskPool.indices) {
|
||||
val task = taskPool[i]
|
||||
if (task != null) {
|
||||
val finishedRaw = task.get() ?: continue@spin
|
||||
emit(finishedRaw)
|
||||
taskPool[i] = null
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TASK_CAPACITY = 8
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a [MetadataExtractor] future and processes it into a [Song.Raw] when completed.
|
||||
* @param context [Context] required to open the audio file.
|
||||
* @param raw [Song.Raw] to process.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Task(context: Context, private val raw: Song.Raw) {
|
||||
// TODO: Unify with MetadataExtractor
|
||||
// Note that we do not leverage future callbacks. This is because errors in the
|
||||
// (highly fallible) extraction process will not bubble up to Indexer when a
|
||||
// listener is used, instead crashing the app entirely.
|
||||
private val future =
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
context,
|
||||
MediaItem.fromUri(
|
||||
requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))
|
||||
|
||||
/**
|
||||
* Try to get a completed song from this [Task], if it has finished processing.
|
||||
* @return A [Song.Raw] instance if processing has completed, null otherwise.
|
||||
*/
|
||||
fun get(): Song.Raw? {
|
||||
if (!future.isDone) {
|
||||
return null
|
||||
}
|
||||
|
||||
val format =
|
||||
try {
|
||||
future.get()[0].getFormat(0)
|
||||
} catch (e: Exception) {
|
||||
logW("Unable to extract metadata for ${raw.name}")
|
||||
logW(e.stackTraceToString())
|
||||
null
|
||||
}
|
||||
if (format == null) {
|
||||
logD("Nothing could be extracted for ${raw.name}")
|
||||
return raw
|
||||
}
|
||||
|
||||
val metadata = format.metadata
|
||||
if (metadata != null) {
|
||||
populateWithMetadata(metadata)
|
||||
} else {
|
||||
logD("No metadata could be extracted for ${raw.name}")
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [Song.Raw] with the newly extracted [Metadata].
|
||||
* @param metadata The [Metadata] to complete the [Song.Raw] with.
|
||||
*/
|
||||
private fun populateWithMetadata(metadata: Metadata) {
|
||||
val id3v2Tags = mutableMapOf<String, List<String>>()
|
||||
val vorbisTags = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
|
||||
// of audio formats. Load both of these types of tags into separate maps, letting the
|
||||
// "source of truth" be the last of a particular tag in a file.
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val tag = metadata[i]) {
|
||||
is TextInformationFrame -> {
|
||||
// Map TXXX frames differently so we can specifically index by their
|
||||
// descriptions.
|
||||
val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
|
||||
val values = tag.values.map { it.sanitize() }.filter { it.isNotEmpty() }
|
||||
if (values.isNotEmpty()) {
|
||||
id3v2Tags[id] = values
|
||||
}
|
||||
}
|
||||
is VorbisComment -> {
|
||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
||||
val id = tag.key.sanitize().uppercase()
|
||||
val value = tag.value.sanitize()
|
||||
if (value.isNotEmpty()) {
|
||||
vorbisTags.getOrPut(id) { mutableListOf() }.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
vorbisTags.isEmpty() -> populateWithId3v2(id3v2Tags)
|
||||
id3v2Tags.isEmpty() -> populateWithVorbis(vorbisTags)
|
||||
else -> {
|
||||
// Some formats (like FLAC) can contain both ID3v2 and Vorbis, so we apply
|
||||
// them both with priority given to vorbis.
|
||||
populateWithId3v2(id3v2Tags)
|
||||
populateWithVorbis(vorbisTags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [Song.Raw] with ID3v2 Text Identification Frames.
|
||||
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
||||
* values.
|
||||
*/
|
||||
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||
// Song
|
||||
textFrames["TXXX:MusicBrainz Release Track Id"]?.let { raw.musicBrainzId = it[0] }
|
||||
textFrames["TIT2"]?.let { raw.name = it[0] }
|
||||
textFrames["TSOT"]?.let { raw.sortName = it[0] }
|
||||
|
||||
// Track. Only parse out the track number and ignore the total tracks value.
|
||||
textFrames["TRCK"]?.run { get(0).parsePositionNum() }?.let { raw.track = it }
|
||||
|
||||
// Disc. Only parse out the disc number and ignore the total discs value.
|
||||
textFrames["TPOS"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it }
|
||||
|
||||
// Dates are somewhat complicated, as not only did their semantics change from a flat year
|
||||
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
|
||||
// date types.
|
||||
// Our hierarchy for dates is as such:
|
||||
// 1. ID3v2.4 Original Date, as it resolves the "Released in X, Remastered in Y" issue
|
||||
// 2. ID3v2.4 Recording Date, as it is the most common date type
|
||||
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
||||
// 4. ID3v2.3 Original Date, as it is like #1
|
||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
||||
(textFrames["TDOR"]?.run { get(0).parseTimestamp() }
|
||||
?: textFrames["TDRC"]?.run { get(0).parseTimestamp() }
|
||||
?: textFrames["TDRL"]?.run { get(0).parseTimestamp() }
|
||||
?: parseId3v23Date(textFrames))
|
||||
?.let { raw.date = it }
|
||||
|
||||
// Album
|
||||
textFrames["TXXX:MusicBrainz Album Id"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
textFrames["TALB"]?.let { raw.albumName = it[0] }
|
||||
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
|
||||
(textFrames["TXXX:MusicBrainz Album Type"] ?: textFrames["GRP1"])?.let {
|
||||
raw.albumTypes = it
|
||||
}
|
||||
|
||||
// Artist
|
||||
textFrames["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
|
||||
textFrames["TPE1"]?.let { raw.artistNames = it }
|
||||
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
textFrames["TXXX:MusicBrainz Album Artist Id"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
|
||||
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
|
||||
|
||||
// Genre
|
||||
textFrames["TCON"]?.let { raw.genreNames = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the ID3v2.3 timestamp specification into a [Date] from the given Text Identification
|
||||
* Frames.
|
||||
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
||||
* values.
|
||||
* @retrn A [Date] of a year value from TORY/TYER, a month and day value from TDAT, and a
|
||||
* hour/minute value from TIME. No second value is included. The latter two fields may not be
|
||||
* included in they cannot be parsed. Will be null if a year value could not be parsed.
|
||||
*/
|
||||
private fun parseId3v23Date(textFrames: Map<String, List<String>>): Date? {
|
||||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||
// is present.
|
||||
val year =
|
||||
textFrames["TORY"]?.run { get(0).toIntOrNull() }
|
||||
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
|
||||
|
||||
val tdat = textFrames["TDAT"]
|
||||
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
|
||||
// TDAT frames consist of a 4-digit string where the first two digits are
|
||||
// the month and the last two digits are the day.
|
||||
val mm = tdat[0].substring(0..1).toInt()
|
||||
val dd = tdat[0].substring(2..3).toInt()
|
||||
|
||||
val time = textFrames["TIME"]
|
||||
if (time != null && time[0].length == 4 && time[0].isDigitsOnly()) {
|
||||
// TIME frames consist of a 4-digit string where the first two digits are
|
||||
// the hour and the last two digits are the minutes. No second value is
|
||||
// possible.
|
||||
val hh = time[0].substring(0..1).toInt()
|
||||
val mi = time[0].substring(2..3).toInt()
|
||||
// Able to return a full date.
|
||||
Date.from(year, mm, dd, hh, mi)
|
||||
} else {
|
||||
// Unable to parse time, just return a date
|
||||
Date.from(year, mm, dd)
|
||||
}
|
||||
} else {
|
||||
// Unable to parse month/day, just return a year
|
||||
return Date.from(year)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [Song.Raw] with Vorbis comments.
|
||||
* @param comments A mapping between vorbis comment names and one or more vorbis comment values.
|
||||
*/
|
||||
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||
// Song
|
||||
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
|
||||
comments["TITLE"]?.let { raw.name = it[0] }
|
||||
comments["TITLESORT"]?.let { raw.sortName = it[0] }
|
||||
|
||||
// Track. The total tracks value is in a different comment, so we can just
|
||||
// convert the entirety of this comment into a number.
|
||||
comments["TRACKNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.track = it }
|
||||
|
||||
// Disc. The total discs value is in a different comment, so we can just
|
||||
// convert the entirety of this comment into a number.
|
||||
comments["DISCNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.disc = it }
|
||||
|
||||
// Vorbis dates are less complicated, but there are still several types
|
||||
// Our hierarchy for dates is as such:
|
||||
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
|
||||
// 2. Date, as it is the most common date type
|
||||
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
|
||||
// date tag that android supports, so it must be 15 years old or more!)
|
||||
(comments["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
|
||||
?: comments["DATE"]?.run { get(0).parseTimestamp() }
|
||||
?: comments["YEAR"]?.run { get(0).parseYear() })
|
||||
?.let { raw.date = it }
|
||||
|
||||
// Album
|
||||
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
comments["ALBUM"]?.let { raw.albumName = it[0] }
|
||||
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
||||
comments["RELEASETYPE"]?.let { raw.albumTypes = it }
|
||||
|
||||
// Artist
|
||||
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
|
||||
comments["ARTIST"]?.let { raw.artistNames = it }
|
||||
comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
|
||||
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
|
||||
|
||||
// Genre
|
||||
comments["GENRE"]?.let { raw.genreNames = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies and sanitizes a possibly native/non-UTF-8 string.
|
||||
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
|
||||
* the Unicode replacement byte sequence.
|
||||
*/
|
||||
private fun String.sanitize() = String(encodeToByteArray())
|
||||
}
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import java.util.UUID
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
|
||||
/**
|
||||
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
|
||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||
* disc number is the 4th+ digit.
|
||||
* @return The track number extracted from the combined integer value, or null if the value was
|
||||
* zero.
|
||||
*/
|
||||
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
|
||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||
* disc number is the 4th+ digit.
|
||||
* @return The disc number extracted from the combined integer field, or null if the value was zero.
|
||||
*/
|
||||
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Parse the number out of a combined number + total position [String] field. These fields often
|
||||
* appear in ID3v2 files, and consist of a number and an (optional) total value delimited by a /.
|
||||
* @return The number value extracted from the string field, or null if the value could not be
|
||||
* parsed or if the value was zero.
|
||||
*/
|
||||
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
|
||||
|
||||
/**
|
||||
* Transform an [Int] year field into a [Date].
|
||||
* @return A [Date] consisting of the year value, or null if the value was zero.
|
||||
* @see Date.from
|
||||
*/
|
||||
fun Int.toDate() = Date.from(this)
|
||||
|
||||
/**
|
||||
* Parse an integer year field from a [String] and transform it into a [Date].
|
||||
* @return A [Date] consisting of the year value, or null if the value could not be parsed or if the
|
||||
* value was zero.
|
||||
* @see Date.from
|
||||
*/
|
||||
fun String.parseYear() = toIntOrNull()?.toDate()
|
||||
|
||||
/**
|
||||
* Parse an ISO-8601 timestamp [String] into a [Date].
|
||||
* @return A [Date] consisting of the year value plus one or more refinement values (ex. month,
|
||||
* day), or null if the timestamp was not valid.
|
||||
*/
|
||||
fun String.parseTimestamp() = Date.from(this)
|
||||
|
||||
/**
|
||||
* Split a [String] by the given selector, automatically handling escaped characters that satisfy
|
||||
* the selector.
|
||||
* @param selector A block that determines if the string should be split at a given character.
|
||||
* @return One or more [String]s split by the selector.
|
||||
*/
|
||||
inline fun String.splitEscaped(selector: (Char) -> Boolean): List<String> {
|
||||
val split = mutableListOf<String>()
|
||||
var currentString = ""
|
||||
var i = 0
|
||||
|
||||
while (i < length) {
|
||||
val a = get(i)
|
||||
val b = getOrNull(i + 1)
|
||||
|
||||
if (selector(a)) {
|
||||
// Non-escaped separator, split the string here, making sure any stray whitespace
|
||||
// is removed.
|
||||
split.add(currentString)
|
||||
currentString = ""
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (b != null && a == '\\' && selector(b)) {
|
||||
// Is an escaped character, add the non-escaped variant and skip two
|
||||
// characters to move on to the next one.
|
||||
currentString += b
|
||||
i += 2
|
||||
} else {
|
||||
// Non-escaped, increment normally.
|
||||
currentString += a
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (currentString.isNotEmpty()) {
|
||||
// Had an in-progress split string that is now terminated, add it.
|
||||
split.add(currentString)
|
||||
}
|
||||
|
||||
return split
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a multi-value tag based on the user configuration. If the value is already composed of more
|
||||
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
|
||||
* user's separator preferences.
|
||||
* @param settings [Settings] required to obtain user separator configuration.
|
||||
* @return A new list of one or more [String]s.
|
||||
*/
|
||||
fun List<String>.parseMultiValue(settings: Settings) =
|
||||
if (size == 1) {
|
||||
get(0).maybeParseSeparators(settings)
|
||||
} else {
|
||||
// Nothing to do.
|
||||
this.map { it.trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to parse a string by the user's separator preferences.
|
||||
* @param settings [Settings] required to obtain user separator configuration.
|
||||
* @return A list of one or more [String]s that were split up by the user-defined separators.
|
||||
*/
|
||||
fun String.maybeParseSeparators(settings: Settings): List<String> {
|
||||
// Get the separators the user desires. If null, there's nothing to do.
|
||||
val separators = settings.musicSeparators ?: return listOf(this)
|
||||
return splitEscaped { separators.contains(it) }.map { it.trim() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a [String] to a [UUID].
|
||||
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
|
||||
* @see UUID.fromString
|
||||
*/
|
||||
fun String.toUuidOrNull(): UUID? =
|
||||
try {
|
||||
UUID.fromString(this)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
|
||||
* representations of genre fields into their named counterparts, and split up singular ID3v2-style
|
||||
* integer genre fields into one or more genres.
|
||||
* @param settings [Settings] required to obtain user separator configuration.
|
||||
* @return A list of one or more genre names..
|
||||
*/
|
||||
fun List<String>.parseId3GenreNames(settings: Settings) =
|
||||
if (size == 1) {
|
||||
get(0).parseId3GenreNames(settings)
|
||||
} else {
|
||||
// Nothing to split, just map any ID3v1 genres to their name counterparts.
|
||||
map { it.parseId3v1Genre() ?: it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single ID3v1/ID3v2 integer genre field into their named representations.
|
||||
* @return A list of one or more genre names.
|
||||
*/
|
||||
fun String.parseId3GenreNames(settings: Settings) =
|
||||
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseSeparators(settings)
|
||||
|
||||
/**
|
||||
* Parse an ID3v1 integer genre field.
|
||||
* @return A named genre if the field is a valid integer, "Cover" or "Remix" if the field is
|
||||
* "CR"/"RX" respectively, and nothing if the field is not a valid ID3v1 integer genre.
|
||||
*/
|
||||
private fun String.parseId3v1Genre(): String? {
|
||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
||||
// try to index the genre table with such. If this fails, then try to compare it
|
||||
// to some other hard-coded values.
|
||||
val numeric = toIntOrNull() ?: return when (this) {
|
||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
||||
"CR" -> "Cover"
|
||||
"RX" -> "Remix"
|
||||
else -> null
|
||||
}
|
||||
|
||||
return GENRE_TABLE.getOrNull(numeric)
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Regex] that implements parsing for ID3v2's genre format. Derived from mutagen:
|
||||
* https://github.com/quodlibet/mutagen
|
||||
*/
|
||||
private val ID3V2_GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?")
|
||||
|
||||
/**
|
||||
* Parse an ID3v2 integer genre field, which has support for multiple genre values and combined
|
||||
* named/integer genres.
|
||||
* @return A list of one or more genres, or null if the field is not a valid ID3v2 integer genre.
|
||||
*/
|
||||
private fun String.parseId3v2Genre(): List<String>? {
|
||||
val groups = (ID3V2_GENRE_RE.matchEntire(this) ?: return null).groupValues
|
||||
val genres = mutableSetOf<String>()
|
||||
|
||||
// ID3v2.3 genres are far more complex and require string grokking to properly implement.
|
||||
// You can read the spec for it here: https://id3.org/id3v2.3.0#TCON
|
||||
// This implementation in particular is based off Mutagen's genre parser.
|
||||
|
||||
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
|
||||
// ID3v1 tags.
|
||||
val genreIds = groups.getOrNull(1)
|
||||
if (genreIds != null && genreIds.isNotEmpty()) {
|
||||
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
||||
for (id in ids) {
|
||||
id.parseId3v1Genre()?.let(genres::add)
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Genre names as a normal string. The only case we have to look out for are
|
||||
// escaped strings formatted as ((genre).
|
||||
val genreName = groups.getOrNull(3)
|
||||
if (genreName != null && genreName.isNotEmpty()) {
|
||||
if (genreName.startsWith("((")) {
|
||||
genres.add(genreName.substring(1))
|
||||
} else {
|
||||
genres.add(genreName)
|
||||
}
|
||||
}
|
||||
|
||||
// If this parsing task didn't change anything, move on.
|
||||
if (genres.size == 1 && genres.first() == this) {
|
||||
return null
|
||||
}
|
||||
|
||||
return genres.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* A table of the "conventional" mapping between ID3v1 integer genres and their named counterparts.
|
||||
* Includes non-standard extensions.
|
||||
*/
|
||||
private val GENRE_TABLE =
|
||||
arrayOf(
|
||||
// ID3 Standard
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
"Alternative",
|
||||
"Ska",
|
||||
"Death Metal",
|
||||
"Pranks",
|
||||
"Soundtrack",
|
||||
"Euro-Techno",
|
||||
"Ambient",
|
||||
"Trip-Hop",
|
||||
"Vocal",
|
||||
"Jazz+Funk",
|
||||
"Fusion",
|
||||
"Trance",
|
||||
"Classical",
|
||||
"Instrumental",
|
||||
"Acid",
|
||||
"House",
|
||||
"Game",
|
||||
"Sound Clip",
|
||||
"Gospel",
|
||||
"Noise",
|
||||
"AlternRock",
|
||||
"Bass",
|
||||
"Soul",
|
||||
"Punk",
|
||||
"Space",
|
||||
"Meditative",
|
||||
"Instrumental Pop",
|
||||
"Instrumental Rock",
|
||||
"Ethnic",
|
||||
"Gothic",
|
||||
"Darkwave",
|
||||
"Techno-Industrial",
|
||||
"Electronic",
|
||||
"Pop-Folk",
|
||||
"Eurodance",
|
||||
"Dream",
|
||||
"Southern Rock",
|
||||
"Comedy",
|
||||
"Cult",
|
||||
"Gangsta",
|
||||
"Top 40",
|
||||
"Christian Rap",
|
||||
"Pop/Funk",
|
||||
"Jungle",
|
||||
"Native American",
|
||||
"Cabaret",
|
||||
"New Wave",
|
||||
"Psychadelic",
|
||||
"Rave",
|
||||
"Showtunes",
|
||||
"Trailer",
|
||||
"Lo-Fi",
|
||||
"Tribal",
|
||||
"Acid Punk",
|
||||
"Acid Jazz",
|
||||
"Polka",
|
||||
"Retro",
|
||||
"Musical",
|
||||
"Rock & Roll",
|
||||
"Hard Rock",
|
||||
|
||||
// Winamp extensions, more or less a de-facto standard
|
||||
"Folk",
|
||||
"Folk-Rock",
|
||||
"National Folk",
|
||||
"Swing",
|
||||
"Fast Fusion",
|
||||
"Bebob",
|
||||
"Latin",
|
||||
"Revival",
|
||||
"Celtic",
|
||||
"Bluegrass",
|
||||
"Avantgarde",
|
||||
"Gothic Rock",
|
||||
"Progressive Rock",
|
||||
"Psychedelic Rock",
|
||||
"Symphonic Rock",
|
||||
"Slow Rock",
|
||||
"Big Band",
|
||||
"Chorus",
|
||||
"Easy Listening",
|
||||
"Acoustic",
|
||||
"Humour",
|
||||
"Speech",
|
||||
"Chanson",
|
||||
"Opera",
|
||||
"Chamber Music",
|
||||
"Sonata",
|
||||
"Symphony",
|
||||
"Booty Bass",
|
||||
"Primus",
|
||||
"Porn Groove",
|
||||
"Satire",
|
||||
"Slow Jam",
|
||||
"Club",
|
||||
"Tango",
|
||||
"Samba",
|
||||
"Folklore",
|
||||
"Ballad",
|
||||
"Power Ballad",
|
||||
"Rhythmic Soul",
|
||||
"Freestyle",
|
||||
"Duet",
|
||||
"Punk Rock",
|
||||
"Drum Solo",
|
||||
"A capella",
|
||||
"Euro-House",
|
||||
"Dance Hall",
|
||||
"Goa",
|
||||
"Drum & Bass",
|
||||
"Club-House",
|
||||
"Hardcore",
|
||||
"Terror",
|
||||
"Indie",
|
||||
"Britpop",
|
||||
"Negerpunk",
|
||||
"Polsk Punk",
|
||||
"Beat",
|
||||
"Christian Gangsta",
|
||||
"Heavy Metal",
|
||||
"Black Metal",
|
||||
"Crossover",
|
||||
"Contemporary Christian",
|
||||
"Christian Rock",
|
||||
"Merengue",
|
||||
"Salsa",
|
||||
"Thrash Metal",
|
||||
"Anime",
|
||||
"JPop",
|
||||
"Synthpop",
|
||||
|
||||
// Winamp 5.6+ extensions, also used by EasyTAG. Not common, but post-rock is a good
|
||||
// genre and should be included in the mapping.
|
||||
"Abstract",
|
||||
"Art Rock",
|
||||
"Baroque",
|
||||
"Bhangra",
|
||||
"Big Beat",
|
||||
"Breakbeat",
|
||||
"Chillout",
|
||||
"Downtempo",
|
||||
"Dub",
|
||||
"EBM",
|
||||
"Eclectic",
|
||||
"Electro",
|
||||
"Electroclash",
|
||||
"Emo",
|
||||
"Experimental",
|
||||
"Garage",
|
||||
"Global",
|
||||
"IDM",
|
||||
"Illbient",
|
||||
"Industro-Goth",
|
||||
"Jam Band",
|
||||
"Krautrock",
|
||||
"Leftfield",
|
||||
"Lounge",
|
||||
"Math Rock",
|
||||
"New Romantic",
|
||||
"Nu-Breakz",
|
||||
"Post-Punk",
|
||||
"Post-Rock",
|
||||
"Psytrance",
|
||||
"Shoegaze",
|
||||
"Space Rock",
|
||||
"Trop Rock",
|
||||
"World Music",
|
||||
"Neoclassical",
|
||||
"Audiobook",
|
||||
"Audio Theatre",
|
||||
"Neue Deutsche Welle",
|
||||
"Podcast",
|
||||
"Indie Rock",
|
||||
"G-Funk",
|
||||
"Dubstep",
|
||||
"Garage Rock",
|
||||
"Psybient",
|
||||
|
||||
// Auxio's extensions, added because Future Garage is also a good genre.
|
||||
"Future Garage")
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.extractor
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.children
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
|
||||
/**
|
||||
* A [ViewBindingDialogFragment] that allows the user to configure the separator characters used to
|
||||
* split tags with multiple values.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogSeparatorsBinding.inflate(inflater)
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
builder
|
||||
.setTitle(R.string.set_separators)
|
||||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
||||
settings.musicSeparators = getCurrentSeparators()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: DialogSeparatorsBinding, savedInstanceState: Bundle?) {
|
||||
for (child in binding.separatorGroup.children) {
|
||||
if (child is MaterialCheckBox) {
|
||||
// Reset the CheckBox state so that we can ensure that state we load in
|
||||
// from settings is not contaminated from the built-in CheckBox saved state.
|
||||
child.isChecked = false
|
||||
}
|
||||
}
|
||||
|
||||
// More efficient to do one iteration through the separator list and initialize
|
||||
// the corresponding CheckBox for each character instead of doing an iteration
|
||||
// through the separator list for each CheckBox.
|
||||
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS) ?: settings.musicSeparators)?.forEach {
|
||||
when (it) {
|
||||
SEPARATOR_COMMA -> binding.separatorComma.isChecked = true
|
||||
SEPARATOR_SEMICOLON -> binding.separatorSemicolon.isChecked = true
|
||||
SEPARATOR_SLASH -> binding.separatorSlash.isChecked = true
|
||||
SEPARATOR_PLUS -> binding.separatorPlus.isChecked = true
|
||||
SEPARATOR_AND -> binding.separatorAnd.isChecked = true
|
||||
else -> error("Unexpected separator in settings data")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString(KEY_PENDING_SEPARATORS, getCurrentSeparators())
|
||||
}
|
||||
|
||||
/** Get the current separator string configuration from the UI. */
|
||||
private fun getCurrentSeparators(): String {
|
||||
// Create the separator list based on the checked configuration of each
|
||||
// view element. It's generally more stable to duplicate this code instead
|
||||
// of use a mapping that could feasibly drift from the actual layout.
|
||||
var separators = ""
|
||||
val binding = requireBinding()
|
||||
if (binding.separatorComma.isChecked) separators += SEPARATOR_COMMA
|
||||
if (binding.separatorSemicolon.isChecked) separators += SEPARATOR_SEMICOLON
|
||||
if (binding.separatorSlash.isChecked) separators += SEPARATOR_SLASH
|
||||
if (binding.separatorPlus.isChecked) separators += SEPARATOR_PLUS
|
||||
if (binding.separatorAnd.isChecked) separators += SEPARATOR_AND
|
||||
return separators
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
|
||||
// TODO: Move these to a more "Correct" location?
|
||||
private const val SEPARATOR_COMMA = ','
|
||||
private const val SEPARATOR_SEMICOLON = ';'
|
||||
private const val SEPARATOR_SLASH = '/'
|
||||
private const val SEPARATOR_PLUS = '+'
|
||||
private const val SEPARATOR_AND = '&'
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* An [RecyclerView.Adapter] that displays a list of [Artist] choices.
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
* @author OxygenCobalt.
|
||||
*/
|
||||
class ArtistChoiceAdapter(private val listener: ClickableListListener) :
|
||||
RecyclerView.Adapter<ArtistChoiceViewHolder>() {
|
||||
private var artists = listOf<Artist>()
|
||||
|
||||
override fun getItemCount() = artists.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
ArtistChoiceViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: ArtistChoiceViewHolder, position: Int) =
|
||||
holder.bind(artists[position], listener)
|
||||
|
||||
/**
|
||||
* Immediately update the [Artist] choices.
|
||||
* @param newArtists The new [Artist]s to show.
|
||||
*/
|
||||
fun submitList(newArtists: List<Artist>) {
|
||||
if (newArtists != artists) {
|
||||
artists = newArtists
|
||||
@Suppress("NotifyDataSetChanged") notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Artist] item, for
|
||||
* use with [ArtistChoiceAdapter]. Use [new] to create an instance.
|
||||
*/
|
||||
class ArtistChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param artist The new [Artist] to bind.
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(artist: Artist, listener: ClickableListListener) {
|
||||
binding.root.setOnClickListener { listener.onClick(artist) }
|
||||
binding.pickerImage.bind(artist)
|
||||
binding.pickerName.text = artist.resolveName(binding.context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
ArtistChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
|
||||
/**
|
||||
* An [ArtistPickerDialog] intended for when [Artist] navigation is ambiguous.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistNavigationPickerDialog : ArtistPickerDialog() {
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
// Information about what Song to show choices for is initially within the navigation arguments
|
||||
// as UIDs, as that is the only safe way to parcel a Song.
|
||||
private val args: ArtistNavigationPickerDialogArgs by navArgs()
|
||||
|
||||
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
|
||||
pickerModel.setItemUid(args.itemUid)
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
super.onClick(item)
|
||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
// User made a choice, navigate to it.
|
||||
navModel.exploreNavigateTo(item)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
|
||||
/**
|
||||
* The base class for dialogs that implements common behavior across all [Artist] pickers. These are
|
||||
* shown whenever what to do with an item's [Artist] is ambiguous, as there are multiple [Artist]'s
|
||||
* to choose from.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
abstract class ArtistPickerDialog :
|
||||
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
||||
protected val pickerModel: PickerViewModel by viewModels()
|
||||
// Okay to leak this since the Listener will not be called until after initialization.
|
||||
private val artistAdapter = ArtistChoiceAdapter(@Suppress("LeakingThis") this)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogMusicPickerBinding.inflate(inflater)
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
builder.setTitle(R.string.lbl_artists).setNegativeButton(R.string.lbl_cancel, null)
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
|
||||
binding.pickerRecycler.adapter = artistAdapter
|
||||
|
||||
collectImmediately(pickerModel.artistChoices) { artists ->
|
||||
if (artists.isNotEmpty()) {
|
||||
// Make sure the artist choices align with any changes in the music library.
|
||||
artistAdapter.submitList(artists)
|
||||
} else {
|
||||
// Not showing any choices, navigate up.
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogMusicPickerBinding) {
|
||||
binding.pickerRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
|
||||
/**
|
||||
* An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ArtistPlaybackPickerDialog : ArtistPickerDialog() {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
// Information about what Song to show choices for is initially within the navigation arguments
|
||||
// as UIDs, as that is the only safe way to parcel a Song.
|
||||
private val args: ArtistPlaybackPickerDialogArgs by navArgs()
|
||||
|
||||
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
|
||||
pickerModel.setItemUid(args.itemUid)
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
super.onClick(item)
|
||||
// User made a choice, play the given song from that artist.
|
||||
check(item is Artist) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
val song = pickerModel.currentItem.value
|
||||
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
playbackModel.playFromArtist(song, item)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package org.oxycblt.auxio.music.picker
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.databinding.ItemPickerChoiceBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* An [RecyclerView.Adapter] that displays a list of [Genre] choices.
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
* @author OxygenCobalt.
|
||||
*/
|
||||
class GenreChoiceAdapter(private val listener: ClickableListListener) :
|
||||
RecyclerView.Adapter<GenreChoiceViewHolder>() {
|
||||
private var genres = listOf<Genre>()
|
||||
|
||||
override fun getItemCount() = genres.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
GenreChoiceViewHolder.new(parent)
|
||||
|
||||
override fun onBindViewHolder(holder: GenreChoiceViewHolder, position: Int) =
|
||||
holder.bind(genres[position], listener)
|
||||
|
||||
/**
|
||||
* Immediately update the [Genre] choices.
|
||||
* @param newGenres The new [Genre]s to show.
|
||||
*/
|
||||
fun submitList(newGenres: List<Genre>) {
|
||||
if (newGenres != genres) {
|
||||
genres = newGenres
|
||||
@Suppress("NotifyDataSetChanged") notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DialogRecyclerView.ViewHolder] that displays a smaller variant of a typical [Genre] item, for
|
||||
* use with [GenreChoiceAdapter]. Use [new] to create an instance.
|
||||
*/
|
||||
class GenreChoiceViewHolder(private val binding: ItemPickerChoiceBinding) :
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param genre The new [Genre] to bind.
|
||||
* @param listener A [ClickableListListener] to bind interactions to.
|
||||
*/
|
||||
fun bind(genre: Genre, listener: ClickableListListener) {
|
||||
binding.root.setOnClickListener { listener.onClick(genre) }
|
||||
binding.pickerImage.bind(genre)
|
||||
binding.pickerName.text = genre.resolveName(binding.context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
GenreChoiceViewHolder(ItemPickerChoiceBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package org.oxycblt.auxio.music.picker
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
|
||||
/**
|
||||
* A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class GenrePlaybackPickerDialog : ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener {
|
||||
private val pickerModel: PickerViewModel by viewModels()
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
// Information about what Song to show choices for is initially within the navigation arguments
|
||||
// as UIDs, as that is the only safe way to parcel a Song.
|
||||
private val args: GenrePlaybackPickerDialogArgs by navArgs()
|
||||
// Okay to leak this since the Listener will not be called until after initialization.
|
||||
private val genreAdapter = GenreChoiceAdapter(@Suppress("LeakingThis") this)
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogMusicPickerBinding.inflate(inflater)
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
builder.setTitle(R.string.lbl_genres).setNegativeButton(R.string.lbl_cancel, null)
|
||||
}
|
||||
|
||||
override fun onBindingCreated(binding: DialogMusicPickerBinding, savedInstanceState: Bundle?) {
|
||||
binding.pickerRecycler.adapter = genreAdapter
|
||||
|
||||
pickerModel.setItemUid(args.itemUid)
|
||||
collectImmediately(pickerModel.genreChoices) { genres ->
|
||||
if (genres.isNotEmpty()) {
|
||||
// Make sure the genre choices align with any changes in the music library.
|
||||
genreAdapter.submitList(genres)
|
||||
} else {
|
||||
// Not showing any choices, navigate up.
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogMusicPickerBinding) {
|
||||
binding.pickerRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onClick(item: Item) {
|
||||
// User made a choice, play the given song from that genre.
|
||||
check(item is Genre) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
val song = pickerModel.currentItem.value
|
||||
check(song is Song) { "Unexpected datatype: ${item::class.simpleName}" }
|
||||
playbackModel.playFromGenre(song, item)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* a [ViewModel] that manages the current music picker state. Make it so that the dialogs just
|
||||
* contain the music themselves and then exit if the library changes.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PickerViewModel : ViewModel(), MusicStore.Callback {
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
|
||||
private val _currentItem = MutableStateFlow<Music?>(null)
|
||||
/** The current item whose artists should be shown in the picker. Null if there is no item. */
|
||||
val currentItem: StateFlow<Music?> get() = _currentItem
|
||||
|
||||
private val _artistChoices = MutableStateFlow<List<Artist>>(listOf())
|
||||
/** The current [Artist] choices. Empty if no item is shown in the picker. */
|
||||
val artistChoices: StateFlow<List<Artist>>
|
||||
get() = _artistChoices
|
||||
|
||||
private val _genreChoices = MutableStateFlow<List<Genre>>(listOf())
|
||||
/** The current [Genre] choices. Empty if no item is shown in the picker. */
|
||||
val genreChoices: StateFlow<List<Genre>>
|
||||
get() = _genreChoices
|
||||
|
||||
override fun onCleared() {
|
||||
musicStore.removeCallback(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
if (library != null) {
|
||||
refreshChoices()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentItem] from it's [Music.UID].
|
||||
* @param uid The [Music.UID] of the [Song] to update to.
|
||||
*/
|
||||
fun setItemUid(uid: Music.UID) {
|
||||
val library = unlikelyToBeNull(musicStore.library)
|
||||
_currentItem.value = library.find(uid)
|
||||
refreshChoices()
|
||||
}
|
||||
|
||||
private fun refreshChoices() {
|
||||
when (val item = _currentItem.value) {
|
||||
is Song -> {
|
||||
_artistChoices.value = item.artists
|
||||
_genreChoices.value = item.genres
|
||||
}
|
||||
is Album -> _artistChoices.value = item.artists
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -15,23 +15,27 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.dirs
|
||||
package org.oxycblt.auxio.music.storage
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.databinding.ItemMusicDirBinding
|
||||
import org.oxycblt.auxio.music.Directory
|
||||
import org.oxycblt.auxio.ui.recycler.DialogViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
/**
|
||||
* Adapter that shows the list of music folder and their "Clear" button.
|
||||
* @author OxygenCobalt
|
||||
* [RecyclerView.Adapter] that manages a list of [Directory] instances.
|
||||
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<MusicDirViewHolder>() {
|
||||
class DirectoryAdapter(private val listener: Listener) :
|
||||
RecyclerView.Adapter<MusicDirViewHolder>() {
|
||||
private val _dirs = mutableListOf<Directory>()
|
||||
/**
|
||||
* The current list of [Directory]s, may not line up with [MusicDirectories] due to removals.
|
||||
*/
|
||||
val dirs: List<Directory> = _dirs
|
||||
|
||||
override fun getItemCount() = dirs.size
|
||||
|
|
@ -42,6 +46,10 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<Mus
|
|||
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
|
||||
holder.bind(dirs[position], listener)
|
||||
|
||||
/**
|
||||
* Add a [Directory] to the end of the list.
|
||||
* @param dir The [Directory] to add.
|
||||
*/
|
||||
fun add(dir: Directory) {
|
||||
if (_dirs.contains(dir)) {
|
||||
return
|
||||
|
|
@ -51,32 +59,54 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<Mus
|
|||
notifyItemInserted(_dirs.lastIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a list of [Directory] instances to the end of the list.
|
||||
* @param dirs The [Directory instances to add.
|
||||
*/
|
||||
fun addAll(dirs: List<Directory>) {
|
||||
val oldLastIndex = dirs.lastIndex
|
||||
_dirs.addAll(dirs)
|
||||
notifyItemRangeInserted(oldLastIndex, dirs.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a [Directory] from the list.
|
||||
* @param dir The [Directory] to remove. Must exist in the list.
|
||||
*/
|
||||
fun remove(dir: Directory) {
|
||||
val idx = _dirs.indexOf(dir)
|
||||
_dirs.removeAt(idx)
|
||||
notifyItemRemoved(idx)
|
||||
}
|
||||
|
||||
/** A Listener for [DirectoryAdapter] interactions. */
|
||||
interface Listener {
|
||||
fun onRemoveDirectory(dir: Directory)
|
||||
}
|
||||
}
|
||||
|
||||
/** The viewholder for [MusicDirAdapter]. Not intended for use in other adapters. */
|
||||
/**
|
||||
* A [RecyclerView.Recycler] that displays a [Directory]. Use [new] to create an instance.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
|
||||
DialogViewHolder(binding.root) {
|
||||
fun bind(item: Directory, listener: MusicDirAdapter.Listener) {
|
||||
binding.dirPath.text = item.resolveName(binding.context)
|
||||
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) }
|
||||
DialogRecyclerView.ViewHolder(binding.root) {
|
||||
/**
|
||||
* Bind new data to this instance.
|
||||
* @param dir The new [Directory] to bind.
|
||||
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
|
||||
*/
|
||||
fun bind(dir: Directory, listener: DirectoryAdapter.Listener) {
|
||||
binding.dirPath.text = dir.resolveName(binding.context)
|
||||
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(dir) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param parent The parent to inflate this instance from.
|
||||
* @return A new instance.
|
||||
*/
|
||||
fun new(parent: View) =
|
||||
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.dirs
|
||||
package org.oxycblt.auxio.music.storage
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
|
|
@ -28,9 +28,8 @@ import androidx.core.view.isVisible
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||
import org.oxycblt.auxio.music.Directory
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
|
@ -38,11 +37,11 @@ import org.oxycblt.auxio.util.showToast
|
|||
|
||||
/**
|
||||
* Dialog that manages the music dirs setting.
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class MusicDirsDialog :
|
||||
ViewBindingDialogFragment<DialogMusicDirsBinding>(), MusicDirAdapter.Listener {
|
||||
private val dirAdapter = MusicDirAdapter(this)
|
||||
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||
private val dirAdapter = DirectoryAdapter(this)
|
||||
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
|
||||
private val storageManager: StorageManager by lifecycleObject { binding ->
|
||||
binding.context.getSystemServiceCompat(StorageManager::class)
|
||||
|
|
@ -59,8 +58,7 @@ class MusicDirsDialog :
|
|||
.setNegativeButton(R.string.lbl_cancel, null)
|
||||
.setPositiveButton(R.string.lbl_save) { _, _ ->
|
||||
val dirs = settings.getMusicDirs(storageManager)
|
||||
val newDirs =
|
||||
MusicDirs(dirs = dirAdapter.dirs, shouldInclude = isInclude(requireBinding()))
|
||||
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
|
||||
if (dirs != newDirs) {
|
||||
logD("Committing changes")
|
||||
settings.setMusicDirs(newDirs)
|
||||
|
|
@ -70,7 +68,8 @@ class MusicDirsDialog :
|
|||
|
||||
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
|
||||
val launcher =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
|
||||
|
||||
// Now that the dialog exists, we get the view manually when the dialog is shown
|
||||
// and override its click listener so that the dialog does not auto-dismiss when we
|
||||
|
|
@ -78,7 +77,6 @@ class MusicDirsDialog :
|
|||
// and the app from crashing in the latter.
|
||||
requireDialog().setOnShowListener {
|
||||
val dialog = it as AlertDialog
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
|
||||
logD("Opening launcher")
|
||||
launcher.launch(null)
|
||||
|
|
@ -94,11 +92,12 @@ class MusicDirsDialog :
|
|||
|
||||
if (savedInstanceState != null) {
|
||||
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
|
||||
|
||||
if (pendingDirs != null) {
|
||||
dirs =
|
||||
MusicDirs(
|
||||
pendingDirs.mapNotNull { Directory.fromDocumentUri(storageManager, it) },
|
||||
MusicDirectories(
|
||||
pendingDirs.mapNotNull {
|
||||
Directory.fromDocumentTreeUri(storageManager, it)
|
||||
},
|
||||
savedInstanceState.getBoolean(KEY_PENDING_MODE))
|
||||
}
|
||||
}
|
||||
|
|
@ -123,7 +122,7 @@ class MusicDirsDialog :
|
|||
super.onSaveInstanceState(outState)
|
||||
outState.putStringArrayList(
|
||||
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map { it.toString() }))
|
||||
outState.putBoolean(KEY_PENDING_MODE, isInclude(requireBinding()))
|
||||
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
|
||||
|
|
@ -136,14 +135,26 @@ class MusicDirsDialog :
|
|||
requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty()
|
||||
}
|
||||
|
||||
private fun addDocTreePath(uri: Uri?) {
|
||||
/**
|
||||
* Add a Document Tree [Uri] chosen by the user to the current [MusicDirectories] instance.
|
||||
* @param uri The document tree [Uri] to add, chosen by the user. Will do nothing if the [Uri]
|
||||
* is null or not valid.
|
||||
*/
|
||||
private fun addDocumentTreeUriToDirs(uri: Uri?) {
|
||||
if (uri == null) {
|
||||
// A null URI means that the user left the file picker without picking a directory
|
||||
logD("No URI given (user closed the dialog)")
|
||||
return
|
||||
}
|
||||
|
||||
val dir = parseExcludedUri(uri)
|
||||
// Convert the document tree URI into it's relative path form, which can then be
|
||||
// parsed into a Directory instance.
|
||||
val docUri =
|
||||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
uri, DocumentsContract.getTreeDocumentId(uri))
|
||||
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
|
||||
val dir = Directory.fromDocumentTreeUri(storageManager, treeUri)
|
||||
|
||||
if (dir != null) {
|
||||
dirAdapter.add(dir)
|
||||
requireBinding().dirsEmpty.isVisible = false
|
||||
|
|
@ -152,33 +163,20 @@ class MusicDirsDialog :
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseExcludedUri(uri: Uri): Directory? {
|
||||
// Turn the raw URI into a document tree URI
|
||||
val docUri =
|
||||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
uri, DocumentsContract.getTreeDocumentId(uri))
|
||||
|
||||
// Turn it into a semi-usable path
|
||||
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
|
||||
|
||||
// Parsing handles the rest
|
||||
return Directory.fromDocumentUri(storageManager, treeUri)
|
||||
}
|
||||
|
||||
private fun updateMode() {
|
||||
val binding = requireBinding()
|
||||
if (isInclude(binding)) {
|
||||
if (isUiModeInclude(binding)) {
|
||||
binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc)
|
||||
} else {
|
||||
binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInclude(binding: DialogMusicDirsBinding) =
|
||||
/** Get if the UI has currently configured [MusicDirectories.shouldInclude] to be true. */
|
||||
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
|
||||
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
|
||||
|
||||
companion object {
|
||||
const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED"
|
||||
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
|
||||
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
|
||||
}
|
||||
209
app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt
Normal file
209
app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* A full absolute path to a file. Only intended for display purposes. For accessing files, URIs are
|
||||
* preferred in all cases due to scoped storage limitations.
|
||||
* @param name The name of the file.
|
||||
* @param parent The parent [Directory] of the file.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class Path(val name: String, val parent: Directory)
|
||||
|
||||
/**
|
||||
* A volume-aware relative path to a directory.
|
||||
* @param volume The [StorageVolume] that the [Directory] is contained in.
|
||||
* @param relativePath The relative path from within the [StorageVolume] to the [Directory].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Directory private constructor(val volume: StorageVolume, val relativePath: String) {
|
||||
/**
|
||||
* Resolve the [Directory] instance into a human-readable path name.
|
||||
* @param context [Context] required to obtain volume descriptions.
|
||||
* @return A human-readable path.
|
||||
* @see StorageVolume.getDescription
|
||||
*/
|
||||
fun resolveName(context: Context) =
|
||||
context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), relativePath)
|
||||
|
||||
/**
|
||||
* Converts this [Directory] instance into an opaque document tree path. This is a huge
|
||||
* violation of the document tree URI contract, but it's also the only one can sensibly work
|
||||
* with these uris in the UI, and it doesn't exactly matter since we never write or read
|
||||
* directory.
|
||||
* @return A URI [String] abiding by the document tree specification, or null if the [Directory]
|
||||
* is not valid.
|
||||
*/
|
||||
fun toDocumentTreeUri() =
|
||||
// Document tree URIs consist of a prefixed volume name followed by a relative path.
|
||||
if (volume.isInternalCompat) {
|
||||
// The primary storage has a volume prefix of "primary", regardless
|
||||
// of if it's internal or not.
|
||||
"$DOCUMENT_URI_PRIMARY_NAME:$relativePath"
|
||||
} else {
|
||||
// Removable storage has a volume prefix of it's UUID.
|
||||
volume.uuidCompat?.let { uuid -> "$uuid:$relativePath" }
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = volume.hashCode()
|
||||
result = 31 * result + relativePath.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun equals(other: Any?) =
|
||||
other is Directory && other.volume == volume && other.relativePath == relativePath
|
||||
|
||||
companion object {
|
||||
/** The name given to the internal volume when in a document tree URI. */
|
||||
private const val DOCUMENT_URI_PRIMARY_NAME = "primary"
|
||||
|
||||
/**
|
||||
* Create a new directory instance from the given components.
|
||||
* @param volume The [StorageVolume] that the [Directory] is contained in.
|
||||
* @param relativePath The relative path from within the [StorageVolume] to the [Directory].
|
||||
* Will be stripped of any trailing separators for a consistent internal representation.
|
||||
* @return A new [Directory] created from the components.
|
||||
*/
|
||||
fun from(volume: StorageVolume, relativePath: String) =
|
||||
Directory(
|
||||
volume, relativePath.removePrefix(File.separator).removeSuffix(File.separator))
|
||||
|
||||
/**
|
||||
* Create a new directory from a document tree URI. This is a huge violation of the document
|
||||
* tree URI contract, but it's also the only one can sensibly work with these uris in the
|
||||
* UI, and it doesn't exactly matter since we never write or read directory.
|
||||
* @param storageManager [StorageManager] in order to obtain the [StorageVolume] specified
|
||||
* in the given URI.
|
||||
* @param uri The URI string to parse into a [Directory].
|
||||
* @return A new [Directory] parsed from the URI, or null if the URI is not valid.
|
||||
*/
|
||||
fun fromDocumentTreeUri(storageManager: StorageManager, uri: String): Directory? {
|
||||
// Document tree URIs consist of a prefixed volume name followed by a relative path,
|
||||
// delimited with a colon.
|
||||
val split = uri.split(File.pathSeparator, limit = 2)
|
||||
val volume =
|
||||
when (split[0]) {
|
||||
// The primary storage has a volume prefix of "primary", regardless
|
||||
// of if it's internal or not.
|
||||
DOCUMENT_URI_PRIMARY_NAME -> storageManager.primaryStorageVolumeCompat
|
||||
// Removable storage has a volume prefix of it's UUID, try to find it
|
||||
// within StorageManager's volume list.
|
||||
else -> storageManager.storageVolumesCompat.find { it.uuidCompat == split[0] }
|
||||
}
|
||||
val relativePath = split.getOrNull(1)
|
||||
return from(volume ?: return null, relativePath ?: return null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the configuration for specific directories to filter to/from when loading music.
|
||||
* @param dirs A list of [Directory] instances. How these are interpreted depends on [shouldInclude]
|
||||
* @param shouldInclude True if the library should only load from the [Directory] instances, false
|
||||
* if the library should not load from the [Directory] instances.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class MusicDirectories(val dirs: List<Directory>, val shouldInclude: Boolean)
|
||||
// TODO: Unify include + exclude
|
||||
|
||||
/**
|
||||
* A mime type of a file. Only intended for display.
|
||||
* @param fromExtension The mime type obtained by analyzing the file extension.
|
||||
* @param fromFormat The mime type obtained by analyzing the file format. Null if could not be
|
||||
* obtained.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class MimeType(val fromExtension: String, val fromFormat: String?) {
|
||||
/**
|
||||
* Resolve the mime type into a human-readable format name, such as "Ogg Vorbis".
|
||||
* @param context [Context] required to obtain human-readable strings.
|
||||
* @return A human-readable name for this mime type. Will first try [fromFormat], then falling
|
||||
* back to [fromExtension], then falling back to the extension name, and then finally a
|
||||
* placeholder "No Format" string.
|
||||
*/
|
||||
fun resolveName(context: Context): String {
|
||||
// We try our best to produce a more readable name for the common audio formats.
|
||||
val formatName =
|
||||
when (fromFormat) {
|
||||
// We start with the extracted mime types, as they are more consistent. Note that
|
||||
// we do not include container formats at all with these names. It is only the
|
||||
// inner codec that we bother with.
|
||||
MediaFormat.MIMETYPE_AUDIO_MPEG -> R.string.cdc_mp3
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC -> R.string.cdc_aac
|
||||
MediaFormat.MIMETYPE_AUDIO_VORBIS -> R.string.cdc_vorbis
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS -> R.string.cdc_opus
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC -> R.string.cdc_flac
|
||||
// We don't give a name to more unpopular formats.
|
||||
else -> -1
|
||||
}
|
||||
|
||||
if (formatName > -1) {
|
||||
return context.getString(formatName)
|
||||
}
|
||||
|
||||
// Fall back to the file extension in the case that we have no mime type or
|
||||
// a useless "audio/raw" mime type. Here:
|
||||
// - We return names for container formats instead of the inner format, as we
|
||||
// cannot parse the file.
|
||||
// - We are at the mercy of the Android OS, hence we check for every possible mime
|
||||
// type for a particular format according to Wikipedia.
|
||||
val extensionName =
|
||||
when (fromExtension) {
|
||||
"audio/mpeg",
|
||||
"audio/mp3" -> R.string.cdc_mp3
|
||||
"audio/mp4",
|
||||
"audio/mp4a-latm",
|
||||
"audio/mpeg4-generic" -> R.string.cdc_mp4
|
||||
"audio/aac",
|
||||
"audio/aacp",
|
||||
"audio/3gpp",
|
||||
"audio/3gpp2" -> R.string.cdc_aac
|
||||
"audio/ogg",
|
||||
"application/ogg",
|
||||
"application/x-ogg" -> R.string.cdc_ogg
|
||||
"audio/flac" -> R.string.cdc_flac
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/wave",
|
||||
"audio/vnd.wave" -> R.string.cdc_wav
|
||||
"audio/x-matroska" -> R.string.cdc_mka
|
||||
else -> -1
|
||||
}
|
||||
|
||||
return if (extensionName > -1) {
|
||||
context.getString(extensionName)
|
||||
} else {
|
||||
// Fall back to the extension if we can't find a special name for this format.
|
||||
MimeTypeMap.getSingleton().getExtensionFromMimeType(fromExtension)?.uppercase()
|
||||
// Fall back to a placeholder if even that fails.
|
||||
?: context.getString(R.string.def_codec)
|
||||
}
|
||||
}
|
||||
}
|
||||
231
app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt
Normal file
231
app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.provider.MediaStore
|
||||
import java.lang.reflect.Method
|
||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||
|
||||
// --- MEDIASTORE UTILITIES ---
|
||||
|
||||
/**
|
||||
* Get a content resolver that will not mangle MediaStore queries on certain devices. See
|
||||
* https://github.com/OxygenCobalt/Auxio/issues/50 for more info.
|
||||
*/
|
||||
val Context.contentResolverSafe: ContentResolver
|
||||
get() = applicationContext.contentResolver
|
||||
|
||||
/**
|
||||
* A shortcut for querying the [ContentResolver] database.
|
||||
* @param uri The [Uri] of content to retrieve.
|
||||
* @param projection A list of SQL columns to query from the database.
|
||||
* @param selector A SQL selection statement to filter results. Spaces where arguments should be
|
||||
* filled in are represented with a "?".
|
||||
* @param args The arguments used for the selector.
|
||||
* @return A [Cursor] of the queried values, organized by the column projection.
|
||||
* @throws IllegalStateException If the [ContentResolver] did not return the queried [Cursor].
|
||||
* @see ContentResolver.query
|
||||
*/
|
||||
fun ContentResolver.safeQuery(
|
||||
uri: Uri,
|
||||
projection: Array<out String>,
|
||||
selector: String? = null,
|
||||
args: Array<String>? = null
|
||||
) = requireNotNull(query(uri, projection, selector, args, null)) { "ContentResolver query failed" }
|
||||
|
||||
/**
|
||||
* A shortcut for [safeQuery] with [use] applied, automatically cleaning up the [Cursor]'s resources
|
||||
* when no longer used.
|
||||
* @param uri The [Uri] of content to retrieve.
|
||||
* @param projection A list of SQL columns to query from the database.
|
||||
* @param selector A SQL selection statement to filter results. Spaces where arguments should be
|
||||
* filled in are represented with a "?".
|
||||
* @param args The arguments used for the selector.
|
||||
* @param block The block of code to run with the queried [Cursor]. Will not be ran if the [Cursor]
|
||||
* is empty.
|
||||
* @throws IllegalStateException If the [ContentResolver] did not return the queried [Cursor].
|
||||
* @see ContentResolver.query
|
||||
*/
|
||||
inline fun <reified R> ContentResolver.useQuery(
|
||||
uri: Uri,
|
||||
projection: Array<out String>,
|
||||
selector: String? = null,
|
||||
args: Array<String>? = null,
|
||||
block: (Cursor) -> R
|
||||
) = safeQuery(uri, projection, selector, args).use(block)
|
||||
|
||||
/** Album art [MediaStore] database is not a built-in constant, have to define it ourselves. */
|
||||
private val EXTERNAL_COVERS_URI = Uri.parse("content://media/external/audio/albumart")
|
||||
|
||||
/**
|
||||
* Convert a [MediaStore] Song ID into a [Uri] to it's audio file.
|
||||
* @return An external storage audio file [Uri]. May not exist.
|
||||
* @see ContentUris.withAppendedId
|
||||
* @see MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
*/
|
||||
fun Long.toAudioUri() =
|
||||
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this)
|
||||
|
||||
/**
|
||||
* Convert a [MediaStore] Album ID into a [Uri] to it's system-provided album cover. This cover will
|
||||
* be fast to load, but will be lower quality.
|
||||
* @return An external storage image [Uri]. May not exist.
|
||||
* @see ContentUris.withAppendedId
|
||||
*/
|
||||
fun Long.toCoverUri() = ContentUris.withAppendedId(EXTERNAL_COVERS_URI, this)
|
||||
|
||||
// --- STORAGEMANAGER UTILITIES ---
|
||||
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles
|
||||
|
||||
/**
|
||||
* Provides the analogous method to [StorageManager.getStorageVolumes] method that is usable from
|
||||
* API 21 to API 23, in which the [StorageManager] API was hidden and differed greatly.
|
||||
* @see StorageManager.getStorageVolumes
|
||||
*/
|
||||
@Suppress("NewApi")
|
||||
private val SM_API21_GET_VOLUME_LIST_METHOD: Method by
|
||||
lazyReflectedMethod(StorageManager::class, "getVolumeList")
|
||||
|
||||
/**
|
||||
* Provides the analogous method to [StorageVolume.getDirectory] method that is usable from API 21
|
||||
* to API 23, in which the [StorageVolume] API was hidden and differed greatly.
|
||||
* @see StorageVolume.getDirectory
|
||||
*/
|
||||
@Suppress("NewApi")
|
||||
private val SV_API21_GET_PATH_METHOD: Method by lazyReflectedMethod(StorageVolume::class, "getPath")
|
||||
|
||||
/**
|
||||
* The [StorageVolume] considered the "primary" volume by the system, obtained in a
|
||||
* version-compatible manner.
|
||||
* @see StorageManager.getPrimaryStorageVolume
|
||||
* @see StorageVolume.isPrimary
|
||||
*/
|
||||
val StorageManager.primaryStorageVolumeCompat: StorageVolume
|
||||
@Suppress("NewApi") get() = primaryStorageVolume
|
||||
|
||||
/**
|
||||
* The list of [StorageVolume]s currently recognized by [StorageManager], in a version-compatible
|
||||
* manner.
|
||||
* @see StorageManager.getStorageVolumes
|
||||
*/
|
||||
val StorageManager.storageVolumesCompat: List<StorageVolume>
|
||||
get() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
storageVolumes.toList()
|
||||
} else {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(SM_API21_GET_VOLUME_LIST_METHOD.invoke(this) as Array<StorageVolume>).toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* The the absolute path to this [StorageVolume]'s directory within the file-system, in a
|
||||
* version-compatible manner. Will be null if the [StorageVolume] cannot be read.
|
||||
* @see StorageVolume.getDirectory
|
||||
*/
|
||||
val StorageVolume.directoryCompat: String?
|
||||
@SuppressLint("NewApi")
|
||||
get() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
directory?.absolutePath
|
||||
} else {
|
||||
// Replicate API: Analogous method if mounted, null if not
|
||||
when (stateCompat) {
|
||||
Environment.MEDIA_MOUNTED,
|
||||
Environment.MEDIA_MOUNTED_READ_ONLY ->
|
||||
SV_API21_GET_PATH_METHOD.invoke(this) as String
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the human-readable description of this volume, such as "Internal Shared Storage".
|
||||
* @param context [Context] required to obtain human-readable string resources.
|
||||
* @return A human-readable name for this volume.
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
fun StorageVolume.getDescriptionCompat(context: Context): String = getDescription(context)
|
||||
|
||||
/**
|
||||
* If this [StorageVolume] is considered the "Primary" volume where the Android System is kept. May
|
||||
* still be a removable volume.
|
||||
* @see StorageVolume.isPrimary
|
||||
*/
|
||||
val StorageVolume.isPrimaryCompat: Boolean
|
||||
@SuppressLint("NewApi") get() = isPrimary
|
||||
|
||||
/**
|
||||
* If this storage is "emulated", i.e intrinsic to the device, obtained in a version compatible
|
||||
* manner.
|
||||
* @see StorageVolume.isEmulated
|
||||
*/
|
||||
val StorageVolume.isEmulatedCompat: Boolean
|
||||
@SuppressLint("NewApi") get() = isEmulated
|
||||
|
||||
/**
|
||||
* If this [StorageVolume] represents the "Internal Shared Storage" volume, also known as "primary"
|
||||
* to [MediaStore] and Document [Uri]s, obtained in a version compatible manner.
|
||||
*/
|
||||
val StorageVolume.isInternalCompat: Boolean
|
||||
// Must contain the android system AND be an emulated drive, as non-emulated system
|
||||
// volumes use their UUID instead of primary in MediaStore/Document URIs.
|
||||
get() = isPrimaryCompat && isEmulatedCompat
|
||||
|
||||
/**
|
||||
* The unique identifier for this [StorageVolume], obtained in a version compatible manner Can be
|
||||
* null.
|
||||
* @see StorageVolume.getUuid
|
||||
*/
|
||||
val StorageVolume.uuidCompat: String?
|
||||
@SuppressLint("NewApi") get() = uuid
|
||||
|
||||
/**
|
||||
* The current state of this [StorageVolume], such as "mounted" or "read-only", obtained in a
|
||||
* version compatible manner.
|
||||
* @see StorageVolume.getState
|
||||
*/
|
||||
val StorageVolume.stateCompat: String
|
||||
@SuppressLint("NewApi") get() = state
|
||||
|
||||
/**
|
||||
* Returns the name of this volume that can be used to interact with [MediaStore], in a version
|
||||
* compatible manner. Will be null if the volume is not scanned by [MediaStore].
|
||||
* @see StorageVolume.getMediaStoreVolumeName
|
||||
*/
|
||||
val StorageVolume.mediaStoreVolumeNameCompat: String?
|
||||
get() =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
mediaStoreVolumeName
|
||||
} else {
|
||||
// Replicate API: primary_external if primary storage, lowercase uuid otherwise
|
||||
if (isPrimaryCompat) {
|
||||
// "primary_external" is used in all versions that Auxio supports, is safe to use.
|
||||
@Suppress("NewApi") MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
} else {
|
||||
uuidCompat?.lowercase()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,337 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.system
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.audioUri
|
||||
import org.oxycblt.auxio.music.parseId3GenreName
|
||||
import org.oxycblt.auxio.music.parsePositionNum
|
||||
import org.oxycblt.auxio.music.parseReleaseType
|
||||
import org.oxycblt.auxio.music.parseTimestamp
|
||||
import org.oxycblt.auxio.music.parseYear
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
|
||||
*
|
||||
* Normally, leveraging ExoPlayer's metadata system would be a terrible idea, as it is horrifically
|
||||
* slow. However, if we parallelize it, we can get similar throughput to other metadata extractors,
|
||||
* which is nice as it means we don't have to bundle a redundant metadata library like JAudioTagger.
|
||||
*
|
||||
* Now, ExoPlayer's metadata API is not the best. It's opaque, undocumented, and prone to weird
|
||||
* pitfalls given ExoPlayer's cozy relationship with native code. However, this backend should do
|
||||
* enough to eliminate such issues.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
|
||||
private val runningTasks: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
|
||||
|
||||
// No need to implement our own query logic, as this backend is still reliant on
|
||||
// MediaStore.
|
||||
override fun query(context: Context) = inner.query(context)
|
||||
|
||||
override fun buildSongs(
|
||||
context: Context,
|
||||
cursor: Cursor,
|
||||
emitIndexing: (Indexer.Indexing) -> Unit
|
||||
): List<Song> {
|
||||
// Metadata retrieval with ExoPlayer is asynchronous, so a callback may at any point
|
||||
// add a completed song to the list. To prevent a crash in that case, we use the
|
||||
// concurrent counterpart to a typical mutable list.
|
||||
val songs = mutableListOf<Song>()
|
||||
val total = cursor.count
|
||||
|
||||
// LEFTOFF: Make logic more consistent?
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
// Note: This call to buildAudio does not populate the genre field. This is
|
||||
// because indexing genres is quite slow with MediaStore, and so keeping the
|
||||
// field blank on unsupported ExoPlayer formats ends up being preferable.
|
||||
val audio = inner.buildAudio(context, cursor)
|
||||
|
||||
// Spin until there is an open slot we can insert a task in. Note that we do
|
||||
// not add callbacks to our new tasks, as Future callbacks run on a different
|
||||
// executor and thus will crash the app if an error occurs instead of bubbling
|
||||
// back up to Indexer.
|
||||
spin@ while (true) {
|
||||
for (i in runningTasks.indices) {
|
||||
val task = runningTasks[i]
|
||||
|
||||
if (task != null) {
|
||||
val song = task.get()
|
||||
if (song != null) {
|
||||
songs.add(song)
|
||||
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
|
||||
runningTasks[i] = Task(context, audio)
|
||||
break@spin
|
||||
}
|
||||
} else {
|
||||
runningTasks[i] = Task(context, audio)
|
||||
break@spin
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spin@ while (true) {
|
||||
// Spin until all of the remaining tasks are complete.
|
||||
for (i in runningTasks.indices) {
|
||||
val task = runningTasks[i]
|
||||
|
||||
if (task != null) {
|
||||
val song = task.get() ?: continue@spin
|
||||
songs.add(song)
|
||||
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
|
||||
runningTasks[i] = null
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
return songs
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The amount of tasks this backend can run efficiently at once. */
|
||||
private const val TASK_CAPACITY = 8
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an ExoPlayer metadata retrieval task in a safe abstraction. Access is done with [get].
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class Task(context: Context, private val audio: MediaStoreBackend.Audio) {
|
||||
private val future =
|
||||
MetadataRetriever.retrieveMetadata(
|
||||
context,
|
||||
MediaItem.fromUri(requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri))
|
||||
|
||||
/**
|
||||
* Get the song that this task is trying to complete. If the task is still busy, this will
|
||||
* return null. Otherwise, it will return a song.
|
||||
*/
|
||||
fun get(): Song? {
|
||||
if (!future.isDone) {
|
||||
return null
|
||||
}
|
||||
|
||||
val format =
|
||||
try {
|
||||
future.get()[0].getFormat(0)
|
||||
} catch (e: Exception) {
|
||||
logW("Unable to extract metadata for ${audio.title}")
|
||||
logW(e.stackTraceToString())
|
||||
null
|
||||
}
|
||||
|
||||
if (format == null) {
|
||||
logD("Nothing could be extracted for ${audio.title}")
|
||||
return audio.toSong()
|
||||
}
|
||||
|
||||
// Populate the format mime type if we have one.
|
||||
format.sampleMimeType?.let { audio.formatMimeType = it }
|
||||
|
||||
val metadata = format.metadata
|
||||
if (metadata != null) {
|
||||
completeAudio(metadata)
|
||||
} else {
|
||||
logD("No metadata could be extracted for ${audio.title}")
|
||||
}
|
||||
|
||||
return audio.toSong()
|
||||
}
|
||||
|
||||
private fun completeAudio(metadata: Metadata) {
|
||||
val id3v2Tags = mutableMapOf<String, String>()
|
||||
val vorbisTags = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
|
||||
// of audio formats. Load both of these types of tags into separate maps, letting the
|
||||
// "source of truth" be the last of a particular tag in a file.
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val tag = metadata[i]) {
|
||||
is TextInformationFrame -> {
|
||||
val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
|
||||
val value = tag.value.sanitize()
|
||||
if (value.isNotEmpty()) {
|
||||
id3v2Tags[id] = value
|
||||
}
|
||||
}
|
||||
is VorbisComment -> {
|
||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
||||
val id = tag.key.sanitize().uppercase()
|
||||
val value = tag.value.sanitize()
|
||||
if (value.isNotEmpty()) {
|
||||
if (vorbisTags.containsKey(id)) {
|
||||
vorbisTags[id]!!.add(value)
|
||||
} else {
|
||||
vorbisTags[id] = mutableListOf(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
vorbisTags.isEmpty() -> populateId3v2(id3v2Tags)
|
||||
id3v2Tags.isEmpty() -> populateVorbis(vorbisTags)
|
||||
else -> {
|
||||
// Some formats (like FLAC) can contain both ID3v2 and Vorbis, so we apply
|
||||
// them both with priority given to vorbis.
|
||||
populateId3v2(id3v2Tags)
|
||||
populateVorbis(vorbisTags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateId3v2(tags: Map<String, String>) {
|
||||
// (Sort) Title
|
||||
tags["TIT2"]?.let { audio.title = it }
|
||||
tags["TSOT"]?.let { audio.sortTitle = it }
|
||||
|
||||
// Track, as NN/TT
|
||||
tags["TRCK"]?.parsePositionNum()?.let { audio.track = it }
|
||||
|
||||
// Disc, as NN/TT
|
||||
tags["TPOS"]?.parsePositionNum()?.let { audio.disc = it }
|
||||
|
||||
// Dates are somewhat complicated, as not only did their semantics change from a flat year
|
||||
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
|
||||
// date types.
|
||||
// Our hierarchy for dates is as such:
|
||||
// 1. ID3v2.4 Original Date, as it resolves the "Released in X, Remastered in Y" issue
|
||||
// 2. ID3v2.4 Recording Date, as it is the most common date type
|
||||
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
||||
// 4. ID3v2.3 Original Date, as it is like #1
|
||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
||||
(tags["TDOR"]?.parseTimestamp()
|
||||
?: tags["TDRC"]?.parseTimestamp() ?: tags["TDRL"]?.parseTimestamp()
|
||||
?: parseId3v23Date(tags))
|
||||
?.let { audio.date = it }
|
||||
|
||||
// (Sort) Album
|
||||
tags["TALB"]?.let { audio.album = it }
|
||||
tags["TSOA"]?.let { audio.sortAlbum = it }
|
||||
|
||||
// (Sort) Artist
|
||||
tags["TPE1"]?.let { audio.artist = it }
|
||||
tags["TSOP"]?.let { audio.sortArtist = it }
|
||||
|
||||
// (Sort) Album artist
|
||||
tags["TPE2"]?.let { audio.albumArtist = it }
|
||||
tags["TSO2"]?.let { audio.sortAlbumArtist = it }
|
||||
|
||||
// Genre, with the weird ID3 rules.
|
||||
tags["TCON"]?.let { audio.genre = it.parseId3GenreName() }
|
||||
|
||||
// Release type (GRP1 is sometimes used for this, so fall back to it)
|
||||
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let {
|
||||
audio.releaseType = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseId3v23Date(tags: Map<String, String>): Date? {
|
||||
val year = tags["TORY"]?.toIntOrNull() ?: tags["TYER"]?.toIntOrNull() ?: return null
|
||||
|
||||
val mmdd = tags["TDAT"]
|
||||
return if (mmdd != null && mmdd.length == 4 && mmdd.isDigitsOnly()) {
|
||||
val mm = mmdd.substring(0..1).toInt()
|
||||
val dd = mmdd.substring(2..3).toInt()
|
||||
|
||||
val hhmi = tags["TIME"]
|
||||
if (hhmi != null && hhmi.length == 4 && hhmi.isDigitsOnly()) {
|
||||
val hh = hhmi.substring(0..1).toInt()
|
||||
val mi = hhmi.substring(2..3).toInt()
|
||||
Date.from(year, mm, dd, hh, mi)
|
||||
} else {
|
||||
Date.from(year, mm, dd)
|
||||
}
|
||||
} else {
|
||||
return Date.from(year)
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateVorbis(tags: Map<String, List<String>>) {
|
||||
// (Sort) Title
|
||||
tags["TITLE"]?.let { audio.title = it[0] }
|
||||
tags["TITLESORT"]?.let { audio.sortTitle = it[0] }
|
||||
|
||||
// Track
|
||||
tags["TRACKNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.track = it }
|
||||
|
||||
// Disc
|
||||
tags["DISCNUMBER"]?.run { get(0).parsePositionNum() }?.let { audio.disc = it }
|
||||
|
||||
// Vorbis dates are less complicated, but there are still several types
|
||||
// Our hierarchy for dates is as such:
|
||||
// 1. Original Date, as it solves the "Released in X, Remastered in Y" issue
|
||||
// 2. Date, as it is the most common date type
|
||||
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
|
||||
// tag that android supports, so it must be 15 years old or more!)
|
||||
(tags["ORIGINALDATE"]?.run { get(0).parseTimestamp() }
|
||||
?: tags["DATE"]?.run { get(0).parseTimestamp() }
|
||||
?: tags["YEAR"]?.run { get(0).parseYear() })
|
||||
?.let { audio.date = it }
|
||||
|
||||
// (Sort) Album
|
||||
tags["ALBUM"]?.let { audio.album = it.joinToString() }
|
||||
tags["ALBUMSORT"]?.let { audio.sortAlbum = it.joinToString() }
|
||||
|
||||
// (Sort) Artist
|
||||
tags["ARTIST"]?.let { audio.artist = it.joinToString() }
|
||||
tags["ARTISTSORT"]?.let { audio.sortArtist = it.joinToString() }
|
||||
|
||||
// (Sort) Album artist
|
||||
tags["ALBUMARTIST"]?.let { audio.albumArtist = it.joinToString() }
|
||||
tags["ALBUMARTISTSORT"]?.let { audio.sortAlbumArtist = it.joinToString() }
|
||||
|
||||
// Genre, no ID3 rules here
|
||||
tags["GENRE"]?.let { audio.genre = it.joinToString() }
|
||||
|
||||
// Release type
|
||||
tags["RELEASETYPE"]?.parseReleaseType()?.let { audio.releaseType = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies and sanitizes this string under the assumption that it is UTF-8.
|
||||
*
|
||||
* Sometimes ExoPlayer emits weird UTF-8. Worse still, sometimes it emits strings backed by data
|
||||
* allocated by some native function. This could easily cause a terrible crash if you even look
|
||||
* at the malformed string the wrong way.
|
||||
*
|
||||
* This function mitigates it by first encoding the string as UTF-8 bytes (replacing malformed
|
||||
* characters with the replacement in the process), and then re-interpreting it as a new string,
|
||||
* which hopefully fixes encoding insanity while also copying the string out of dodgy native
|
||||
* memory.
|
||||
*/
|
||||
private fun String.sanitize() = String(encodeToByteArray())
|
||||
}
|
||||
|
|
@ -20,63 +20,60 @@ package org.oxycblt.auxio.music.system
|
|||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.extractor.*
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.Sort
|
||||
import org.oxycblt.auxio.util.TaskGuard
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logE
|
||||
import org.oxycblt.auxio.util.logW
|
||||
import org.oxycblt.auxio.util.requireBackgroundThread
|
||||
|
||||
/**
|
||||
* Auxio's media indexer.
|
||||
* Core music loading state class.
|
||||
*
|
||||
* Auxio's media indexer is somewhat complicated, as it has grown to support a variety of use cases
|
||||
* (and hacky garbage) in order to produce the best possible experience. It is split into three
|
||||
* distinct steps:
|
||||
* This class provides low-level access into the exact state of the music loading process. **This
|
||||
* class should not be used in most cases.** It is highly volatile and provides far more information
|
||||
* than is usually needed. Use [MusicStore] instead if you do not need to work with the exact music
|
||||
* loading state.
|
||||
*
|
||||
* 1. Finding a [Backend] to use and then querying the media database with it.
|
||||
* 2. Using the [Backend] and the media data to create songs
|
||||
* 3. Using the songs to build the library, which primarily involves linking up all data objects
|
||||
* with their corresponding parents/children.
|
||||
*
|
||||
* This class in particular handles 3 primarily. For the code that handles 1 and 2, see the
|
||||
* [Backend] implementations.
|
||||
*
|
||||
* This class also fulfills the role of maintaining the current music loading state, which seems
|
||||
* like a job for [MusicStore] but in practice is only really leveraged by the components that
|
||||
* directly work with music loading, making such redundant.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Indexer {
|
||||
class Indexer private constructor() {
|
||||
private var lastResponse: Response? = null
|
||||
private var indexingState: Indexing? = null
|
||||
|
||||
private var guard = TaskGuard()
|
||||
private var controller: Controller? = null
|
||||
private var callback: Callback? = null
|
||||
|
||||
/** Whether music loading is occurring or not. */
|
||||
val isIndexing: Boolean
|
||||
get() = indexingState != null
|
||||
|
||||
/**
|
||||
* Whether this instance is in an indeterminate state or not, where nothing has been previously
|
||||
* loaded, yet no loading is going on.
|
||||
* Whether this instance has not completed a loading process and is not currently loading music.
|
||||
* This often occurs early in an app's lifecycle, and consumers should try to avoid showing any
|
||||
* state when this flag is true.
|
||||
*/
|
||||
val isIndeterminate: Boolean
|
||||
get() = lastResponse == null && indexingState == null
|
||||
|
||||
/** Whether this instance is actively indexing or not. */
|
||||
val isIndexing: Boolean
|
||||
get() = indexingState != null
|
||||
|
||||
/** Register a [Controller] with this instance. */
|
||||
/**
|
||||
* Register a [Controller] for this instance. This instance will handle any commands to start
|
||||
* the music loading process. There can be only one [Controller] at a time. Will invoke all
|
||||
* [Callback] methods to initialize the instance with the current state.
|
||||
* @param controller The [Controller] to register. Will do nothing if already registered.
|
||||
*/
|
||||
@Synchronized
|
||||
fun registerController(controller: Controller) {
|
||||
if (BuildConfig.DEBUG && this.controller != null) {
|
||||
|
|
@ -84,10 +81,19 @@ class Indexer {
|
|||
return
|
||||
}
|
||||
|
||||
// Initialize the controller with the current state.
|
||||
val currentState =
|
||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||
controller.onIndexerStateChanged(currentState)
|
||||
this.controller = controller
|
||||
}
|
||||
|
||||
/** Unregister a [Controller] with this instance. */
|
||||
/**
|
||||
* Unregister the [Controller] from this instance, prevent it from recieving any further
|
||||
* commands.
|
||||
* @param controller The [Controller] to unregister. Must be the current [Controller]. Does
|
||||
* nothing if invoked by another [Controller] implementation.
|
||||
*/
|
||||
@Synchronized
|
||||
fun unregisterController(controller: Controller) {
|
||||
if (BuildConfig.DEBUG && this.controller !== controller) {
|
||||
|
|
@ -98,21 +104,32 @@ class Indexer {
|
|||
this.controller = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the [Callback] for this instance. This can be used to receive rapid-fire updates to
|
||||
* the current music loading state. There can be only one [Callback] at a time. Will invoke all
|
||||
* [Callback] methods to initialize the instance with the current state.
|
||||
* @param callback The [Callback] to add.
|
||||
*/
|
||||
@Synchronized
|
||||
fun registerCallback(callback: Callback) {
|
||||
if (BuildConfig.DEBUG && this.callback != null) {
|
||||
logW("Callback is already registered")
|
||||
logW("Listener is already registered")
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize the listener with the current state.
|
||||
val currentState =
|
||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||
|
||||
callback.onIndexerStateChanged(currentState)
|
||||
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a [Callback] from this instance, preventing it from recieving any further updates.
|
||||
* @param callback The [Callback] to unregister. Must be the current [Callback]. Does nothing if
|
||||
* invoked by another [Callback] implementation.
|
||||
* @see Callback
|
||||
*/
|
||||
@Synchronized
|
||||
fun unregisterCallback(callback: Callback) {
|
||||
if (BuildConfig.DEBUG && this.callback !== callback) {
|
||||
|
|
@ -124,275 +141,265 @@ class Indexer {
|
|||
}
|
||||
|
||||
/**
|
||||
* Start the indexing process. This should be done by [Controller] in a background thread. When
|
||||
* complete, a new completion state will be pushed to each callback.
|
||||
* Start the indexing process. This should be done from in the background from [Controller]'s
|
||||
* context after a command has been received to start the process.
|
||||
* @param context [Context] required to load music.
|
||||
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
|
||||
* be written, but no cache entries will be loaded into the new library.
|
||||
*/
|
||||
suspend fun index(context: Context) {
|
||||
requireBackgroundThread()
|
||||
|
||||
val handle = guard.newHandle()
|
||||
|
||||
val notGranted =
|
||||
ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED
|
||||
|
||||
if (notGranted) {
|
||||
emitCompletion(Response.NoPerms, handle)
|
||||
suspend fun index(context: Context, withCache: Boolean) {
|
||||
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED) {
|
||||
// No permissions, signal that we can't do anything.
|
||||
emitCompletion(Response.NoPerms)
|
||||
return
|
||||
}
|
||||
|
||||
val response =
|
||||
try {
|
||||
val start = System.currentTimeMillis()
|
||||
val library = indexImpl(context, handle)
|
||||
val library = indexImpl(context, withCache)
|
||||
if (library != null) {
|
||||
// Successfully loaded a library.
|
||||
logD(
|
||||
"Music indexing completed successfully in " +
|
||||
"${System.currentTimeMillis() - start}ms")
|
||||
Response.Ok(library)
|
||||
} else {
|
||||
// Loaded a library, but it contained no music.
|
||||
logE("No music found")
|
||||
Response.NoMusic
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Got cancelled, propagate upwards
|
||||
// Got cancelled, propagate upwards to top-level co-routine.
|
||||
logD("Loading routine was cancelled")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Music loading process failed due to something we have not handled.
|
||||
logE("Music indexing failed")
|
||||
logE(e.stackTraceToString())
|
||||
Response.Err(e)
|
||||
}
|
||||
|
||||
emitCompletion(response, handle)
|
||||
emitCompletion(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request that re-indexing should be done. This should be used by components that do not manage
|
||||
* the indexing process to re-index music.
|
||||
* Request that the music library should be reloaded. This should be used by components that do
|
||||
* not manage the indexing process in order to signal that the [Controller] should call [index]
|
||||
* eventually.
|
||||
* @param withCache Whether to use the cache when loading music. Does nothing if there is no
|
||||
* [Controller].
|
||||
*/
|
||||
@Synchronized
|
||||
fun requestReindex() {
|
||||
fun requestReindex(withCache: Boolean) {
|
||||
logD("Requesting reindex")
|
||||
controller?.onStartIndexing()
|
||||
controller?.onStartIndexing(withCache)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Cancel" the last job by making it unable to send further state updates. This will cause the
|
||||
* worker operating the job for that specific handle to cancel as soon as it tries to send a
|
||||
* state update.
|
||||
* Reset the current loading state to signal that the instance is not loading. This should be
|
||||
* called by [Controller] after it's indexing co-routine was cancelled.
|
||||
*/
|
||||
@Synchronized
|
||||
fun cancelLast() {
|
||||
fun reset() {
|
||||
logD("Cancelling last job")
|
||||
val handle = guard.newHandle()
|
||||
emitIndexing(null, handle)
|
||||
emitIndexing(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the proper music loading process. [handle] must be a truthful handle of the task calling
|
||||
* this function.
|
||||
* Internal implementation of the music loading process.
|
||||
* @param context [Context] required to load music.
|
||||
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
|
||||
* be written, but no cache entries will be loaded into the new library.
|
||||
* @return A newly-loaded [MusicStore.Library], or null if nothing was loaded.
|
||||
*/
|
||||
private fun indexImpl(context: Context, handle: Long): MusicStore.Library? {
|
||||
emitIndexing(Indexing.Indeterminate, handle)
|
||||
|
||||
// Since we have different needs for each version, we determine a "Backend" to use
|
||||
// when loading music and then leverage that to create the initial song list.
|
||||
// This is technically dependency injection. Except it doesn't increase your compile
|
||||
// times by 3x. Isn't that nice.
|
||||
|
||||
val mediaStoreBackend =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreBackend()
|
||||
else -> Api21MediaStoreBackend()
|
||||
}
|
||||
|
||||
val settings = Settings(context)
|
||||
val backend =
|
||||
if (settings.useQualityTags) {
|
||||
ExoPlayerBackend(mediaStoreBackend)
|
||||
private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library? {
|
||||
// Create the chain of extractors. Each extractor builds on the previous and
|
||||
// enables version-specific features in order to create the best possible music
|
||||
// experience.
|
||||
val cacheDatabase =
|
||||
if (withCache) {
|
||||
ReadWriteCacheExtractor(context)
|
||||
} else {
|
||||
mediaStoreBackend
|
||||
WriteOnlyCacheExtractor(context)
|
||||
}
|
||||
|
||||
val songs = buildSongs(context, backend, handle)
|
||||
val mediaStoreExtractor =
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
||||
Api30MediaStoreExtractor(context, cacheDatabase)
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
|
||||
Api29MediaStoreExtractor(context, cacheDatabase)
|
||||
else -> Api21MediaStoreExtractor(context, cacheDatabase)
|
||||
}
|
||||
|
||||
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
|
||||
|
||||
val songs = buildSongs(metadataExtractor, Settings(context))
|
||||
if (songs.isEmpty()) {
|
||||
// No songs, nothing else to do.
|
||||
return null
|
||||
}
|
||||
|
||||
// Build the rest of the music library from the song list. This is much more powerful
|
||||
// and reliable compared to using MediaStore to obtain grouping information.
|
||||
val buildStart = System.currentTimeMillis()
|
||||
|
||||
val albums = buildAlbums(songs)
|
||||
val artists = buildArtists(albums)
|
||||
val artists = buildArtists(songs, albums)
|
||||
val genres = buildGenres(songs)
|
||||
|
||||
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
|
||||
for (song in songs) {
|
||||
if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
|
||||
error(
|
||||
"Found unlinked song: ${song.rawName} [" +
|
||||
"missing album: ${song._isMissingAlbum} " +
|
||||
"missing artist: ${song._isMissingArtist} " +
|
||||
"missing genre: ${song._isMissingGenre}]")
|
||||
}
|
||||
}
|
||||
|
||||
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
|
||||
|
||||
return MusicStore.Library(genres, artists, albums, songs)
|
||||
return MusicStore.Library(songs, albums, artists, genres)
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the initial query over the song database using [backend]. The songs returned by this
|
||||
* function are **not** well-formed. The companion [buildAlbums], [buildArtists], and
|
||||
* [buildGenres] functions must be called with the returned list so that all songs are properly
|
||||
* linked up.
|
||||
* Load a list of [Song]s from the device.
|
||||
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
|
||||
* instances.
|
||||
* @param settings [Settings] required to create [Song] instances.
|
||||
* @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked
|
||||
* with parent [Album], [Artist], and [Genre] items in order to be usable.
|
||||
*/
|
||||
private fun buildSongs(context: Context, backend: Backend, handle: Long): List<Song> {
|
||||
private suspend fun buildSongs(
|
||||
metadataExtractor: MetadataExtractor,
|
||||
settings: Settings
|
||||
): List<Song> {
|
||||
logD("Starting indexing process")
|
||||
val start = System.currentTimeMillis()
|
||||
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
|
||||
// how long a media database query will take.
|
||||
emitIndexing(Indexing.Indeterminate)
|
||||
val total = metadataExtractor.init()
|
||||
yield()
|
||||
|
||||
var songs =
|
||||
backend.query(context).use { cursor ->
|
||||
logD(
|
||||
"Successfully queried media database " +
|
||||
"in ${System.currentTimeMillis() - start}ms")
|
||||
// Note: We use a set here so we can eliminate song duplicates.
|
||||
val songs = mutableSetOf<Song>()
|
||||
val rawSongs = mutableListOf<Song.Raw>()
|
||||
metadataExtractor.parse { rawSong ->
|
||||
songs.add(Song(rawSong, settings))
|
||||
rawSongs.add(rawSong)
|
||||
|
||||
backend.buildSongs(context, cursor) { emitIndexing(it, handle) }
|
||||
}
|
||||
|
||||
// Deduplicate songs to prevent (most) deformed music clones
|
||||
songs =
|
||||
songs
|
||||
.distinctBy {
|
||||
it.rawName to
|
||||
it._albumName to
|
||||
it._artistName to
|
||||
it._albumArtistName to
|
||||
it._genreName to
|
||||
it.track to
|
||||
it.disc to
|
||||
it.durationMs
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
// Ensure that sorting order is consistent so that grouping is also consistent.
|
||||
Sort(Sort.Mode.ByName, true).songsInPlace(songs)
|
||||
|
||||
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
|
||||
|
||||
return songs
|
||||
}
|
||||
|
||||
/**
|
||||
* Group songs up into their respective albums. Instead of using the unreliable album or artist
|
||||
* databases, we instead group up songs by their *lowercase* artist and album name to create
|
||||
* albums. This serves two purposes:
|
||||
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
|
||||
* makes sure both of those are resolved into a single artist called "Rammstein"
|
||||
* 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
|
||||
* that all songs are unified under a single album.
|
||||
*
|
||||
* This does come with some costs, it's far slower than using the album ID itself, and it may
|
||||
* result in an unrelated album cover being selected depending on the song chosen as the
|
||||
* template, but it seems to work pretty well.
|
||||
*/
|
||||
private fun buildAlbums(songs: List<Song>): List<Album> {
|
||||
val albums = mutableListOf<Album>()
|
||||
val songsByAlbum = songs.groupBy { it._albumGroupingId }
|
||||
|
||||
for (entry in songsByAlbum) {
|
||||
val albumSongs = entry.value
|
||||
|
||||
// Use the song with the latest year as our metadata song.
|
||||
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
|
||||
// weird years like "0" wont show up if there are alternatives.
|
||||
val templateSong =
|
||||
albumSongs.maxWith(compareBy(Sort.Mode.NullableComparator.DATE) { it._date })
|
||||
|
||||
albums.add(
|
||||
Album(
|
||||
rawName = templateSong._albumName,
|
||||
rawSortName = templateSong._albumSortName,
|
||||
date = templateSong._date,
|
||||
releaseType = templateSong._albumReleaseType ?: ReleaseType.Album(null),
|
||||
coverUri = templateSong._albumCoverUri,
|
||||
songs = entry.value,
|
||||
_artistGroupingName = templateSong._artistGroupingName,
|
||||
_artistGroupingSortName = templateSong._artistGroupingSortName))
|
||||
// Now we can signal a defined progress by showing how many songs we have
|
||||
// loaded, and the projected amount of songs we found in the library
|
||||
// (obtained by the extractors)
|
||||
yield()
|
||||
emitIndexing(Indexing.Songs(songs.size, total))
|
||||
}
|
||||
|
||||
logD("Successfully built ${albums.size} albums")
|
||||
// Finalize the extractors with the songs we have now loaded. There is no ETA
|
||||
// on this process, so go back to an indeterminate state.
|
||||
emitIndexing(Indexing.Indeterminate)
|
||||
metadataExtractor.finalize(rawSongs)
|
||||
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
|
||||
|
||||
// Ensure that sorting order is consistent so that grouping is also consistent.
|
||||
// Rolling this into the set is not an option, as songs with the same sort result
|
||||
// would be lost.
|
||||
return Sort(Sort.Mode.ByName, true).songs(songs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of [Album]s from the given [Song]s.
|
||||
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
|
||||
* [Album]s when created.
|
||||
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
|
||||
* with parent [Artist] instances in order to be usable.
|
||||
*/
|
||||
private fun buildAlbums(songs: List<Song>): List<Album> {
|
||||
// Group songs by their singular raw album, then map the raw instances and their
|
||||
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
|
||||
val songsByAlbum = songs.groupBy { it._rawAlbum }
|
||||
val albums = songsByAlbum.map { Album(it.key, it.value) }
|
||||
logD("Successfully built ${albums.size} albums")
|
||||
return albums
|
||||
}
|
||||
|
||||
/**
|
||||
* Group up albums into artists. This also requires a de-duplication step due to some edge cases
|
||||
* where [buildAlbums] could not detect duplicates.
|
||||
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
|
||||
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by
|
||||
* artist names, and [Album]s being grouped primarily by album artist names.
|
||||
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
|
||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
||||
* created.
|
||||
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
|
||||
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
|
||||
* created.
|
||||
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
|
||||
* of [Song]s and [Album]s.
|
||||
*/
|
||||
private fun buildArtists(albums: List<Album>): List<Artist> {
|
||||
val artists = mutableListOf<Artist>()
|
||||
val albumsByArtist = albums.groupBy { it._artistGroupingId }
|
||||
private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
|
||||
// Add every raw artist credited to each Song/Album to the grouping. This way,
|
||||
// different multi-artist combinations are not treated as different artists.
|
||||
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
|
||||
|
||||
for (entry in albumsByArtist) {
|
||||
// The first album will suffice for template metadata.
|
||||
val templateAlbum = entry.value[0]
|
||||
artists.add(
|
||||
Artist(
|
||||
rawName = templateAlbum._artistGroupingName,
|
||||
rawSortName = templateAlbum._artistGroupingSortName,
|
||||
albums = entry.value))
|
||||
for (song in songs) {
|
||||
for (rawArtist in song._rawArtists) {
|
||||
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
|
||||
}
|
||||
}
|
||||
|
||||
logD("Successfully built ${artists.size} artists")
|
||||
for (album in albums) {
|
||||
for (rawArtist in album._rawArtists) {
|
||||
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the combined mapping into artist instances.
|
||||
val artists = musicByArtist.map { Artist(it.key, it.value) }
|
||||
logD("Successfully built ${artists.size} artists")
|
||||
return artists
|
||||
}
|
||||
|
||||
/**
|
||||
* Group up songs into genres. This is a relatively simple step compared to the other library
|
||||
* steps, as there is no demand to deduplicate genres by a lowercase name.
|
||||
* Group up [Song]s into [Genre] instances.
|
||||
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
|
||||
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
|
||||
* created.
|
||||
* @return A non-empty list of [Genre]s.
|
||||
*/
|
||||
private fun buildGenres(songs: List<Song>): List<Genre> {
|
||||
val genres = mutableListOf<Genre>()
|
||||
val songsByGenre = songs.groupBy { it._genreGroupingId }
|
||||
|
||||
for (entry in songsByGenre) {
|
||||
// The first song fill suffice for template metadata.
|
||||
val templateSong = entry.value[0]
|
||||
genres.add(Genre(rawName = templateSong._genreName, songs = entry.value))
|
||||
// Add every raw genre credited to each Song to the grouping. This way,
|
||||
// different multi-genre combinations are not treated as different genres.
|
||||
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
|
||||
for (song in songs) {
|
||||
for (rawGenre in song._rawGenres) {
|
||||
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the mapping into genre instances.
|
||||
val genres = songsByGenre.map { Genre(it.key, it.value) }
|
||||
logD("Successfully built ${genres.size} genres")
|
||||
|
||||
return genres
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a new [State.Indexing] state. This can be used to signal the current state of the music
|
||||
* loading process to external code. Assumes that the callee has already checked if they have
|
||||
* not been canceled and thus have the ability to emit a new state.
|
||||
* @param indexing The new [Indexing] state to emit, or null if no loading process is occurring.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun emitIndexing(indexing: Indexing?, handle: Long) {
|
||||
guard.yield(handle)
|
||||
|
||||
if (indexing == indexingState) {
|
||||
// Ignore redundant states used when the backends just want to check for
|
||||
// a cancellation
|
||||
return
|
||||
}
|
||||
|
||||
private fun emitIndexing(indexing: Indexing?) {
|
||||
indexingState = indexing
|
||||
|
||||
// If we have canceled the loading process, we want to revert to a previous completion
|
||||
// whenever possible to prevent state inconsistency.
|
||||
val state =
|
||||
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
|
||||
|
||||
controller?.onIndexerStateChanged(state)
|
||||
callback?.onIndexerStateChanged(state)
|
||||
}
|
||||
|
||||
private suspend fun emitCompletion(response: Response, handle: Long) {
|
||||
guard.yield(handle)
|
||||
|
||||
/**
|
||||
* Emit a new [State.Complete] state. This can be used to signal the completion of the music
|
||||
* loading process to external code. Will check if the callee has not been canceled and thus has
|
||||
* the ability to emit a new state
|
||||
* @param response The new [Response] to emit, representing the outcome of the music loading
|
||||
* process.
|
||||
*/
|
||||
private suspend fun emitCompletion(response: Response) {
|
||||
yield()
|
||||
// Swap to the Main thread so that downstream callbacks don't crash from being on
|
||||
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -401,39 +408,76 @@ class Indexer {
|
|||
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
|
||||
lastResponse = response
|
||||
indexingState = null
|
||||
|
||||
// Signal that the music loading process has been completed.
|
||||
val state = State.Complete(response)
|
||||
|
||||
controller?.onIndexerStateChanged(state)
|
||||
callback?.onIndexerStateChanged(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents the current indexer state. */
|
||||
/** Represents the current state of [Indexer]. */
|
||||
sealed class State {
|
||||
/**
|
||||
* Music loading is ongoing.
|
||||
* @param indexing The current music loading progress..
|
||||
* @see Indexer.Indexing
|
||||
*/
|
||||
data class Indexing(val indexing: Indexer.Indexing) : State()
|
||||
|
||||
/**
|
||||
* Music loading has completed.
|
||||
* @param response The outcome of the music loading process.
|
||||
* @see Response
|
||||
*/
|
||||
data class Complete(val response: Response) : State()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current progress of the music loader. Usually encapsulated in a [State].
|
||||
* @see State.Indexing
|
||||
*/
|
||||
sealed class Indexing {
|
||||
/**
|
||||
* Music loading is occurring, but no definite estimate can be put on the current progress.
|
||||
*/
|
||||
object Indeterminate : Indexing()
|
||||
|
||||
/**
|
||||
* Music loading has a definite progress.
|
||||
* @param current The current amount of songs that have been loaded.
|
||||
* @param total The projected total amount of songs that will be loaded.
|
||||
*/
|
||||
class Songs(val current: Int, val total: Int) : Indexing()
|
||||
}
|
||||
|
||||
/** Represents the possible outcomes of a loading process. */
|
||||
/** Represents the possible outcomes of the music loading process. */
|
||||
sealed class Response {
|
||||
/**
|
||||
* Music load was successful and produced a [MusicStore.Library].
|
||||
* @param library The loaded [MusicStore.Library].
|
||||
*/
|
||||
data class Ok(val library: MusicStore.Library) : Response()
|
||||
|
||||
/**
|
||||
* Music loading encountered an unexpected error.
|
||||
* @param throwable The error thrown.
|
||||
*/
|
||||
data class Err(val throwable: Throwable) : Response()
|
||||
|
||||
/** Music loading occurred, but resulted in no music. */
|
||||
object NoMusic : Response()
|
||||
|
||||
/** Music loading could not occur due to a lack of storage permissions. */
|
||||
object NoPerms : Response()
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback to use when the indexing state changes.
|
||||
* A listener for rapid-fire changes in the music loading state.
|
||||
*
|
||||
* This callback is low-level and not guaranteed to be single-thread. For that,
|
||||
* [MusicStore.Callback] is recommended instead.
|
||||
* This is only useful for code that absolutely must show the current loading process.
|
||||
* Otherwise, [MusicStore.Callback] is highly recommended due to it's updates only consisting of
|
||||
* the [MusicStore.Library].
|
||||
*/
|
||||
interface Callback {
|
||||
/**
|
||||
|
|
@ -447,38 +491,43 @@ class Indexer {
|
|||
fun onIndexerStateChanged(state: State?)
|
||||
}
|
||||
|
||||
/**
|
||||
* Context that runs the music loading process. Implementations should be capable of running the
|
||||
* background for long periods of time without android killing the process.
|
||||
*/
|
||||
interface Controller : Callback {
|
||||
fun onStartIndexing()
|
||||
}
|
||||
|
||||
/** Represents a backend that metadata can be extracted from. */
|
||||
interface Backend {
|
||||
/** Query the media database for a basic cursor. */
|
||||
fun query(context: Context): Cursor
|
||||
|
||||
/** Create a list of songs from the [Cursor] queried in [query]. */
|
||||
fun buildSongs(
|
||||
context: Context,
|
||||
cursor: Cursor,
|
||||
emitIndexing: (Indexing) -> Unit
|
||||
): List<Song>
|
||||
/**
|
||||
* Called when a new music loading process was requested. Implementations should forward
|
||||
* this to [index].
|
||||
* @param withCache Whether to use the cache or not when loading. If false, the cache should
|
||||
* still be written, but no cache entries will be loaded into the new library.
|
||||
* @see index
|
||||
*/
|
||||
fun onStartIndexing(withCache: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: Indexer? = null
|
||||
|
||||
/**
|
||||
* A version-compatible identifier for the read external storage permission required by the
|
||||
* system to load audio.
|
||||
*/
|
||||
val PERMISSION_READ_AUDIO =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// TODO: Move elsewhere.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
|
||||
Manifest.permission.READ_MEDIA_AUDIO
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
/** Get the process-level instance of [Indexer]. */
|
||||
/**
|
||||
* Get a singleton instance.
|
||||
* @return The (possibly newly-created) singleton instance.
|
||||
*/
|
||||
fun getInstance(): Indexer {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
if (currentInstance != null) {
|
||||
return currentInstance
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,24 @@
|
|||
package org.oxycblt.auxio.music.system
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.system.ServiceNotification
|
||||
import org.oxycblt.auxio.service.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
||||
/** The notification responsible for showing the indexer state. */
|
||||
/**
|
||||
* A dynamic [ForegroundServiceNotification] that shows the current music loading state.
|
||||
* @param context [Context] required to create the notification.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class IndexingNotification(private val context: Context) :
|
||||
ServiceNotification(context, INDEXER_CHANNEL) {
|
||||
ForegroundServiceNotification(context, INDEXER_CHANNEL) {
|
||||
private var lastUpdateTime = -1L
|
||||
|
||||
init {
|
||||
setSmallIcon(R.drawable.ic_indexer_24)
|
||||
setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
|
|
@ -44,32 +51,50 @@ class IndexingNotification(private val context: Context) :
|
|||
override val code: Int
|
||||
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
|
||||
|
||||
/**
|
||||
* Update this notification with the new music loading state.
|
||||
* @param indexing The new music loading state to display in the notification.
|
||||
* @return true if the notification updated, false otherwise
|
||||
*/
|
||||
fun updateIndexingState(indexing: Indexer.Indexing): Boolean {
|
||||
when (indexing) {
|
||||
is Indexer.Indexing.Indeterminate -> {
|
||||
// Indeterminate state, use a vaguer description and in-determinate progress.
|
||||
// These events are not very frequent, and thus we don't need to safeguard
|
||||
// against rate limiting.
|
||||
logD("Updating state to $indexing")
|
||||
lastUpdateTime = -1
|
||||
setContentText(context.getString(R.string.lng_indexing))
|
||||
setProgress(0, 0, true)
|
||||
return true
|
||||
}
|
||||
is Indexer.Indexing.Songs -> {
|
||||
// Only update the notification every 50 songs to prevent excessive updates.
|
||||
if (indexing.current % 50 == 0) {
|
||||
logD("Updating state to $indexing")
|
||||
setContentText(
|
||||
context.getString(R.string.fmt_indexing, indexing.current, indexing.total))
|
||||
setProgress(indexing.total, indexing.current, false)
|
||||
return true
|
||||
// Determinate state, show an active progress meter. Since these updates arrive
|
||||
// highly rapidly, only update every 1.5 seconds to prevent notification rate
|
||||
// limiting.
|
||||
// TODO: Can I port this to the playback notification somehow?
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
|
||||
return false
|
||||
}
|
||||
lastUpdateTime = SystemClock.elapsedRealtime()
|
||||
logD("Updating state to $indexing")
|
||||
setContentText(
|
||||
context.getString(R.string.fmt_indexing, indexing.current, indexing.total))
|
||||
setProgress(indexing.total, indexing.current, false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The notification responsible for showing the indexer state. */
|
||||
class ObservingNotification(context: Context) : ServiceNotification(context, INDEXER_CHANNEL) {
|
||||
/**
|
||||
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
|
||||
* monitoring the music library for changes.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ObservingNotification(context: Context) :
|
||||
ForegroundServiceNotification(context, INDEXER_CHANNEL) {
|
||||
init {
|
||||
setSmallIcon(R.drawable.ic_indexer_24)
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
|
|
@ -85,6 +110,7 @@ class ObservingNotification(context: Context) : ServiceNotification(context, IND
|
|||
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
|
||||
}
|
||||
|
||||
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
|
||||
private val INDEXER_CHANNEL =
|
||||
ServiceNotification.ChannelInfo(
|
||||
ForegroundServiceNotification.ChannelInfo(
|
||||
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ package org.oxycblt.auxio.music.system
|
|||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.database.ContentObserver
|
||||
import android.os.*
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.MediaStore
|
||||
import coil.imageLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -30,60 +33,61 @@ import kotlinx.coroutines.launch
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.service.ForegroundManager
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.system.ForegroundManager
|
||||
import org.oxycblt.auxio.util.contentResolverSafe
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/**
|
||||
* A [Service] that handles the music loading process.
|
||||
* A [Service] that manages the background music loading process.
|
||||
*
|
||||
* Loading music is actually somewhat time-consuming, to the point where it's likely better suited
|
||||
* to a service that is less likely to be killed by the OS.
|
||||
* Loading music is a time-consuming process that would likely be killed by the system before it
|
||||
* could complete if ran anywhere else. So, this [Service] manages the music loading process as an
|
||||
* instance of [Indexer.Controller].
|
||||
*
|
||||
* You could probably do the same using WorkManager and the GooberQueue library or whatever, but the
|
||||
* boilerplate you skip is not worth the insanity of androidx.
|
||||
* This [Service] also handles automatic rescanning, as that is a similarly long-running background
|
||||
* operation that would be unsuitable elsewhere in the app.
|
||||
*
|
||||
* @author OxygenCobalt
|
||||
* TODO: Unify with PlaybackService as part of the service independence project
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
||||
private val indexer = Indexer.getInstance()
|
||||
private val musicStore = MusicStore.getInstance()
|
||||
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val serviceJob = Job()
|
||||
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
|
||||
private var currentIndexJob: Job? = null
|
||||
private lateinit var foregroundManager: ForegroundManager
|
||||
private lateinit var indexingNotification: IndexingNotification
|
||||
private lateinit var observingNotification: ObservingNotification
|
||||
|
||||
private lateinit var settings: Settings
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
private lateinit var indexerContentObserver: SystemContentObserver
|
||||
private lateinit var settings: Settings
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize the core service components first.
|
||||
foregroundManager = ForegroundManager(this)
|
||||
indexingNotification = IndexingNotification(this)
|
||||
observingNotification = ObservingNotification(this)
|
||||
|
||||
wakeLock =
|
||||
getSystemServiceCompat(PowerManager::class)
|
||||
.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
|
||||
|
||||
settings = Settings(this, this)
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
indexerContentObserver = SystemContentObserver()
|
||||
|
||||
settings = Settings(this, this)
|
||||
indexer.registerController(this)
|
||||
// An indeterminate indexer and a missing library implies we are extremely early
|
||||
// in app initialization so start loading music.
|
||||
if (musicStore.library == null && indexer.isIndeterminate) {
|
||||
logD("No library present and no previous response, indexing music now")
|
||||
onStartIndexing()
|
||||
onStartIndexing(true)
|
||||
}
|
||||
|
||||
logD("Service created.")
|
||||
|
|
@ -95,28 +99,29 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
// De-initialize core service components first.
|
||||
foregroundManager.release()
|
||||
wakeLock.releaseSafe()
|
||||
|
||||
// De-initialize the components first to prevent stray reloading events
|
||||
settings.release()
|
||||
// Then cancel the listener-dependent components to ensure that stray reloading
|
||||
// events will not occur.
|
||||
indexerContentObserver.release()
|
||||
settings.release()
|
||||
indexer.unregisterController(this)
|
||||
|
||||
// Then cancel the other components.
|
||||
indexer.cancelLast()
|
||||
// Then cancel any remaining music loading jobs.
|
||||
serviceJob.cancel()
|
||||
indexer.reset()
|
||||
}
|
||||
|
||||
// --- CONTROLLER CALLBACKS ---
|
||||
|
||||
override fun onStartIndexing() {
|
||||
override fun onStartIndexing(withCache: Boolean) {
|
||||
if (indexer.isIndexing) {
|
||||
indexer.cancelLast()
|
||||
// Cancel the previous music loading job.
|
||||
currentIndexJob?.cancel()
|
||||
indexer.reset()
|
||||
}
|
||||
|
||||
indexScope.launch { indexer.index(this@IndexerService) }
|
||||
// Start a new music loading job on a co-routine.
|
||||
currentIndexJob = indexScope.launch { indexer.index(this@IndexerService, withCache) }
|
||||
}
|
||||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
|
|
@ -125,28 +130,23 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
if (state.response is Indexer.Response.Ok &&
|
||||
state.response.library != musicStore.library) {
|
||||
logD("Applying new library")
|
||||
|
||||
val newLibrary = state.response.library
|
||||
|
||||
// We only care if the newly-loaded library is going to replace a previously
|
||||
// loaded library.
|
||||
if (musicStore.library != null) {
|
||||
// This is a new library to replace an existing one.
|
||||
|
||||
// Wipe possibly-invalidated album covers
|
||||
// Wipe possibly-invalidated outdated covers
|
||||
imageLoader.memoryCache?.clear()
|
||||
|
||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||
// to a callback as it is bad practice for a shared object to attach to
|
||||
// the callback system of another.
|
||||
// to a listener as it is bad practice for a shared object to attach to
|
||||
// the listener system of another.
|
||||
playbackManager.sanitize(newLibrary)
|
||||
}
|
||||
|
||||
musicStore.updateLibrary(newLibrary)
|
||||
// Forward the new library to MusicStore to continue the update process.
|
||||
musicStore.library = newLibrary
|
||||
}
|
||||
|
||||
// On errors, while we would want to show a notification that displays the
|
||||
// error, in practice that comes into conflict with the upcoming Android 13
|
||||
// notification permission, and there is no point implementing permission
|
||||
// on-boarding for such when it will only be used for this.
|
||||
// error, that requires the Android 13 notification permission, which is not
|
||||
// handled right now.
|
||||
updateIdleSession()
|
||||
}
|
||||
is Indexer.State.Indexing -> {
|
||||
|
|
@ -163,21 +163,29 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
|
||||
// --- INTERNAL ---
|
||||
|
||||
/**
|
||||
* Update the current state to "Active", in which the service signals that music loading is
|
||||
* on-going.
|
||||
* @param state The current music loading state.
|
||||
*/
|
||||
private fun updateActiveSession(state: Indexer.Indexing) {
|
||||
// When loading, we want to enter the foreground state so that android does
|
||||
// not shut off the loading process. Note that while we will always post the
|
||||
// notification when initially starting, we will not update the notification
|
||||
// unless it indicates that we have changed it.
|
||||
// unless it indicates that it has changed.
|
||||
val changed = indexingNotification.updateIndexingState(state)
|
||||
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
|
||||
logD("Notification changed, re-posting notification")
|
||||
indexingNotification.post()
|
||||
}
|
||||
|
||||
// Make sure we can keep the CPU on while loading music
|
||||
wakeLock.acquireSafe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current state to "Idle", in which it either does nothing or signals that it's
|
||||
* currently monitoring the music library for changes.
|
||||
*/
|
||||
private fun updateIdleSession() {
|
||||
if (settings.shouldBeObserving) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
|
|
@ -186,27 +194,34 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
// we can go foreground later.
|
||||
// 2. If a non-foreground service is killed, the app will probably still be alive,
|
||||
// and thus the music library will not be updated at all.
|
||||
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
|
||||
// this anymore.
|
||||
if (!foregroundManager.tryStartForeground(observingNotification)) {
|
||||
observingNotification.post()
|
||||
}
|
||||
} else {
|
||||
// Not observing and done loading, exit foreground.
|
||||
foregroundManager.tryStopForeground()
|
||||
}
|
||||
|
||||
// Release our wake lock (if we were using it)
|
||||
wakeLock.releaseSafe()
|
||||
}
|
||||
|
||||
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.acquireSafe() {
|
||||
// Avoid unnecessary acquire calls.
|
||||
if (!wakeLock.isHeld) {
|
||||
logD("Acquiring wake lock")
|
||||
|
||||
// We always drop the wakelock eventually. Timeout is not needed.
|
||||
@Suppress("WakelockTimeout") acquire()
|
||||
// Time out after a minute, which is the average music loading time for a medium-sized
|
||||
// library. If this runs out, we will re-request the lock, and if music loading is
|
||||
// shorter than the timeout, it will be released early.
|
||||
acquire(WAKELOCK_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
|
||||
private fun PowerManager.WakeLock.releaseSafe() {
|
||||
// Avoid unnecessary release calls.
|
||||
if (wakeLock.isHeld) {
|
||||
logD("Releasing wake lock")
|
||||
release()
|
||||
|
|
@ -217,10 +232,16 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
|
||||
override fun onSettingChanged(key: String) {
|
||||
when (key) {
|
||||
// Hook changes in music settings to a new music loading event.
|
||||
getString(R.string.set_key_exclude_non_music),
|
||||
getString(R.string.set_key_music_dirs),
|
||||
getString(R.string.set_key_music_dirs_include),
|
||||
getString(R.string.set_key_quality_tags) -> onStartIndexing()
|
||||
getString(R.string.set_key_separators) -> onStartIndexing(true)
|
||||
getString(R.string.set_key_observing) -> {
|
||||
// Make sure we don't override the service state with the observing
|
||||
// notification if we were actively loading when the automatic rescanning
|
||||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (!indexer.isIndexing) {
|
||||
updateIdleSession()
|
||||
}
|
||||
|
|
@ -228,35 +249,46 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
|
|||
}
|
||||
}
|
||||
|
||||
/** Internal content observer intended to work with the automatic reloading system. */
|
||||
private inner class SystemContentObserver(
|
||||
private val handler: Handler = Handler(Looper.getMainLooper())
|
||||
) : ContentObserver(handler), Runnable {
|
||||
/**
|
||||
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
|
||||
* known to the user as automatic rescanning. The active (and not passive) nature of observing
|
||||
* the database is what requires [IndexerService] to stay foreground when this is enabled.
|
||||
*/
|
||||
private inner class SystemContentObserver :
|
||||
ContentObserver(Handler(Looper.getMainLooper())), Runnable {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
contentResolverSafe.registerContentObserver(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release this instance, preventing it from further observing the database and cancelling
|
||||
* any pending update events.
|
||||
*/
|
||||
fun release() {
|
||||
handler.removeCallbacks(this)
|
||||
contentResolverSafe.unregisterContentObserver(this)
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
// Batch rapid-fire updates to the library into a single call to run after 500ms
|
||||
handler.removeCallbacks(this)
|
||||
handler.postDelayed(this, REINDEX_DELAY)
|
||||
handler.postDelayed(this, REINDEX_DELAY_MS)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
// Check here if we should even start a reindex. This is much less bug-prone than
|
||||
// registering and de-registering this component as this setting changes.
|
||||
if (settings.shouldBeObserving) {
|
||||
onStartIndexing()
|
||||
onStartIndexing(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REINDEX_DELAY = 500L
|
||||
private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
|
||||
private const val REINDEX_DELAY_MS = 500L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,577 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.system
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.storage.StorageManager
|
||||
import android.os.storage.StorageVolume
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.contentResolverSafe
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
||||
/*
|
||||
* This file acts as the base for most the black magic required to get a remotely sensible music
|
||||
* indexing system while still optimizing for time. I would recommend you leave this file now
|
||||
* before you lose your sanity trying to understand the hoops I had to jump through for this system,
|
||||
* but if you really want to stay, here's a debrief on why this code is so awful.
|
||||
*
|
||||
* MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to
|
||||
* other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime
|
||||
* against humanity and probably a way to summon Zalgo if you look at it the wrong way.
|
||||
*
|
||||
* You think that if you wanted to query a song's genre from a media database, you could just put
|
||||
* "genre" in the query and it would return it, right? But not with MediaStore! No, that's too
|
||||
* straightforward for this contract that was dropped on it's head as a baby. So instead, you have
|
||||
* to query for each genre, query all the songs in each genre, and then iterate through those songs
|
||||
* to link every song with their genre. This is not documented anywhere, and the O(mom im scared)
|
||||
* algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no
|
||||
* point have the devs considered that this system is absolutely insane, and instead focused on
|
||||
* adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play
|
||||
* Music, and of course every Google Play Music user knew how great that turned out!
|
||||
*
|
||||
* It's not even ergonomics that makes this API bad. It's base implementation is completely borked
|
||||
* as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? I
|
||||
* sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see that
|
||||
* the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or DATE tag.
|
||||
* Once again, this is because internally android uses an ancient in-house metadata parser to get
|
||||
* everything indexed, and so far they have not bothered to modernize this parser or even switch it
|
||||
* to something that actually works, not even in Android 12. ID3v2.4 has been around for *21
|
||||
* years.* *It can drink now.*
|
||||
*
|
||||
* Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums
|
||||
* table, so we have to go for the less efficient "make a big query on all the songs lol" method so
|
||||
* that songs don't end up fragmented across artists. Pretty much every OEM has added some extension
|
||||
* or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the
|
||||
* normal tables so that you're railroaded into their music app. I have to use a semi-deprecated
|
||||
* field to work with file paths, and the supposedly "modern" method is SLOWER and causes even more
|
||||
* problems since some devices just don't expose those fields for some insane reason. Sometimes
|
||||
* music will have a deformed clone that I can't filter out, sometimes Genres will just break for
|
||||
* no reason, and sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to
|
||||
* Latin-1 to *Shift JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY
|
||||
*
|
||||
* Is there anything we can do about it? No. Google has routinely shut down issues that begged
|
||||
* google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
|
||||
* shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
|
||||
* listening is. As a result, Auxio exposes an option to use an internal parser based on ExoPlayer
|
||||
* that at least tries to correct the insane metadata that this API returns, but not only is that
|
||||
* system horrifically slow and bug-prone, it also faces the even larger issue of how google keeps
|
||||
* trying to kill the filesystem and force you into their ContentResolver API. In the future
|
||||
* MediaStore could be the only system we have, which is also the day that greenland melts and
|
||||
* birthdays stop happening forever.
|
||||
*
|
||||
* I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
|
||||
* probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
|
||||
* Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
|
||||
* to your AlgoPop StreamMix™.
|
||||
*
|
||||
* I wish I was born in the neolithic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a [Indexer.Backend] that loads music from the media database ([MediaStore]). This is
|
||||
* not a fully-featured class by itself, and it's API-specific derivatives should be used instead.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
abstract class MediaStoreBackend : Indexer.Backend {
|
||||
private var idIndex = -1
|
||||
private var titleIndex = -1
|
||||
private var displayNameIndex = -1
|
||||
private var mimeTypeIndex = -1
|
||||
private var sizeIndex = -1
|
||||
private var dateAddedIndex = -1
|
||||
private var durationIndex = -1
|
||||
private var yearIndex = -1
|
||||
private var albumIndex = -1
|
||||
private var albumIdIndex = -1
|
||||
private var artistIndex = -1
|
||||
private var albumArtistIndex = -1
|
||||
|
||||
protected val volumes = mutableListOf<StorageVolume>()
|
||||
|
||||
override fun query(context: Context): Cursor {
|
||||
val settings = Settings(context)
|
||||
val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||
volumes.addAll(storageManager.storageVolumesCompat)
|
||||
val dirs = settings.getMusicDirs(storageManager)
|
||||
|
||||
val args = mutableListOf<String>()
|
||||
var selector = BASE_SELECTOR
|
||||
|
||||
if (dirs.dirs.isNotEmpty()) {
|
||||
// Need to select for directories. The path query is the same, only difference is
|
||||
// the presence of a NOT.
|
||||
selector +=
|
||||
if (dirs.shouldInclude) {
|
||||
logD("Need to select dirs (Include)")
|
||||
" AND ("
|
||||
} else {
|
||||
logD("Need to select dirs (Exclude)")
|
||||
" AND NOT ("
|
||||
}
|
||||
|
||||
// Each impl adds the directories that they want selected.
|
||||
for (i in dirs.dirs.indices) {
|
||||
if (addDirToSelectorArgs(dirs.dirs[i], args)) {
|
||||
selector +=
|
||||
if (i < dirs.dirs.lastIndex) {
|
||||
"$dirSelector OR "
|
||||
} else {
|
||||
dirSelector
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selector += ')'
|
||||
}
|
||||
|
||||
logD("Starting query [proj: ${projection.toList()}, selector: $selector, args: $args]")
|
||||
|
||||
return requireNotNull(
|
||||
context.contentResolverSafe.queryCursor(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selector,
|
||||
args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
|
||||
}
|
||||
|
||||
override fun buildSongs(
|
||||
context: Context,
|
||||
cursor: Cursor,
|
||||
emitIndexing: (Indexer.Indexing) -> Unit
|
||||
): List<Song> {
|
||||
val audios = mutableListOf<Audio>()
|
||||
while (cursor.moveToNext()) {
|
||||
audios.add(buildAudio(context, cursor))
|
||||
if (cursor.position % 50 == 0) {
|
||||
// Only check for a cancellation every 50 songs or so (~20ms).
|
||||
// While this seems redundant, each call to emitIndexing checks for a
|
||||
// cancellation of the co-routine this loading task is running on.
|
||||
emitIndexing(Indexer.Indexing.Indeterminate)
|
||||
}
|
||||
}
|
||||
|
||||
// The audio is not actually complete at this point, as we cannot obtain a genre
|
||||
// through a song query. Instead, we have to do the hack where we iterate through
|
||||
// every genre and assign it's name to audios that match it's child ID.
|
||||
context.contentResolverSafe.useQuery(
|
||||
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
|
||||
val idIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
|
||||
val nameIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
|
||||
|
||||
while (genreCursor.moveToNext()) {
|
||||
// Genre names could theoretically be anything, including null for some reason.
|
||||
// Null values are junk and should be ignored, but since we cannot assume the
|
||||
// format a genre was derived from, we have to treat them like they are ID3
|
||||
// genres, even when they might not be.
|
||||
val id = genreCursor.getLong(idIndex)
|
||||
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).parseId3GenreName()
|
||||
|
||||
context.contentResolverSafe.useQuery(
|
||||
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
|
||||
arrayOf(MediaStore.Audio.Genres.Members._ID)) { cursor ->
|
||||
val songIdIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val songId = cursor.getLong(songIdIndex)
|
||||
audios.find { it.id == songId }?.let { song -> song.genre = name }
|
||||
|
||||
if (cursor.position % 50 == 0) {
|
||||
// Only check for a cancellation every 50 songs or so (~20ms).
|
||||
emitIndexing(Indexer.Indexing.Indeterminate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a cancellation every time we finish a genre too, in the case that
|
||||
// the genre has <50 songs.
|
||||
emitIndexing(Indexer.Indexing.Indeterminate)
|
||||
}
|
||||
|
||||
return audios.map { it.toSong() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection to use when querying media. Add version-specific columns here in an
|
||||
* implementation.
|
||||
*/
|
||||
open val projection: Array<String>
|
||||
get() =
|
||||
arrayOf(
|
||||
// These columns are guaranteed to work on all versions of android
|
||||
MediaStore.Audio.AudioColumns._ID,
|
||||
MediaStore.Audio.AudioColumns.TITLE,
|
||||
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
||||
MediaStore.Audio.AudioColumns.MIME_TYPE,
|
||||
MediaStore.Audio.AudioColumns.SIZE,
|
||||
MediaStore.Audio.AudioColumns.DATE_ADDED,
|
||||
MediaStore.Audio.AudioColumns.DURATION,
|
||||
MediaStore.Audio.AudioColumns.YEAR,
|
||||
MediaStore.Audio.AudioColumns.ALBUM,
|
||||
MediaStore.Audio.AudioColumns.ALBUM_ID,
|
||||
MediaStore.Audio.AudioColumns.ARTIST,
|
||||
AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
|
||||
abstract val dirSelector: String
|
||||
abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean
|
||||
|
||||
/**
|
||||
* Build an [Audio] based on the current cursor values. Each implementation should try to obtain
|
||||
* an upstream [Audio] first, and then populate it with version-specific fields outlined in
|
||||
* [projection].
|
||||
*/
|
||||
open fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
// Initialize our cursor indices if we haven't already.
|
||||
if (idIndex == -1) {
|
||||
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
|
||||
displayNameIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||
mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
|
||||
sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
|
||||
dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
|
||||
durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
||||
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
|
||||
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
|
||||
albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
|
||||
artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
||||
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
||||
}
|
||||
|
||||
val audio = Audio()
|
||||
|
||||
audio.id = cursor.getLong(idIndex)
|
||||
audio.title = cursor.getString(titleIndex)
|
||||
|
||||
audio.extensionMimeType = cursor.getString(mimeTypeIndex)
|
||||
audio.size = cursor.getLong(sizeIndex)
|
||||
audio.dateAdded = cursor.getLong(dateAddedIndex)
|
||||
|
||||
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
|
||||
// from the android system.
|
||||
audio.displayName = cursor.getStringOrNull(displayNameIndex)
|
||||
|
||||
audio.duration = cursor.getLong(durationIndex)
|
||||
audio.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
|
||||
|
||||
// A non-existent album name should theoretically be the name of the folder it contained
|
||||
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
|
||||
// file is not actually in the root internal storage directory. We can't do anything to
|
||||
// fix this, really.
|
||||
audio.album = cursor.getString(albumIndex)
|
||||
audio.albumId = cursor.getLong(albumIdIndex)
|
||||
|
||||
// Android does not make a non-existent artist tag null, it instead fills it in
|
||||
// as <unknown>, which makes absolutely no sense given how other fields default
|
||||
// to null if they are not present. If this field is <unknown>, null it so that
|
||||
// it's easier to handle later.
|
||||
audio.artist =
|
||||
cursor.getString(artistIndex).run {
|
||||
if (this != MediaStore.UNKNOWN_STRING) this else null
|
||||
}
|
||||
|
||||
// The album artist field is nullable and never has placeholder values.
|
||||
audio.albumArtist = cursor.getStringOrNull(albumArtistIndex)
|
||||
|
||||
return audio
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a song as it is represented by MediaStore. This is progressively mutated in the
|
||||
* chain of Backend instances until it is complete enough to be transformed into an immutable
|
||||
* song.
|
||||
*/
|
||||
data class Audio(
|
||||
var id: Long? = null,
|
||||
var title: String? = null,
|
||||
var sortTitle: String? = null,
|
||||
var displayName: String? = null,
|
||||
var dir: Directory? = null,
|
||||
var extensionMimeType: String? = null,
|
||||
var formatMimeType: String? = null,
|
||||
var size: Long? = null,
|
||||
var dateAdded: Long? = null,
|
||||
var duration: Long? = null,
|
||||
var track: Int? = null,
|
||||
var disc: Int? = null,
|
||||
var date: Date? = null,
|
||||
var albumId: Long? = null,
|
||||
var album: String? = null,
|
||||
var sortAlbum: String? = null,
|
||||
var releaseType: ReleaseType? = null,
|
||||
var artist: String? = null,
|
||||
var sortArtist: String? = null,
|
||||
var albumArtist: String? = null,
|
||||
var sortAlbumArtist: String? = null,
|
||||
var genre: String? = null
|
||||
) {
|
||||
fun toSong() =
|
||||
Song(
|
||||
// Assert that the fields that should always exist are present. I can't confirm
|
||||
// that every device provides these fields, but it seems likely that they do.
|
||||
rawName = requireNotNull(title) { "Malformed audio: No title" },
|
||||
rawSortName = sortTitle,
|
||||
path =
|
||||
Path(
|
||||
name = requireNotNull(displayName) { "Malformed audio: No display name" },
|
||||
parent = requireNotNull(dir) { "Malformed audio: No parent directory" }),
|
||||
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
|
||||
mimeType =
|
||||
MimeType(
|
||||
fromExtension =
|
||||
requireNotNull(extensionMimeType) { "Malformed audio: No mime type" },
|
||||
fromFormat = formatMimeType),
|
||||
size = requireNotNull(size) { "Malformed audio: No size" },
|
||||
dateAdded = requireNotNull(dateAdded) { "Malformed audio: No date added" },
|
||||
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
|
||||
track = track,
|
||||
disc = disc,
|
||||
_date = date,
|
||||
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
|
||||
_albumSortName = sortAlbum,
|
||||
_albumReleaseType = releaseType,
|
||||
_albumCoverUri =
|
||||
requireNotNull(albumId) { "Malformed audio: No album id" }.albumCoverUri,
|
||||
_artistName = artist,
|
||||
_artistSortName = sortArtist,
|
||||
_albumArtistName = albumArtist,
|
||||
_albumArtistSortName = sortAlbumArtist,
|
||||
_genreName = genre)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The album_artist MediaStore field has existed since at least API 21, but until API 30 it
|
||||
* was a proprietary extension for Google Play Music and was not documented. Since this
|
||||
* field probably works on all versions Auxio supports, we suppress the warning about using
|
||||
* a possibly-unsupported constant.
|
||||
*/
|
||||
@Suppress("InlinedApi")
|
||||
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
|
||||
|
||||
/**
|
||||
* External has existed since at least API 21, but no constant existed for it until API 29.
|
||||
* This constant is safe to use.
|
||||
*/
|
||||
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
|
||||
|
||||
/**
|
||||
* The base selector that works across all versions of android. Does not exclude
|
||||
* directories.
|
||||
*/
|
||||
private const val BASE_SELECTOR =
|
||||
"${MediaStore.Audio.Media.IS_MUSIC}=1 " + "AND NOT ${MediaStore.Audio.Media.SIZE}=0"
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The separation between version-specific backends may not be the cleanest. To preserve
|
||||
// speed, we only want to add redundancy on known issues, not with possible issues.
|
||||
|
||||
/**
|
||||
* A [MediaStoreBackend] that completes the music loading process in a way compatible from
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class Api21MediaStoreBackend : MediaStoreBackend() {
|
||||
private var trackIndex = -1
|
||||
private var dataIndex = -1
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA)
|
||||
|
||||
override val dirSelector: String
|
||||
get() = "${MediaStore.Audio.Media.DATA} LIKE ?"
|
||||
|
||||
override fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean {
|
||||
// Generate an equivalent DATA value from the volume directory and the relative path.
|
||||
args.add("${dir.volume.directoryCompat ?: return false}/${dir.relativePath}%")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
|
||||
// Initialize our indices if we have not already.
|
||||
if (trackIndex == -1) {
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
||||
}
|
||||
|
||||
val data = cursor.getString(dataIndex)
|
||||
|
||||
// On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
|
||||
// that this only applies to below API 29, as beyond API 29, this field not being
|
||||
// present would completely break the scoped storage system. Fill it in with DATA
|
||||
// if it's not available.
|
||||
if (audio.displayName == null) {
|
||||
audio.displayName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
|
||||
}
|
||||
|
||||
// Find the volume that transforms the DATA field into a relative path. This is
|
||||
// the volume and relative path we will use.
|
||||
val rawPath = data.substringBeforeLast(File.separatorChar)
|
||||
for (volume in volumes) {
|
||||
val volumePath = volume.directoryCompat ?: continue
|
||||
val strippedPath = rawPath.removePrefix(volumePath)
|
||||
if (strippedPath != rawPath) {
|
||||
audio.dir = Directory.from(volume, strippedPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val rawTrack = cursor.getIntOrNull(trackIndex)
|
||||
if (rawTrack != null) {
|
||||
rawTrack.unpackTrackNo()?.let { audio.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { audio.disc = it }
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MediaStoreBackend] that selects directories and builds paths using the modern volume fields
|
||||
* available from API 29 onwards.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
open class BaseApi29MediaStoreBackend : MediaStoreBackend() {
|
||||
private var volumeIndex = -1
|
||||
private var relativePathIndex = -1
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(
|
||||
MediaStore.Audio.AudioColumns.VOLUME_NAME,
|
||||
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||
|
||||
override val dirSelector: String
|
||||
get() =
|
||||
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
||||
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
||||
|
||||
override fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean {
|
||||
// Leverage new the volume field when selecting our directories.
|
||||
args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false)
|
||||
args.add("${dir.relativePath}%")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
|
||||
if (volumeIndex == -1) {
|
||||
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
|
||||
relativePathIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||
}
|
||||
|
||||
val volumeName = cursor.getString(volumeIndex)
|
||||
val relativePath = cursor.getString(relativePathIndex)
|
||||
|
||||
// Find the StorageVolume whose MediaStore name corresponds to this song.
|
||||
// This is what we use for the Directory's volume.
|
||||
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
|
||||
if (volume != null) {
|
||||
audio.dir = Directory.from(volume, relativePath)
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MediaStoreBackend] that completes the music loading process in a way compatible with at least
|
||||
* API 29.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
open class Api29MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
||||
private var trackIndex = -1
|
||||
|
||||
override val projection: Array<String>
|
||||
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
|
||||
if (trackIndex == -1) {
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
}
|
||||
|
||||
// This backend is volume-aware, but does not support the modern track fields.
|
||||
// Use the old field instead.
|
||||
val rawTrack = cursor.getIntOrNull(trackIndex)
|
||||
if (rawTrack != null) {
|
||||
rawTrack.unpackTrackNo()?.let { audio.track = it }
|
||||
rawTrack.unpackDiscNo()?.let { audio.disc = it }
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MediaStoreBackend] that completes the music loading process in a way compatible with at least
|
||||
* API 30.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class Api30MediaStoreBackend : BaseApi29MediaStoreBackend() {
|
||||
private var trackIndex: Int = -1
|
||||
private var discIndex: Int = -1
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(
|
||||
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
|
||||
MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
|
||||
// Populate our indices if we have not already.
|
||||
if (trackIndex == -1) {
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
|
||||
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||
}
|
||||
|
||||
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
|
||||
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
|
||||
// N is the number and T is the total. Parse the number while leaving out the
|
||||
// total, as we have no use for it.
|
||||
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { audio.track = it }
|
||||
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { audio.disc = it }
|
||||
|
||||
return audio
|
||||
}
|
||||
}
|
||||
62
app/src/main/java/org/oxycblt/auxio/playback/ActionMode.kt
Normal file
62
app/src/main/java/org/oxycblt/auxio/playback/ActionMode.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.playback
|
||||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
|
||||
/**
|
||||
* Represents a configuration option for what kind of "secondary" action to show in a particular UI
|
||||
* context.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class ActionMode {
|
||||
/** Use a "Skip next" button for the secondary action. */
|
||||
NEXT,
|
||||
/** Use a repeat mode button for the secondary action. */
|
||||
REPEAT,
|
||||
/** Use a shuffle mode button for the secondary action. */
|
||||
SHUFFLE;
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
* @see fromIntCode
|
||||
*/
|
||||
val intCode: Int
|
||||
get() =
|
||||
when (this) {
|
||||
NEXT -> IntegerTable.ACTION_MODE_NEXT
|
||||
REPEAT -> IntegerTable.ACTION_MODE_REPEAT
|
||||
SHUFFLE -> IntegerTable.ACTION_MODE_SHUFFLE
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Convert a [ActionMode] integer representation into an instance.
|
||||
* @param intCode An integer representation of a [ActionMode]
|
||||
* @return The corresponding [ActionMode], or null if the [ActionMode] is invalid.
|
||||
* @see ActionMode.intCode
|
||||
*/
|
||||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.ACTION_MODE_NEXT -> NEXT
|
||||
IntegerTable.ACTION_MODE_REPEAT -> REPEAT
|
||||
IntegerTable.ACTION_MODE_SHUFFLE -> SHUFFLE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,8 +20,6 @@ package org.oxycblt.auxio.playback
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import kotlin.math.max
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
|
@ -29,17 +27,15 @@ import org.oxycblt.auxio.playback.state.RepeatMode
|
|||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.msToDs
|
||||
|
||||
/**
|
||||
* A fragment showing the current playback state in a compact manner. Used as the bar for the
|
||||
* playback sheet.
|
||||
* @author OxygenCobalt
|
||||
* A [ViewBindingFragment] that shows the current playback state in a compact manner.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
|
|
@ -52,30 +48,47 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
binding: FragmentPlaybackBarBinding,
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
val context = requireContext()
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.root.apply {
|
||||
setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Expand) }
|
||||
|
||||
setOnLongClickListener {
|
||||
playbackModel.song.value?.let(navModel::exploreNavigateTo)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// Set up marquee on song information
|
||||
binding.playbackSong.isSelected = true
|
||||
binding.playbackInfo.isSelected = true
|
||||
|
||||
// Set up actions
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
||||
setupSecondaryActions(binding, Settings(context))
|
||||
|
||||
// Load the track color in manually as it's unclear whether the track actually supports
|
||||
// using a ColorStateList in the resources
|
||||
// using a ColorStateList in the resources.
|
||||
binding.playbackProgressBar.trackColor =
|
||||
context.getColorCompat(R.color.sel_track).defaultColor
|
||||
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
|
||||
// -- VIEWMODEL SETUP ---
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
|
||||
collectImmediately(playbackModel.positionDs, ::updatePosition)
|
||||
}
|
||||
|
||||
// Update the secondary action to match the setting.
|
||||
override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
// Marquee elements leak if they are not disabled when the views are destroyed.
|
||||
binding.playbackSong.isSelected = false
|
||||
binding.playbackInfo.isSelected = false
|
||||
}
|
||||
|
||||
when (Settings(context).barAction) {
|
||||
BarAction.NEXT -> {
|
||||
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) {
|
||||
when (settings.playbackBarAction) {
|
||||
ActionMode.NEXT -> {
|
||||
binding.playbackSecondaryAction.apply {
|
||||
setIconResource(R.drawable.ic_skip_next_24)
|
||||
contentDescription = getString(R.string.desc_skip_next)
|
||||
|
|
@ -83,35 +96,24 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
setOnClickListener { playbackModel.next() }
|
||||
}
|
||||
}
|
||||
BarAction.REPEAT -> {
|
||||
ActionMode.REPEAT -> {
|
||||
binding.playbackSecondaryAction.apply {
|
||||
contentDescription = getString(R.string.desc_change_repeat)
|
||||
iconTint = context.getColorCompat(R.color.sel_accented)
|
||||
setOnClickListener { playbackModel.incrementRepeatMode() }
|
||||
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
||||
setOnClickListener { playbackModel.toggleRepeatMode() }
|
||||
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
|
||||
}
|
||||
}
|
||||
BarAction.SHUFFLE -> {
|
||||
ActionMode.SHUFFLE -> {
|
||||
binding.playbackSecondaryAction.apply {
|
||||
setIconResource(R.drawable.sel_shuffle_state_24)
|
||||
contentDescription = getString(R.string.desc_shuffle)
|
||||
iconTint = context.getColorCompat(R.color.sel_accented)
|
||||
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
|
||||
setOnClickListener { playbackModel.invertShuffled() }
|
||||
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- VIEWMODEL SETUP ---
|
||||
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.isPlaying, ::updateIsPlaying)
|
||||
collectImmediately(playbackModel.positionDs, ::updatePosition)
|
||||
}
|
||||
|
||||
override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) {
|
||||
super.onDestroyBinding(binding)
|
||||
binding.playbackSong.isSelected = false
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
|
|
@ -120,18 +122,19 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
val binding = requireBinding()
|
||||
binding.playbackCover.bind(song)
|
||||
binding.playbackSong.text = song.resolveName(context)
|
||||
binding.playbackInfo.text = song.resolveIndividualArtistName(context)
|
||||
binding.playbackInfo.text = song.resolveArtistContents(context)
|
||||
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateIsPlaying(isPlaying: Boolean) {
|
||||
private fun updatePlaying(isPlaying: Boolean) {
|
||||
requireBinding().playbackPlayPause.isActivated = isPlaying
|
||||
}
|
||||
|
||||
private fun updateRepeat(repeatMode: RepeatMode) {
|
||||
requireBinding().playbackSecondaryAction.apply {
|
||||
setIconResource(repeatMode.icon)
|
||||
// Icon tinting is controlled through isActivated, so update that flag as well.
|
||||
isActivated = repeatMode != RepeatMode.NONE
|
||||
}
|
||||
}
|
||||
|
|
@ -144,21 +147,3 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
|
|||
requireBinding().playbackProgressBar.progress = positionDs.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents the action that should be shown on the playback bar. */
|
||||
enum class BarAction {
|
||||
NEXT,
|
||||
REPEAT,
|
||||
SHUFFLE;
|
||||
|
||||
companion object {
|
||||
/** Convert an int [code] into an instance, or null if it isn't valid. */
|
||||
fun fromIntCode(code: Int) =
|
||||
when (code) {
|
||||
IntegerTable.BAR_ACTION_NEXT -> NEXT
|
||||
IntegerTable.BAR_ACTION_REPEAT -> REPEAT
|
||||
IntegerTable.BAR_ACTION_SHUFFLE -> SHUFFLE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,17 +25,16 @@ import android.view.View
|
|||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.AuxioSheetBehavior
|
||||
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimen
|
||||
|
||||
/**
|
||||
* The coordinator layout behavior used for the playback sheet, hacking in the many fixes required
|
||||
* to make bottom sheets like this work.
|
||||
* @author OxygenCobalt
|
||||
* The [BaseBottomSheetBehavior] for the playback bottom sheet. This bottom sheet
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
AuxioSheetBehavior<V>(context, attributeSet) {
|
||||
class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
|
||||
BaseBottomSheetBehavior<V>(context, attributeSet) {
|
||||
val sheetBackgroundDrawable =
|
||||
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
|
||||
fillColor = context.getAttrColorCompat(R.attr.colorSurface)
|
||||
|
|
@ -57,6 +56,8 @@ class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeS
|
|||
override fun createBackground(context: Context) =
|
||||
LayerDrawable(
|
||||
arrayOf(
|
||||
// Add another colored background so that there is always an obscuring
|
||||
// element even as the actual "background" element is faded out.
|
||||
MaterialShapeDrawable(sheetBackgroundDrawable.shapeAppearanceModel).apply {
|
||||
fillColor = sheetBackgroundDrawable.fillColor
|
||||
},
|
||||
|
|
@ -26,33 +26,36 @@ import android.view.MenuItem
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.MainFragmentDirections
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.state.RepeatMode
|
||||
import org.oxycblt.auxio.playback.ui.StyledSeekBar
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.fragment.MenuFragment
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingFragment
|
||||
import org.oxycblt.auxio.util.androidActivityViewModels
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.msToDs
|
||||
import org.oxycblt.auxio.util.showToast
|
||||
import org.oxycblt.auxio.util.systemBarInsetsCompat
|
||||
|
||||
/**
|
||||
* A [Fragment] that displays more information about the song, along with more media controls.
|
||||
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.**
|
||||
* @author OxygenCobalt
|
||||
*
|
||||
* TODO: Make seek thumb grow when selected
|
||||
* A [ViewBindingFragment] more information about the currently playing song, alongside all
|
||||
* available controls.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class PlaybackPanelFragment :
|
||||
MenuFragment<FragmentPlaybackPanelBinding>(),
|
||||
StyledSeekBar.Callback,
|
||||
Toolbar.OnMenuItemClickListener {
|
||||
// AudioEffect expects you to use startActivityForResult with the panel intent. Use
|
||||
// the contract analogue for this since there is no built-in contract for AudioEffect.
|
||||
private val activityLauncher by lifecycleObject {
|
||||
ViewBindingFragment<FragmentPlaybackPanelBinding>(),
|
||||
Toolbar.OnMenuItemClickListener,
|
||||
StyledSeekBar.Listener {
|
||||
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
|
||||
private val navModel: NavigationViewModel by activityViewModels()
|
||||
// AudioEffect expects you to use startActivityForResult with the panel intent. There is no
|
||||
// contract analogue for this intent, so the generic contract is used instead.
|
||||
private val equalizerLauncher by lifecycleObject {
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
// Nothing to do
|
||||
}
|
||||
|
|
@ -65,8 +68,9 @@ class PlaybackPanelFragment :
|
|||
binding: FragmentPlaybackPanelBinding,
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
// --- UI SETUP ---
|
||||
super.onBindingCreated(binding, savedInstanceState)
|
||||
|
||||
// --- UI SETUP ---
|
||||
binding.root.setOnApplyWindowInsetsListener { view, insets ->
|
||||
val bars = insets.systemBarInsetsCompat
|
||||
view.updatePadding(top = bars.top, bottom = bars.bottom)
|
||||
|
|
@ -78,30 +82,31 @@ class PlaybackPanelFragment :
|
|||
setOnMenuItemClickListener(this@PlaybackPanelFragment)
|
||||
}
|
||||
|
||||
// Set up marquee on song information, alongside click handlers that navigate to each
|
||||
// respective item.
|
||||
binding.playbackSong.apply {
|
||||
// Make marquee of the song title work
|
||||
isSelected = true
|
||||
setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) }
|
||||
}
|
||||
|
||||
binding.playbackArtist.setOnClickListener {
|
||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) }
|
||||
binding.playbackArtist.apply {
|
||||
isSelected = true
|
||||
setOnClickListener { navigateToCurrentArtist() }
|
||||
}
|
||||
binding.playbackAlbum.apply {
|
||||
isSelected = true
|
||||
setOnClickListener { navigateToCurrentAlbum() }
|
||||
}
|
||||
|
||||
binding.playbackAlbum.setOnClickListener {
|
||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) }
|
||||
}
|
||||
binding.playbackSeekBar.listener = this
|
||||
|
||||
binding.playbackSeekBar.callback = this
|
||||
|
||||
binding.playbackRepeat.setOnClickListener { playbackModel.incrementRepeatMode() }
|
||||
// Set up actions
|
||||
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
|
||||
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
|
||||
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
|
||||
binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
|
||||
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
|
||||
|
||||
// --- VIEWMODEL SETUP --
|
||||
|
||||
collectImmediately(playbackModel.song, ::updateSong)
|
||||
collectImmediately(playbackModel.parent, ::updateParent)
|
||||
collectImmediately(playbackModel.positionDs, ::updatePosition)
|
||||
|
|
@ -112,58 +117,67 @@ class PlaybackPanelFragment :
|
|||
|
||||
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
|
||||
binding.playbackToolbar.setOnMenuItemClickListener(null)
|
||||
// Marquee elements leak if they are not disabled when the views are destroyed.
|
||||
binding.playbackSong.isSelected = false
|
||||
binding.playbackSeekBar.callback = null
|
||||
binding.playbackArtist.isSelected = false
|
||||
binding.playbackAlbum.isSelected = false
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
override fun onMenuItemClick(item: MenuItem) =
|
||||
when (item.itemId) {
|
||||
R.id.action_open_equalizer -> {
|
||||
// Launch the system equalizer app, if possible.
|
||||
// TODO: Move this to a utility
|
||||
val equalizerIntent =
|
||||
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
|
||||
// Provide audio session ID so equalizer can show options for this app
|
||||
// in particular.
|
||||
.putExtra(
|
||||
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)
|
||||
// Signal music type so that the equalizer settings are appropriate for
|
||||
// music playback.
|
||||
.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
|
||||
|
||||
try {
|
||||
activityLauncher.launch(equalizerIntent)
|
||||
equalizerLauncher.launch(equalizerIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
requireContext().showToast(R.string.err_no_app)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
R.id.action_go_artist -> {
|
||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) }
|
||||
navigateToCurrentArtist()
|
||||
true
|
||||
}
|
||||
R.id.action_go_album -> {
|
||||
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) }
|
||||
navigateToCurrentAlbum()
|
||||
true
|
||||
}
|
||||
R.id.action_song_detail -> {
|
||||
playbackModel.song.value?.let {
|
||||
navModel.mainNavigateTo(MainNavigationAction.SongDetails(it))
|
||||
playbackModel.song.value?.let { song ->
|
||||
navModel.mainNavigateTo(
|
||||
MainNavigationAction.Directions(
|
||||
MainFragmentDirections.actionShowDetails(song.uid)))
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTo(positionDs: Long) {
|
||||
override fun onSeekConfirmed(positionDs: Long) {
|
||||
playbackModel.seekTo(positionDs)
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
if (song == null) return
|
||||
if (song == null) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
val binding = requireBinding()
|
||||
val context = requireContext()
|
||||
binding.playbackCover.bind(song)
|
||||
binding.playbackSong.text = song.resolveName(context)
|
||||
binding.playbackArtist.text = song.resolveIndividualArtistName(context)
|
||||
binding.playbackArtist.text = song.resolveArtistContents(context)
|
||||
binding.playbackAlbum.text = song.album.resolveName(context)
|
||||
binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
|
||||
}
|
||||
|
|
@ -171,7 +185,6 @@ class PlaybackPanelFragment :
|
|||
private fun updateParent(parent: MusicParent?) {
|
||||
val binding = requireBinding()
|
||||
val context = requireContext()
|
||||
|
||||
binding.playbackToolbar.subtitle =
|
||||
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
|
||||
}
|
||||
|
|
@ -194,4 +207,16 @@ class PlaybackPanelFragment :
|
|||
private fun updateShuffled(isShuffled: Boolean) {
|
||||
requireBinding().playbackShuffle.isActivated = isShuffled
|
||||
}
|
||||
|
||||
/** Navigate to one of the currently playing [Song]'s Artists. */
|
||||
private fun navigateToCurrentArtist() {
|
||||
val song = playbackModel.song.value ?: return
|
||||
navModel.exploreNavigateToParentArtist(song)
|
||||
}
|
||||
|
||||
/** Navigate to the currently playing [Song]'s albums. */
|
||||
private fun navigateToCurrentAlbum() {
|
||||
val song = playbackModel.song.value ?: return
|
||||
navModel.exploreNavigateTo(song.album)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue