Merge pull request #300 from OxygenCobalt/dev

Version 3.0.0
This commit is contained in:
Alexander Capehart 2022-12-30 08:27:04 -07:00 committed by GitHub
commit b7bc0a6206
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
308 changed files with 16168 additions and 10144 deletions

View file

@ -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: 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 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. - **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: If you do make a request, provide the following:
- What is it that you want? - 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? - 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.** 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 ## Translations
Go to Auxio's weblate project [here](https://hosted.weblate.org/engage/auxio/). 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. - Please ***FULLY TEST*** your changes before creating a PR. Untested code will not be merged.
- Java code will **NOT** be accepted. Kotlin only. - 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. - 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.

View file

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

View 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

View file

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

View 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

View file

@ -3,7 +3,6 @@
#### What is it? #### What is it?
- [ ] Bugfix (user facing) - [ ] Bugfix (user facing)
- [ ] Feature (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) - [ ] Codebase improvement (dev facing)
- [ ] Meta improvement to the project (dev facing) - [ ] Meta improvement to the project (dev facing)
@ -25,4 +24,4 @@ debug.zip
#### Due Diligence #### Due Diligence
- [ ] I have read the [Contribution Guidelines](https://github.com/OxygenCobalt/Auxio/blob/dev/.github/CONTRIBUTING.md). - [ ] 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.

View file

@ -2,6 +2,57 @@
## dev ## 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 ## 2.6.4
#### What's Fixed #### What's Fixed

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v2.6.4"> <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=v2.6.4&color=0D5AF5"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.0&color=0D5AF5">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
@ -13,7 +13,7 @@
</a> </a>
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-32B5ED"> <img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-32B5ED">
</p> </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"> <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://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> <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 ## 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. 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 - Snappy UI derived from the latest Material Design guidelines
- Opinionated UX that prioritizes ease of use over edge cases - Opinionated UX that prioritizes ease of use over edge cases
- Customizable behavior - Customizable behavior
- Advanced media indexer that prioritizes correct metadata - Support for disc numbers, multiple artists, release types,
- Precise/Original Dates, Sort Tags, and Release Type support (Experimental) precise/original dates, sort tags, and more
- Advanced artist system that unifies artists and album artists
- SD Card-aware folder management - SD Card-aware folder management
- Reliable playback state persistence - 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) - External equalizer support (ex. Wavelet)
- Edge-to-edge - Edge-to-edge
- Embedded covers support - Embedded covers support
- Search Functionality - Search functionality
- Headset autoplay - Headset autoplay
- Stylish widgets that automatically adapt to their size - Stylish widgets that automatically adapt to their size
- Completely private and offline - Completely private and offline
- No rounded album covers (Unless you want them. Then you can.) - 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 ## Permissions
- Storage (`READ_EXTERNAL_STORAGE`): to read and play your media files - 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 - Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background
## Building ## 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 [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 published by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
More information can be found [here](https://github.com/OxygenCobalt/Auxio/wiki/Licenses).

View file

@ -3,6 +3,7 @@ plugins {
id "kotlin-android" id "kotlin-android"
id "androidx.navigation.safeargs.kotlin" id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless" id "com.diffplug.spotless"
id "kotlin-parcelize"
} }
android { android {
@ -11,8 +12,8 @@ android {
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "2.6.4" versionName "3.0.0"
versionCode 23 versionCode 24
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
@ -23,7 +24,6 @@ android {
} }
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile. // ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
@ -53,10 +53,6 @@ android {
} }
} }
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
}
dependencies { dependencies {
// Kotlin // Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
@ -69,9 +65,9 @@ dependencies {
// General // General
// 1.4.0 is used in order to avoid a ripple bug in material components // 1.4.0 is used in order to avoid a ripple bug in material components
implementation "androidx.appcompat:appcompat:1.4.0" implementation "androidx.appcompat:appcompat:1.4.0"
implementation "androidx.core:core-ktx:1.8.0" implementation "androidx.core:core-ktx:1.9.0"
implementation "androidx.activity:activity-ktx:1.6.0-rc01" implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.2" implementation "androidx.fragment:fragment-ktx:1.5.5"
// UI // UI
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.2.1"
@ -100,14 +96,18 @@ dependencies {
// Exoplayer // Exoplayer
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE PRE-BUILD SCRIPT. // 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. // 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"]) implementation fileTree(dir: "libs", include: ["extension-*.aar"])
// Image loading // Image loading
implementation "io.coil-kt:coil:2.1.0" implementation "io.coil-kt:coil:2.1.0"
// Material // Material
// Locked below 1.7.0-alpha03 to avoid the same ripple bug
implementation "com.google.android.material:material:1.7.0-alpha02" implementation "com.google.android.material:material:1.7.0-alpha02"
// LeakCanary // LeakCanary
@ -117,7 +117,7 @@ dependencies {
spotless { spotless {
kotlin { kotlin {
target "src/**/*.kt" target "src/**/*.kt"
ktfmt("0.37").dropboxStyle() ktfmt().dropboxStyle()
licenseHeaderFile("NOTICE") licenseHeaderFile("NOTICE")
} }
} }

View file

@ -20,8 +20,6 @@
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keep class org.oxycblt.auxio.AuxioApp # Obsfucation is what proprietary software does to keep the user unaware of it's abuses.
-keep class org.oxycblt.auxio.settings.SettingsListFragment # Also it's easier to debug if the class names remain unmangled.
# Free software does not obsfucate. Also it's easier to debug stack traces.
-dontobfuscate -dontobfuscate

View file

@ -8,6 +8,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <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 --> <!-- Work around ExoPlayer requiring network permissions we do not use -->
<uses-permission <uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" android:name="android.permission.ACCESS_NETWORK_STATE"
@ -70,8 +75,8 @@
</activity> </activity>
<!-- <!--
IndexerService handles querying the media database, Service handling querying the media database, extracting metadata, and constructing
extracting metadata, and constructing the music library. the music library.
--> -->
<service <service
android:name=".music.system.IndexerService" android:name=".music.system.IndexerService"
@ -81,7 +86,7 @@
android:roundIcon="@mipmap/ic_launcher" /> android:roundIcon="@mipmap/ic_launcher" />
<!-- <!--
PlaybackService handles music playback, system components, and state saving. Service handling music playback, system components, and state saving.
--> -->
<service <service
android:name=".playback.system.PlaybackService" android:name=".playback.system.PlaybackService"
@ -102,7 +107,16 @@
</intent-filter> </intent-filter>
</receiver> </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 <receiver
android:name=".widgets.WidgetProvider" android:name=".widgets.WidgetProvider"
android:exported="false" android:exported="false"

View file

@ -83,11 +83,11 @@ import java.util.Map;
* window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
* BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}. * 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> { 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 { 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 * @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
* #removeBottomSheetCallback(BottomSheetCallback)} instead * #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) { public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
if (!callbacks.contains(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) { public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) {
callbacks.remove(callback); callbacks.remove(callback);

View file

@ -25,16 +25,22 @@ import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.request.CachePolicy import coil.request.CachePolicy
import org.oxycblt.auxio.image.AlbumCoverFetcher import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
import org.oxycblt.auxio.image.ArtistImageFetcher import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
import org.oxycblt.auxio.image.CrossfadeTransitionFactory import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
import org.oxycblt.auxio.image.GenreImageFetcher import org.oxycblt.auxio.image.extractor.GenreImageFetcher
import org.oxycblt.auxio.image.MusicKeyer 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 { class AuxioApp : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.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 // Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release // manually, as it will properly handle the difference between debug and release
// Auxio instances. // Auxio instances.
@ -54,18 +60,23 @@ class AuxioApp : Application(), ImageLoaderFactory {
override fun newImageLoader() = override fun newImageLoader() =
ImageLoader.Builder(applicationContext) ImageLoader.Builder(applicationContext)
.components { .components {
// Add fetchers for Music components to make them usable with ImageRequest
add(MusicKeyer())
add(AlbumCoverFetcher.SongFactory()) add(AlbumCoverFetcher.SongFactory())
add(AlbumCoverFetcher.AlbumFactory()) add(AlbumCoverFetcher.AlbumFactory())
add(ArtistImageFetcher.Factory()) add(ArtistImageFetcher.Factory())
add(GenreImageFetcher.Factory()) add(GenreImageFetcher.Factory())
add(MusicKeyer())
} }
.transitionFactory(CrossfadeTransitionFactory()) // Use our own crossfade with error drawable support
.diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching .transitionFactory(ErrorCrossfadeTransitionFactory())
// Not downloading anything, so no disk-caching
.diskCachePolicy(CachePolicy.DISABLED)
.build() .build()
companion object { 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" 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"
} }
} }

View file

@ -17,7 +17,11 @@
package org.oxycblt.auxio 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 { object IntegerTable {
/** SongViewHolder */ /** SongViewHolder */
const val VIEW_TYPE_SONG = 0xA000 const val VIEW_TYPE_SONG = 0xA000
@ -45,21 +49,18 @@ object IntegerTable {
const val VIEW_TYPE_GENRE_DETAIL = 0xA00B const val VIEW_TYPE_GENRE_DETAIL = 0xA00B
/** DiscHeaderViewHolder */ /** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00C const val VIEW_TYPE_DISC_HEADER = 0xA00C
/** "Music playback" notification code */ /** "Music playback" notification code */
const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0 const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** "Music loading" notification code */ /** "Music loading" notification code */
const val INDEXER_NOTIFICATION_CODE = 0xA0A1 const val INDEXER_NOTIFICATION_CODE = 0xA0A1
/** Intent request code */ /** MainActivity Intent request code */
const val REQUEST_CODE = 0xA0C0 const val REQUEST_CODE = 0xA0C0
/** RepeatMode.NONE */ /** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100 const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */ /** RepeatMode.ALL */
const val REPEAT_MODE_ALL = 0xA101 const val REPEAT_MODE_ALL = 0xA101
/** RepeatMode.TRACK */ /** RepeatMode.TRACK */
const val REPEAT_MODE_TRACK = 0xA102 const val REPEAT_MODE_TRACK = 0xA102
/** PlaybackMode.IN_GENRE */ /** PlaybackMode.IN_GENRE */
const val PLAYBACK_MODE_IN_GENRE = 0xA103 const val PLAYBACK_MODE_IN_GENRE = 0xA103
/** PlaybackMode.IN_ARTIST */ /** PlaybackMode.IN_ARTIST */
@ -68,21 +69,16 @@ object IntegerTable {
const val PLAYBACK_MODE_IN_ALBUM = 0xA105 const val PLAYBACK_MODE_IN_ALBUM = 0xA105
/** PlaybackMode.ALL_SONGS */ /** PlaybackMode.ALL_SONGS */
const val PLAYBACK_MODE_ALL_SONGS = 0xA106 const val PLAYBACK_MODE_ALL_SONGS = 0xA106
/** DisplayMode.NONE (No Longer used but still reserved) */ /** DisplayMode.NONE (No Longer used but still reserved) */
// const val DISPLAY_MODE_NONE = 0xA107 // const val DISPLAY_MODE_NONE = 0xA107
/** DisplayMode.SHOW_GENRES */ /** MusicMode._GENRES */
const val DISPLAY_MODE_SHOW_GENRES = 0xA108 const val MUSIC_MODE_GENRES = 0xA108
/** DisplayMode.SHOW_ARTISTS */ /** MusicMode._ARTISTS */
const val DISPLAY_MODE_SHOW_ARTISTS = 0xA109 const val MUSIC_MODE_ARTISTS = 0xA109
/** DisplayMode.SHOW_ALBUMS */ /** MusicMode._ALBUMS */
const val DISPLAY_MODE_SHOW_ALBUMS = 0xA10A const val MUSIC_MODE_ALBUMS = 0xA10A
/** DisplayMode.SHOW_SONGS */ /** MusicMode.SONGS */
const val DISPLAY_MODE_SHOW_SONGS = 0xA10B const val MUSIC_MODE_SONGS = 0xA10B
// Note: Sort integer codes are non-contiguous due to significant amounts of time
// passing between the additions of new sort modes.
/** Sort.ByName */ /** Sort.ByName */
const val SORT_BY_NAME = 0xA10C const val SORT_BY_NAME = 0xA10C
/** Sort.ByArtist */ /** Sort.ByArtist */
@ -101,7 +97,6 @@ object IntegerTable {
const val SORT_BY_TRACK = 0xA117 const val SORT_BY_TRACK = 0xA117
/** Sort.ByDateAdded */ /** Sort.ByDateAdded */
const val SORT_BY_DATE_ADDED = 0xA118 const val SORT_BY_DATE_ADDED = 0xA118
/** ReplayGainMode.Off (No longer used but still reserved) */ /** ReplayGainMode.Off (No longer used but still reserved) */
// const val REPLAY_GAIN_MODE_OFF = 0xA110 // const val REPLAY_GAIN_MODE_OFF = 0xA110
/** ReplayGainMode.Track */ /** ReplayGainMode.Track */
@ -110,11 +105,16 @@ object IntegerTable {
const val REPLAY_GAIN_MODE_ALBUM = 0xA112 const val REPLAY_GAIN_MODE_ALBUM = 0xA112
/** ReplayGainMode.Dynamic */ /** ReplayGainMode.Dynamic */
const val REPLAY_GAIN_MODE_DYNAMIC = 0xA113 const val REPLAY_GAIN_MODE_DYNAMIC = 0xA113
/** ActionMode.Next */
/** BarAction.Next */ const val ACTION_MODE_NEXT = 0xA119
const val BAR_ACTION_NEXT = 0xA119 /** ActionMode.Repeat */
/** BarAction.Repeat */ const val ACTION_MODE_REPEAT = 0xA11A
const val BAR_ACTION_REPEAT = 0xA11A /** ActionMode.Shuffle */
/** BarAction.Shuffle */ const val ACTION_MODE_SHUFFLE = 0xA11B
const val BAR_ACTION_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
} }

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -37,7 +36,7 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* The single [AppCompatActivity] for Auxio. * Auxio's single [AppCompatActivity].
* *
* TODO: Add error screens * TODO: Add error screens
* *
@ -45,22 +44,30 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* *
* TODO: Add multi-select * 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() { class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by androidViewModels() private val playbackModel: PlaybackViewModel by androidViewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setupTheme() setupTheme()
// Inflate the views after setting up the theme so that the theme attributes are applied.
val binding = ActivityMainBinding.inflate(layoutInflater) val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setupEdgeToEdge(binding.root) setupEdgeToEdge(binding.root)
logD("Activity created") logD("Activity created")
} }
@ -71,6 +78,7 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, PlaybackService::class.java)) startService(Intent(this, PlaybackService::class.java))
if (!startIntentAction(intent)) { if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state.
playbackModel.startAction(InternalPlayer.Action.RestoreState) playbackModel.startAction(InternalPlayer.Action.RestoreState)
} }
} }
@ -80,46 +88,12 @@ class MainActivity : AppCompatActivity() {
startIntentAction(intent) 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() { private fun setupTheme() {
val settings = Settings(this) val settings = Settings(this)
// Apply the theme configuration.
// Disable theme customization above Android 12, as it's far enough in as a version to AppCompatDelegate.setDefaultNightMode(settings.theme)
// the point where most phones should have an option for light/dark theming. // Apply the color scheme. The black theme requires it's own set of themes since
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // it's not possible to modify the themes at run-time.
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
// The black theme has a completely separate set of styles since style attributes cannot
// be modified at runtime.
if (isNight && settings.useBlackTheme) { if (isNight && settings.useBlackTheme) {
logD("Applying black theme [accent ${settings.accent}]") logD("Applying black theme [accent ${settings.accent}]")
setTheme(settings.accent.blackTheme) setTheme(settings.accent.blackTheme)
@ -131,14 +105,47 @@ class MainActivity : AppCompatActivity() {
private fun setupEdgeToEdge(contentView: View) { private fun setupEdgeToEdge(contentView: View) {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
contentView.setOnApplyWindowInsetsListener { view, insets -> 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 val bars = insets.systemBarInsetsCompat
view.updatePadding(left = bars.left, right = bars.right) view.updatePadding(left = bars.left, right = bars.right)
insets 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 { companion object {
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED" private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
} }

View file

@ -26,6 +26,8 @@ import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.NeoBottomSheetBehavior 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.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding 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.Music
import org.oxycblt.auxio.music.Song 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.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.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
/** /**
* A wrapper around the home fragment that shows the playback fragment and controls the more * A wrapper around the home fragment that shows the playback fragment and controls the more
* high-level navigation features. * high-level navigation features.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class MainFragment : class MainFragment :
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener { ViewBindingFragment<FragmentMainBinding>(),
ViewTreeObserver.OnPreDrawListener,
NavController.OnDestinationChangedListener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback() private val callback = DynamicBackPressedCallback()
private var lastInsets: WindowInsets? = null private var lastInsets: WindowInsets? = null
private var initialNavDestinationChange = true
private val elevationNormal: Float by lifecycleObject { binding -> private val elevationNormal: Float by lifecycleObject { binding ->
binding.context.getDimen(R.dimen.elevation_normal) binding.context.getDimen(R.dimen.elevation_normal)
} }
@ -69,9 +76,12 @@ class MainFragment :
override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentMainBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentMainBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --- // --- UI SETUP ---
val context = requireActivity() 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) context.onBackPressedDispatcher.addCallback(viewLifecycleOwner, callback)
binding.root.setOnApplyWindowInsetsListener { _, insets -> binding.root.setOnApplyWindowInsetsListener { _, insets ->
@ -85,26 +95,29 @@ class MainFragment :
ViewCompat.setAccessibilityPaneTitle( ViewCompat.setAccessibilityPaneTitle(
binding.queueSheet, context.getString(R.string.lbl_queue)) 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) { if (queueSheetBehavior != null) {
// Bottom sheet mode, set up click listeners.
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
unlikelyToBeNull(binding.handleWrapper).setOnClickListener { unlikelyToBeNull(binding.handleWrapper).setOnClickListener {
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED && if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED &&
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) { queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is expanded and queue sheet is collapsed, we can expand it.
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
} }
} }
} else { } else {
// Dual-pane mode, color/pad the queue sheet manually. // Dual-pane mode, manually style the static queue sheet.
binding.queueSheet.apply { binding.queueSheet.apply {
// Emulate the elevated bottom sheet style.
background = background =
MaterialShapeDrawable.createWithElevationOverlay(context).apply { MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface) fillColor = context.getAttrColorCompat(R.attr.colorSurface)
elevation = context.getDimen(R.dimen.elevation_normal) elevation = context.getDimen(R.dimen.elevation_normal)
} }
// Apply bar insets for the queue's RecyclerView to usee.
setOnApplyWindowInsetsListener { v, insets -> setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(top = insets.systemBarInsetsCompat.top) v.updatePadding(top = insets.systemBarInsetsCompat.top)
insets insets
@ -113,54 +126,63 @@ class MainFragment :
} }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(navModel.mainNavigationAction, ::handleMainNavigation) collect(navModel.mainNavigationAction, ::handleMainNavigation)
collect(navModel.exploreNavigationItem, ::handleExploreNavigation) collect(navModel.exploreNavigationItem, ::handleExploreNavigation)
collect(navModel.exploreArtistNavigationItem, ::handleArtistNavigationPicker)
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collect(playbackModel.artistPickerSong, ::handlePlaybackArtistPicker)
collect(playbackModel.genrePickerSong, ::handlePlaybackGenrePicker)
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
val binding = requireBinding()
// Callback could still reasonably fire even if we clear the binding, attach/detach // 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. // our pre-draw listener our listener in onStart/onStop respectively.
requireBinding().playbackSheet.viewTreeObserver.addOnPreDrawListener(this) binding.playbackSheet.viewTreeObserver.addOnPreDrawListener(this@MainFragment)
} }
override fun onStop() { override fun onStop() {
super.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 { override fun onPreDraw(): Boolean {
// CoordinatorLayout is insane and thus makes bottom sheet callbacks insane. Do our // We overload CoordinatorLayout far too much to rely on any of it's typical
// checks before every draw, which is not ideal in the slightest but also has minimal // listener functionality. Just update all transitions before every draw. Should
// performance impact since we are only mutating attributes used during drawing. // probably be cheap enough.
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = 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 playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
val outPlaybackRatio = 1 - playbackRatio val outPlaybackRatio = 1 - playbackRatio
val halfOutRatio = min(playbackRatio * 2, 1f) val halfOutRatio = min(playbackRatio * 2, 1f)
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2 val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
val queueSheetBehavior = binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior?
if (queueSheetBehavior != null) { if (queueSheetBehavior != null) {
// Queue sheet, take queue into account so the playback bar is shown and the playback // Queue sheet available, the normal transition applies, but it now much be combined
// panel is hidden when the queue sheet is expanded. // with another transition where the playback panel disappears and the playback bar
// appears as the queue sheet expands.
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f) val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
val halfOutQueueRatio = min(queueRatio * 2, 1f) val halfOutQueueRatio = min(queueRatio * 2, 1f)
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2 val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio) binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio)
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio) binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
binding.queueFragment.alpha = queueRatio binding.queueFragment.alpha = queueRatio
if (playbackModel.song.value != null) { 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 = playbackSheetBehavior.isDraggable =
queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED queueSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED
} }
@ -170,62 +192,82 @@ class MainFragment :
binding.playbackPanelFragment.alpha = halfInPlaybackRatio binding.playbackPanelFragment.alpha = halfInPlaybackRatio
} }
// Fade out the content as the playback panel expands.
// TODO: Replace with shadow?
binding.exploreNavHost.apply { binding.exploreNavHost.apply {
alpha = outPlaybackRatio alpha = outPlaybackRatio
// Prevent interactions when the content fully fades out.
isInvisible = alpha == 0f 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 binding.playbackSheet.translationZ = elevationNormal * outPlaybackRatio
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt() playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt()
// Fade out the playback bar as the panel expands.
binding.playbackBarFragment.apply { binding.playbackBarFragment.apply {
// Prevent interactions when the playback bar fully fades out.
isInvisible = alpha == 0f isInvisible = alpha == 0f
// As the playback bar expands, we also want to subtly translate the bar to
// align with the top inset. This results in both a smooth transition from the bar
// to the playback panel's toolbar, but also a correctly positioned playback bar
// for when the queue sheet expands.
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio } lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
} }
// Prevent interactions when the playback panell fully fades out.
binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
binding.queueSheet.apply { binding.queueSheet.apply {
// Queue sheet (not queue content) should fade out with the playback panel.
alpha = halfInPlaybackRatio alpha = halfInPlaybackRatio
// Prevent interactions when the queue sheet fully fades out.
binding.queueSheet.isInvisible = alpha == 0f binding.queueSheet.isInvisible = alpha == 0f
} }
// Prevent interactions when the queue content fully fades out.
binding.queueFragment.isInvisible = binding.queueFragment.alpha == 0f binding.queueFragment.isInvisible = binding.queueFragment.alpha == 0f
if (playbackModel.song.value == null) { if (playbackModel.song.value == null) {
// Sometimes lingering drags can un-hide the playback sheet even when we intend to // Sometimes lingering drags can un-hide the playback sheet even when we intend to
// hide it, make sure we keep it hidden. // 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. // every frame.
callback.updateEnabledState() callback.invalidateEnabled()
return true return true
} }
private fun updateSong(song: Song?) { override fun onDestinationChanged(
if (song != null) { controller: NavController,
tryUnhideAll() destination: NavDestination,
} else { arguments: Bundle?
tryHideAll() ) {
// 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?) { private fun handleMainNavigation(action: MainNavigationAction?) {
if (action == null) return if (action == null) {
// Nothing to do.
return
}
when (action) { when (action) {
is MainNavigationAction.Expand -> tryExpandAll() is MainNavigationAction.Expand -> tryExpandSheets()
is MainNavigationAction.Collapse -> tryCollapseAll() is MainNavigationAction.Collapse -> tryCollapseSheets()
is MainNavigationAction.Settings -> is MainNavigationAction.Directions -> findNavController().navigate(action.directions)
findNavController().navigate(MainFragmentDirections.actionShowSettings())
is MainNavigationAction.About ->
findNavController().navigate(MainFragmentDirections.actionShowAbout())
is MainNavigationAction.SongDetails ->
findNavController()
.navigate(MainFragmentDirections.actionShowDetails(action.song.id))
} }
navModel.finishMainNavigation() navModel.finishMainNavigation()
@ -233,44 +275,75 @@ class MainFragment :
private fun handleExploreNavigation(item: Music?) { private fun handleExploreNavigation(item: Music?) {
if (item != null) { 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 binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_COLLAPSED) { 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 playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
} }
} }
private fun tryCollapseAll() { private fun tryCollapseSheets() {
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) { if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
// Make sure the queue is also collapsed here. // Make sure the queue is also collapsed here.
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED queueSheetBehavior?.state = NeoBottomSheetBehavior.STATE_COLLAPSED
} }
} }
private fun tryUnhideAll() { private fun tryShowSheets() {
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) { if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_HIDDEN) {
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed // Queue sheet behavior is either collapsed or expanded, no hiding needed
queueSheetBehavior?.isDraggable = true queueSheetBehavior?.isDraggable = true
@ -283,17 +356,15 @@ class MainFragment :
} }
} }
private fun tryHideAll() { private fun tryHideAllSheets() {
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) { if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Make these views non-draggable so the user can't halt the hiding event.
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
queueSheetBehavior?.apply { queueSheetBehavior?.apply {
isDraggable = false isDraggable = false
state = NeoBottomSheetBehavior.STATE_COLLAPSED 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 * A [OnBackPressedCallback] that overrides the back button to first navigate out of internal
* fragments and the playback panel. * app components, such as the Bottom Sheets or Explore Navigation.
*/ */
private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) { private inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
val binding = requireBinding() val binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// If expanded, collapse the queue sheet first.
if (queueSheetBehavior != null && if (queueSheetBehavior != null &&
queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && queueSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) { playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
// Collapse the queue first if it is expanded.
queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED queueSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
return return
} }
// If expanded, collapse the playback sheet next.
if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED && if (playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_COLLAPSED &&
playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) { playbackSheetBehavior.state != NeoBottomSheetBehavior.STATE_HIDDEN) {
// Then collapse the playback sheet.
playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
return return
} }
// Then try to navigate out of the explore navigation fragments (i.e Detail Views)
binding.exploreNavHost.findNavController().navigateUp() 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 binding = requireBinding()
val playbackSheetBehavior = val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
val queueSheetBehavior = val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueSheetBehavior? binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val exploreNavController = binding.exploreNavHost.findNavController() val exploreNavController = binding.exploreNavHost.findNavController()
isEnabled = 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.currentDestination?.id !=
exploreNavController.graph.startDestinationId exploreNavController.graph.startDestinationId
} }

View file

@ -17,13 +17,10 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
@ -32,40 +29,38 @@ import com.google.android.material.transition.MaterialSharedAxis
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song 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.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.canScroll
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A fragment that shows information for a particular [Album]. * A [ListFragment] that shows information about an [Album].
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumDetailFragment : class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
MenuFragment<FragmentDetailBinding>(),
Toolbar.OnMenuItemClickListener,
AlbumDetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() 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 args: AlbumDetailFragmentArgs by navArgs()
private val detailAdapter = AlbumDetailAdapter(this) private val detailAdapter = AlbumDetailAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
@ -74,9 +69,13 @@ class AlbumDetailFragment :
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun getSelectionToolbar(binding: FragmentDetailBinding) =
detailModel.setAlbumId(args.albumId) binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
binding.detailToolbar.apply { binding.detailToolbar.apply {
inflateMenu(R.menu.menu_album_detail) inflateMenu(R.menu.menu_album_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -86,12 +85,14 @@ class AlbumDetailFragment :
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = detailAdapter
// -- VIEWMODEL SETUP --- // -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
collectImmediately(detailModel.currentAlbum, ::handleItemChange) detailModel.setAlbumUid(args.albumUid)
collectImmediately(detailModel.albumData, detailAdapter::submitList) collectImmediately(detailModel.currentAlbum, ::updateAlbum)
collectImmediately(detailModel.albumList, detailAdapter::submitList)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
} }
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
@ -101,50 +102,58 @@ class AlbumDetailFragment :
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
val currentAlbum = unlikelyToBeNull(detailModel.currentAlbum.value)
return when (item.itemId) { return when (item.itemId) {
R.id.action_play_next -> { R.id.action_play_next -> {
playbackModel.playNext(unlikelyToBeNull(detailModel.currentAlbum.value)) playbackModel.playNext(currentAlbum)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_queue_add -> { R.id.action_queue_add -> {
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentAlbum.value)) playbackModel.addToQueue(currentAlbum)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_go_artist -> { R.id.action_go_artist -> {
navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artist) onNavigateToParentArtist()
true true
} }
else -> false else -> false
} }
} }
override fun onItemClick(item: Item) { override fun onRealClick(music: Music) {
if (item is Song) { check(music is Song) { "Unexpected datatype: ${music::class.java}" }
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_ALBUM) 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) { override fun onOpenMenu(item: Item, anchor: View) {
if (item is Song) { check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" }
musicMenu(anchor, R.menu.menu_album_song_actions, item) openMusicMenu(anchor, R.menu.menu_album_song_actions, item)
return
}
error("Unexpected datatype when opening menu: ${item::class.java}")
} }
override fun onPlayParent() { override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), false) playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
} }
override fun onShuffleParent() { override fun onShuffle() {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), true) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
} }
override fun onShowSortMenu(anchor: View) { override fun onOpenSortMenu(anchor: View) {
menu(anchor, R.menu.menu_album_sort) { openMenu(anchor, R.menu.menu_album_sort) {
val sort = detailModel.albumSort val sort = detailModel.albumSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
@ -161,50 +170,56 @@ class AlbumDetailFragment :
} }
} }
override fun onNavigateToArtist() { override fun onNavigateToParentArtist() {
findNavController() navModel.exploreNavigateToParentArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
.navigate(
AlbumDetailFragmentDirections.actionShowArtist(
unlikelyToBeNull(detailModel.currentAlbum.value).artist.id))
} }
private fun handleItemChange(album: Album?) { private fun updateAlbum(album: Album?) {
if (album == null) { if (album == null) {
// Album we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
requireBinding().detailToolbar.title = album.resolveName(requireContext()) 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?) { private fun handleNavigation(item: Music?) {
val binding = requireBinding() val binding = requireBinding()
when (item) { when (item) {
// Songs should be scrolled to if the album matches, or a new detail // Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise. // fragment should be launched otherwise.
is Song -> { 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") logD("Navigating to a song in this album")
scrollToItem(item.id) scrollToAlbumSong(item)
navModel.finishExploreNavigation() navModel.finishExploreNavigation()
} else { } else {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() 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 // If the album matches, no need to do anything. Otherwise launch a new
// detail fragment. // detail fragment.
is Album -> { is Album -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value).id == item.id) { if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) {
logD("Navigating to the top of this album") logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0) binding.detailRecycler.scrollToPosition(0)
navModel.finishExploreNavigation() navModel.finishExploreNavigation()
} else { } else {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id)) .navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.uid))
} }
} }
@ -212,24 +227,42 @@ class AlbumDetailFragment :
is Artist -> { is Artist -> {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController() findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id)) .navigate(AlbumDetailFragmentDirections.actionShowArtist(item.uid))
} }
null -> {} 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 scrollToAlbumSong(song: Song) {
private fun scrollToItem(id: Long) {
// Calculate where the item for the currently played song is // 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) { if (pos != -1) {
// Only scroll if the song is within this album.
val binding = requireBinding() val binding = requireBinding()
binding.detailRecycler.post { 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 // Make sure to increment the position to make up for the detail header
binding.detailRecycler.layoutManager?.startSmoothScroll( binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
CenterSmoothScroller(requireContext(), pos))
// If the recyclerview can scroll, its certain that it will have to scroll to // 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 // 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) { private fun updateSelection(selected: List<Music>) {
val binding = requireBinding() detailAdapter.setSelectedItems(selected)
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
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)
}
} }
} }

View file

@ -21,7 +21,6 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
@ -30,37 +29,37 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song 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.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.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A fragment that shows information for a particular [Artist]. * A [ListFragment] that shows information about an [Artist].
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistDetailFragment : class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
MenuFragment<FragmentDetailBinding>(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() 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 args: ArtistDetailFragmentArgs by navArgs()
private val detailAdapter = ArtistDetailAdapter(this) private val detailAdapter = ArtistDetailAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
@ -69,9 +68,13 @@ class ArtistDetailFragment :
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun getSelectionToolbar(binding: FragmentDetailBinding) =
detailModel.setArtistId(args.artistId) binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply { binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail) inflateMenu(R.menu.menu_genre_artist_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -81,12 +84,14 @@ class ArtistDetailFragment :
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = detailAdapter
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
collectImmediately(detailModel.currentArtist, ::handleItemChange) detailModel.setArtistUid(args.artistUid)
collectImmediately(detailModel.artistData, detailAdapter::submitList) collectImmediately(detailModel.currentArtist, ::updateItem)
collectImmediately(detailModel.artistList, detailAdapter::submitList)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
} }
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
@ -96,14 +101,19 @@ class ArtistDetailFragment :
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
return when (item.itemId) { return when (item.itemId) {
R.id.action_play_next -> { R.id.action_play_next -> {
playbackModel.playNext(unlikelyToBeNull(detailModel.currentArtist.value)) playbackModel.playNext(currentArtist)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_queue_add -> { R.id.action_queue_add -> {
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentArtist.value)) playbackModel.addToQueue(currentArtist)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
@ -111,32 +121,44 @@ class ArtistDetailFragment :
} }
} }
override fun onItemClick(item: Item) { override fun onRealClick(music: Music) {
when (item) { when (music) {
is Song -> is Song -> {
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_ARTIST) when (Settings(requireContext()).detailPlaybackMode) {
is Album -> navModel.exploreNavigateTo(item) // 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) { override fun onOpenMenu(item: Item, anchor: View) {
when (item) { when (item) {
is Song -> musicMenu(anchor, R.menu.menu_artist_song_actions, item) is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item)
is Album -> musicMenu(anchor, R.menu.menu_artist_album_actions, item) is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}") else -> error("Unexpected datatype: ${item::class.simpleName}")
} }
} }
override fun onPlayParent() { override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), false) playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
} }
override fun onShuffleParent() { override fun onShuffle() {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), true) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
} }
override fun onShowSortMenu(anchor: View) { override fun onOpenSortMenu(anchor: View) {
menu(anchor, R.menu.menu_artist_sort) { openMenu(anchor, R.menu.menu_artist_sort) {
val sort = detailModel.artistSort val sort = detailModel.artistSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending 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) { if (artist == null) {
// Artist we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
@ -164,47 +187,58 @@ class ArtistDetailFragment :
requireBinding().detailToolbar.title = artist.resolveName(requireContext()) 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?) { private fun handleNavigation(item: Music?) {
val binding = requireBinding() val binding = requireBinding()
when (item) { when (item) {
// Songs should be shown in their album, not in their artist.
is Song -> { is Song -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() 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 -> { is Album -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController() 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 -> { is Artist -> {
if (item.id == detailModel.currentArtist.value?.id) { if (item.uid == detailModel.currentArtist.value?.uid) {
logD("Navigating to the top of this artist") logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0) binding.detailRecycler.scrollToPosition(0)
navModel.finishExploreNavigation() navModel.finishExploreNavigation()
} else { } else {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController() findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id)) .navigate(ArtistDetailFragmentDirections.actionShowArtist(item.uid))
} }
} }
null -> {} 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) { private fun updateSelection(selected: List<Music>) {
var item: Item? = null detailAdapter.setSelectedItems(selected)
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
if (parent is Album) {
item = parent
}
if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
item = song
}
detailAdapter.updateIndicator(item, isPlaying)
} }
} }

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

View file

@ -22,8 +22,8 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatTextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -32,20 +32,27 @@ import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field import java.lang.reflect.Field
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AuxioAppBarLayout import org.oxycblt.auxio.ui.AuxioAppBarLayout
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedField
/** /**
* An [AuxioAppBarLayout] variant that also shows the name of the toolbar whenever the detail * An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
* recyclerview is scrolled beyond it's first item (a.k.a the header). This is used instead of * beyond it's first item.
* CollapsingToolbarLayout since that thing is a mess with crippling bugs and state issues. This *
* just works. * This is intended for the detail views, in which the first item is the album/artist/genre header,
* @author OxygenCobalt * 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 class DetailAppBarLayout
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioAppBarLayout(context, attrs, defStyleAttr) { AuxioAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: AppCompatTextView? = null private var titleView: TextView? = null
private var recycler: RecyclerView? = null private var recycler: RecyclerView? = null
private var titleShown: Boolean? = 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) (layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
} }
private fun findTitleView(): AppCompatTextView { private fun findTitleView(): TextView {
val titleView = titleView val titleView = titleView
if (titleView != null) { if (titleView != null) {
return titleView 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) val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
// Reflect to get the actual title view to do transformations on // The Toolbar's title view is actually hidden. To avoid having to create our own
val newTitleView = TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as AppCompatTextView // 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 this.titleView = newTitleView
return newTitleView return newTitleView
} }
@ -78,6 +93,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return recycler return recycler
} }
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId) val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
this.recycler = newRecycler this.recycler = newRecycler
return newRecycler return newRecycler
@ -85,7 +101,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private fun setTitleVisibility(visible: Boolean) { private fun setTitleVisibility(visible: Boolean) {
if (titleShown == visible) return if (titleShown == visible) return
titleShown = visible titleShown = visible
val titleAnimator = titleAnimator val titleAnimator = titleAnimator
@ -94,6 +109,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
this.titleAnimator = null 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 titleView = findTitleView()
val from: Float val from: Float
val to: Float val to: Float
@ -106,12 +123,20 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
to = 0f to = 0f
} }
if (titleView.alpha == to) return if (titleView.alpha == to) {
// Nothing to do
return
}
this.titleAnimator = this.titleAnimator =
ValueAnimator.ofFloat(from, to).apply { ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { titleView.alpha = it.animatedValue as Float } 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() start()
} }
} }
@ -131,19 +156,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
) { ) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type) super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
val appBar = child as DetailAppBarLayout val appBarLayout = child as DetailAppBarLayout
val recycler = appBar.findRecyclerView() val recycler = appBarLayout.findRecyclerView()
val showTitle = // Title should be visible if we are no longer showing the top item
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0 // (i.e the header)
appBarLayout.setTitleVisibility(
appBar.setTitleVisibility(showTitle) (recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
} }
} }
companion object { companion object {
private const val TOOLBAR_FADE_DURATION = 150L
private val TOOLBAR_TITLE_TEXT_FIELD: Field by private val TOOLBAR_TITLE_TEXT_FIELD: Field by
lazyReflectedField(Toolbar::class, "mTitleTextView") lazyReflectedField(Toolbar::class, "mTitleTextView")
} }

View file

@ -24,168 +24,272 @@ import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.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.settings.Settings
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.util.*
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
/** /**
* ViewModel that stores data for the detail fragments. This includes: * [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of
* - What item the fragment should be showing * the current item they are showing, sub-data to display, and configuration. Since this ViewModel
* - The RecyclerView data for each fragment * requires a context, it must be instantiated [AndroidViewModel]'s Factory.
* - The sorts for each type of data * @param application [Application] context required to initialize certain information.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Unify how detail items are indicated [When playlists are implemented]
*/ */
class DetailViewModel(application: Application) : class DetailViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Callback { 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 musicStore = MusicStore.getInstance()
private val settings = Settings(application) private val settings = Settings(application)
private var currentSongJob: Job? = null
// --- SONG ---
private val _currentSong = MutableStateFlow<DetailSong?>(null) 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?> val currentSong: StateFlow<DetailSong?>
get() = _currentSong get() = _currentSong
// --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null) private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?> val currentAlbum: StateFlow<Album?>
get() = _currentAlbum get() = _currentAlbum
private val _albumData = MutableStateFlow(listOf<Item>()) private val _albumList = MutableStateFlow(listOf<Item>())
val albumData: StateFlow<List<Item>> /** The current list data derived from [currentAlbum]. */
get() = _albumData val albumList: StateFlow<List<Item>>
get() = _albumList
/** The current [Sort] used for [Song]s in [albumList]. */
var albumSort: Sort var albumSort: Sort
get() = settings.detailAlbumSort get() = settings.detailAlbumSort
set(value) { set(value) {
settings.detailAlbumSort = 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) private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?> val currentArtist: StateFlow<Artist?>
get() = _currentArtist get() = _currentArtist
private val _artistData = MutableStateFlow(listOf<Item>()) private val _artistList = MutableStateFlow(listOf<Item>())
val artistData: StateFlow<List<Item>> = _artistData /** The current list derived from [currentArtist]. */
val artistList: StateFlow<List<Item>> = _artistList
/** The current [Sort] used for [Song]s in [artistList]. */
var artistSort: Sort var artistSort: Sort
get() = settings.detailArtistSort get() = settings.detailArtistSort
set(value) { set(value) {
logD(value)
settings.detailArtistSort = 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) private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?> val currentGenre: StateFlow<Genre?>
get() = _currentGenre get() = _currentGenre
private val _genreData = MutableStateFlow(listOf<Item>()) private val _genreList = MutableStateFlow(listOf<Item>())
val genreData: StateFlow<List<Item>> = _genreData /** 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 var genreSort: Sort
get() = settings.detailGenreSort get() = settings.detailGenreSort
set(value) { set(value) {
settings.detailGenreSort = 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 { init {
musicStore.addCallback(this) musicStore.addCallback(this)
} }
private fun generateDetailSong(song: Song) { override fun onCleared() {
_currentSong.value = DetailSong(song, null) musicStore.removeCallback(this)
viewModelScope.launch(Dispatchers.IO) { }
val handle = songGuard.newHandle()
val info = generateDetailSongInfo(song) override fun onLibraryChanged(library: MusicStore.Library?) {
songGuard.yield(handle) if (library == null) {
_currentSong.value = DetailSong(song, info) // 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() val extractor = MediaExtractor()
try { try {
extractor.setDataSource(application, song.uri, emptyMap()) extractor.setDataSource(context, song.uri, emptyMap())
} catch (e: Exception) { } 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("Unable to extract song attributes.")
logW(e.stackTraceToString()) 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) 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 = val bitrate =
try { try {
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 // bps -> kbps // Convert bytes-per-second to kilobytes-per-second.
} catch (e: Exception) { format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000
} catch (e: NullPointerException) {
logD("Unable to extract bit rate field")
null null
} }
val sampleRate = val sampleRate =
try { try {
format.getInteger(MediaFormat.KEY_SAMPLE_RATE) format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
} catch (e: Exception) { } catch (e: NullPointerException) {
logE("Unable to extract sample rate field")
null null
} }
@ -194,138 +298,113 @@ class DetailViewModel(application: Application) :
// ExoPlayer was already able to populate the format. // ExoPlayer was already able to populate the format.
song.mimeType song.mimeType
} else { } else {
// ExoPlayer couldn't populate the format somehow, populate it here.
val formatMimeType = val formatMimeType =
try { try {
format.getString(MediaFormat.KEY_MIME) format.getString(MediaFormat.KEY_MIME)
} catch (e: Exception) { } catch (e: NullPointerException) {
logE("Unable to extract mime type field")
null null
} }
MimeType(song.mimeType.fromExtension, formatMimeType) 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") logD("Refreshing album data")
val data = mutableListOf<Item>(album) val data = mutableListOf<Item>(album)
data.add(SortHeader(R.string.lbl_songs)) data.add(SortHeader(R.string.lbl_songs))
// To create a good user experience regarding disc numbers, we intersperse // To create a good user experience regarding disc numbers, we group the album's
// items that show the disc number throughout the album's songs. In the case // songs up by disc and then delimit the groups by a disc header.
// that the album does not have distinct disc numbers, we omit such a header.
val songs = albumSort.songs(album.songs) val songs = albumSort.songs(album.songs)
// Songs without disc tags become part of Disc 1.
val byDisc = songs.groupBy { it.disc ?: 1 } val byDisc = songs.groupBy { it.disc ?: 1 }
if (byDisc.size > 1) { if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) { for (entry in byDisc.entries) {
val disc = entry.key data.add(DiscHeader(entry.key))
val discSongs = entry.value data.addAll(entry.value)
data.add(DiscHeader(disc)) // Ensure ID uniqueness
data.addAll(discSongs)
} }
} else { } else {
// Album only has one disc, don't add any redundant headers
data.addAll(songs) data.addAll(songs)
} }
_albumData.value = data _albumList.value = data
} }
private fun refreshArtistData(artist: Artist) { private fun refreshArtistList(artist: Artist) {
logD("Refreshing artist data") logD("Refreshing artist data")
val data = mutableListOf<Item>(artist) 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 = val byReleaseGroup =
albums.groupBy { albums.groupBy {
when (it.releaseType.refinement) { // Remap the complicated Album.Type data structure into an easier
ReleaseType.Refinement.LIVE -> R.string.lbl_live_group // "AlbumGrouping" enum that will automatically group and sort
ReleaseType.Refinement.REMIX -> R.string.lbl_remix_group // the artist's albums.
when (it.type.refinement) {
Album.Type.Refinement.LIVE -> AlbumGrouping.LIVE
Album.Type.Refinement.REMIX -> AlbumGrouping.REMIXES
null -> null ->
when (it.releaseType) { when (it.type) {
is ReleaseType.Album -> R.string.lbl_albums is Album.Type.Album -> AlbumGrouping.ALBUMS
is ReleaseType.EP -> R.string.lbl_eps is Album.Type.EP -> AlbumGrouping.EPS
is ReleaseType.Single -> R.string.lbl_singles is Album.Type.Single -> AlbumGrouping.SINGLES
is ReleaseType.Compilation -> R.string.lbl_compilations is Album.Type.Compilation -> AlbumGrouping.COMPILATIONS
is ReleaseType.Soundtrack -> R.string.lbl_soundtracks is Album.Type.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is ReleaseType.Mixtape -> R.string.lbl_mixtapes 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 }) { for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
data.add(Header(entry.key)) data.add(Header(entry.key.headerTitleRes))
data.addAll(entry.value) data.addAll(entry.value)
} }
data.add(SortHeader(R.string.lbl_songs)) // Artists may not be linked to any songs, only include a header entry if we have any.
data.addAll(artistSort.songs(artist.songs)) if (artist.songs.isNotEmpty()) {
_artistData.value = data.toList() 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") logD("Refreshing genre data")
val data = mutableListOf<Item>(genre) 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.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSort.songs(genre.songs)) data.addAll(genreSort.songs(genre.songs))
_genreData.value = data _genreList.value = data
} }
// --- CALLBACKS --- /**
* A simpler mapping of [Album.Type] used for grouping and sorting songs.
override fun onLibraryChanged(library: MusicStore.Library?) { * @param headerTitleRes The title string resource to use for a header created out of an
if (library != null) { * instance of this enum.
val song = currentSong.value */
if (song != null) { private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
logD("Song changed, refreshing data") ALBUMS(R.string.lbl_albums),
val newSong = library.sanitize(song.song) EPS(R.string.lbl_eps),
if (newSong != null) { SINGLES(R.string.lbl_singles),
generateDetailSong(newSong) COMPILATIONS(R.string.lbl_compilations),
} else { SOUNDTRACKS(R.string.lbl_soundtracks),
_currentSong.value = null MIXES(R.string.lbl_mixes),
} MIXTAPES(R.string.lbl_mixtapes),
} LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group),
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)
} }
} }
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()
}

View file

@ -21,7 +21,6 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
@ -30,35 +29,33 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song 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.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.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* A fragment that shows information for a particular [Genre]. * A [ListFragment] that shows information for a particular [Genre].
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class GenreDetailFragment : class GenreDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
MenuFragment<FragmentDetailBinding>(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() 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 args: GenreDetailFragmentArgs by navArgs()
private val detailAdapter = GenreDetailAdapter(this) private val detailAdapter = GenreDetailAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -70,9 +67,13 @@ class GenreDetailFragment :
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { override fun getSelectionToolbar(binding: FragmentDetailBinding) =
detailModel.setGenreId(args.genreId) binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply { binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail) inflateMenu(R.menu.menu_genre_artist_detail)
setNavigationOnClickListener { findNavController().navigateUp() } setNavigationOnClickListener { findNavController().navigateUp() }
@ -82,12 +83,14 @@ class GenreDetailFragment :
binding.detailRecycler.adapter = detailAdapter binding.detailRecycler.adapter = detailAdapter
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
collectImmediately(detailModel.currentGenre, ::handleItemChange) detailModel.setGenreUid(args.genreUid)
collectImmediately(detailModel.genreData, detailAdapter::submitList) collectImmediately(detailModel.currentGenre, ::updateItem)
collectImmediately(detailModel.genreList, detailAdapter::submitList)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
} }
override fun onDestroyBinding(binding: FragmentDetailBinding) { override fun onDestroyBinding(binding: FragmentDetailBinding) {
@ -97,14 +100,19 @@ class GenreDetailFragment :
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
val currentGenre = unlikelyToBeNull(detailModel.currentGenre.value)
return when (item.itemId) { return when (item.itemId) {
R.id.action_play_next -> { R.id.action_play_next -> {
playbackModel.playNext(unlikelyToBeNull(detailModel.currentGenre.value)) playbackModel.playNext(currentGenre)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
R.id.action_queue_add -> { R.id.action_queue_add -> {
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentGenre.value)) playbackModel.addToQueue(currentGenre)
requireContext().showToast(R.string.lng_queue_added) requireContext().showToast(R.string.lng_queue_added)
true true
} }
@ -112,32 +120,43 @@ class GenreDetailFragment :
} }
} }
override fun onItemClick(item: Item) { override fun onRealClick(music: Music) {
when (item) { when (music) {
is Artist -> navModel.exploreNavigateTo(music)
is Song -> is Song ->
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_GENRE) when (Settings(requireContext()).detailPlaybackMode) {
is Album -> // When configured to play from the selected item, we already have a Genre
findNavController() // to play from.
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id)) 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) { override fun onOpenMenu(item: Item, anchor: View) {
if (item is Song) { when (item) {
musicMenu(anchor, R.menu.menu_song_actions, 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() { override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), false) playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
} }
override fun onShuffleParent() { override fun onShuffle() {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value), true) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
} }
override fun onShowSortMenu(anchor: View) { override fun onOpenSortMenu(anchor: View) {
menu(anchor, R.menu.menu_genre_sort) { openMenu(anchor, R.menu.menu_genre_sort) {
val sort = detailModel.genreSort val sort = detailModel.genreSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending 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) { if (genre == null) {
// Genre we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
@ -163,21 +183,36 @@ class GenreDetailFragment :
requireBinding().detailToolbar.title = genre.resolveName(requireContext()) 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?) { private fun handleNavigation(item: Music?) {
when (item) { when (item) {
is Song -> { is Song -> {
logD("Navigating to another song") logD("Navigating to another song")
findNavController() findNavController()
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.id)) .navigate(GenreDetailFragmentDirections.actionShowAlbum(item.album.uid))
} }
is Album -> { is Album -> {
logD("Navigating to another album") logD("Navigating to another album")
findNavController().navigate(GenreDetailFragmentDirections.actionShowAlbum(item.id)) findNavController()
.navigate(GenreDetailFragmentDirections.actionShowAlbum(item.uid))
} }
is Artist -> { is Artist -> {
logD("Navigating to another artist") logD("Navigating to another artist")
findNavController() findNavController()
.navigate(GenreDetailFragmentDirections.actionShowArtist(item.id)) .navigate(GenreDetailFragmentDirections.actionShowArtist(item.uid))
} }
is Genre -> { is Genre -> {
navModel.finishExploreNavigation() navModel.finishExploreNavigation()
@ -186,12 +221,8 @@ class GenreDetailFragment :
} }
} }
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updateSelection(selected: List<Music>) {
if (parent is Genre && parent.id == unlikelyToBeNull(detailModel.currentGenre.value).id) { detailAdapter.setSelectedItems(selected)
detailAdapter.updateIndicator(song, isPlaying) requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
} else {
// Ignore song playback not from the genre
detailAdapter.updateIndicator(null, isPlaying)
}
} }
} }

View file

@ -25,11 +25,12 @@ import com.google.android.material.textfield.TextInputEditText
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
/** /**
* A [TextInputEditText] that deliberately restricts all input except for selection. Yes, this is a * A [TextInputEditText] that deliberately restricts all input except for selection. This will work
* blatant abuse of Material Design Guidelines, but I also don't want to figure out how to plain * just like a normal block of selectable/copyable text, but with nicer aesthetics.
* text selectable.
* *
* @author OxygenCobalt * Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
*
* @author Alexander Capehart (OxygenCobalt)
*/ */
class ReadOnlyTextInput class ReadOnlyTextInput
@JvmOverloads @JvmOverloads
@ -38,17 +39,18 @@ constructor(
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.editTextStyle defStyleAttr: Int = R.attr.editTextStyle
) : TextInputEditText(context, attrs, defStyleAttr) { ) : TextInputEditText(context, attrs, defStyleAttr) {
init { init {
// Enable selection, but still disable focus (i.e Keyboard opening)
setTextIsSelectable(true) setTextIsSelectable(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
focusable = View.FOCUSABLE_AUTO focusable = View.FOCUSABLE_AUTO
} }
} }
// Make text immutable
override fun getFreezesText() = false override fun getFreezesText() = false
// Prevent editing by default
override fun getDefaultEditable() = false override fun getDefaultEditable() = false
// Remove the movement method that allows cursor scrolling
override fun getDefaultMovementMethod() = null override fun getDefaultMovementMethod() = null
} }

View file

@ -21,23 +21,24 @@ import android.os.Bundle
import android.text.format.Formatter import android.text.format.Formatter
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone import androidx.core.view.isInvisible
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding 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.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately 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 * A [ViewBindingDialogFragment] that shows information about a Song.
* the properties of the audio file itself. * @author Alexander Capehart (OxygenCobalt)
* @author OxygenCobalt
*/ */
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() { class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
private val detailModel: DetailViewModel by androidActivityViewModels() 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() private val args: SongDetailDialogArgs by navArgs()
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
@ -50,46 +51,48 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
detailModel.setSongId(args.songId) // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.itemUid)
collectImmediately(detailModel.currentSong, ::updateSong) collectImmediately(detailModel.currentSong, ::updateSong)
} }
override fun onDestroy() { private fun updateSong(song: DetailSong?) {
super.onDestroy() if (song == null) {
detailModel.clearSong() // Song we were showing no longer exists.
} findNavController().navigateUp()
return
}
private fun updateSong(song: DetailViewModel.DetailSong?) {
val binding = requireBinding() 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) { val context = requireContext()
if (song.info != null) { binding.detailFileName.setText(song.song.path.name)
val context = requireContext() binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
binding.detailContainer.isGone = false binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context))
binding.detailFileName.setText(song.song.path.name) binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context)) binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
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))
if (song.info.bitrateKbps != null) { if (song.properties.bitrateKbps != null) {
binding.detailBitrate.setText( binding.detailBitrate.setText(
getString(R.string.fmt_bitrate, song.info.bitrateKbps)) getString(R.string.fmt_bitrate, song.properties.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)
}
} else { } 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 { } else {
findNavController().navigateUp() // Loading is still on-going, don't show anything yet.
binding.detailLoading.isInvisible = false
binding.detailContainer.isInvisible = true
} }
} }
} }

View file

@ -27,26 +27,38 @@ import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.detail.DiscHeader 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.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter import org.oxycblt.auxio.playback.formatDurationMs
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.context
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* An adapter for displaying [Album] information and it's children. * An [DetailAdapter] implementing the header and sub-items for the [Album] detail view.
* @author OxygenCobalt * @param listener A [Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumDetailAdapter(private val listener: Listener) : class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
DetailAdapter<AlbumDetailAdapter.Listener>(listener, DIFFER) { /**
* 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) = override fun getItemViewType(position: Int) =
when (differ.currentList[position]) { when (differ.currentList[position]) {
// Support the Album header, sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE is Album -> AlbumDetailViewHolder.VIEW_TYPE
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE
@ -61,162 +73,209 @@ class AlbumDetailAdapter(private val listener: Listener) :
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
override fun onBindViewHolder( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder: RecyclerView.ViewHolder, super.onBindViewHolder(holder, position)
position: Int, when (val item = differ.currentList[position]) {
payloads: List<Any> is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
) { is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
super.onBindViewHolder(holder, position, payloads) is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
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 isItemFullWidth(position: Int): Boolean { override fun isItemFullWidth(position: Int): Boolean {
// The album and disc headers should be full-width in all configurations.
val item = differ.currentList[position] val item = differ.currentList[position]
return super.isItemFullWidth(position) || item is Album || item is DiscHeader return super.isItemFullWidth(position) || item is Album || item is DiscHeader
} }
companion object { companion object {
private val DIFFER = /** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is Album && newItem is Album -> oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is DiscHeader && newItem is DiscHeader -> oldItem is DiscHeader && newItem is DiscHeader ->
DiscHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFFER.areItemsTheSame(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) : private class AlbumDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(item: Album, listener: AlbumDetailAdapter.Listener) { /**
binding.detailCover.bind(item) * Bind new data to this instance.
binding.detailType.text = binding.context.getString(item.releaseType.stringRes) * @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 { binding.detailSubhead.apply {
text = item.artist.resolveName(context) text = album.resolveArtistContents(context)
setOnClickListener { listener.onNavigateToArtist() }
// 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 { binding.detailInfo.apply {
val date = // Fall back to a friendlier "No date" text if the album doesn't have date information
item.date?.let { context.getString(R.string.fmt_number, it.year) } val date = album.date?.resolveDate(context) ?: context.getString(R.string.def_date)
?: context.getString(R.string.def_date) val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
val songCount = context.getPlural(R.plurals.fmt_song_count, item.songs.size)
val duration = item.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration) text = context.getString(R.string.fmt_three, date, songCount, duration)
} }
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
} }
companion object { companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_DETAIL 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) = fun new(parent: View) =
AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) AlbumDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Album>() { object : SimpleItemCallback<Album>() {
override fun areItemsTheSame(oldItem: Album, newItem: Album) = override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.artist.rawName == newItem.artist.rawName && oldItem.areArtistContentsTheSame(newItem) &&
oldItem.date == newItem.date && oldItem.date == newItem.date &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs && 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) { RecyclerView.ViewHolder(binding.root) {
/**
fun bind(item: DiscHeader) { * Bind new data to this instance.
binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, item.disc) * @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 { companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DISC_HEADER 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) = fun new(parent: View) =
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<DiscHeader>() { object : SimpleItemCallback<DiscHeader>() {
override fun areItemsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
oldItem.disc == newItem.disc 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) : private class AlbumSongViewHolder private constructor(private val binding: ItemAlbumSongBinding) :
IndicatorAdapter.ViewHolder(binding.root) { SelectionIndicatorAdapter.ViewHolder(binding.root) {
fun bind(item: Song, listener: MenuItemListener) { /**
// Hide the track number view if the song does not have a track. * Bind new data to this instance.
if (item.track != null) { * @param song The new [Song] to bind.
binding.songTrack.apply { * @param listener A [SelectableListListener] to bind interactions to.
text = context.getString(R.string.fmt_number, item.track) */
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 isInvisible = false
contentDescription = context.getString(R.string.desc_track_number, item.track) contentDescription = context.getString(R.string.desc_track_number, song.track)
} } else {
} else { // No track, do not show a number, instead showing a generic icon.
binding.songTrack.apply {
text = "" text = ""
isInvisible = true isInvisible = true
contentDescription = context.getString(R.string.def_track) contentDescription = context.getString(R.string.def_track)
} }
} }
binding.songName.text = item.resolveName(binding.context) binding.songName.text = song.resolveName(binding.context)
binding.songDuration.text = item.durationMs.formatDurationMs(false)
// binding.songMenu.setOnClickListener { listener.onOpenMenu(item, it) } // Use duration instead of album or artist for each song, as this text would
binding.root.setOnLongClickListener { // be homogenous otherwise.
listener.onOpenMenu(item, it) binding.songDuration.text = song.durationMs.formatDurationMs(false)
true
}
binding.root.setOnClickListener { listener.onItemClick(item) }
} }
override fun updateIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive binding.root.isSelected = isActive
binding.songTrackBg.isPlaying = isPlaying binding.songTrackBg.isPlaying = isPlaying
} }
override fun updateSelectionIndicator(isSelected: Boolean) {
binding.root.isActivated = isSelected
}
companion object { companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ALBUM_SONG 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) = fun new(parent: View) =
AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater)) AlbumSongViewHolder(ItemAlbumSongBinding.inflate(parent.context.inflater))
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Song>() { 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 oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs
} }
} }

View file

@ -19,35 +19,33 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Song 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.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* An adapter for displaying [Artist] information and it's children. Unlike the other adapters, this * A [DetailAdapter] implementing the header and sub-items for the [Artist] detail view.
* one actually contains both album information and song information. * @param listener A [DetailAdapter.Listener] to bind interactions to.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistDetailAdapter(private val listener: Listener) : class ArtistDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (differ.currentList[position]) { when (differ.currentList[position]) {
// Support an artist header, and special artist albums/songs.
is Artist -> ArtistDetailViewHolder.VIEW_TYPE is Artist -> ArtistDetailViewHolder.VIEW_TYPE
is Album -> ArtistAlbumViewHolder.VIEW_TYPE is Album -> ArtistAlbumViewHolder.VIEW_TYPE
is Song -> ArtistSongViewHolder.VIEW_TYPE is Song -> ArtistSongViewHolder.VIEW_TYPE
@ -62,148 +60,212 @@ class ArtistDetailAdapter(private val listener: Listener) :
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
override fun onBindViewHolder( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder: RecyclerView.ViewHolder, super.onBindViewHolder(holder, position)
position: Int, // Re-binding an item with new data and not just a changed selection/playing state.
payloads: List<Any> when (val item = differ.currentList[position]) {
) { is Artist -> (holder as ArtistDetailViewHolder).bind(item, listener)
super.onBindViewHolder(holder, position, payloads) is Album -> (holder as ArtistAlbumViewHolder).bind(item, listener)
is Song -> (holder as ArtistSongViewHolder).bind(item, listener)
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 isItemFullWidth(position: Int): Boolean { override fun isItemFullWidth(position: Int): Boolean {
// Artist headers should be full-width in all configurations.
val item = differ.currentList[position] val item = differ.currentList[position]
return super.isItemFullWidth(position) || item is Artist return super.isItemFullWidth(position) || item is Artist
} }
companion object { companion object {
private val DIFFER = /** A comparator that can be used with DiffUtil. */
private val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is Artist && newItem is Artist -> oldItem is Artist && newItem is Artist ->
ArtistDetailViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) ArtistDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(
oldItem, newItem)
oldItem is Album && newItem is Album -> oldItem is Album && newItem is Album ->
ArtistAlbumViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) ArtistAlbumViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
ArtistSongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) ArtistSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFFER.areItemsTheSame(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) : private class ArtistDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) { 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.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 if (artist.songs.isNotEmpty()) {
// the most "Prominent" genre. // Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.text = binding.detailSubhead.apply {
item.songs isVisible = true
.groupBy { it.genre.resolveName(binding.context) } text = artist.resolveGenreContents(binding.context)
.entries }
.maxByOrNull { it.value.size }
?.key
?: binding.context.getString(R.string.def_genre)
binding.detailInfo.text = // Song and album counts map to the info
binding.context.getString( binding.detailInfo.text =
R.string.fmt_two, binding.context.getString(
binding.context.getPlural(R.plurals.fmt_album_count, item.albums.size), R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size)) 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() } // In the case that this header used to he configured to have no songs,
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } // 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 { companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_DETAIL 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) = fun new(parent: View) =
ArtistDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) 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( * A [RecyclerView.ViewHolder] that displays an [Album] in the context of an [Artist]. Use [new] to
private val binding: ItemParentBinding, * create an instance.
) : IndicatorAdapter.ViewHolder(binding.root) { * @author Alexander Capehart (OxygenCobalt)
fun bind(item: Album, listener: MenuItemListener) { */
binding.parentImage.bind(item) private class ArtistAlbumViewHolder private constructor(private val binding: ItemParentBinding) :
binding.parentName.text = item.resolveName(binding.context) SelectionIndicatorAdapter.ViewHolder(binding.root) {
binding.parentInfo.text = item.date.resolveYear(binding.context) /**
// binding.parentMenu.setOnClickListener { listener.onOpenMenu(item, it) } * Bind new data to this instance.
binding.root.setOnLongClickListener { * @param album The new [Album] to bind.
listener.onOpenMenu(item, it) * @param listener An [SelectableListListener] to bind interactions to.
true */
} fun bind(album: Album, listener: SelectableListListener) {
binding.root.setOnClickListener { listener.onItemClick(item) } 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive binding.root.isSelected = isActive
binding.parentImage.isPlaying = isPlaying binding.parentImage.isPlaying = isPlaying
} }
override fun updateSelectionIndicator(isSelected: Boolean) {
binding.root.isActivated = isSelected
}
companion object { companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_ALBUM 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) = fun new(parent: View) =
ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater)) ArtistAlbumViewHolder(ItemParentBinding.inflate(parent.context.inflater))
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Album>() { 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 oldItem.rawName == newItem.rawName && oldItem.date == newItem.date
} }
} }
} }
private class ArtistSongViewHolder /**
private constructor( * A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Artist]. Use [new] to
private val binding: ItemSongBinding, * create an instance.
) : IndicatorAdapter.ViewHolder(binding.root) { * @author Alexander Capehart (OxygenCobalt)
fun bind(item: Song, listener: MenuItemListener) { */
binding.songAlbumCover.bind(item) private class ArtistSongViewHolder private constructor(private val binding: ItemSongBinding) :
binding.songName.text = item.resolveName(binding.context) SelectionIndicatorAdapter.ViewHolder(binding.root) {
binding.songInfo.text = item.album.resolveName(binding.context) /**
// binding.songMenu.setOnClickListener { listener.onOpenMenu(item, it) } * Bind new data to this instance.
binding.root.setOnLongClickListener { * @param song The new [Song] to bind.
listener.onOpenMenu(item, it) * @param listener An [SelectableListListener] to bind interactions to.
true */
} fun bind(song: Song, listener: SelectableListListener) {
binding.root.setOnClickListener { listener.onItemClick(item) } 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
binding.root.isActivated = isActive binding.root.isSelected = isActive
binding.songAlbumCover.isPlaying = isPlaying binding.songAlbumCover.isPlaying = isPlaying
} }
override fun updateSelectionIndicator(isSelected: Boolean) {
binding.root.isActivated = isSelected
}
companion object { companion object {
/** Unique ID for this ViewHolder type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_ARTIST_SONG 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) = fun new(parent: View) =
ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater)) ArtistSongViewHolder(ItemSongBinding.inflate(parent.context.inflater))
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Song>() { object : SimpleItemCallback<Song>() {
override fun areItemsTheSame(oldItem: Song, newItem: Song) = override fun areContentsTheSame(oldItem: Song, newItem: Song) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.album.rawName == newItem.album.rawName oldItem.album.rawName == newItem.album.rawName
} }

View file

@ -26,26 +26,30 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.detail.SortHeader import org.oxycblt.auxio.detail.SortHeader
import org.oxycblt.auxio.ui.recycler.AuxioRecyclerView import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.ui.recycler.Header import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.ui.recycler.HeaderViewHolder import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter import org.oxycblt.auxio.list.recycler.*
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.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
abstract class DetailAdapter<L : DetailAdapter.Listener>( /**
private val listener: L, * A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
diffCallback: DiffUtil.ItemCallback<Item> * @param listener A [Listener] to bind interactions to.
) : IndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup { * @param itemCallback A [DiffUtil.ItemCallback] to use with [AsyncListDiffer] when updating the
private var isPlaying = false * internal list.
* @author Alexander Capehart (OxygenCobalt)
@Suppress("LeakingThis") override fun getItemCount() = differ.currentList.size */
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) = override fun getItemViewType(position: Int) =
when (differ.currentList[position]) { when (differ.currentList[position]) {
// Implement support for headers and sort headers
is Header -> HeaderViewHolder.VIEW_TYPE is Header -> HeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
@ -58,82 +62,109 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
else -> error("Invalid item type $viewType") else -> error("Invalid item type $viewType")
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) = override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
throw IllegalStateException() when (val item = differ.currentList[position]) {
is Header -> (holder as HeaderViewHolder).bind(item)
override fun onBindViewHolder( is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
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)
}
} }
super.onBindViewHolder(holder, position, payloads)
} }
override fun isItemFullWidth(position: Int): Boolean { override fun isItemFullWidth(position: Int): Boolean {
// Headers should be full-width in all configurations.
val item = differ.currentList[position] val item = differ.currentList[position]
return item is Header || item is SortHeader return item is Header || item is SortHeader
} }
protected val differ = AsyncListDiffer(this, diffCallback)
override val currentList: List<Item> override val currentList: List<Item>
get() = differ.currentList 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 { companion object {
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is Header && newItem is Header -> oldItem is Header && newItem is Header ->
HeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader -> oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> false 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) { 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 { 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) TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener(listener::onShowSortMenu) setOnClickListener(listener::onOpenSortMenu)
} }
} }
companion object { companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SORT_HEADER 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) = fun new(parent: View) =
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater)) SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<SortHeader>() { object : SimpleItemCallback<SortHeader>() {
override fun areItemsTheSame(oldItem: SortHeader, newItem: SortHeader) = override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
oldItem.string == newItem.string oldItem.titleRes == newItem.titleRes
} }
} }
} }

View file

@ -19,32 +19,35 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailBinding 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.Genre
import org.oxycblt.auxio.music.Song 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.context
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* An adapter for displaying genre information and it's children. * An [DetailAdapter] implementing the header and sub-items for the [Genre] detail view.
* @author OxygenCobalt * @param listener A [DetailAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/ */
class GenreDetailAdapter(private val listener: Listener) : class GenreDetailAdapter(private val listener: Listener) : DetailAdapter(listener, DIFF_CALLBACK) {
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null
private var isPlaying = false
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (differ.currentList[position]) { 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 Genre -> GenreDetailViewHolder.VIEW_TYPE
is Artist -> ArtistViewHolder.VIEW_TYPE
is Song -> SongViewHolder.VIEW_TYPE is Song -> SongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
@ -52,68 +55,88 @@ class GenreDetailAdapter(private val listener: Listener) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.new(parent) GenreDetailViewHolder.VIEW_TYPE -> GenreDetailViewHolder.new(parent)
ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.new(parent)
SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent) SongViewHolder.VIEW_TYPE -> SongViewHolder.new(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
override fun onBindViewHolder( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder: RecyclerView.ViewHolder, super.onBindViewHolder(holder, position)
position: Int, when (val item = differ.currentList[position]) {
payloads: List<Any> is Genre -> (holder as GenreDetailViewHolder).bind(item, listener)
) { is Artist -> (holder as ArtistViewHolder).bind(item, listener)
super.onBindViewHolder(holder, position, payloads) is Song -> (holder as SongViewHolder).bind(item, listener)
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 isItemFullWidth(position: Int): Boolean { override fun isItemFullWidth(position: Int): Boolean {
// Genre headers should be full-width in all configurations
val item = differ.currentList[position] val item = differ.currentList[position]
return super.isItemFullWidth(position) || item is Genre return super.isItemFullWidth(position) || item is Genre
} }
companion object { companion object {
val DIFFER = val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() { object : SimpleItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is Genre && newItem is Genre -> 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 -> oldItem is Song && newItem is Song ->
SongViewHolder.DIFFER.areItemsTheSame(oldItem, newItem) SongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> DetailAdapter.DIFFER.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) : private class GenreDetailViewHolder private constructor(private val binding: ItemDetailBinding) :
RecyclerView.ViewHolder(binding.root) { 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.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = item.resolveName(binding.context) binding.detailName.text = genre.resolveName(binding.context)
binding.detailSubhead.text = // Nothing about a genre is applicable to the sub-head text.
binding.context.getPlural(R.plurals.fmt_song_count, item.songs.size) binding.detailSubhead.isVisible = false
binding.detailInfo.text = item.durationMs.formatDurationMs(false) // The song count of the genre maps to the info text.
binding.detailPlayButton.setOnClickListener { listener.onPlayParent() } binding.detailInfo.text =
binding.detailShuffleButton.setOnClickListener { listener.onShuffleParent() } 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 { companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_GENRE_DETAIL 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) = fun new(parent: View) =
GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater)) GenreDetailViewHolder(ItemDetailBinding.inflate(parent.context.inflater))
val DIFFER = /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Genre>() { object : SimpleItemCallback<Genre>() {
override fun areItemsTheSame(oldItem: Genre, newItem: Genre) = override fun areContentsTheSame(oldItem: Genre, newItem: Genre) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs oldItem.durationMs == newItem.durationMs

View file

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

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A [FrameLayout] that automatically applies bottom insets. * A [FrameLayout] that automatically applies bottom insets.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class EdgeFrameLayout class EdgeFrameLayout
@JvmOverloads @JvmOverloads
@ -37,7 +37,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { 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() translationY = -insets.systemBarInsetsCompat.bottom.toFloat()
return insets return insets
} }

View file

@ -23,12 +23,13 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
@ -39,37 +40,43 @@ import com.google.android.material.transition.MaterialSharedAxis
import java.lang.reflect.Field import java.lang.reflect.Field
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.home.list.AlbumListFragment import org.oxycblt.auxio.home.list.AlbumListFragment
import org.oxycblt.auxio.home.list.ArtistListFragment import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment 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.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.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
/** /**
* The main "Launching Point" fragment of Auxio, allowing navigation to the detail views for each * The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
* respective item. * to other views.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuItemClickListener { class HomeFragment :
private val playbackModel: PlaybackViewModel by androidActivityViewModels() SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
private val navModel: NavigationViewModel by activityViewModels()
private val homeModel: HomeViewModel by androidActivityViewModels() private val homeModel: HomeViewModel by androidActivityViewModels()
private val musicModel: MusicViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
// lifecycleObject builds this in the creation step, so doing this is okay. // lifecycleObject builds this in the creation step, so doing this is okay.
private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject { private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject {
registerForActivityResult(ActivityResultContracts.RequestPermission()) { registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reindex() musicModel.refresh()
} }
} }
@ -86,28 +93,21 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// our transitions. // our transitions.
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1) val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1)
if (axis > -1) { if (axis > -1) {
initAxisTransitions(axis) setupAxisTransitions(axis)
} }
} }
} }
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentHomeBinding) = binding.homeSelectionToolbar
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
binding.homeAppbar.apply { super.onBindingCreated(binding, savedInstanceState)
addOnOffsetChangedListener { _, offset ->
val range = binding.homeAppbar.totalScrollRange
binding.homeToolbar.alpha = 1f - (abs(offset.toFloat()) / (range.toFloat() / 2)) // --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeContent.updatePadding( binding.homeToolbar.setOnMenuItemClickListener(this)
bottom = binding.homeAppbar.totalScrollRange + offset)
}
}
binding.homeToolbar.setOnMenuItemClickListener(this@HomeFragment)
updateTabConfiguration()
// Load the track color in manually as it's unclear whether the track actually supports // 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
@ -115,39 +115,49 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
requireContext().getColorCompat(R.color.sel_track).defaultColor requireContext().getColorCompat(R.color.sel_track).defaultColor
binding.homePager.apply { 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 // 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 // limit to that. This also prevents the appbar lift state from being confused during
// page transitions. // page transitions.
offscreenPageLimit = homeModel.tabs.size offscreenPageLimit = homeModel.currentTabModes.size
reduceSensitivity(3) // By default, ViewPager2's sensitivity is high enough to result in vertical scroll
// events being registered as horizontal scroll events. Reflect into the internal
registerOnPageChangeCallback( // RecyclerView and change the touch slope so that touch actions will act more as a
object : ViewPager2.OnPageChangeCallback() { // scroll than as a swipe. Derived from:
override fun onPageSelected(position: Int) = // https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
homeModel.updateCurrentTab(position) 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)
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 }
} }
// Further initialization must be done in the function that also handles
// re-creating the ViewPager.
setupPager(binding)
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() } binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.shouldRecreate, ::handleRecreate)
collect(homeModel.recreateTabs, ::handleRecreateTabs) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.currentTab, ::updateCurrentTab) collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.libraryExists, homeModel.isFastScrolling, ::updateFab) collectImmediately(musicModel.indexerState, ::updateIndexerState)
collectImmediately(musicModel.indexerState, ::handleIndexerState)
collect(navModel.exploreNavigationItem, ::handleNavigation) collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
@ -161,111 +171,77 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
override fun onDestroyBinding(binding: FragmentHomeBinding) { override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding) super.onDestroyBinding(binding)
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null) 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 { override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
when (item.itemId) { when (item.itemId) {
// Handle main actions (Search, Settings, About)
R.id.action_search -> { R.id.action_search -> {
logD("Navigating to search") logD("Navigating to search")
initAxisTransitions(MaterialSharedAxis.Z) setupAxisTransitions(MaterialSharedAxis.Z)
findNavController().navigate(HomeFragmentDirections.actionShowSearch()) findNavController().navigate(HomeFragmentDirections.actionShowSearch())
} }
R.id.action_settings -> { R.id.action_settings -> {
logD("Navigating to settings") logD("Navigating to settings")
navModel.mainNavigateTo(MainNavigationAction.Settings) navModel.mainNavigateTo(
MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
} }
R.id.action_about -> { R.id.action_about -> {
logD("Navigating to about") logD("Navigating to about")
navModel.mainNavigateTo(MainNavigationAction.About) navModel.mainNavigateTo(
MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
} }
// Handle sort menu
R.id.submenu_sorting -> { R.id.submenu_sorting -> {
// Junk click event when opening the menu // Junk click event when opening the menu
} }
R.id.option_sort_asc -> { R.id.option_sort_asc -> {
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
homeModel.updateCurrentSort( homeModel.setSortForCurrentTab(
homeModel homeModel
.getSortForDisplay(homeModel.currentTab.value) .getSortForTab(homeModel.currentTabMode.value)
.withAscending(item.isChecked)) .withAscending(item.isChecked))
} }
else -> { else -> {
// Sorting option was selected, mark it as selected and update the mode // Sorting option was selected, mark it as selected and update the mode
item.isChecked = true item.isChecked = true
homeModel.updateCurrentSort( homeModel.setSortForCurrentTab(
homeModel homeModel
.getSortForDisplay(homeModel.currentTab.value) .getSortForTab(homeModel.currentTabMode.value)
.withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId)))) .withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId))))
} }
} }
// Always handling it one way or another, so always return true
return true return true
} }
private fun updateCurrentTab(tab: DisplayMode) { private fun setupPager(binding: FragmentHomeBinding) {
// Make sure that we update the scrolling view and allowed menu items whenever binding.homePager.adapter =
// the tab changes. HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
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 updateSortMenu(displayMode: DisplayMode, isVisible: (Int) -> Boolean) { val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams
val sortMenu = requireNotNull(sortItem.subMenu) if (homeModel.currentTabModes.size == 1) {
val toHighlight = homeModel.getSortForDisplay(displayMode) // A single tab makes the tab layout redundant, hide it and disable the collapsing
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
// behavior. // behavior.
binding.homeTabs.isVisible = false binding.homeTabs.isVisible = false
binding.homeAppbar.setExpanded(true, 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_SCROLL or
AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS 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() val binding = requireBinding()
when (state) { when (state) {
is Indexer.State.Complete -> handleIndexerResponse(binding, state.response) is Indexer.State.Complete -> setupCompleteState(binding, state.response)
is Indexer.State.Indexing -> handleIndexingState(binding, state.indexing) is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
null -> { null -> {
logD("Indexer is in indeterminate state") logD("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE 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) { if (response is Indexer.Response.Ok) {
logD("Received ok response")
binding.homeFab.show() binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE binding.homeIndexingContainer.visibility = View.INVISIBLE
} else { } else {
logD("Received non-ok response")
val context = requireContext() val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
logD("Received non-ok response $response")
when (response) { when (response) {
is Indexer.Response.Err -> { 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) binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry) text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.reindex() } setOnClickListener { musicModel.refresh() }
} }
} }
is Indexer.Response.NoMusic -> { 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) binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry) text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.reindex() } setOnClickListener { musicModel.refresh() }
} }
} }
is Indexer.Response.NoPerms -> { 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) binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
text = context.getString(R.string.lbl_grant) 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.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingAction.visibility = View.INVISIBLE binding.homeIndexingAction.visibility = View.INVISIBLE
val context = requireContext()
when (indexing) { when (indexing) {
is Indexer.Indexing.Indeterminate -> { 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 binding.homeIndexingProgress.isIndeterminate = true
} }
is Indexer.Indexing.Songs -> { is Indexer.Indexing.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingStatus.text = 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 { binding.homeIndexingProgress.apply {
isIndeterminate = false isIndeterminate = false
max = indexing.total 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() 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() binding.homeFab.hide()
} else { } else {
binding.homeFab.show() binding.homeFab.show()
@ -372,23 +423,32 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
private fun handleNavigation(item: Music?) { private fun handleNavigation(item: Music?) {
val action = val action =
when (item) { when (item) {
is Song -> HomeFragmentDirections.actionShowAlbum(item.album.id) is Song -> HomeFragmentDirections.actionShowAlbum(item.album.uid)
is Album -> HomeFragmentDirections.actionShowAlbum(item.id) is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
is Artist -> HomeFragmentDirections.actionShowArtist(item.id) is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
is Genre -> HomeFragmentDirections.actionShowGenre(item.id) is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
else -> return else -> return
} }
initAxisTransitions(MaterialSharedAxis.X) setupAxisTransitions(MaterialSharedAxis.X)
findNavController().navigate(action) findNavController().navigate(action)
} }
private fun initAxisTransitions(axis: Int) { private fun updateSelection(selected: List<Music>) {
// Sanity check val binding = requireBinding()
if (axis != MaterialSharedAxis.X && axis != MaterialSharedAxis.Z) { if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
logW("Invalid axis provided") selected.isNotEmpty()) {
return 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) 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 * Get the ID of the RecyclerView contained by [ViewPager2] tab represented with the given
* registered as horizontal scroll events. Reflect into the internal recyclerview and change the * [MusicMode].
* touch slope so that touch actions will act more as a scroll than as a swipe. Derived from: * @param tabMode The [MusicMode] of the tab.
* https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414 * @return The ID of the RecyclerView contained by the given tab.
*/ */
private fun ViewPager2.reduceSensitivity(by: Int) { private fun getTabRecyclerId(tabMode: MusicMode) =
val recycler = VIEW_PAGER_RECYCLER_FIELD.get(this@reduceSensitivity) when (tabMode) {
val slop = VIEW_PAGER_TOUCH_SLOP_FIELD.get(recycler) as Int MusicMode.SONGS -> R.id.home_song_recycler
VIEW_PAGER_TOUCH_SLOP_FIELD.set(recycler, slop * by) MusicMode.ALBUMS -> R.id.home_album_recycler
} MusicMode.ARTISTS -> R.id.home_artist_recycler
MusicMode.GENRES -> R.id.home_genre_recycler
/** 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()
}
} }
/**
* [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 { companion object {
private val VIEW_PAGER_RECYCLER_FIELD: Field by private val VP_RECYCLER_FIELD: Field by
lazyReflectedField(ViewPager2::class, "mRecyclerView") 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") lazyReflectedField(RecyclerView::class, "mTouchSlop")
private const val KEY_LAST_TRANSITION_AXIS = private const val KEY_LAST_TRANSITION_AXIS =
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS" BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"

View file

@ -26,135 +26,184 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.application
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state. * The ViewModel for managing the tab data and lists of the home view.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class HomeViewModel(application: Application) : class HomeViewModel(application: Application) :
AndroidViewModel(application), Settings.Callback, MusicStore.Callback { AndroidViewModel(application), Settings.Callback, MusicStore.Callback {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val settings = Settings(application, this) private val settings = Settings(application, this)
private val _songs = MutableStateFlow(listOf<Song>()) private val _songsList = MutableStateFlow(listOf<Song>())
val songs: StateFlow<List<Song>> /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
get() = _songs val songLists: StateFlow<List<Song>>
get() = _songsList
private val _albums = MutableStateFlow(listOf<Album>()) private val _albumsLists = MutableStateFlow(listOf<Album>())
val albums: StateFlow<List<Album>> /** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */
get() = _albums val albumsList: StateFlow<List<Album>>
get() = _albumsLists
private val _artists = MutableStateFlow(listOf<Artist>()) private val _artistsList = MutableStateFlow(listOf<Artist>())
val artists: MutableStateFlow<List<Artist>> /**
get() = _artists * 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>()) private val _genresList = MutableStateFlow(listOf<Genre>())
val genres: StateFlow<List<Genre>> /** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */
get() = _genres val genresList: StateFlow<List<Genre>>
get() = _genresList
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
/** /**
* Marker to recreate all library tabs, usually initiated by a settings change. When this flag * A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible
* is set, all tabs (and their respective viewpager fragments) will be recreated from scratch. * [Tab]s.
*/ */
private val _shouldRecreateTabs = MutableStateFlow(false) var currentTabModes: List<MusicMode> = makeTabModes()
val recreateTabs: StateFlow<Boolean> = _shouldRecreateTabs 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) 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 val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
init { init {
musicStore.addCallback(this) 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() { override fun onCleared() {
super.onCleared() super.onCleared()
musicStore.removeCallback(this) musicStore.removeCallback(this)
settings.release() 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 }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.ui.fastscroll package org.oxycblt.auxio.home.fastscroll
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
@ -35,20 +35,20 @@ import androidx.core.widget.TextViewCompat
import com.google.android.material.textview.MaterialTextView import com.google.android.material.textview.MaterialTextView
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenSize import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.isRtl import org.oxycblt.auxio.util.isRtl
/** /**
* Internal view responsible for the fast scroller popup. * A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
* @author OxygenCobalt, Hai Zhang * @author Alexander Capehart (OxygenCobalt), Hai Zhang
*/ */
class FastScrollPopupView class FastScrollPopupView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
MaterialTextView(context, attrs, defStyleRes) { MaterialTextView(context, attrs, defStyleRes) {
init { init {
minimumWidth = context.getDimenSize(R.dimen.fast_scroll_popup_min_width) minimumWidth = context.getDimenPixels(R.dimen.fast_scroll_popup_min_width)
minimumHeight = context.getDimenSize(R.dimen.fast_scroll_popup_min_height) minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge) TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(context.getAttrColorCompat(R.attr.colorOnSecondary)) setTextColor(context.getAttrColorCompat(R.attr.colorOnSecondary))
@ -57,7 +57,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
includeFontPadding = false includeFontPadding = false
alpha = 0f alpha = 0f
elevation = context.getDimenSize(R.dimen.elevation_normal).toFloat() elevation = context.getDimenPixels(R.dimen.elevation_normal).toFloat()
background = FastScrollPopupDrawable(context) background = FastScrollPopupDrawable(context)
} }
@ -72,8 +72,8 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
private val path = Path() private val path = Path()
private val matrix = Matrix() private val matrix = Matrix()
private val paddingStart = context.getDimenSize(R.dimen.fast_scroll_popup_padding_start) private val paddingStart = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_start)
private val paddingEnd = context.getDimenSize(R.dimen.fast_scroll_popup_padding_end) private val paddingEnd = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_end)
override fun draw(canvas: Canvas) { override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint) canvas.drawPath(path, paint)
@ -170,7 +170,7 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0)
} }
companion object { companion object {
// Pre-calculate sqrt(2) for faster drawing // Pre-calculate sqrt(2)
private const val SQRT2 = 1.4142135f private const val SQRT2 = 1.4142135f
} }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.ui.fastscroll package org.oxycblt.auxio.home.fastscroll
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
@ -35,12 +35,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.recycler.AuxioRecyclerView import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
import org.oxycblt.auxio.util.getDimenSize import org.oxycblt.auxio.util.*
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of * 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 drag listener
* - Added documentation * - 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 class FastScrollRecyclerView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) { AuxioRecyclerView(context, attrs, defStyleAttr) {
/** 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 // Thumb
private val thumbView = private val thumbView =
View(context).apply { View(context).apply {
@ -98,7 +118,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply { .apply {
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP 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 // Touch
private val minTouchTargetSize = 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 val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f private var downX = 0f
@ -133,33 +153,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
removeCallbacks(hideThumbRunnable) removeCallbacks(hideThumbRunnable)
showScrollbar() showScrollbar()
showPopup() showPopup()
listener?.onFastScrollStart()
} else { } else {
postAutoHideScrollbar() postAutoHideScrollbar()
hidePopup() hidePopup()
listener?.onFastScrollStop()
} }
listener?.onFastScrollingChanged(field)
} }
private val tRect = Rect() 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 var popupProvider: PopupProvider? = null
var listener: Listener? = 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
init { init {
overlay.add(thumbView) overlay.add(thumbView)
@ -208,13 +213,20 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight) 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 popupText: String
val provider = popupProvider val provider = popupProvider
if (firstPos != NO_POSITION && provider != null) { if (firstAdapterPos != NO_POSITION && provider != null) {
popupView.isInvisible = false popupView.isInvisible = false
// Get the popup text. If there is none, we default to "?". // Get the popup text. If there is none, we default to "?".
popupText = provider.getPopup(firstPos) ?: "?" popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else { } else {
// No valid position or provider, do not show the popup. // No valid position or provider, do not show the popup.
popupView.isInvisible = true 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 // Combine the previous item dimensions with the current item top to find our scroll
// position // position
getDecoratedBoundsWithMargins(getChildAt(0), tRect) 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 val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
// Then calculate the thumb position, which is just: // Then calculate the thumb position, which is just:
@ -332,7 +352,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (!dragging && if (!dragging &&
thumbView.isUnder(downX, thumbView.top.toFloat(), minTouchTargetSize) && thumbView.isUnder(downX, thumbView.top.toFloat(), minTouchTargetSize) &&
abs(eventY - downY) > touchSlop) { abs(eventY - downY) > touchSlop) {
if (thumbView.isUnder(downX, downY, minTouchTargetSize)) { if (thumbView.isUnder(downX, downY, minTouchTargetSize)) {
dragStartY = lastY dragStartY = lastY
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
@ -413,7 +432,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
showingThumb = true showingThumb = true
animateView(thumbView, 1f) animateViewIn(thumbView)
} }
private fun hideScrollbar() { private fun hideScrollbar() {
@ -422,7 +441,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
showingThumb = false showingThumb = false
animateView(thumbView, 0f) animateViewOut(thumbView)
} }
private fun showPopup() { private fun showPopup() {
@ -431,7 +450,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
showingPopup = true showingPopup = true
animateView(popupView, 1f) animateViewIn(popupView)
} }
private fun hidePopup() { private fun hidePopup() {
@ -440,11 +459,23 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
showingPopup = false showingPopup = false
animateView(popupView, 0f) animateViewOut(popupView)
} }
private fun animateView(view: View, alpha: Float) { private fun animateViewIn(view: View) {
view.animate().alpha(alpha).setDuration(ANIM_MILLIS).start() 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 --- // --- LAYOUT STATE ---
@ -474,21 +505,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val scrollOffsetRange: Int private val scrollOffsetRange: Int
get() = scrollRange - height 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 private val itemHeight: Int
get() { get() {
if (childCount == 0) { if (childCount == 0) {
@ -509,7 +525,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
companion object { companion object {
private const val ANIM_MILLIS = 150L
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500 private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
} }
} }

View file

@ -19,59 +19,83 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup 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.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding 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.Album
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.recycler.AlbumViewHolder import org.oxycblt.auxio.playback.secsToMs
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.util.collectImmediately 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. * A [ListFragment] that shows a list of [Album]s.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumListFragment : HomeListFragment<Album>() { class AlbumListFragment :
private val homeAdapter = AlbumAdapter(this) ListFragment<FragmentHomeListBinding>(),
private val formatterSb = StringBuilder(32) 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) private val formatter = Formatter(formatterSb)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply { binding.homeRecycler.apply {
id = R.id.home_album_list id = R.id.home_album_recycler
adapter = homeAdapter adapter = albumAdapter
popupProvider = this@AlbumListFragment
listener = this@AlbumListFragment
} }
collectImmediately(homeModel.albums, homeAdapter::replaceList) collectImmediately(homeModel.albumsList, albumAdapter::replaceList)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent) 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? { override fun getPopup(pos: Int): String? {
val album = homeModel.albums.value[pos] val album = homeModel.albumsList.value[pos]
// Change how we display the popup depending on the current sort mode.
// Change how we display the popup depending on the mode. return when (homeModel.getSortForTab(MusicMode.ALBUMS).mode) {
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ALBUMS).mode) {
// By Name -> Use Name // 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 // By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> album.artist.sortName?.run { first().uppercase() } is Sort.Mode.ByArtist ->
album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year // Year -> Use Full Year
is Sort.Mode.ByYear -> album.date?.resolveYear(requireContext()) is Sort.Mode.ByDate -> album.date?.resolveDate(requireContext())
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> album.durationMs.formatDurationMs(false)
@ -97,47 +121,47 @@ class AlbumListFragment : HomeListFragment<Album>() {
} }
} }
override fun onItemClick(item: Item) { override fun onFastScrollingChanged(isFastScrolling: Boolean) {
check(item is Music) homeModel.setFastScrolling(isFastScrolling)
navModel.exploreNavigateTo(item) }
override fun onRealClick(music: Music) {
check(music is Album) { "Unexpected datatype: ${music::class.java}" }
navModel.exploreNavigateTo(music)
} }
override fun onOpenMenu(item: Item, anchor: View) { override fun onOpenMenu(item: Item, anchor: View) {
when (item) { check(item is Album) { "Unexpected datatype: ${item::class.java}" }
is Album -> musicMenu(anchor, R.menu.menu_album_actions, item) openMusicMenu(anchor, R.menu.menu_album_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
}
} }
private fun handleParent(parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album) { // If an album is playing, highlight it within this adapter.
homeAdapter.updateIndicator(parent, isPlaying) albumAdapter.setPlayingItem(parent as? Album, isPlaying)
} else {
// Ignore playback not from albums
homeAdapter.updateIndicator(null, isPlaying)
}
} }
private class AlbumAdapter(private val listener: MenuItemListener) : /**
IndicatorAdapter<AlbumViewHolder>() { * A [SelectionIndicatorAdapter] that shows a list of [Album]s using [AlbumViewHolder].
private val differ = SyncListDiffer(this, AlbumViewHolder.DIFFER) * @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> override val currentList: List<Item>
get() = differ.currentList get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumViewHolder.new(parent) AlbumViewHolder.new(parent)
override fun onBindViewHolder(holder: AlbumViewHolder, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: AlbumViewHolder, position: Int) {
super.onBindViewHolder(holder, position, payloads) holder.bind(differ.currentList[position], listener)
if (payloads.isEmpty()) {
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>) { fun replaceList(newList: List<Album>) {
differ.replaceList(newList) differ.replaceList(newList)
} }

View file

@ -18,106 +18,125 @@
package org.oxycblt.auxio.home.list package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.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.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.playback.formatDurationMs
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.util.collectImmediately 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. * A [ListFragment] that shows a list of [Artist]s.
* @author OxygenCobalt * @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) private val homeAdapter = ArtistAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply { binding.homeRecycler.apply {
id = R.id.home_artist_list id = R.id.home_artist_recycler
adapter = homeAdapter adapter = homeAdapter
popupProvider = this@ArtistListFragment
listener = this@ArtistListFragment
} }
collectImmediately(homeModel.artists, homeAdapter::replaceList) collectImmediately(homeModel.artistsList, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handleParent) 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? { override fun getPopup(pos: Int): String? {
val artist = homeModel.artists.value[pos] val artist = homeModel.artistsList.value[pos]
// Change how we display the popup depending on the current sort mode.
// Change how we display the popup depending on the mode. return when (homeModel.getSortForTab(MusicMode.ARTISTS).mode) {
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_ARTISTS).mode) {
// By Name -> Use Name // 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 // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
// Count -> Use song count // 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 // Unsupported sort, error gracefully
else -> null else -> null
} }
} }
override fun onItemClick(item: Item) { override fun onFastScrollingChanged(isFastScrolling: Boolean) {
check(item is Music) homeModel.setFastScrolling(isFastScrolling)
navModel.exploreNavigateTo(item) }
override fun onRealClick(music: Music) {
check(music is Artist) { "Unexpected datatype: ${music::class.java}" }
navModel.exploreNavigateTo(music)
} }
override fun onOpenMenu(item: Item, anchor: View) { override fun onOpenMenu(item: Item, anchor: View) {
when (item) { check(item is Artist) { "Unexpected datatype: ${item::class.java}" }
is Artist -> musicMenu(anchor, R.menu.menu_genre_artist_actions, item) openMusicMenu(anchor, R.menu.menu_artist_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
}
} }
private fun handleParent(parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
if (parent is Artist) { // If an artist is playing, highlight it within this adapter.
homeAdapter.updateIndicator(parent, isPlaying) homeAdapter.setPlayingItem(parent as? Artist, isPlaying)
} else {
// Ignore playback not from artists
homeAdapter.updateIndicator(null, isPlaying)
}
} }
private class ArtistAdapter(private val listener: MenuItemListener) : /**
IndicatorAdapter<ArtistViewHolder>() { * A [SelectionIndicatorAdapter] that shows a list of [Artist]s using [ArtistViewHolder].
private val differ = SyncListDiffer(this, ArtistViewHolder.DIFFER) * @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> override val currentList: List<Item>
get() = differ.currentList get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistViewHolder.new(parent) ArtistViewHolder.new(parent)
override fun onBindViewHolder( override fun onBindViewHolder(holder: ArtistViewHolder, position: Int) {
holder: ArtistViewHolder, holder.bind(differ.currentList[position], listener)
position: Int,
payloads: List<Any>
) {
super.onBindViewHolder(holder, position, payloads)
if (payloads.isEmpty()) {
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>) { fun replaceList(newList: List<Artist>) {
differ.replaceList(newList) differ.replaceList(newList)
} }

View file

@ -18,49 +18,71 @@
package org.oxycblt.auxio.home.list package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.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.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.playback.formatDurationMs
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.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDurationMs
/** /**
* A [HomeListFragment] for showing a list of [Genre]s. * A [ListFragment] that shows a list of [Genre]s.
* @author OxygenCobalt * @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) private val homeAdapter = GenreAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply { binding.homeRecycler.apply {
id = R.id.home_genre_list id = R.id.home_genre_recycler
adapter = homeAdapter adapter = homeAdapter
popupProvider = this@GenreListFragment
listener = this@GenreListFragment
} }
collectImmediately(homeModel.genres, homeAdapter::replaceList) collectImmediately(homeModel.genresList, homeAdapter::replaceList)
collectImmediately(playbackModel.parent, playbackModel.isPlaying, ::handlePlayback) 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? { override fun getPopup(pos: Int): String? {
val genre = homeModel.genres.value[pos] val genre = homeModel.genresList.value[pos]
// Change how we display the popup depending on the current sort mode.
// Change how we display the popup depending on the mode. return when (homeModel.getSortForTab(MusicMode.GENRES).mode) {
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_GENRES).mode) {
// By Name -> Use Name // 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 // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -73,47 +95,47 @@ class GenreListFragment : HomeListFragment<Genre>() {
} }
} }
override fun onItemClick(item: Item) { override fun onFastScrollingChanged(isFastScrolling: Boolean) {
check(item is Music) homeModel.setFastScrolling(isFastScrolling)
navModel.exploreNavigateTo(item) }
override fun onRealClick(music: Music) {
check(music is Genre) { "Unexpected datatype: ${music::class.java}" }
navModel.exploreNavigateTo(music)
} }
override fun onOpenMenu(item: Item, anchor: View) { override fun onOpenMenu(item: Item, anchor: View) {
when (item) { check(item is Genre) { "Unexpected datatype: ${item::class.java}" }
is Genre -> musicMenu(anchor, R.menu.menu_genre_artist_actions, item) openMusicMenu(anchor, R.menu.menu_artist_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
}
} }
private fun handlePlayback(parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(parent: MusicParent?, isPlaying: Boolean) {
if (parent is Genre) { // If a genre is playing, highlight it within this adapter.
homeAdapter.updateIndicator(parent, isPlaying) homeAdapter.setPlayingItem(parent as? Genre, isPlaying)
} else {
// Ignore playback not from genres
homeAdapter.updateIndicator(null, isPlaying)
}
} }
private class GenreAdapter(private val listener: MenuItemListener) : /**
IndicatorAdapter<GenreViewHolder>() { * A [SelectionIndicatorAdapter] that shows a list of [Genre]s using [GenreViewHolder].
private val differ = SyncListDiffer(this, GenreViewHolder.DIFFER) * @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> override val currentList: List<Item>
get() = differ.currentList get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreViewHolder.new(parent) GenreViewHolder.new(parent)
override fun onBindViewHolder(holder: GenreViewHolder, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: GenreViewHolder, position: Int) {
super.onBindViewHolder(holder, position, payloads) holder.bind(differ.currentList[position], listener)
if (payloads.isEmpty()) {
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>) { fun replaceList(newList: List<Genre>) {
differ.replaceList(newList) differ.replaceList(newList)
} }

View file

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

View file

@ -19,67 +19,91 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import java.util.Formatter import java.util.Formatter
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.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.MusicParent
import org.oxycblt.auxio.music.Song 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.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.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. * A [ListFragment] that shows a list of [Song]s.
* @author OxygenCobalt * @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 homeAdapter = SongAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } // Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(50) private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb) private val formatter = Formatter(formatterSb)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply { binding.homeRecycler.apply {
id = R.id.home_song_list id = R.id.home_song_recycler
adapter = homeAdapter adapter = homeAdapter
popupProvider = this@SongListFragment
listener = this@SongListFragment
} }
collectImmediately(homeModel.songs, homeAdapter::replaceList) collectImmediately(homeModel.songLists, homeAdapter::replaceList)
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
collectImmediately( 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? { override fun getPopup(pos: Int): String? {
val song = homeModel.songs.value[pos] val song = homeModel.songLists.value[pos]
// Change how we display the popup depending on the current sort mode.
// Change how we display the popup depending on the mode.
// Note: We don't use the more correct individual artist name here, as sorts are largely // 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. // 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 // 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 // Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artist.sortName?.run { first().uppercase() } is Sort.Mode.ByArtist ->
song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Album -> Use Album Name // 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 // 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 // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
@ -102,47 +126,56 @@ class SongListFragment : HomeListFragment<Song>() {
} }
} }
override fun onItemClick(item: Item) { override fun onFastScrollingChanged(isFastScrolling: Boolean) {
check(item is Song) homeModel.setFastScrolling(isFastScrolling)
playbackModel.play(item, settings.libPlaybackMode) }
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) { override fun onOpenMenu(item: Item, anchor: View) {
when (item) { check(item is Song) { "Unexpected datatype: ${item::class.java}" }
is Song -> musicMenu(anchor, R.menu.menu_song_actions, item) openMusicMenu(anchor, R.menu.menu_song_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
}
} }
private fun handlePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) { private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent == null) { if (parent == null) {
homeAdapter.updateIndicator(song, isPlaying) homeAdapter.setPlayingItem(song, isPlaying)
} else { } else {
// Ignore playback that is not from all songs // 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>() { * A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
private val differ = SyncListDiffer(this, SongViewHolder.DIFFER) * @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> override val currentList: List<Item>
get() = differ.currentList get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.new(parent) SongViewHolder.new(parent)
override fun onBindViewHolder(holder: SongViewHolder, position: Int, payloads: List<Any>) { override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
super.onBindViewHolder(holder, position, payloads) holder.bind(differ.currentList[position], listener)
if (payloads.isEmpty()) {
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>) { fun replaceList(newList: List<Song>) {
differ.replaceList(newList) differ.replaceList(newList)
} }

View file

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

View file

@ -17,60 +17,66 @@
package org.oxycblt.auxio.home.tabs package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
/** /**
* A data representation of a library tab. A tab can come in two moves, [Visible] or [Invisible]. * A representation of a library tab suitable for configuration.
* Invisibility means that the tab will still be present in the customization menu, but will not be * @param mode The type of list in the home view this instance corresponds to.
* shown on the home UI. * @author Alexander Capehart (OxygenCobalt)
*
* 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
*/ */
sealed class Tab(open val mode: DisplayMode) { sealed class Tab(open val mode: MusicMode) {
data class Visible(override val mode: DisplayMode) : Tab(mode) /**
data class Invisible(override val mode: DisplayMode) : Tab(mode) * 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 { 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 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 = const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
arrayOf(
DisplayMode.SHOW_SONGS,
DisplayMode.SHOW_ALBUMS,
DisplayMode.SHOW_ARTISTS,
DisplayMode.SHOW_GENRES)
/** Convert an array [tabs] into a sequence of tabs. */ /** Maps between the integer code in the tab sequence and it's [MusicMode]. */
fun toSequence(tabs: Array<Tab>): Int { 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. // Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.mode } val distinct = tabs.distinctBy { it.mode }
var sequence = 0b0100 var sequence = 0b0100
var shift = SEQUENCE_LEN * 4 var shift = SEQUENCE_LEN * 4
for (tab in distinct) { for (tab in distinct) {
val bin = val bin =
when (tab) { when (tab) {
@ -85,14 +91,18 @@ sealed class Tab(open val mode: DisplayMode) {
return sequence 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>() val tabs = mutableListOf<Tab>()
// Try to parse a mode for each chunk in the sequence. // Try to parse a mode for each chunk in the sequence.
// If we can't parse one, just skip it. // If we can't parse one, just skip it.
for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) { 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 val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue

View file

@ -22,74 +22,127 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding import org.oxycblt.auxio.databinding.ItemTabBinding
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.ui.recycler.DialogViewHolder import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater 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>() { class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */
var tabs = arrayOf<Tab>() var tabs = arrayOf<Tab>()
private set private set
override fun getItemCount() = tabs.size override fun getItemCount() = tabs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent)
override fun onBindViewHolder(holder: TabViewHolder, position: Int) { override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
holder.bind(tabs[position], listener) 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>) { fun submitTabs(newTabs: Array<Tab>) {
tabs = newTabs 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) { fun setTab(at: Int, tab: Tab) {
tabs[at] = tab tabs[at] = tab
// Use a payload to avoid an item change animation.
notifyItemChanged(at, PAYLOAD_TAB_CHANGED) notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
} }
fun moveItems(from: Int, to: Int) { /**
val t = tabs[to] * Swap two tabs with each other.
val f = tabs[from] * @param a The position of the first tab to swap.
tabs[from] = t * @param b The position of the second tab to swap.
tabs[to] = f */
notifyItemMoved(from, to) 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 { 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 { 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) : 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") @SuppressLint("ClickableViewAccessibility")
fun bind(item: Tab, listener: TabAdapter.Listener) { fun bind(tab: Tab, listener: TabAdapter.Listener) {
binding.root.setOnClickListener { listener.onVisibilityToggled(item.mode) } binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) }
binding.tabIcon.apply { binding.tabCheckBox.apply {
setText(item.mode.string) // Update the CheckBox name to align with the mode
isChecked = item is Tab.Visible 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.setOnTouchListener { _, motionEvent ->
binding.tabDragHandle.performClick() binding.tabDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) { if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onPickUpTab(this) listener.onPickUp(this)
true true
} else false } else false
} }
} }
companion object { 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)) fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
} }
} }

View file

@ -25,19 +25,20 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* The dialog for customizing library tabs. * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener { class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener {
private val tabAdapter = TabAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val tabAdapter = TabAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject { private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(TabDragCallback(tabAdapter)) ItemTouchHelper(TabDragCallback(tabAdapter))
} }
@ -55,14 +56,17 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
} }
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
val savedTabs = findSavedTabState(savedInstanceState) var tabs = settings.libTabs
if (savedTabs != null) { // Try to restore a pending tab configuration that was saved prior.
logD("Found saved tab state") if (savedInstanceState != null) {
tabAdapter.submitTabs(savedTabs) val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
} else { if (savedTabs != null) {
tabAdapter.submitTabs(settings.libTabs) tabs = savedTabs
}
} }
// Set up the tab RecyclerView
tabAdapter.submitTabs(tabs)
binding.tabRecycler.apply { binding.tabRecycler.apply {
adapter = tabAdapter adapter = tabAdapter
touchHelper.attachToRecyclerView(this) touchHelper.attachToRecyclerView(this)
@ -71,7 +75,8 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) 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) { override fun onDestroyBinding(binding: DialogTabsBinding) {
@ -79,40 +84,31 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
binding.tabRecycler.adapter = null binding.tabRecycler.adapter = null
} }
override fun onVisibilityToggled(displayMode: DisplayMode) { override fun onToggleVisibility(tabMode: MusicMode) {
// Tab viewholders bind with the initial tab state, which will drift from the actual logD("Toggling tab $tabMode")
// 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)
})
}
// 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 = (requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
tabAdapter.tabs.filterIsInstance<Tab.Visible>().isNotEmpty() tabAdapter.tabs.filterIsInstance<Tab.Visible>().isNotEmpty()
} }
override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) { override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(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 { companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.TAB_CUSTOMIZE" private const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
} }
} }

View file

@ -22,14 +22,15 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
/** /**
* A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu. * An [ItemTouchHelper.Callback] that implements dragging in the [TabAdapter].
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple. * @author Alexander Capehart (OxygenCobalt)
*/ */
class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() { class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() {
override fun getMovementFlags( override fun getMovementFlags(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder 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( override fun onChildDraw(
c: Canvas, c: Canvas,
@ -40,8 +41,6 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
actionState: Int, actionState: Int,
isCurrentlyActive: Boolean 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.translationX = dX
viewHolder.itemView.translationY = dY viewHolder.itemView.translationY = dY
} }
@ -56,12 +55,14 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder target: RecyclerView.ViewHolder
): Boolean { ): 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 return true
} }
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
// We use a custom drag handle, so disable the long press action. // We use a custom drag handle, so disable the long press action.
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled() = false
} }

View file

@ -24,79 +24,99 @@ import coil.imageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Size import coil.size.Size
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Song 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 * When it comes to components that load images manually as [Bitmap] instances, queued
* request with some target callbacks could result in overlapping requests causing incorrect * [ImageRequest]s may cause a race condition that results in the incorrect image being drawn. This
* updates. This class (to an extent) resolves this by adding several guards * 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) { 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 currentRequest: Request? = null
private var guard = TaskGuard() private var currentHandle = 0L
/** If this provider is currently attempting to load something. */ /** If this provider is currently attempting to load something. */
val isBusy: Boolean val isBusy: Boolean
get() = currentRequest?.run { !disposable.isDisposed } ?: false get() = currentRequest?.run { !disposable.isDisposed } ?: false
/** /**
* Load a bitmap from [song]. [target] should be a new object, not a reference to an existing * Load the Album cover [Bitmap] from a [Song].
* callback. * @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 @Synchronized
fun load(song: Song, target: Target) { 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?.run { disposable.dispose() }
currentRequest = null currentRequest = null
val request = val imageRequest =
target.onConfigRequest( target
ImageRequest.Builder(context) .onConfigRequest(
.data(song) ImageRequest.Builder(context)
.size(Size.ORIGINAL) .data(song)
.target( // Use ORIGINAL sizing, as we are not loading into any View-like component.
onSuccess = { .size(Size.ORIGINAL)
if (guard.check(handle)) { .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()) 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) target.onCompleted(null)
} }
}) }
.transformations(SquareFrameTransform.INSTANCE)) })
currentRequest = Request(context.imageLoader.enqueue(imageRequest.build()), target)
currentRequest = Request(context.imageLoader.enqueue(request.build()), target)
} }
/** /** Release this instance, cancelling any currently running operations. */
* Release this instance, canceling all image load jobs. This should be ran when the object is
* no longer used.
*/
@Synchronized @Synchronized
fun release() { fun release() {
++currentHandle
currentRequest?.run { disposable.dispose() } currentRequest?.run { disposable.dispose() }
currentRequest = null 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?)
}
} }

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

View file

@ -17,63 +17,85 @@
package org.oxycblt.auxio.image package org.oxycblt.auxio.image
import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.updateMarginsRelative
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getInteger
/** /**
* Effectively a super-charged [StyledImageView]. * A super-charged [StyledImageView]. This class enables the following features in addition to
* * [StyledImageView]:
* This class enables the following features alongside the base features pf [StyledImageView]: * - A selection indicator
* - Activation indicator * - An activation (playback) indicator
* - (Eventually) selection indicator
* - Support for ONE custom view * - Support for ONE custom view
* *
* This class is primarily intended for list items. For most uses, the simpler [StyledImageView] is * This class is primarily intended for list items. For other uses, [StyledImageView] is more
* more efficient and suitable. * suitable.
* *
* @author OxygenCobalt * TODO: Rework content descriptions here
*
* @author Alexander Capehart (OxygenCobalt)
*/ */
class ImageGroup class ImageGroup
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) { FrameLayout(context, attrs, defStyleAttr) {
private val cornerRadius: Float private val innerImageView: StyledImageView
private val inner: StyledImageView
private var customView: View? = null 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 { init {
// Android wants you to make separate attributes for each view type, but will // Obtain some StyledImageView attributes to use later when theming the cusotm view.
// then throw an error if you do because of duplicate attribute names.
@SuppressLint("CustomViewStyleable") @SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) 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) cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
styledAttrs.recycle() styledAttrs.recycle()
inner = StyledImageView(context, attrs) // Initialize what views we can here.
indicator = IndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius } 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() { override fun onFinishInflate() {
super.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) { // Get the second inflated child, making sure we customize it to align with
error("Only one custom view is allowed") // the rest of this view.
}
customView = customView =
getChildAt(1)?.apply { getChildAt(1)?.apply {
background = 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() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
invalidateIndicator() // Initialize each component before this view is drawn.
invalidateImageAlpha()
invalidatePlayingIndicator()
invalidateSelectionIndicator()
} }
override fun setActivated(activated: Boolean) { override fun setActivated(activated: Boolean) {
super.setActivated(activated) super.setActivated(activated)
invalidateIndicator() invalidateSelectionIndicator()
} }
override fun setEnabled(enabled: Boolean) { override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled) 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 var isPlaying: Boolean
get() = indicator.isPlaying get() = playbackIndicatorView.isPlaying
set(value) { set(value) {
indicator.isPlaying = value playbackIndicatorView.isPlaying = value
} }
private fun invalidateIndicator() { private fun invalidateImageAlpha() {
if (isActivated) { // If this view is disabled, show it at half-opacity, *unless* it is also marked
alpha = 1f // 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 customView?.alpha = 0f
inner.alpha = 0f innerImageView.alpha = 0f
indicator.alpha = 1f playbackIndicatorView.alpha = 1f
} else { } else {
alpha = if (isEnabled) 1f else 0.5f // View is not "selected", hide the playing indicator.
customView?.alpha = 1f customView?.alpha = 1f
inner.alpha = 1f innerImageView.alpha = 1f
indicator.alpha = 0f playbackIndicatorView.alpha = 0f
} }
} }
fun bind(song: Song) { private fun invalidateSelectionIndicator() {
inner.bind(song) // Set up a target transition for the selection indicator.
contentDescription = val targetAlpha: Float
context.getString(R.string.desc_album_cover, song.album.resolveName(context)) val targetDuration: Long
}
fun bind(album: Album) { if (isActivated) {
inner.bind(album) // View is "activated" (i.e marked as selected), so show the selection indicator.
contentDescription = targetAlpha = 1f
context.getString(R.string.desc_album_cover, album.resolveName(context)) 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) { if (selectionIndicatorView.alpha == targetAlpha) {
inner.bind(artist) // Nothing to do.
contentDescription = return
context.getString(R.string.desc_artist_image, artist.resolveName(context)) }
}
fun bind(genre: Genre) { if (!isLaidOut) {
inner.bind(genre) // Not laid out, initialize it without animation before drawing.
contentDescription = selectionIndicatorView.alpha = targetAlpha
context.getString(R.string.desc_genre_image, genre.resolveName(context)) 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()
}
} }
} }

View file

@ -33,27 +33,31 @@ import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat import org.oxycblt.auxio.util.getDrawableCompat
/** /**
* View that displays the playback indicator. Nominally emulates [StyledImageView], but is much * A view that displays an activation (i.e playback) indicator, with an accented styling and an
* different internally as an animated icon can't be wrapped within StyledDrawable without causing * animated equalizer icon.
* insane issues. *
* @author OxygenCobalt * 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 @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) { AppCompatImageView(context, attrs, defStyleAttr) {
private val playingIndicatorDrawable = private val playingIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable
private val pausedIndicatorDrawable = private val pausedIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_paused_indicator_24) context.getDrawableCompat(R.drawable.ic_paused_indicator_24)
private val indicatorMatrix = Matrix() private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF() private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF() private val indicatorMatrixDst = RectF()
private val settings = Settings(context) 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 var cornerRadius = 0f
set(value) { set(value) {
field = 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 { 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 // 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 // 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 // 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) fillColor = context.getColorCompat(R.color.sel_cover_bg)
setCornerSize(cornerRadius) setCornerSize(cornerRadius)
} }
scaleType = ScaleType.MATRIX
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// Emulate StyledDrawable scaling with matrix scaling.
val iconSize = max(measuredWidth, measuredHeight) / 2 val iconSize = max(measuredWidth, measuredHeight) / 2
imageMatrix = imageMatrix =
indicatorMatrix.apply { indicatorMatrix.apply {
reset() reset()
drawable?.let { drawable -> 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. // First scale the icon up to the desired size.
indicatorMatrixSrc.set( indicatorMatrixSrc.set(
0f, 0f,
@ -106,23 +126,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
indicatorMatrix.setRectToRect( indicatorMatrix.setRectToRect(
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER) indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
// Then actually center it into the icon, which the previous call does not // Then actually center it into the icon.
// actually do.
indicatorMatrix.postTranslate( indicatorMatrix.postTranslate(
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f) (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)
}
}
} }

View file

@ -33,6 +33,7 @@ import coil.dispose
import coil.load import coil.load
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
@ -43,41 +44,33 @@ import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat import org.oxycblt.auxio.util.getDrawableCompat
/** /**
* An [AppCompatImageView] that applies many of the stylistic choices that Auxio uses regarding * An [AppCompatImageView] with some additional styling, including:
* images.
* *
* Default behavior includes the addition of a tonal background, automatic sizing of icons to half * - Tonal background
* of the view size, and corner radius application depending on user preference. * - 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 class StyledImageView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) { 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 { 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 // 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 // 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 // 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 = background =
MaterialShapeDrawable().apply { MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg) 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) * Bind an [Genre]'s image to this view, also updating the content description.
* @param genre the [Genre] to bind.
private fun <T : Music> loadImpl(music: T, @DrawableRes error: Int, @StringRes desc: Int) { */
if (staticIcon != null) { fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
error("Static StyledImageViews cannot bind new images")
}
contentDescription = context.getString(desc, music.resolveName(context))
/**
* 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() dispose()
load(music) { load(music) {
error(StyledDrawable(context, context.getDrawableCompat(error))) error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
transformations(SquareFrameTransform.INSTANCE) 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 { 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) { 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 adjustWidth = bounds.width() / 4
val adjustHeight = bounds.height() / 4 val adjustHeight = bounds.height() / 4
src.bounds.set( inner.bounds.set(
adjustWidth, adjustWidth,
adjustHeight, adjustHeight,
bounds.width() - adjustWidth, bounds.width() - adjustWidth,
bounds.height() - adjustHeight) bounds.height() - adjustHeight)
src.draw(canvas) inner.draw(canvas)
} }
// Required drawable overrides. Just forward to the wrapped drawable.
override fun setAlpha(alpha: Int) { override fun setAlpha(alpha: Int) {
src.alpha = alpha inner.alpha = alpha
} }
override fun setColorFilter(colorFilter: ColorFilter?) { override fun setColorFilter(colorFilter: ColorFilter?) {
src.colorFilter = colorFilter inner.colorFilter = colorFilter
} }
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT override fun getOpacity(): Int = PixelFormat.TRANSLUCENT

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image package org.oxycblt.auxio.image.extractor
import android.content.Context import android.content.Context
import coil.ImageLoader import coil.ImageLoader
@ -35,41 +35,44 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song 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> { class MusicKeyer : Keyer<Music> {
override fun key(data: Music, options: Options): String { override fun key(data: Music, options: Options) =
return if (data is Song) { if (data is Song) {
// Group up song covers with album covers for better caching // Group up song covers with album covers for better caching
key(data.album, options) data.album.uid.toString()
} else { } 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 * Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
* used. * [AlbumFactory] for instantiation.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumCoverFetcher 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? = override suspend fun fetch(): FetchResult? =
fetchArt(context, album)?.let { stream -> Covers.fetch(context, album)?.run {
SourceResult( SourceResult(
source = ImageSource(stream.source().buffer(), context), source = ImageSource(source().buffer(), context),
mimeType = null, mimeType = null,
dataSource = DataSource.DISK) dataSource = DataSource.DISK)
} }
/** A [Fetcher.Factory] implementation that works with [Song]s. */
class SongFactory : Fetcher.Factory<Song> { class SongFactory : Fetcher.Factory<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader): Fetcher { override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
return AlbumCoverFetcher(options.context, data.album) AlbumCoverFetcher(options.context, data.album)
}
} }
/** A [Fetcher.Factory] implementation that works with [Album]s. */
class AlbumFactory : Fetcher.Factory<Album> { class AlbumFactory : Fetcher.Factory<Album> {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) = override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, data) 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] * [Fetcher] for [Artist] images. Use [Factory] for instantiation.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class ArtistImageFetcher class ArtistImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val size: Size, private val size: Size,
private val artist: Artist, private val artist: Artist
) : BaseFetcher() { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
val albums = Sort(Sort.Mode.ByName, true).albums(artist.albums) // Pick the "most prominent" albums (i.e albums with the most songs) to show in the image.
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) } val albums = Sort(Sort.Mode.ByCount, false).albums(artist.albums)
return createMosaic(context, results, size) val results = albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, album) }
return Images.createMosaic(context, results, size)
} }
/** [Fetcher.Factory] implementation. */
class Factory : Fetcher.Factory<Artist> { class Factory : Fetcher.Factory<Artist> {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) = override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
ArtistImageFetcher(options.context, options.size, data) ArtistImageFetcher(options.context, options.size, data)
@ -99,35 +104,21 @@ private constructor(
} }
/** /**
* Fetcher that fetches the image for a [Genre] * [Fetcher] for [Genre] images. Use [Factory] for instantiation.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class GenreImageFetcher class GenreImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val size: Size, private val size: Size,
private val genre: Genre, private val genre: Genre
) : BaseFetcher() { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
// Genre logic is the most complicated, as we want to ensure album cover variation (i.e val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, it) }
// all four covers shouldn't be from the same artist) while also still leveraging mosaics return Images.createMosaic(context, results, size)
// 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)
} }
/** [Fetcher.Factory] implementation. */
class Factory : Fetcher.Factory<Genre> { class Factory : Fetcher.Factory<Genre> {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) = override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
GenreImageFetcher(options.context, options.size, data) 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 * Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be
* null is returned, then that item will be skipped. * 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, n: Int,
transform: (T) -> R? transform: (T) -> R?
): List<R> { ): List<R> {
@ -146,11 +141,12 @@ private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(
val out = mutableListOf<R>() val out = mutableListOf<R>()
for (item in this) { for (item in this) {
if (out.size < until) { if (out.size >= until) {
transform(item)?.let(out::add)
} else {
break break
} }
// Still have more data we can transform.
transform(item)?.let(out::add)
} }
return out return out

View file

@ -15,24 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image package org.oxycblt.auxio.image.extractor
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.media.MediaMetadataRetriever 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.MediaItem
import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
@ -42,36 +28,32 @@ import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.buffer import org.oxycblt.auxio.image.CoverMode
import okio.source
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
/** /**
* The base implementation for all image fetchers in Auxio. * Internal utilities for loading album covers.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt).
*
* TODO: File-system derived images [cover.jpg, Artist Images]
*/ */
abstract class BaseFetcher : Fetcher { object Covers {
/** /**
* Fetch the artwork of an [album]. This call respects user configuration and has proper * Fetch an album cover, respecting the current cover configuration.
* redundancy in the case that metadata fails to load. * @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) val settings = Settings(context)
if (!settings.showCovers) {
return null
}
return try { return try {
if (settings.useQualityCovers) { when (settings.coverMode) {
fetchQualityCovers(context, album) CoverMode.OFF -> null
} else { CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
fetchMediaStoreCovers(context, album) CoverMode.QUALITY -> fetchQualityCovers(context, album)
} }
} catch (e: Exception) { } catch (e: Exception) {
logW("Unable to extract album cover due to an error: $e") 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) = 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) fetchAospMetadataCovers(context, album)
?: fetchExoplayerCover(context, album) ?: fetchMediaStoreCovers(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? { private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
MediaMetadataRetriever().apply { MediaMetadataRetriever().apply {
// This call is time-consuming but it also doesn't seem to hold up the main thread, // 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? { private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri val uri = album.songs[0].uri
val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri)) val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri))
// future.get is a blocking call that makes us spin until the future is done. // 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 // 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 // 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. // sure that the runner can do other coroutines.
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
val tracks = val tracks =
withContext(Dispatchers.IO) { withContext(Dispatchers.Default) {
try { try {
future.get() future.get()
} catch (e: Exception) { } catch (e: Exception) {
@ -172,80 +168,15 @@ abstract class BaseFetcher : Fetcher {
return stream 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 * Loads an [Album] cover from MediaStore.
* https://github.com/kabouzeid/Phonograph * @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( @Suppress("BlockingMethodInNonBlockingContext")
context: Context, private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
streams: List<InputStream>, // Eliminate any chance that this blocking call might mess up the loading process
size: Size return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
): 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
} }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image package org.oxycblt.auxio.image.extractor
import coil.decode.DataSource import coil.decode.DataSource
import coil.drawable.CrossfadeDrawable import coil.drawable.CrossfadeDrawable
@ -26,11 +26,10 @@ import coil.transition.Transition
import coil.transition.TransitionTarget import coil.transition.TransitionTarget
/** /**
* A copy of [CrossfadeTransition.Factory] that applies a transition to error results. You know. * A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
* Like they used to. * @author Coil Team, Alexander Capehart (OxygenCobalt)
* @author Coil Team
*/ */
class CrossfadeTransitionFactory : Transition.Factory { class ErrorCrossfadeTransitionFactory : Transition.Factory {
override fun create(target: TransitionTarget, result: ImageResult): Transition { override fun create(target: TransitionTarget, result: ImageResult): Transition {
// Don't animate if the request was fulfilled by the memory cache. // Don't animate if the request was fulfilled by the memory cache.
if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) { if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) {

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

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap import android.graphics.Bitmap
import coil.size.Size import coil.size.Size
@ -24,9 +24,9 @@ import coil.transform.Transformation
import kotlin.math.min import kotlin.math.min
/** /**
* A transformation that performs a center crop-style transformation on an image, however unlike the * A transformation that performs a center crop-style transformation on an image. Allowing this
* actual ScaleType, this isn't affected by any hacks we do with ImageView itself. * behavior to be intrinsic without any view configuration.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class SquareFrameTransform : Transformation { class SquareFrameTransform : Transformation {
override val cacheKey: String override val cacheKey: String
@ -38,20 +38,19 @@ class SquareFrameTransform : Transformation {
val dstSize = min(input.width, input.height) val dstSize = min(input.width, input.height)
val x = (input.width - dstSize) / 2 val x = (input.width - dstSize) / 2
val y = (input.height - dstSize) / 2 val y = (input.height - dstSize) / 2
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
val desiredWidth = size.width.pxOrElse { dstSize } val desiredWidth = size.width.pxOrElse { dstSize }
val desiredHeight = size.height.pxOrElse { dstSize } val desiredHeight = size.height.pxOrElse { dstSize }
val dst = Bitmap.createBitmap(input, x, y, dstSize, dstSize)
if (dstSize != desiredWidth || dstSize != desiredHeight) { if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Image is not the desired size, upscale it.
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true) return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
} }
return dst return dst
} }
companion object { companion object {
/** A re-usable instance. */
val INSTANCE = SquareFrameTransform() val INSTANCE = SquareFrameTransform()
} }
} }

View file

@ -15,9 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.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 */ /** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
data class MusicDirs(val dirs: List<Directory>, val shouldInclude: Boolean) 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

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

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

View file

@ -15,10 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.ui.recycler package org.oxycblt.auxio.list.recycler
import android.content.Context import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
@ -27,30 +26,36 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat 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 open class AuxioRecyclerView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
RecyclerView(context, attrs, defStyleAttr) { RecyclerView(context, attrs, defStyleAttr) {
private val initialPadding = Rect(paddingLeft, paddingTop, paddingRight, paddingBottom) private val initialPaddingBottom = paddingBottom
init { init {
// Prevent children from being clipped by window insets // Prevent children from being clipped by window insets
clipToPadding = false clipToPadding = false
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
// so we can enable fixed-size optimizations.
setHasFixedSize(true) setHasFixedSize(true)
} }
final override fun setHasFixedSize(hasFixedSize: Boolean) { final override fun setHasFixedSize(hasFixedSize: Boolean) {
// Prevent a this leak by marking setHasFixedSize as final.
super.setHasFixedSize(hasFixedSize) super.setHasFixedSize(hasFixedSize)
} }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding( // Update the RecyclerView's padding such that the bottom insets are applied
initialPadding.left, // while still preserving bottom padding.
initialPadding.top, updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
initialPadding.right,
initialPadding.bottom + insets.systemBarInsetsCompat.bottom)
return insets return insets
} }
@ -58,16 +63,28 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.setAdapter(adapter) super.setAdapter(adapter)
if (adapter is SpanSizeLookup) { if (adapter is SpanSizeLookup) {
// This adapter has support for special span sizes, hook it up to the
// GridLayoutManager.
val glm = (layoutManager as GridLayoutManager) val glm = (layoutManager as GridLayoutManager)
val fullWidthSpanCount = glm.spanCount
glm.spanSizeLookup = glm.spanSizeLookup =
object : GridLayoutManager.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) = 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 { 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 fun isItemFullWidth(position: Int): Boolean
} }
} }

View file

@ -15,10 +15,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.ui.recycler package org.oxycblt.auxio.list.recycler
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
@ -27,12 +28,13 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.R 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 * A [RecyclerView] intended for use in Dialogs, adding features such as:
* used in dialogs. * - NestedScrollView scrollIndicators behavior emulation
* @author OxygenCobalt * - Dialog-specific [ViewHolder] that automatically resolves certain issues.
* @author Alexander Capehart (OxygenCobalt)
*/ */
class DialogRecyclerView class DialogRecyclerView
@JvmOverloads @JvmOverloads
@ -40,56 +42,70 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
RecyclerView(context, attrs, defStyleAttr) { RecyclerView(context, attrs, defStyleAttr) {
private val topDivider = MaterialDivider(context) private val topDivider = MaterialDivider(context)
private val bottomDivider = 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 { 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) updatePadding(top = spacingMedium)
// Disable over-scrolling, the top and bottom dividers have the same purpose.
overScrollMode = OVER_SCROLL_NEVER overScrollMode = OVER_SCROLL_NEVER
// Safer to use the overlay than the actual RecyclerView children.
overlay.apply { overlay.apply {
add(topDivider) add(topDivider)
add(bottomDivider) add(bottomDivider)
} }
} }
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
invalidateDividers()
}
override fun onMeasure(widthSpec: Int, heightSpec: Int) { override fun onMeasure(widthSpec: Int, heightSpec: Int) {
super.onMeasure(widthSpec, heightSpec) super.onMeasure(widthSpec, heightSpec)
measureDivider(topDivider) measureDivider(topDivider)
measureDivider(bottomDivider) 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) { private fun measureDivider(divider: MaterialDivider) {
val widthMeasureSpec = val widthMeasureSpec =
ViewGroup.getChildMeasureSpec( ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
0, 0,
divider.layoutParams.width) divider.layoutParams.width)
val heightMeasureSpec = val heightMeasureSpec =
ViewGroup.getChildMeasureSpec( ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
0, 0,
divider.layoutParams.height) divider.layoutParams.height)
divider.measure(widthMeasureSpec, heightMeasureSpec) divider.measure(widthMeasureSpec, heightMeasureSpec)
} }
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { private fun invalidateDividers() {
super.onLayout(changed, l, t, r, b) val lmm = layoutManager as LinearLayoutManager
topDivider.layout(l, spacingMedium, r, spacingMedium + topDivider.measuredHeight) // Top divider should only be visible when the first item has gone off-screen.
bottomDivider.layout(l, measuredHeight - bottomDivider.measuredHeight, r, b) topDivider.isInvisible = lmm.findFirstCompletelyVisibleItemPosition() < 1
invalidateDividers() // Bottom divider should only be visible when the lsat item is completely on-screen.
bottomDivider.isInvisible =
lmm.findLastCompletelyVisibleItemPosition() == (lmm.itemCount - 1)
} }
private fun invalidateDividers() { /** A [RecyclerView.ViewHolder] that implements dialog-specific fixes. */
val manager = layoutManager as LinearLayoutManager abstract class ViewHolder(root: View) : RecyclerView.ViewHolder(root) {
topDivider.isInvisible = manager.findFirstCompletelyVisibleItemPosition() < 1 init {
bottomDivider.isInvisible = // ViewHolders are not automatically full-width in dialogs, manually resize
manager.findLastCompletelyVisibleItemPosition() == (manager.itemCount - 1) // them to be as such.
root.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
} }
} }

View file

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

View file

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

View file

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

View file

@ -15,48 +15,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.ui.recycler package org.oxycblt.auxio.list.recycler
import android.view.View
import androidx.annotation.StringRes
import androidx.recyclerview.widget.AdapterListUpdateCallback import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
/** /**
* The base for all items in Auxio. Any datatype can derive this type and gain some behavior not * A list differ that operates synchronously. This can help resolve some shortcomings with
* provided for free by the normal adapter implementations, such as certain types of diffing. * AsyncListDiffer, at the cost of performance. Derived from Material Files:
*/ * https://github.com/zhanghai/MaterialFiles
abstract class Item { * @author Hai Zhang, Alexander Capehart (OxygenCobalt)
/** 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].
*/ */
class SyncListDiffer<T>( class SyncListDiffer<T>(
adapter: RecyclerView.Adapter<*>, adapter: RecyclerView.Adapter<*>,
@ -141,17 +110,28 @@ class SyncListDiffer<T>(
result.dispatchUpdatesTo(updateCallback) 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>) { fun submitList(newList: List<T>) {
if (newList == currentList) {
// Nothing to do.
return
}
currentList = newList currentList = newList
} }
/** /**
* Replace this list with a new list. This is useful for very large list diffs that would * Replace this list with a new list. This is good for large diffs that are too slow to update
* generally be too chaotic and slow to provide a good UX. * synchronously, but too chaotic to update asynchronously.
* @param newList The list to update to.
*/ */
fun replaceList(newList: List<T>) { fun replaceList(newList: List<T>) {
if (newList == currentList) { if (newList == currentList) {
// Nothing to do.
return return
} }
@ -159,14 +139,3 @@ class SyncListDiffer<T>(
currentList = newList 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
}
}

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

View file

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

View file

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

View file

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

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

View file

@ -20,115 +20,170 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns 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 * This can be used to obtain certain music items, or await changes to the music library. It is
* cripples any kind of advanced metadata functionality. Instead, Auxio loads all music into a * generally recommended to use this over Indexer to keep track of the library state, as the
* in-memory relational data-structure called [Library]. This costs more memory-wise, but is also * interface will be less volatile.
* much more sensible.
* *
* The only other, memory-efficient option is to create our own hybrid database that leverages both * @author Alexander Capehart (OxygenCobalt)
* 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
*/ */
class MusicStore private constructor() { class MusicStore private constructor() {
private val callbacks = mutableListOf<Callback>() 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 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 @Synchronized
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
callback.onLibraryChanged(library) callback.onLibraryChanged(library)
callbacks.add(callback) 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 @Synchronized
fun removeCallback(callback: Callback) { fun removeCallback(callback: Callback) {
callbacks.remove(callback) callbacks.remove(callback)
} }
/** Update the library in this instance. This is only meant for use by the internal indexer. */ /**
@Synchronized * A library of [Music] instances.
fun updateLibrary(newLibrary: Library?) { * @param songs All [Song]s loaded from the device.
library = newLibrary * @param albums All [Album]s that could be created.
for (callback in callbacks) { * @param artists All [Artist]s that could be created.
callback.onLibraryChanged(library) * @param genres All [Genre]s that could be created.
} */
}
/** Represents a library of music owned by [MusicStore]. */
data class Library( data class Library(
val genres: List<Genre>, val songs: List<Song>,
val artists: List<Artist>,
val albums: List<Album>, 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 uidMap = HashMap<Music.UID, Music>()
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) } }
/** Find a [Song] by it's ID. Null if no song exists with that ID. */ init {
fun findSongById(songId: Long) = songIdMap[songId] // 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. */ for (album in albums) {
fun findAlbumById(albumId: Long) = albumIdMap[albumId] album._finalize()
uidMap[album.uid] = album
}
/** Find a [Artist] by it's ID. Null if no artist exists with that ID. */ for (artist in artists) {
fun findArtistById(artistId: Long) = artistIdMap[artistId] artist._finalize()
uidMap[artist.uid] = artist
}
/** Find a [Genre] by it's ID. Null if no genre exists with that ID. */ for (genre in genres) {
fun findGenreById(genreId: Long) = genreIdMap[genreId] 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) * Finds a [Music] item [T] in the library by it's [Music.UID].
/** Sanitize an old item to find the corresponding item in a new library. */ * @param uid The [Music.UID] to search for.
fun sanitize(songs: List<Song>) = songs.mapNotNull { sanitize(it) } * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found
/** Sanitize an old item to find the corresponding item in a new library. */ * or the [Music.UID] did not correspond to a [T].
fun sanitize(album: Album) = findAlbumById(album.id) */
/** Sanitize an old item to find the corresponding item in a new library. */ @Suppress("UNCHECKED_CAST") fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
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)
/** 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) = fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(uri, arrayOf(OpenableColumns.DISPLAY_NAME)) { context.contentResolverSafe.useQuery(
cursor -> uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst() 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 = val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
songs.find { it.path.name == displayName } 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 { 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?) fun onLibraryChanged(library: Library?)
} }
companion object { companion object {
@Volatile private var INSTANCE: MusicStore? = null @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 { fun getInstance(): MusicStore {
val currentInstance = INSTANCE val currentInstance = INSTANCE
if (currentInstance != null) { if (currentInstance != null) {
return currentInstance return currentInstance
} }

View file

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

View file

@ -23,36 +23,67 @@ import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.music.system.Indexer
/** /**
* A ViewModel representing the current indexing state. * A [ViewModel] providing data specific to the music loading process.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicViewModel : ViewModel(), Indexer.Callback { class MusicViewModel : ViewModel(), Indexer.Callback {
private val indexer = Indexer.getInstance() private val indexer = Indexer.getInstance()
private val _indexerState = MutableStateFlow<Indexer.State?>(null) 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 val indexerState: StateFlow<Indexer.State?> = _indexerState
private val _libraryExists = MutableStateFlow(false) private val _statistics = MutableStateFlow<Statistics?>(null)
/** Whether a music library has successfully been loaded. */ /** [Statistics] about the last completed music load. */
val libraryExists: StateFlow<Boolean> = _libraryExists val statistics: StateFlow<Statistics?>
get() = _statistics
init { init {
indexer.registerCallback(this) indexer.registerCallback(this)
} }
fun reindex() { override fun onCleared() {
indexer.requestReindex() indexer.unregisterCallback(this)
} }
override fun onIndexerStateChanged(state: Indexer.State?) { override fun onIndexerStateChanged(state: Indexer.State?) {
_indexerState.value = state _indexerState.value = state
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) { 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() { /** Requests that the music library should be re-loaded while leveraging the cache. */
indexer.unregisterCallback(this) 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
)
} }

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = '&'
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,23 +15,27 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.dirs package org.oxycblt.auxio.music.storage
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemMusicDirBinding import org.oxycblt.auxio.databinding.ItemMusicDirBinding
import org.oxycblt.auxio.music.Directory import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.ui.recycler.DialogViewHolder
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
/** /**
* Adapter that shows the list of music folder and their "Clear" button. * [RecyclerView.Adapter] that manages a list of [Directory] instances.
* @author OxygenCobalt * @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>() 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 val dirs: List<Directory> = _dirs
override fun getItemCount() = dirs.size 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) = override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
holder.bind(dirs[position], listener) holder.bind(dirs[position], listener)
/**
* Add a [Directory] to the end of the list.
* @param dir The [Directory] to add.
*/
fun add(dir: Directory) { fun add(dir: Directory) {
if (_dirs.contains(dir)) { if (_dirs.contains(dir)) {
return return
@ -51,32 +59,54 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<Mus
notifyItemInserted(_dirs.lastIndex) 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>) { fun addAll(dirs: List<Directory>) {
val oldLastIndex = dirs.lastIndex val oldLastIndex = dirs.lastIndex
_dirs.addAll(dirs) _dirs.addAll(dirs)
notifyItemRangeInserted(oldLastIndex, dirs.size) 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) { fun remove(dir: Directory) {
val idx = _dirs.indexOf(dir) val idx = _dirs.indexOf(dir)
_dirs.removeAt(idx) _dirs.removeAt(idx)
notifyItemRemoved(idx) notifyItemRemoved(idx)
} }
/** A Listener for [DirectoryAdapter] interactions. */
interface Listener { interface Listener {
fun onRemoveDirectory(dir: Directory) 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) : class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
DialogViewHolder(binding.root) { DialogRecyclerView.ViewHolder(binding.root) {
fun bind(item: Directory, listener: MusicDirAdapter.Listener) { /**
binding.dirPath.text = item.resolveName(binding.context) * Bind new data to this instance.
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) } * @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 { companion object {
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = fun new(parent: View) =
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater)) MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.dirs package org.oxycblt.auxio.music.storage
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@ -28,9 +28,8 @@ import androidx.core.view.isVisible
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.settings.Settings 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.context
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -38,11 +37,11 @@ import org.oxycblt.auxio.util.showToast
/** /**
* Dialog that manages the music dirs setting. * Dialog that manages the music dirs setting.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicDirsDialog : class MusicDirsDialog :
ViewBindingDialogFragment<DialogMusicDirsBinding>(), MusicDirAdapter.Listener { ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = MusicDirAdapter(this) private val dirAdapter = DirectoryAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val storageManager: StorageManager by lifecycleObject { binding -> private val storageManager: StorageManager by lifecycleObject { binding ->
binding.context.getSystemServiceCompat(StorageManager::class) binding.context.getSystemServiceCompat(StorageManager::class)
@ -59,8 +58,7 @@ class MusicDirsDialog :
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ -> .setPositiveButton(R.string.lbl_save) { _, _ ->
val dirs = settings.getMusicDirs(storageManager) val dirs = settings.getMusicDirs(storageManager)
val newDirs = val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
MusicDirs(dirs = dirAdapter.dirs, shouldInclude = isInclude(requireBinding()))
if (dirs != newDirs) { if (dirs != newDirs) {
logD("Committing changes") logD("Committing changes")
settings.setMusicDirs(newDirs) settings.setMusicDirs(newDirs)
@ -70,7 +68,8 @@ class MusicDirsDialog :
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
val launcher = 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 // 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 // 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. // and the app from crashing in the latter.
requireDialog().setOnShowListener { requireDialog().setOnShowListener {
val dialog = it as AlertDialog val dialog = it as AlertDialog
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
logD("Opening launcher") logD("Opening launcher")
launcher.launch(null) launcher.launch(null)
@ -94,11 +92,12 @@ class MusicDirsDialog :
if (savedInstanceState != null) { if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
if (pendingDirs != null) { if (pendingDirs != null) {
dirs = dirs =
MusicDirs( MusicDirectories(
pendingDirs.mapNotNull { Directory.fromDocumentUri(storageManager, it) }, pendingDirs.mapNotNull {
Directory.fromDocumentTreeUri(storageManager, it)
},
savedInstanceState.getBoolean(KEY_PENDING_MODE)) savedInstanceState.getBoolean(KEY_PENDING_MODE))
} }
} }
@ -123,7 +122,7 @@ class MusicDirsDialog :
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putStringArrayList( outState.putStringArrayList(
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map { it.toString() })) 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) { override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
@ -136,14 +135,26 @@ class MusicDirsDialog :
requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty() 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) { if (uri == null) {
// A null URI means that the user left the file picker without picking a directory // A null URI means that the user left the file picker without picking a directory
logD("No URI given (user closed the dialog)") logD("No URI given (user closed the dialog)")
return 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) { if (dir != null) {
dirAdapter.add(dir) dirAdapter.add(dir)
requireBinding().dirsEmpty.isVisible = false 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() { private fun updateMode() {
val binding = requireBinding() val binding = requireBinding()
if (isInclude(binding)) { if (isUiModeInclude(binding)) {
binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc) binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc)
} else { } else {
binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc) 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 binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
companion object { 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_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE" const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
} }

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

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

View file

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

View file

@ -20,63 +20,60 @@ package org.oxycblt.auxio.music.system
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.database.Cursor
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig 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.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.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW 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 * This class provides low-level access into the exact state of the music loading process. **This
* (and hacky garbage) in order to produce the best possible experience. It is split into three * class should not be used in most cases.** It is highly volatile and provides far more information
* distinct steps: * 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. * @author Alexander Capehart (OxygenCobalt)
* 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
*/ */
class Indexer { class Indexer private constructor() {
private var lastResponse: Response? = null private var lastResponse: Response? = null
private var indexingState: Indexing? = null private var indexingState: Indexing? = null
private var guard = TaskGuard()
private var controller: Controller? = null private var controller: Controller? = null
private var callback: Callback? = 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 * Whether this instance has not completed a loading process and is not currently loading music.
* loaded, yet no loading is going on. * 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 val isIndeterminate: Boolean
get() = lastResponse == null && indexingState == null get() = lastResponse == null && indexingState == null
/** Whether this instance is actively indexing or not. */ /**
val isIndexing: Boolean * Register a [Controller] for this instance. This instance will handle any commands to start
get() = indexingState != null * 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.
/** Register a [Controller] with this instance. */ * @param controller The [Controller] to register. Will do nothing if already registered.
*/
@Synchronized @Synchronized
fun registerController(controller: Controller) { fun registerController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller != null) { if (BuildConfig.DEBUG && this.controller != null) {
@ -84,10 +81,19 @@ class Indexer {
return 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 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 @Synchronized
fun unregisterController(controller: Controller) { fun unregisterController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) { if (BuildConfig.DEBUG && this.controller !== controller) {
@ -98,21 +104,32 @@ class Indexer {
this.controller = null 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 @Synchronized
fun registerCallback(callback: Callback) { fun registerCallback(callback: Callback) {
if (BuildConfig.DEBUG && this.callback != null) { if (BuildConfig.DEBUG && this.callback != null) {
logW("Callback is already registered") logW("Listener is already registered")
return return
} }
// Initialize the listener with the current state.
val currentState = val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
callback.onIndexerStateChanged(currentState) callback.onIndexerStateChanged(currentState)
this.callback = callback 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 @Synchronized
fun unregisterCallback(callback: Callback) { fun unregisterCallback(callback: Callback) {
if (BuildConfig.DEBUG && this.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 * Start the indexing process. This should be done from in the background from [Controller]'s
* complete, a new completion state will be pushed to each callback. * 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) { suspend fun index(context: Context, withCache: Boolean) {
requireBackgroundThread() if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
val handle = guard.newHandle() // No permissions, signal that we can't do anything.
emitCompletion(Response.NoPerms)
val notGranted =
ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED
if (notGranted) {
emitCompletion(Response.NoPerms, handle)
return return
} }
val response = val response =
try { try {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val library = indexImpl(context, handle) val library = indexImpl(context, withCache)
if (library != null) { if (library != null) {
// Successfully loaded a library.
logD( logD(
"Music indexing completed successfully in " + "Music indexing completed successfully in " +
"${System.currentTimeMillis() - start}ms") "${System.currentTimeMillis() - start}ms")
Response.Ok(library) Response.Ok(library)
} else { } else {
// Loaded a library, but it contained no music.
logE("No music found") logE("No music found")
Response.NoMusic Response.NoMusic
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Got cancelled, propagate upwards // Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled") logD("Loading routine was cancelled")
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
// Music loading process failed due to something we have not handled.
logE("Music indexing failed") logE("Music indexing failed")
logE(e.stackTraceToString()) logE(e.stackTraceToString())
Response.Err(e) 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 * Request that the music library should be reloaded. This should be used by components that do
* the indexing process to re-index music. * 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 @Synchronized
fun requestReindex() { fun requestReindex(withCache: Boolean) {
logD("Requesting reindex") 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 * Reset the current loading state to signal that the instance is not loading. This should be
* worker operating the job for that specific handle to cancel as soon as it tries to send a * called by [Controller] after it's indexing co-routine was cancelled.
* state update.
*/ */
@Synchronized @Synchronized
fun cancelLast() { fun reset() {
logD("Cancelling last job") logD("Cancelling last job")
val handle = guard.newHandle() emitIndexing(null)
emitIndexing(null, handle)
} }
/** /**
* Run the proper music loading process. [handle] must be a truthful handle of the task calling * Internal implementation of the music loading process.
* this function. * @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? { private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library? {
emitIndexing(Indexing.Indeterminate, handle) // Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music
// Since we have different needs for each version, we determine a "Backend" to use // experience.
// when loading music and then leverage that to create the initial song list. val cacheDatabase =
// This is technically dependency injection. Except it doesn't increase your compile if (withCache) {
// times by 3x. Isn't that nice. ReadWriteCacheExtractor(context)
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)
} else { } 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()) { if (songs.isEmpty()) {
// No songs, nothing else to do.
return null 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 buildStart = System.currentTimeMillis()
val albums = buildAlbums(songs) val albums = buildAlbums(songs)
val artists = buildArtists(albums) val artists = buildArtists(songs, albums)
val genres = buildGenres(songs) 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") 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 * Load a list of [Song]s from the device.
* function are **not** well-formed. The companion [buildAlbums], [buildArtists], and * @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
* [buildGenres] functions must be called with the returned list so that all songs are properly * instances.
* linked up. * @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() 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 = // Note: We use a set here so we can eliminate song duplicates.
backend.query(context).use { cursor -> val songs = mutableSetOf<Song>()
logD( val rawSongs = mutableListOf<Song.Raw>()
"Successfully queried media database " + metadataExtractor.parse { rawSong ->
"in ${System.currentTimeMillis() - start}ms") songs.add(Song(rawSong, settings))
rawSongs.add(rawSong)
backend.buildSongs(context, cursor) { emitIndexing(it, handle) } // 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)
// Deduplicate songs to prevent (most) deformed music clones yield()
songs = emitIndexing(Indexing.Songs(songs.size, total))
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))
} }
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 return albums
} }
/** /**
* Group up albums into artists. This also requires a de-duplication step due to some edge cases * Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
* where [buildAlbums] could not detect duplicates. * 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> { private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>() // Add every raw artist credited to each Song/Album to the grouping. This way,
val albumsByArtist = albums.groupBy { it._artistGroupingId } // different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
for (entry in albumsByArtist) { for (song in songs) {
// The first album will suffice for template metadata. for (rawArtist in song._rawArtists) {
val templateAlbum = entry.value[0] musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
artists.add( }
Artist(
rawName = templateAlbum._artistGroupingName,
rawSortName = templateAlbum._artistGroupingSortName,
albums = entry.value))
} }
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 return artists
} }
/** /**
* Group up songs into genres. This is a relatively simple step compared to the other library * Group up [Song]s into [Genre] instances.
* steps, as there is no demand to deduplicate genres by a lowercase name. * @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> { private fun buildGenres(songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>() // Add every raw genre credited to each Song to the grouping. This way,
val songsByGenre = songs.groupBy { it._genreGroupingId } // different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
for (entry in songsByGenre) { for (song in songs) {
// The first song fill suffice for template metadata. for (rawGenre in song._rawGenres) {
val templateSong = entry.value[0] songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
genres.add(Genre(rawName = templateSong._genreName, songs = entry.value)) }
} }
// Convert the mapping into genre instances.
val genres = songsByGenre.map { Genre(it.key, it.value) }
logD("Successfully built ${genres.size} genres") logD("Successfully built ${genres.size} genres")
return 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 @Synchronized
private fun emitIndexing(indexing: Indexing?, handle: Long) { private fun emitIndexing(indexing: Indexing?) {
guard.yield(handle)
if (indexing == indexingState) {
// Ignore redundant states used when the backends just want to check for
// a cancellation
return
}
indexingState = indexing indexingState = indexing
// If we have canceled the loading process, we want to revert to a previous completion // If we have canceled the loading process, we want to revert to a previous completion
// whenever possible to prevent state inconsistency. // whenever possible to prevent state inconsistency.
val state = val state =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller?.onIndexerStateChanged(state) controller?.onIndexerStateChanged(state)
callback?.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 // 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. // a background thread. Does not occur in emitIndexing due to efficiency reasons.
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -401,39 +408,76 @@ class Indexer {
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete. // from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
lastResponse = response lastResponse = response
indexingState = null indexingState = null
// Signal that the music loading process has been completed.
val state = State.Complete(response) val state = State.Complete(response)
controller?.onIndexerStateChanged(state) controller?.onIndexerStateChanged(state)
callback?.onIndexerStateChanged(state) callback?.onIndexerStateChanged(state)
} }
} }
} }
/** Represents the current indexer state. */ /** Represents the current state of [Indexer]. */
sealed class State { 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() 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() 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 { sealed class Indexing {
/**
* Music loading is occurring, but no definite estimate can be put on the current progress.
*/
object Indeterminate : Indexing() 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() 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 { 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() 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() data class Err(val throwable: Throwable) : Response()
/** Music loading occurred, but resulted in no music. */
object NoMusic : Response() object NoMusic : Response()
/** Music loading could not occur due to a lack of storage permissions. */
object NoPerms : Response() 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, * This is only useful for code that absolutely must show the current loading process.
* [MusicStore.Callback] is recommended instead. * Otherwise, [MusicStore.Callback] is highly recommended due to it's updates only consisting of
* the [MusicStore.Library].
*/ */
interface Callback { interface Callback {
/** /**
@ -447,38 +491,43 @@ class Indexer {
fun onIndexerStateChanged(state: State?) 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 { interface Controller : Callback {
fun onStartIndexing() /**
} * Called when a new music loading process was requested. Implementations should forward
* this to [index].
/** Represents a backend that metadata can be extracted from. */ * @param withCache Whether to use the cache or not when loading. If false, the cache should
interface Backend { * still be written, but no cache entries will be loaded into the new library.
/** Query the media database for a basic cursor. */ * @see index
fun query(context: Context): Cursor */
fun onStartIndexing(withCache: Boolean)
/** Create a list of songs from the [Cursor] queried in [query]. */
fun buildSongs(
context: Context,
cursor: Cursor,
emitIndexing: (Indexing) -> Unit
): List<Song>
} }
companion object { companion object {
@Volatile private var INSTANCE: Indexer? = null @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 = 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 // READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
Manifest.permission.READ_MEDIA_AUDIO Manifest.permission.READ_MEDIA_AUDIO
} else { } else {
Manifest.permission.READ_EXTERNAL_STORAGE 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 { fun getInstance(): Indexer {
val currentInstance = INSTANCE val currentInstance = INSTANCE
if (currentInstance != null) { if (currentInstance != null) {
return currentInstance return currentInstance
} }

View file

@ -18,17 +18,24 @@
package org.oxycblt.auxio.music.system package org.oxycblt.auxio.music.system
import android.content.Context import android.content.Context
import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R 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.logD
import org.oxycblt.auxio.util.newMainPendingIntent 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) : class IndexingNotification(private val context: Context) :
ServiceNotification(context, INDEXER_CHANNEL) { ForegroundServiceNotification(context, INDEXER_CHANNEL) {
private var lastUpdateTime = -1L
init { init {
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_PROGRESS) setCategory(NotificationCompat.CATEGORY_PROGRESS)
@ -44,32 +51,50 @@ class IndexingNotification(private val context: Context) :
override val code: Int override val code: Int
get() = IntegerTable.INDEXER_NOTIFICATION_CODE 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 { fun updateIndexingState(indexing: Indexer.Indexing): Boolean {
when (indexing) { when (indexing) {
is Indexer.Indexing.Indeterminate -> { 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") logD("Updating state to $indexing")
lastUpdateTime = -1
setContentText(context.getString(R.string.lng_indexing)) setContentText(context.getString(R.string.lng_indexing))
setProgress(0, 0, true) setProgress(0, 0, true)
return true return true
} }
is Indexer.Indexing.Songs -> { is Indexer.Indexing.Songs -> {
// Only update the notification every 50 songs to prevent excessive updates. // Determinate state, show an active progress meter. Since these updates arrive
if (indexing.current % 50 == 0) { // highly rapidly, only update every 1.5 seconds to prevent notification rate
logD("Updating state to $indexing") // limiting.
setContentText( // TODO: Can I port this to the playback notification somehow?
context.getString(R.string.fmt_indexing, indexing.current, indexing.total)) val now = SystemClock.elapsedRealtime()
setProgress(indexing.total, indexing.current, false) if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
return true 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 { init {
setSmallIcon(R.drawable.ic_indexer_24) setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE) setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -85,6 +110,7 @@ class ObservingNotification(context: Context) : ServiceNotification(context, IND
get() = IntegerTable.INDEXER_NOTIFICATION_CODE get() = IntegerTable.INDEXER_NOTIFICATION_CODE
} }
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val INDEXER_CHANNEL = private val INDEXER_CHANNEL =
ServiceNotification.ChannelInfo( ForegroundServiceNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer) id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

@ -20,7 +20,10 @@ package org.oxycblt.auxio.music.system
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.database.ContentObserver 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 android.provider.MediaStore
import coil.imageLoader import coil.imageLoader
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -30,60 +33,61 @@ import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings 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.getSystemServiceCompat
import org.oxycblt.auxio.util.logD 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 * Loading music is a time-consuming process that would likely be killed by the system before it
* to a service that is less likely to be killed by the OS. * 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 * This [Service] also handles automatic rescanning, as that is a similarly long-running background
* boilerplate you skip is not worth the insanity of androidx. * 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 { class IndexerService : Service(), Indexer.Controller, Settings.Callback {
private val indexer = Indexer.getInstance() private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val playbackManager = PlaybackStateManager.getInstance()
private val serviceJob = Job() private val serviceJob = Job()
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
private var currentIndexJob: Job? = null
private val playbackManager = PlaybackStateManager.getInstance()
private lateinit var foregroundManager: ForegroundManager private lateinit var foregroundManager: ForegroundManager
private lateinit var indexingNotification: IndexingNotification private lateinit var indexingNotification: IndexingNotification
private lateinit var observingNotification: ObservingNotification private lateinit var observingNotification: ObservingNotification
private lateinit var settings: Settings
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var indexerContentObserver: SystemContentObserver private lateinit var indexerContentObserver: SystemContentObserver
private lateinit var settings: Settings
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Initialize the core service components first.
foregroundManager = ForegroundManager(this) foregroundManager = ForegroundManager(this)
indexingNotification = IndexingNotification(this) indexingNotification = IndexingNotification(this)
observingNotification = ObservingNotification(this) observingNotification = ObservingNotification(this)
wakeLock = wakeLock =
getSystemServiceCompat(PowerManager::class) getSystemServiceCompat(PowerManager::class)
.newWakeLock( .newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService") PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
// Initialize any listener-dependent components last as we wouldn't want a listener race
settings = Settings(this, this) // condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver() indexerContentObserver = SystemContentObserver()
settings = Settings(this, this)
indexer.registerController(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) { if (musicStore.library == null && indexer.isIndeterminate) {
logD("No library present and no previous response, indexing music now") logD("No library present and no previous response, indexing music now")
onStartIndexing() onStartIndexing(true)
} }
logD("Service created.") logD("Service created.")
@ -95,28 +99,29 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// De-initialize core service components first.
foregroundManager.release() foregroundManager.release()
wakeLock.releaseSafe() wakeLock.releaseSafe()
// Then cancel the listener-dependent components to ensure that stray reloading
// De-initialize the components first to prevent stray reloading events // events will not occur.
settings.release()
indexerContentObserver.release() indexerContentObserver.release()
settings.release()
indexer.unregisterController(this) indexer.unregisterController(this)
// Then cancel any remaining music loading jobs.
// Then cancel the other components.
indexer.cancelLast()
serviceJob.cancel() serviceJob.cancel()
indexer.reset()
} }
// --- CONTROLLER CALLBACKS --- // --- CONTROLLER CALLBACKS ---
override fun onStartIndexing() { override fun onStartIndexing(withCache: Boolean) {
if (indexer.isIndexing) { if (indexer.isIndexing) {
indexer.cancelLast() // Cancel the previous music loading job.
currentIndexJob?.cancel()
indexer.reset()
} }
// Start a new music loading job on a co-routine.
indexScope.launch { indexer.index(this@IndexerService) } currentIndexJob = indexScope.launch { indexer.index(this@IndexerService, withCache) }
} }
override fun onIndexerStateChanged(state: Indexer.State?) { override fun onIndexerStateChanged(state: Indexer.State?) {
@ -125,28 +130,23 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
if (state.response is Indexer.Response.Ok && if (state.response is Indexer.Response.Ok &&
state.response.library != musicStore.library) { state.response.library != musicStore.library) {
logD("Applying new library") logD("Applying new library")
val newLibrary = state.response.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) { if (musicStore.library != null) {
// This is a new library to replace an existing one. // Wipe possibly-invalidated outdated covers
// Wipe possibly-invalidated album covers
imageLoader.memoryCache?.clear() imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected // Clear invalid models from PlaybackStateManager. This is not connected
// to a callback as it is bad practice for a shared object to attach to // to a listener as it is bad practice for a shared object to attach to
// the callback system of another. // the listener system of another.
playbackManager.sanitize(newLibrary) playbackManager.sanitize(newLibrary)
} }
// Forward the new library to MusicStore to continue the update process.
musicStore.updateLibrary(newLibrary) musicStore.library = newLibrary
} }
// On errors, while we would want to show a notification that displays the // 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 // error, that requires the Android 13 notification permission, which is not
// notification permission, and there is no point implementing permission // handled right now.
// on-boarding for such when it will only be used for this.
updateIdleSession() updateIdleSession()
} }
is Indexer.State.Indexing -> { is Indexer.State.Indexing -> {
@ -163,21 +163,29 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// --- INTERNAL --- // --- 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) { private fun updateActiveSession(state: Indexer.Indexing) {
// When loading, we want to enter the foreground state so that android does // 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 // not shut off the loading process. Note that while we will always post the
// notification when initially starting, we will not update the notification // 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) val changed = indexingNotification.updateIndexingState(state)
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) { if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
logD("Notification changed, re-posting notification") logD("Notification changed, re-posting notification")
indexingNotification.post() indexingNotification.post()
} }
// Make sure we can keep the CPU on while loading music // Make sure we can keep the CPU on while loading music
wakeLock.acquireSafe() 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() { private fun updateIdleSession() {
if (settings.shouldBeObserving) { if (settings.shouldBeObserving) {
// There are a few reasons why we stay in the foreground with automatic rescanning: // 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. // we can go foreground later.
// 2. If a non-foreground service is killed, the app will probably still be alive, // 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. // 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)) { if (!foregroundManager.tryStartForeground(observingNotification)) {
observingNotification.post() observingNotification.post()
} }
} else { } else {
// Not observing and done loading, exit foreground.
foregroundManager.tryStopForeground() foregroundManager.tryStopForeground()
} }
// Release our wake lock (if we were using it) // Release our wake lock (if we were using it)
wakeLock.releaseSafe() wakeLock.releaseSafe()
} }
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.acquireSafe() { private fun PowerManager.WakeLock.acquireSafe() {
// Avoid unnecessary acquire calls.
if (!wakeLock.isHeld) { if (!wakeLock.isHeld) {
logD("Acquiring wake lock") logD("Acquiring wake lock")
// Time out after a minute, which is the average music loading time for a medium-sized
// We always drop the wakelock eventually. Timeout is not needed. // library. If this runs out, we will re-request the lock, and if music loading is
@Suppress("WakelockTimeout") acquire() // 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() { private fun PowerManager.WakeLock.releaseSafe() {
// Avoid unnecessary release calls.
if (wakeLock.isHeld) { if (wakeLock.isHeld) {
logD("Releasing wake lock") logD("Releasing wake lock")
release() release()
@ -217,10 +232,16 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onSettingChanged(key: String) { override fun onSettingChanged(key: String) {
when (key) { 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),
getString(R.string.set_key_music_dirs_include), 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) -> { 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) { if (!indexer.isIndexing) {
updateIdleSession() 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( * A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
private val handler: Handler = Handler(Looper.getMainLooper()) * known to the user as automatic rescanning. The active (and not passive) nature of observing
) : ContentObserver(handler), Runnable { * 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 { init {
contentResolverSafe.registerContentObserver( contentResolverSafe.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this) 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() { fun release() {
handler.removeCallbacks(this)
contentResolverSafe.unregisterContentObserver(this) contentResolverSafe.unregisterContentObserver(this)
} }
override fun onChange(selfChange: Boolean) { override fun onChange(selfChange: Boolean) {
// Batch rapid-fire updates to the library into a single call to run after 500ms // Batch rapid-fire updates to the library into a single call to run after 500ms
handler.removeCallbacks(this) handler.removeCallbacks(this)
handler.postDelayed(this, REINDEX_DELAY) handler.postDelayed(this, REINDEX_DELAY_MS)
} }
override fun run() { override fun run() {
// Check here if we should even start a reindex. This is much less bug-prone than // 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. // registering and de-registering this component as this setting changes.
if (settings.shouldBeObserving) { if (settings.shouldBeObserving) {
onStartIndexing() onStartIndexing(true)
} }
} }
} }
companion object { companion object {
const val REINDEX_DELAY = 500L private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
private const val REINDEX_DELAY_MS = 500L
} }
} }

View file

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

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

View file

@ -20,8 +20,6 @@ package org.oxycblt.auxio.playback
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
import org.oxycblt.auxio.music.Song 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.settings.Settings
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel 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.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.msToDs
/** /**
* A fragment showing the current playback state in a compact manner. Used as the bar for the * A [ViewBindingFragment] that shows the current playback state in a compact manner.
* playback sheet. * @author Alexander Capehart (OxygenCobalt)
* @author OxygenCobalt
*/ */
class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() { class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels()
@ -52,30 +48,47 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding: FragmentPlaybackBarBinding, binding: FragmentPlaybackBarBinding,
savedInstanceState: Bundle? savedInstanceState: Bundle?
) { ) {
super.onBindingCreated(binding, savedInstanceState)
val context = requireContext() val context = requireContext()
// --- UI SETUP ---
binding.root.apply { binding.root.apply {
setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Expand) } setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Expand) }
setOnLongClickListener { setOnLongClickListener {
playbackModel.song.value?.let(navModel::exploreNavigateTo) playbackModel.song.value?.let(navModel::exploreNavigateTo)
true true
} }
} }
// Set up marquee on song information
binding.playbackSong.isSelected = true 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 // 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 = binding.playbackProgressBar.trackColor =
context.getColorCompat(R.color.sel_track).defaultColor 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) { private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) {
BarAction.NEXT -> { when (settings.playbackBarAction) {
ActionMode.NEXT -> {
binding.playbackSecondaryAction.apply { binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.ic_skip_next_24) setIconResource(R.drawable.ic_skip_next_24)
contentDescription = getString(R.string.desc_skip_next) contentDescription = getString(R.string.desc_skip_next)
@ -83,35 +96,24 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
setOnClickListener { playbackModel.next() } setOnClickListener { playbackModel.next() }
} }
} }
BarAction.REPEAT -> { ActionMode.REPEAT -> {
binding.playbackSecondaryAction.apply { binding.playbackSecondaryAction.apply {
contentDescription = getString(R.string.desc_change_repeat) contentDescription = getString(R.string.desc_change_repeat)
iconTint = context.getColorCompat(R.color.sel_accented) iconTint = context.getColorCompat(R.color.sel_activatable_icon)
setOnClickListener { playbackModel.incrementRepeatMode() } setOnClickListener { playbackModel.toggleRepeatMode() }
collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.repeatMode, ::updateRepeat)
} }
} }
BarAction.SHUFFLE -> { ActionMode.SHUFFLE -> {
binding.playbackSecondaryAction.apply { binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.sel_shuffle_state_24) setIconResource(R.drawable.sel_shuffle_state_24)
contentDescription = getString(R.string.desc_shuffle) contentDescription = getString(R.string.desc_shuffle)
iconTint = context.getColorCompat(R.color.sel_accented) iconTint = context.getColorCompat(R.color.sel_activatable_icon)
setOnClickListener { playbackModel.invertShuffled() } setOnClickListener { playbackModel.invertShuffled() }
collectImmediately(playbackModel.isShuffled, ::updateShuffled) 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?) { private fun updateSong(song: Song?) {
@ -120,18 +122,19 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
val binding = requireBinding() val binding = requireBinding()
binding.playbackCover.bind(song) binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context) 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() binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
} }
} }
private fun updateIsPlaying(isPlaying: Boolean) { private fun updatePlaying(isPlaying: Boolean) {
requireBinding().playbackPlayPause.isActivated = isPlaying requireBinding().playbackPlayPause.isActivated = isPlaying
} }
private fun updateRepeat(repeatMode: RepeatMode) { private fun updateRepeat(repeatMode: RepeatMode) {
requireBinding().playbackSecondaryAction.apply { requireBinding().playbackSecondaryAction.apply {
setIconResource(repeatMode.icon) setIconResource(repeatMode.icon)
// Icon tinting is controlled through isActivated, so update that flag as well.
isActivated = repeatMode != RepeatMode.NONE isActivated = repeatMode != RepeatMode.NONE
} }
} }
@ -144,21 +147,3 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
requireBinding().playbackProgressBar.progress = positionDs.toInt() 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
}
}
}

View file

@ -25,17 +25,16 @@ import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R 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.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen import org.oxycblt.auxio.util.getDimen
/** /**
* The coordinator layout behavior used for the playback sheet, hacking in the many fixes required * The [BaseBottomSheetBehavior] for the playback bottom sheet. This bottom sheet
* to make bottom sheets like this work. * @author Alexander Capehart (OxygenCobalt)
* @author OxygenCobalt
*/ */
class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) : class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioSheetBehavior<V>(context, attributeSet) { BaseBottomSheetBehavior<V>(context, attributeSet) {
val sheetBackgroundDrawable = val sheetBackgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(context).apply { MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface) fillColor = context.getAttrColorCompat(R.attr.colorSurface)
@ -57,6 +56,8 @@ class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeS
override fun createBackground(context: Context) = override fun createBackground(context: Context) =
LayerDrawable( LayerDrawable(
arrayOf( 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 { MaterialShapeDrawable(sheetBackgroundDrawable.shapeAppearanceModel).apply {
fillColor = sheetBackgroundDrawable.fillColor fillColor = sheetBackgroundDrawable.fillColor
}, },

View file

@ -26,33 +26,36 @@ import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.updatePadding 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.R
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.RepeatMode 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.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.collectImmediately
import org.oxycblt.auxio.util.msToDs
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
* A [Fragment] that displays more information about the song, along with more media controls. * A [ViewBindingFragment] more information about the currently playing song, alongside all
* Instantiation is done by the navigation component, **do not instantiate this fragment manually.** * available controls.
* @author OxygenCobalt * @author Alexander Capehart (OxygenCobalt)
*
* TODO: Make seek thumb grow when selected
*/ */
class PlaybackPanelFragment : class PlaybackPanelFragment :
MenuFragment<FragmentPlaybackPanelBinding>(), ViewBindingFragment<FragmentPlaybackPanelBinding>(),
StyledSeekBar.Callback, Toolbar.OnMenuItemClickListener,
Toolbar.OnMenuItemClickListener { StyledSeekBar.Listener {
// AudioEffect expects you to use startActivityForResult with the panel intent. Use private val playbackModel: PlaybackViewModel by androidActivityViewModels()
// the contract analogue for this since there is no built-in contract for AudioEffect. private val navModel: NavigationViewModel by activityViewModels()
private val activityLauncher by lifecycleObject { // 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()) { registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
// Nothing to do // Nothing to do
} }
@ -65,8 +68,9 @@ class PlaybackPanelFragment :
binding: FragmentPlaybackPanelBinding, binding: FragmentPlaybackPanelBinding,
savedInstanceState: Bundle? savedInstanceState: Bundle?
) { ) {
// --- UI SETUP --- super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.root.setOnApplyWindowInsetsListener { view, insets -> binding.root.setOnApplyWindowInsetsListener { view, insets ->
val bars = insets.systemBarInsetsCompat val bars = insets.systemBarInsetsCompat
view.updatePadding(top = bars.top, bottom = bars.bottom) view.updatePadding(top = bars.top, bottom = bars.bottom)
@ -78,30 +82,31 @@ class PlaybackPanelFragment :
setOnMenuItemClickListener(this@PlaybackPanelFragment) setOnMenuItemClickListener(this@PlaybackPanelFragment)
} }
// Set up marquee on song information, alongside click handlers that navigate to each
// respective item.
binding.playbackSong.apply { binding.playbackSong.apply {
// Make marquee of the song title work
isSelected = true isSelected = true
setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) } setOnClickListener { playbackModel.song.value?.let(navModel::exploreNavigateTo) }
} }
binding.playbackArtist.apply {
binding.playbackArtist.setOnClickListener { isSelected = true
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) } setOnClickListener { navigateToCurrentArtist() }
}
binding.playbackAlbum.apply {
isSelected = true
setOnClickListener { navigateToCurrentAlbum() }
} }
binding.playbackAlbum.setOnClickListener { binding.playbackSeekBar.listener = this
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) }
}
binding.playbackSeekBar.callback = this // Set up actions
binding.playbackRepeat.setOnClickListener { playbackModel.toggleRepeatMode() }
binding.playbackRepeat.setOnClickListener { playbackModel.incrementRepeatMode() }
binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() } binding.playbackSkipPrev.setOnClickListener { playbackModel.prev() }
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() } binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
binding.playbackSkipNext.setOnClickListener { playbackModel.next() } binding.playbackSkipNext.setOnClickListener { playbackModel.next() }
binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() } binding.playbackShuffle.setOnClickListener { playbackModel.invertShuffled() }
// --- VIEWMODEL SETUP -- // --- VIEWMODEL SETUP --
collectImmediately(playbackModel.song, ::updateSong) collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.parent, ::updateParent) collectImmediately(playbackModel.parent, ::updateParent)
collectImmediately(playbackModel.positionDs, ::updatePosition) collectImmediately(playbackModel.positionDs, ::updatePosition)
@ -112,58 +117,67 @@ class PlaybackPanelFragment :
override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) {
binding.playbackToolbar.setOnMenuItemClickListener(null) binding.playbackToolbar.setOnMenuItemClickListener(null)
// Marquee elements leak if they are not disabled when the views are destroyed.
binding.playbackSong.isSelected = false binding.playbackSong.isSelected = false
binding.playbackSeekBar.callback = null binding.playbackArtist.isSelected = false
binding.playbackAlbum.isSelected = false
} }
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem) =
return when (item.itemId) { when (item.itemId) {
R.id.action_open_equalizer -> { R.id.action_open_equalizer -> {
// Launch the system equalizer app, if possible.
// TODO: Move this to a utility
val equalizerIntent = val equalizerIntent =
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
// Provide audio session ID so equalizer can show options for this app
// in particular.
.putExtra( .putExtra(
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId) 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) .putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
try { try {
activityLauncher.launch(equalizerIntent) equalizerLauncher.launch(equalizerIntent)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
requireContext().showToast(R.string.err_no_app) requireContext().showToast(R.string.err_no_app)
} }
true true
} }
R.id.action_go_artist -> { R.id.action_go_artist -> {
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album.artist) } navigateToCurrentArtist()
true true
} }
R.id.action_go_album -> { R.id.action_go_album -> {
playbackModel.song.value?.let { navModel.exploreNavigateTo(it.album) } navigateToCurrentAlbum()
true true
} }
R.id.action_song_detail -> { R.id.action_song_detail -> {
playbackModel.song.value?.let { playbackModel.song.value?.let { song ->
navModel.mainNavigateTo(MainNavigationAction.SongDetails(it)) navModel.mainNavigateTo(
MainNavigationAction.Directions(
MainFragmentDirections.actionShowDetails(song.uid)))
} }
true true
} }
else -> false else -> false
} }
}
override fun seekTo(positionDs: Long) { override fun onSeekConfirmed(positionDs: Long) {
playbackModel.seekTo(positionDs) playbackModel.seekTo(positionDs)
} }
private fun updateSong(song: Song?) { private fun updateSong(song: Song?) {
if (song == null) return if (song == null) {
// Nothing to do.
return
}
val binding = requireBinding() val binding = requireBinding()
val context = requireContext() val context = requireContext()
binding.playbackCover.bind(song) binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context) 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.playbackAlbum.text = song.album.resolveName(context)
binding.playbackSeekBar.durationDs = song.durationMs.msToDs() binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
} }
@ -171,7 +185,6 @@ class PlaybackPanelFragment :
private fun updateParent(parent: MusicParent?) { private fun updateParent(parent: MusicParent?) {
val binding = requireBinding() val binding = requireBinding()
val context = requireContext() val context = requireContext()
binding.playbackToolbar.subtitle = binding.playbackToolbar.subtitle =
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs) parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)
} }
@ -194,4 +207,16 @@ class PlaybackPanelFragment :
private fun updateShuffled(isShuffled: Boolean) { private fun updateShuffled(isShuffled: Boolean) {
requireBinding().playbackShuffle.isActivated = isShuffled 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