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:
- **Has it already been requested?** Make sure request for this feature is not already here.
- **Has it been already added?** Make sure this feature has not already been added in the most recent release.
- **Will it be accepted?** Read the [Accepted Additions and Requests](../info/ADDITIONS.md) in order to see the likelihood that your request will be implemented.
- **Will it be accepted?** Read the [Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F) in order to see the likelihood that your request will be implemented.
If you do make a request, provide the following:
- What is it that you want?
@ -29,7 +29,7 @@ If you do make a request, provide the following:
- Why do you think it will benefit everyone's usage of the app?
If you have the knowledge, you can also implement the feature yourself and create a [Pull Request](https://github.com/OxygenCobalt/Auxio/pulls), but its recommended that **you create an issue beforehand to give me a heads up.**
Its also recommended that you read about [Auxio's Architecture](../info/ARCHITECTURE.md) as well to make changes better and more efficient.
Its also recommended that you read about [Auxio's Architecture](https://github.com/OxygenCobalt/Auxio/wiki/Architecture) as well to make changes better and more efficient.
## Translations
Go to Auxio's weblate project [here](https://hosted.weblate.org/engage/auxio/).
@ -44,4 +44,3 @@ If you have knowledge of Android/Kotlin, feel free to to contribute to the proje
- Please ***FULLY TEST*** your changes before creating a PR. Untested code will not be merged.
- Java code will **NOT** be accepted. Kotlin only.
- Keep your code up the date with the upstream and continue to maintain it after you create the PR. This makes it less of a hassle to merge.
- Make sure you have read about the [Accepted Additions and Requests](../info/ADDITIONS.md) before working on your addition.

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?
- [ ] Bugfix (user facing)
- [ ] Feature (user facing)
- [ ] Translation to: <!-- Include the language here. Note if this translation is for a regional dialect. --> (user facing)
- [ ] Codebase improvement (dev facing)
- [ ] Meta improvement to the project (dev facing)
@ -25,4 +24,4 @@ debug.zip
#### Due Diligence
- [ ] I have read the [Contribution Guidelines](https://github.com/OxygenCobalt/Auxio/blob/dev/.github/CONTRIBUTING.md).
- [ ] I have read the [Accepted additions & Requests](https://github.com/OxygenCobalt/Auxio/blob/dev/info/ADDITIONS.md) document.
- [ ] I have read the [Why Are These Features Missing?](https://github.com/OxygenCobalt/Auxio/wiki/Why-Are-These-Features-Missing%3F) page.

View file

@ -2,6 +2,57 @@
## dev
## 3.0.0
#### What's New
- Added multi-value tags support
- Added support for multiple artists
- Added support for multiple genres
- Artists and album artists are now both given UI entires
- Added setting to hide "collaborator" artists
- Upgraded music ID management:
- Added support for MusicBrainz IDs (MBIDs)
- Use the more unique MD5 hash of metadata when MBIDs can't be used
- Genres now display a list of artists
- Added toggle to load non-music (Such as podcasts)
- Music loader now caches parsed metadata for faster load times
- Redesigned icon
- Added animated splash screen on Android 12+
- Added support for MP4 ReplayGain (`----`) atoms
#### What's Improved
- Sorting now takes accented characters into account
- Added support for compilation sub-release-types like (DJ) Mix
- Album dates now start from the earliest date instead of latest date
- Reshuffling the queue will no longer drop any songs you have added/removed
- Allowed light/dark theme to be customized on Android 12+
- All information now scrolls in the playback view
- A month is now shown for song/album dates when available
- Added loading indicator to song properties view
- List items have been made more compact
#### What's Fixed
- Fixed issue where the scroll popup would not display correctly in landscape mode [#230]
- Fixed issue where the playback progress would continue in the notification when
audio focus was lost
- Fixed issue where the artist name would not be shown in the OS audio switcher menu
- Fixed issue where the search view would not update if the library changed
- Fixed visual bug with transitions in the black theme
- Fixed toolbar flickering when fast-scrolling in the home UI
#### What's Changed
- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed.
- Removed the "Play from genre" option in the library/detail playback mode settings+
- "Use alternate notification action" is now "Custom notification action"
- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers"
#### Dev/Meta
- Created new wiki with more information about app functionality
- Switched to issue forms
- Completed migration to reactive playback system
- Refactor music backends into a unified chain of extractors
- Add bluetooth connection receiver (No functionality in app yet)
## 2.6.4
#### What's Fixed

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v2.6.4">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v2.6.4&color=0D5AF5">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.0">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.0&color=0D5AF5">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
@ -13,7 +13,7 @@
</a>
<img alt="Minimum SDK Version" src="https://img.shields.io/badge/API-21%2B-32B5ED">
</p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="/info/FAQ.md">FAQ</a> | <a href="/info/LICENSES.md">Licenses</a> | <a href="/.github/CONTRIBUTING.md">Contributing</a> | <a href="/info/ARCHITECTURE.md">Architecture</a></h4>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a></h4>
<p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
<a href="https://hosted.weblate.org/engage/auxio/"><img src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
@ -21,7 +21,7 @@
## About
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of <a href="https://exoplayer.dev/">Exoplayer</a>, Auxio has a much better listening experience compared to other apps that use the native MediaPlayer API. In short, **It plays music.**
Auxio is a local music player with a fast, reliable UI/UX without the many useless features present in other music players. Built off of <a href="https://exoplayer.dev/">Exoplayer</a>, Auxio has superior library support and listening quality compared to other apps that use outdated android functionality. In short, **It plays music.**
I primarily built Auxio for myself, but you can use it too, I guess.
@ -46,32 +46,25 @@ I primarily built Auxio for myself, but you can use it too, I guess.
- Snappy UI derived from the latest Material Design guidelines
- Opinionated UX that prioritizes ease of use over edge cases
- Customizable behavior
- Advanced media indexer that prioritizes correct metadata
- Precise/Original Dates, Sort Tags, and Release Type support (Experimental)
- Support for disc numbers, multiple artists, release types,
precise/original dates, sort tags, and more
- Advanced artist system that unifies artists and album artists
- SD Card-aware folder management
- Reliable playback state persistence
- Full ReplayGain support (On MP3, MP4, FLAC, OGG, and OPUS)
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
- External equalizer support (ex. Wavelet)
- Edge-to-edge
- Embedded covers support
- Search Functionality
- Search functionality
- Headset autoplay
- Stylish widgets that automatically adapt to their size
- Completely private and offline
- No rounded album covers (Unless you want them. Then you can.)
## To come in the future:
- Playlists
- Liked songs
- Artist Images
- More customization options
- Other things, probably
## Permissions
- Storage (`READ_EXTERNAL_STORAGE`): to read and play your media files
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`): to keep the music playing even if the app itself is in background
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your media files
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing even if the app itself is in background
## Building
@ -97,3 +90,5 @@ will. Specifically you can redistribute and/or modify it under the terms of the
[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as
published by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
More information can be found [here](https://github.com/OxygenCobalt/Auxio/wiki/Licenses).

View file

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

View file

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

View file

@ -8,6 +8,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Bluetooth auto-connect functionality (Disabled until permission workflow can be made) -->
<!-- <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />-->
<!-- <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" android:minSdkVersion="31" />-->
<!-- <uses-feature android:name="android.hardware.bluetooth" android:required="false"/>-->
<!-- Work around ExoPlayer requiring network permissions we do not use -->
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
@ -70,8 +75,8 @@
</activity>
<!--
IndexerService handles querying the media database,
extracting metadata, and constructing the music library.
Service handling querying the media database, extracting metadata, and constructing
the music library.
-->
<service
android:name=".music.system.IndexerService"
@ -81,7 +86,7 @@
android:roundIcon="@mipmap/ic_launcher" />
<!--
PlaybackService handles music playback, system components, and state saving.
Service handling music playback, system components, and state saving.
-->
<service
android:name=".playback.system.PlaybackService"
@ -102,7 +107,16 @@
</intent-filter>
</receiver>
<!-- Auxio's one and only AppWidget. -->
<!-- Receiver for bluetooth headset events -->
<!-- <receiver-->
<!-- android:name=".playback.system.BluetoothConnectReceiver"-->
<!-- android:exported="true">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED" />-->
<!-- </intent-filter>-->
<!-- </receiver>-->
<!-- "Now Playing" widget.. -->
<receiver
android:name=".widgets.WidgetProvider"
android:exported="false"

View file

@ -83,11 +83,11 @@ import java.util.Map;
* window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
* BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
*
* Modified at several points by OxygenCobalt to work around miscellaneous insanity.
* Modified at several points by Alexander Capehart to work around miscellaneous issues.
*/
public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
/** Callback for monitoring events about bottom sheets. */
/** Listener for monitoring events about bottom sheets. */
public abstract static class BottomSheetCallback {
/**
@ -1205,9 +1205,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* Sets a callback to be notified of bottom sheet events.
* Sets a listener to be notified of bottom sheet events.
*
* @param callback The callback to notify when bottom sheet events occur.
* @param callback The listener to notify when bottom sheet events occur.
* @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
* #removeBottomSheetCallback(BottomSheetCallback)} instead
*/
@ -1227,9 +1227,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* Adds a callback to be notified of bottom sheet events.
* Adds a listener to be notified of bottom sheet events.
*
* @param callback The callback to notify when bottom sheet events occur.
* @param callback The listener to notify when bottom sheet events occur.
*/
public void addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
if (!callbacks.contains(callback)) {
@ -1238,9 +1238,9 @@ public class NeoBottomSheetBehavior<V extends View> extends CoordinatorLayout.Be
}
/**
* Removes a previously added callback.
* Removes a previously added listener.
*
* @param callback The callback to remove.
* @param callback The listener to remove.
*/
public void removeBottomSheetCallback(@NonNull BottomSheetCallback callback) {
callbacks.remove(callback);

View file

@ -25,16 +25,22 @@ import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.request.CachePolicy
import org.oxycblt.auxio.image.AlbumCoverFetcher
import org.oxycblt.auxio.image.ArtistImageFetcher
import org.oxycblt.auxio.image.CrossfadeTransitionFactory
import org.oxycblt.auxio.image.GenreImageFetcher
import org.oxycblt.auxio.image.MusicKeyer
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
import org.oxycblt.auxio.image.extractor.GenreImageFetcher
import org.oxycblt.auxio.image.extractor.MusicKeyer
import org.oxycblt.auxio.settings.Settings
/**
* Auxio: A simple, rational music player for android.
* @author Alexander Capehart (OxygenCobalt)
*/
class AuxioApp : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
// Migrate any settings that may have changed in an app update.
Settings(this).migrate()
// Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release
// Auxio instances.
@ -54,18 +60,23 @@ class AuxioApp : Application(), ImageLoaderFactory {
override fun newImageLoader() =
ImageLoader.Builder(applicationContext)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(MusicKeyer())
add(AlbumCoverFetcher.SongFactory())
add(AlbumCoverFetcher.AlbumFactory())
add(ArtistImageFetcher.Factory())
add(GenreImageFetcher.Factory())
add(MusicKeyer())
}
.transitionFactory(CrossfadeTransitionFactory())
.diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())
// Not downloading anything, so no disk-caching
.diskCachePolicy(CachePolicy.DISABLED)
.build()
companion object {
const val SHORTCUT_SHUFFLE_ID = "shortcut_shuffle"
/** The [Intent] name for the "Shuffle All" shortcut. */
const val INTENT_KEY_SHORTCUT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE_ALL"
/** The ID of the "Shuffle All" shortcut. */
private const val SHORTCUT_SHUFFLE_ID = "shortcut_shuffle"
}
}

View file

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

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
@ -37,7 +36,7 @@ import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
* The single [AppCompatActivity] for Auxio.
* Auxio's single [AppCompatActivity].
*
* TODO: Add error screens
*
@ -45,22 +44,30 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
*
* TODO: Add multi-select
*
* TODO: Remove asterisk imports
* TODO: Use proper material attributes (Not the weird dimen attributes I currently have)
*
* @author OxygenCobalt
* TODO: Migrate to material animation system
*
* TODO: Unit testing
*
* TODO: Standardize from/new usage
*
* TODO: Standardize companion object usage
*
* TODO: Standardize callback/listener naming.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MainActivity : AppCompatActivity() {
private val playbackModel: PlaybackViewModel by androidViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupTheme()
// Inflate the views after setting up the theme so that the theme attributes are applied.
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupEdgeToEdge(binding.root)
logD("Activity created")
}
@ -71,6 +78,7 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, PlaybackService::class.java))
if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state.
playbackModel.startAction(InternalPlayer.Action.RestoreState)
}
}
@ -80,46 +88,12 @@ class MainActivity : AppCompatActivity() {
startIntentAction(intent)
}
private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) {
return false
}
if (intent.getBooleanExtra(KEY_INTENT_USED, false)) {
// Don't commit the action, but also return that the intent was applied.
// This is because onStart can run multiple times, and thus we really don't
// want to return false and override the original delayed action with a
// RestoreState action.
return true
}
intent.putExtra(KEY_INTENT_USED, true)
val action =
when (intent.action) {
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> {
InternalPlayer.Action.ShuffleAll
}
else -> return false
}
playbackModel.startAction(action)
return true
}
private fun setupTheme() {
val settings = Settings(this)
// Disable theme customization above Android 12, as it's far enough in as a version to
// the point where most phones should have an option for light/dark theming.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
// The black theme has a completely separate set of styles since style attributes cannot
// be modified at runtime.
// Apply the theme configuration.
AppCompatDelegate.setDefaultNightMode(settings.theme)
// Apply the color scheme. The black theme requires it's own set of themes since
// it's not possible to modify the themes at run-time.
if (isNight && settings.useBlackTheme) {
logD("Applying black theme [accent ${settings.accent}]")
setTheme(settings.accent.blackTheme)
@ -131,14 +105,47 @@ class MainActivity : AppCompatActivity() {
private fun setupEdgeToEdge(contentView: View) {
WindowCompat.setDecorFitsSystemWindows(window, false)
contentView.setOnApplyWindowInsetsListener { view, insets ->
// Automatically inset the view to the left/right, as component support for
// these insets are highly lacking.
val bars = insets.systemBarInsetsCompat
view.updatePadding(left = bars.left, right = bars.right)
insets
}
}
/**
* Transform an [Intent] given to [MainActivity] into a [InternalPlayer.Action] that can be used
* in the playback system.
* @param intent The (new) [Intent] given to this [MainActivity], or null if there is no intent.
* @return true If the analogous [InternalPlayer.Action] to the given [Intent] was started,
* false otherwise.
*/
private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) {
// Nothing to do.
return false
}
if (intent.getBooleanExtra(KEY_INTENT_USED, false)) {
// Don't commit the action, but also return that the intent was applied.
// This is because onStart can run multiple times, and thus we really don't
// want to return false and override the original delayed action with a
// RestoreState action.
return true
}
intent.putExtra(KEY_INTENT_USED, true)
val action =
when (intent.action) {
Intent.ACTION_VIEW -> InternalPlayer.Action.Open(intent.data ?: return false)
AuxioApp.INTENT_KEY_SHORTCUT_SHUFFLE -> InternalPlayer.Action.ShuffleAll
else -> return false
}
playbackModel.startAction(action)
return true
}
companion object {
private const val KEY_INTENT_USED = BuildConfig.APPLICATION_ID + ".key.FILE_INTENT_USED"
}

View file

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

View file

@ -17,13 +17,10 @@
package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.view.children
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@ -32,40 +29,38 @@ import com.google.android.material.transition.MaterialSharedAxis
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A fragment that shows information for a particular [Album].
* @author OxygenCobalt
* A [ListFragment] that shows information about an [Album].
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailFragment :
MenuFragment<FragmentDetailBinding>(),
Toolbar.OnMenuItemClickListener,
AlbumDetailAdapter.Listener {
class AlbumDetailFragment : ListFragment<FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
// Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs()
private val detailAdapter = AlbumDetailAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
@ -74,9 +69,13 @@ class AlbumDetailFragment :
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setAlbumId(args.albumId)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
binding.detailToolbar.apply {
inflateMenu(R.menu.menu_album_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
@ -86,12 +85,14 @@ class AlbumDetailFragment :
binding.detailRecycler.adapter = detailAdapter
// -- VIEWMODEL SETUP ---
collectImmediately(detailModel.currentAlbum, ::handleItemChange)
collectImmediately(detailModel.albumData, detailAdapter::submitList)
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbumUid(args.albumUid)
collectImmediately(detailModel.currentAlbum, ::updateAlbum)
collectImmediately(detailModel.albumList, detailAdapter::submitList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
@ -101,50 +102,58 @@ class AlbumDetailFragment :
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
val currentAlbum = unlikelyToBeNull(detailModel.currentAlbum.value)
return when (item.itemId) {
R.id.action_play_next -> {
playbackModel.playNext(unlikelyToBeNull(detailModel.currentAlbum.value))
playbackModel.playNext(currentAlbum)
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_queue_add -> {
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentAlbum.value))
playbackModel.addToQueue(currentAlbum)
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_go_artist -> {
navModel.exploreNavigateTo(unlikelyToBeNull(detailModel.currentAlbum.value).artist)
onNavigateToParentArtist()
true
}
else -> false
}
}
override fun onItemClick(item: Item) {
if (item is Song) {
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_ALBUM)
override fun onRealClick(music: Music) {
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
when (Settings(requireContext()).detailPlaybackMode) {
// "Play from shown item" and "Play from album" functionally have the same
// behavior since a song can only have one album.
null,
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
}
}
override fun onOpenMenu(item: Item, anchor: View) {
if (item is Song) {
musicMenu(anchor, R.menu.menu_album_song_actions, item)
return
}
error("Unexpected datatype when opening menu: ${item::class.java}")
check(item is Song) { "Unexpected datatype: ${item::class.simpleName}" }
openMusicMenu(anchor, R.menu.menu_album_song_actions, item)
}
override fun onPlayParent() {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), false)
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onShuffleParent() {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value), true)
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onShowSortMenu(anchor: View) {
menu(anchor, R.menu.menu_album_sort) {
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_album_sort) {
val sort = detailModel.albumSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
@ -161,50 +170,56 @@ class AlbumDetailFragment :
}
}
override fun onNavigateToArtist() {
findNavController()
.navigate(
AlbumDetailFragmentDirections.actionShowArtist(
unlikelyToBeNull(detailModel.currentAlbum.value).artist.id))
override fun onNavigateToParentArtist() {
navModel.exploreNavigateToParentArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
}
private fun handleItemChange(album: Album?) {
private fun updateAlbum(album: Album?) {
if (album == null) {
// Album we were showing no longer exists.
findNavController().navigateUp()
return
}
requireBinding().detailToolbar.title = album.resolveName(requireContext())
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent is Album && parent == unlikelyToBeNull(detailModel.currentAlbum.value)) {
detailAdapter.setPlayingItem(song, isPlaying)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.setPlayingItem(null, isPlaying)
}
}
private fun handleNavigation(item: Music?) {
val binding = requireBinding()
when (item) {
// Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise.
is Song -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value).id == item.album.id) {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == item.album) {
logD("Navigating to a song in this album")
scrollToItem(item.id)
scrollToAlbumSong(item)
navModel.finishExploreNavigation()
} else {
logD("Navigating to another album")
findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.id))
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.album.uid))
}
}
// If the album matches, no need to do anything. Otherwise launch a new
// detail fragment.
is Album -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value).id == item.id) {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == item) {
logD("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0)
navModel.finishExploreNavigation()
} else {
logD("Navigating to another album")
findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.id))
.navigate(AlbumDetailFragmentDirections.actionShowAlbum(item.uid))
}
}
@ -212,24 +227,42 @@ class AlbumDetailFragment :
is Artist -> {
logD("Navigating to another artist")
findNavController()
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.id))
.navigate(AlbumDetailFragmentDirections.actionShowArtist(item.uid))
}
null -> {}
else -> error("Unexpected navigation item ${item::class.java}")
else -> error("Unexpected datatype: ${item::class.java}")
}
}
/** Scroll to an song using its [id]. */
private fun scrollToItem(id: Long) {
private fun scrollToAlbumSong(song: Song) {
// Calculate where the item for the currently played song is
val pos = detailModel.albumData.value.indexOfFirst { it.id == id && it is Song }
val pos = detailModel.albumList.value.indexOf(song)
if (pos != -1) {
// Only scroll if the song is within this album.
val binding = requireBinding()
binding.detailRecycler.post {
// Use a custom smooth scroller that will settle the item in the middle of
// the screen rather than the end.
val centerSmoothScroller =
object : LinearSmoothScroller(context) {
init {
targetPosition = pos
}
override fun calculateDtToFit(
viewStart: Int,
viewEnd: Int,
boxStart: Int,
boxEnd: Int,
snapPreference: Int
): Int =
(boxStart + (boxEnd - boxStart) / 2) -
(viewStart + (viewEnd - viewStart) / 2)
}
// Make sure to increment the position to make up for the detail header
binding.detailRecycler.layoutManager?.startSmoothScroll(
CenterSmoothScroller(requireContext(), pos))
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
// If the recyclerview can scroll, its certain that it will have to scroll to
// correctly center the playing item, so make sure that the Toolbar is lifted in
@ -239,44 +272,8 @@ class AlbumDetailFragment :
}
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
val binding = requireBinding()
for (item in binding.detailToolbar.menu.children) {
// If there is no playback going in, any queue additions will be wiped as soon as
// something is played. Disable these actions when playback is going on so that
// it isn't possible to add anything during that time.
if (item.itemId == R.id.action_play_next || item.itemId == R.id.action_queue_add) {
item.isEnabled = song != null
}
}
if (parent is Album && parent.id == unlikelyToBeNull(detailModel.currentAlbum.value).id) {
detailAdapter.updateIndicator(song, isPlaying)
} else {
// Clear the ViewHolders if the mode isn't ALL_SONGS
detailAdapter.updateIndicator(null, isPlaying)
}
}
/**
* [LinearSmoothScroller] subclass that centers the item on the screen instead of snapping to
* the top or bottom.
*/
private class CenterSmoothScroller(context: Context, target: Int) :
LinearSmoothScroller(context) {
init {
targetPosition = target
}
override fun calculateDtToFit(
viewStart: Int,
viewEnd: Int,
boxStart: Int,
boxEnd: Int,
snapPreference: Int
): Int {
return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2)
}
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelectedItems(selected)
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

View file

@ -21,7 +21,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@ -30,37 +29,37 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.fragment.MenuFragment
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* A fragment that shows information for a particular [Artist].
* @author OxygenCobalt
* A [ListFragment] that shows information about an [Artist].
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailFragment :
MenuFragment<FragmentDetailBinding>(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener {
class ArtistDetailFragment : ListFragment<FragmentDetailBinding>(), DetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs()
private val detailAdapter = ArtistDetailAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
@ -69,9 +68,13 @@ class ArtistDetailFragment :
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
detailModel.setArtistId(args.artistId)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailToolbar.apply {
inflateMenu(R.menu.menu_genre_artist_detail)
setNavigationOnClickListener { findNavController().navigateUp() }
@ -81,12 +84,14 @@ class ArtistDetailFragment :
binding.detailRecycler.adapter = detailAdapter
// --- VIEWMODEL SETUP ---
collectImmediately(detailModel.currentArtist, ::handleItemChange)
collectImmediately(detailModel.artistData, detailAdapter::submitList)
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtistUid(args.artistUid)
collectImmediately(detailModel.currentArtist, ::updateItem)
collectImmediately(detailModel.artistList, detailAdapter::submitList)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
@ -96,14 +101,19 @@ class ArtistDetailFragment :
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
return when (item.itemId) {
R.id.action_play_next -> {
playbackModel.playNext(unlikelyToBeNull(detailModel.currentArtist.value))
playbackModel.playNext(currentArtist)
requireContext().showToast(R.string.lng_queue_added)
true
}
R.id.action_queue_add -> {
playbackModel.addToQueue(unlikelyToBeNull(detailModel.currentArtist.value))
playbackModel.addToQueue(currentArtist)
requireContext().showToast(R.string.lng_queue_added)
true
}
@ -111,32 +121,44 @@ class ArtistDetailFragment :
}
}
override fun onItemClick(item: Item) {
when (item) {
is Song ->
playbackModel.play(item, settings.detailPlaybackMode ?: PlaybackMode.IN_ARTIST)
is Album -> navModel.exploreNavigateTo(item)
override fun onRealClick(music: Music) {
when (music) {
is Song -> {
when (Settings(requireContext()).detailPlaybackMode) {
// When configured to play from the selected item, we already have an Artist
// to play from.
null ->
playbackModel.playFromArtist(
music, unlikelyToBeNull(detailModel.currentArtist.value))
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
}
}
is Album -> navModel.exploreNavigateTo(music)
else -> error("Unexpected datatype: ${music::class.simpleName}")
}
}
override fun onOpenMenu(item: Item, anchor: View) {
when (item) {
is Song -> musicMenu(anchor, R.menu.menu_artist_song_actions, item)
is Album -> musicMenu(anchor, R.menu.menu_artist_album_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
is Song -> openMusicMenu(anchor, R.menu.menu_artist_song_actions, item)
is Album -> openMusicMenu(anchor, R.menu.menu_artist_album_actions, item)
else -> error("Unexpected datatype: ${item::class.simpleName}")
}
}
override fun onPlayParent() {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), false)
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onShuffleParent() {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value), true)
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onShowSortMenu(anchor: View) {
menu(anchor, R.menu.menu_artist_sort) {
override fun onOpenSortMenu(anchor: View) {
openMenu(anchor, R.menu.menu_artist_sort) {
val sort = detailModel.artistSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
@ -155,8 +177,9 @@ class ArtistDetailFragment :
}
}
private fun handleItemChange(artist: Artist?) {
private fun updateItem(artist: Artist?) {
if (artist == null) {
// Artist we were showing no longer exists.
findNavController().navigateUp()
return
}
@ -164,47 +187,58 @@ class ArtistDetailFragment :
requireBinding().detailToolbar.title = artist.resolveName(requireContext())
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
val currentArtist = unlikelyToBeNull(detailModel.currentArtist.value)
val playingItem =
when (parent) {
// Always highlight a playing album if it's from this artist.
is Album -> parent
// If the parent is the artist itself, use the currently playing song.
currentArtist -> song
// Nothing is playing from this artist.
else -> null
}
detailAdapter.setPlayingItem(playingItem, isPlaying)
}
private fun handleNavigation(item: Music?) {
val binding = requireBinding()
when (item) {
// Songs should be shown in their album, not in their artist.
is Song -> {
logD("Navigating to another album")
findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.id))
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.album.uid))
}
// Launch a new detail view for an album, even if it is part of
// this artist.
is Album -> {
logD("Navigating to another album")
findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.id))
.navigate(ArtistDetailFragmentDirections.actionShowAlbum(item.uid))
}
// If the artist that should be navigated to is this artist, then
// scroll back to the top. Otherwise launch a new detail view.
is Artist -> {
if (item.id == detailModel.currentArtist.value?.id) {
if (item.uid == detailModel.currentArtist.value?.uid) {
logD("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0)
navModel.finishExploreNavigation()
} else {
logD("Navigating to another artist")
findNavController()
.navigate(ArtistDetailFragmentDirections.actionShowArtist(item.id))
.navigate(ArtistDetailFragmentDirections.actionShowArtist(item.uid))
}
}
null -> {}
else -> error("Unexpected navigation item ${item::class.java}")
else -> error("Unexpected datatype: ${item::class.java}")
}
}
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
var item: Item? = null
if (parent is Album) {
item = parent
}
if (parent is Artist && parent.id == unlikelyToBeNull(detailModel.currentArtist.value).id) {
item = song
}
detailAdapter.updateIndicator(item, isPlaying)
private fun updateSelection(selected: List<Music>) {
detailAdapter.setSelectedItems(selected)
requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size)
}
}

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.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatTextView
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.LinearLayoutManager
@ -32,20 +32,27 @@ import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AuxioAppBarLayout
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
/**
* An [AuxioAppBarLayout] variant that also shows the name of the toolbar whenever the detail
* recyclerview is scrolled beyond it's first item (a.k.a the header). This is used instead of
* CollapsingToolbarLayout since that thing is a mess with crippling bugs and state issues. This
* just works.
* @author OxygenCobalt
* An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
* beyond it's first item.
*
* This is intended for the detail views, in which the first item is the album/artist/genre header,
* and thus scrolling past them should make the toolbar show the name in order to give context on
* where the user currently is.
*
* This task should nominally be accomplished with CollapsingToolbarLayout, but I have not figured
* out how to get that working sensibly yet.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailAppBarLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: AppCompatTextView? = null
private var titleView: TextView? = null
private var recycler: RecyclerView? = null
private var titleShown: Boolean? = null
@ -56,18 +63,26 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
}
private fun findTitleView(): AppCompatTextView {
private fun findTitleView(): TextView {
val titleView = titleView
if (titleView != null) {
return titleView
}
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts.
val toolbar = findViewById<Toolbar>(R.id.detail_toolbar)
// Reflect to get the actual title view to do transformations on
val newTitleView = TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as AppCompatTextView
// The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field.
val newTitleView =
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
// We can never properly initialize the title view's state before draw time,
// so we just set it's alpha to 0f to produce a less jarring initialization
// animation..
alpha = 0f
}
newTitleView.alpha = 0f
this.titleView = newTitleView
return newTitleView
}
@ -78,6 +93,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return recycler
}
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
@ -85,7 +101,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private fun setTitleVisibility(visible: Boolean) {
if (titleShown == visible) return
titleShown = visible
val titleAnimator = titleAnimator
@ -94,6 +109,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
this.titleAnimator = null
}
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
// the title view's alpha instead of the AppBarLayout's elevation.
val titleView = findTitleView()
val from: Float
val to: Float
@ -106,12 +123,20 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
to = 0f
}
if (titleView.alpha == to) return
if (titleView.alpha == to) {
// Nothing to do
return
}
this.titleAnimator =
ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { titleView.alpha = it.animatedValue as Float }
duration = TOOLBAR_FADE_DURATION
duration =
if (titleShown == true) {
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
start()
}
}
@ -131,19 +156,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
val appBar = child as DetailAppBarLayout
val recycler = appBar.findRecyclerView()
val appBarLayout = child as DetailAppBarLayout
val recycler = appBarLayout.findRecyclerView()
val showTitle =
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0
appBar.setTitleVisibility(showTitle)
// Title should be visible if we are no longer showing the top item
// (i.e the header)
appBarLayout.setTitleVisibility(
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
}
}
companion object {
private const val TOOLBAR_FADE_DURATION = 150L
private val TOOLBAR_TITLE_TEXT_FIELD: Field by
lazyReflectedField(Toolbar::class, "mTitleTextView")
}

View file

@ -24,168 +24,272 @@ import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.Header
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.util.TaskGuard
import org.oxycblt.auxio.util.application
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.auxio.util.*
/**
* ViewModel that stores data for the detail fragments. This includes:
* - What item the fragment should be showing
* - The RecyclerView data for each fragment
* - The sorts for each type of data
* @author OxygenCobalt
*
* TODO: Unify how detail items are indicated [When playlists are implemented]
* [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of
* the current item they are showing, sub-data to display, and configuration. Since this ViewModel
* requires a context, it must be instantiated [AndroidViewModel]'s Factory.
* @param application [Application] context required to initialize certain information.
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Callback {
data class DetailSong(val song: Song, val info: SongInfo?)
data class SongInfo(
val bitrateKbps: Int?,
val sampleRate: Int?,
val resolvedMimeType: MimeType
)
private val musicStore = MusicStore.getInstance()
private val settings = Settings(application)
private var currentSongJob: Job? = null
// --- SONG ---
private val _currentSong = MutableStateFlow<DetailSong?>(null)
/**
* The current [DetailSong] to display. Null if there is nothing to show.
*
* TODO: De-couple Song and Properties?
*/
val currentSong: StateFlow<DetailSong?>
get() = _currentSong
// --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?>
get() = _currentAlbum
private val _albumData = MutableStateFlow(listOf<Item>())
val albumData: StateFlow<List<Item>>
get() = _albumData
private val _albumList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentAlbum]. */
val albumList: StateFlow<List<Item>>
get() = _albumList
/** The current [Sort] used for [Song]s in [albumList]. */
var albumSort: Sort
get() = settings.detailAlbumSort
set(value) {
settings.detailAlbumSort = value
currentAlbum.value?.let(::refreshAlbumData)
// Refresh the album list to reflect the new sort.
currentAlbum.value?.let(::refreshAlbumList)
}
// --- ARTIST ---
private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?>
get() = _currentArtist
private val _artistData = MutableStateFlow(listOf<Item>())
val artistData: StateFlow<List<Item>> = _artistData
private val _artistList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */
val artistList: StateFlow<List<Item>> = _artistList
/** The current [Sort] used for [Song]s in [artistList]. */
var artistSort: Sort
get() = settings.detailArtistSort
set(value) {
logD(value)
settings.detailArtistSort = value
currentArtist.value?.let(::refreshArtistData)
// Refresh the artist list to reflect the new sort.
currentArtist.value?.let(::refreshArtistList)
}
// --- GENRE ---
private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?>
get() = _currentGenre
private val _genreData = MutableStateFlow(listOf<Item>())
val genreData: StateFlow<List<Item>> = _genreData
private val _genreList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */
val genreList: StateFlow<List<Item>> = _genreList
/** The current [Sort] used for [Song]s in [genreList]. */
var genreSort: Sort
get() = settings.detailGenreSort
set(value) {
settings.detailGenreSort = value
currentGenre.value?.let(::refreshGenreData)
// Refresh the genre list to reflect the new sort.
currentGenre.value?.let(::refreshGenreList)
}
private val songGuard = TaskGuard()
fun setSongId(id: Long) {
if (_currentSong.value?.run { song.id } == id) return
val library = unlikelyToBeNull(musicStore.library)
val song = requireNotNull(library.findSongById(id)) { "Invalid song id provided" }
generateDetailSong(song)
}
fun clearSong() {
songGuard.newHandle()
_currentSong.value = null
}
fun setAlbumId(id: Long) {
if (_currentAlbum.value?.id == id) return
val library = unlikelyToBeNull(musicStore.library)
val album = requireNotNull(library.findAlbumById(id)) { "Invalid album id provided " }
_currentAlbum.value = album
refreshAlbumData(album)
}
fun setArtistId(id: Long) {
if (_currentArtist.value?.id == id) return
val library = unlikelyToBeNull(musicStore.library)
val artist = requireNotNull(library.findArtistById(id)) { "Invalid artist id provided" }
_currentArtist.value = artist
refreshArtistData(artist)
}
fun setGenreId(id: Long) {
if (_currentGenre.value?.id == id) return
val library = unlikelyToBeNull(musicStore.library)
val genre = requireNotNull(library.findGenreById(id)) { "Invalid genre id provided" }
_currentGenre.value = genre
refreshGenreData(genre)
}
init {
musicStore.addCallback(this)
}
private fun generateDetailSong(song: Song) {
_currentSong.value = DetailSong(song, null)
viewModelScope.launch(Dispatchers.IO) {
val handle = songGuard.newHandle()
val info = generateDetailSongInfo(song)
songGuard.yield(handle)
_currentSong.value = DetailSong(song, info)
override fun onCleared() {
musicStore.removeCallback(this)
}
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library == null) {
// Nothing to do.
return
}
// If we are showing any item right now, we will need to refresh it (and any information
// related to it) with the new library in order to prevent stale items from showing up
// in the UI.
val song = currentSong.value
if (song != null) {
val newSong = library.sanitize(song.song)
if (newSong != null) {
loadDetailSong(newSong)
} else {
_currentSong.value = null
}
logD("Updated song to $newSong")
}
val album = currentAlbum.value
if (album != null) {
_currentAlbum.value = library.sanitize(album)?.also(::refreshAlbumList)
logD("Updated genre to ${currentAlbum.value}")
}
val artist = currentArtist.value
if (artist != null) {
_currentArtist.value = library.sanitize(artist)?.also(::refreshArtistList)
logD("Updated genre to ${currentArtist.value}")
}
val genre = currentGenre.value
if (genre != null) {
_currentGenre.value = library.sanitize(genre)?.also(::refreshGenreList)
logD("Updated genre to ${currentGenre.value}")
}
}
private fun generateDetailSongInfo(song: Song): SongInfo {
/**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, a new loading
* process will begin and the newly-loaded [DetailSong] will be set to [currentSong].
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSongUid(uid: Music.UID) {
if (_currentSong.value?.run { song.uid } == uid) {
// Nothing to do.
return
}
logD("Opening Song [uid: $uid]")
loadDetailSong(requireMusic(uid))
}
/**
* Set a new [currentAlbum] from it's [Music.UID]. If the [Music.UID] differs, [currentAlbum]
* and [albumList] will be updated to align with the new [Album].
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/
fun setAlbumUid(uid: Music.UID) {
if (_currentAlbum.value?.uid == uid) {
// Nothing to do.
return
}
logD("Opening Album [uid: $uid]")
_currentAlbum.value = requireMusic<Album>(uid).also { refreshAlbumList(it) }
}
/**
* Set a new [currentArtist] from it's [Music.UID]. If the [Music.UID] differs, [currentArtist]
* and [artistList] will be updated to align with the new [Artist].
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/
fun setArtistUid(uid: Music.UID) {
if (_currentArtist.value?.uid == uid) {
// Nothing to do.
return
}
logD("Opening Artist [uid: $uid]")
_currentArtist.value = requireMusic<Artist>(uid).also { refreshArtistList(it) }
}
/**
* Set a new [currentGenre] from it's [Music.UID]. If the [Music.UID] differs, [currentGenre]
* and [genreList] will be updated to align with the new album.
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/
fun setGenreUid(uid: Music.UID) {
if (_currentGenre.value?.uid == uid) {
// Nothing to do.
return
}
logD("Opening Genre [uid: $uid]")
_currentGenre.value = requireMusic<Genre>(uid).also { refreshGenreList(it) }
}
private fun <T : Music> requireMusic(uid: Music.UID): T =
requireNotNull(unlikelyToBeNull(musicStore.library).find(uid)) { "Invalid id provided" }
/**
* Start a new job to load a [DetailSong] based on the properties of the given [Song]'s file.
* @param song The song to load.
*/
private fun loadDetailSong(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_currentSong.value = DetailSong(song, null)
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = loadProperties(song)
yield()
_currentSong.value = DetailSong(song, info)
}
}
private fun loadProperties(song: Song): DetailSong.Properties {
// While we would use ExoPlayer to extract this information, it doesn't support
// common data like bit rate in progressive data sources due to there being no
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
val extractor = MediaExtractor()
try {
extractor.setDataSource(application, song.uri, emptyMap())
extractor.setDataSource(context, song.uri, emptyMap())
} catch (e: Exception) {
// Can feasibly fail with invalid file formats. Note that this isn't considered
// an error condition in the UI, as there is still plenty of other song information
// that we can show.
logW("Unable to extract song attributes.")
logW(e.stackTraceToString())
return SongInfo(null, null, song.mimeType)
return DetailSong.Properties(null, null, song.mimeType)
}
// Get the first track from the extractor (This is basically always the only
// track we need to analyze).
val format = extractor.getTrackFormat(0)
// Accessing fields can throw an exception if the fields are not present, and
// the new method for using default values is not available on lower API levels.
// So, we are forced to handle the exception and map it to a saner null value.
val bitrate =
try {
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000 // bps -> kbps
} catch (e: Exception) {
// Convert bytes-per-second to kilobytes-per-second.
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000
} catch (e: NullPointerException) {
logD("Unable to extract bit rate field")
null
}
val sampleRate =
try {
format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
} catch (e: Exception) {
} catch (e: NullPointerException) {
logE("Unable to extract sample rate field")
null
}
@ -194,138 +298,113 @@ class DetailViewModel(application: Application) :
// ExoPlayer was already able to populate the format.
song.mimeType
} else {
// ExoPlayer couldn't populate the format somehow, populate it here.
val formatMimeType =
try {
format.getString(MediaFormat.KEY_MIME)
} catch (e: Exception) {
} catch (e: NullPointerException) {
logE("Unable to extract mime type field")
null
}
MimeType(song.mimeType.fromExtension, formatMimeType)
}
return SongInfo(bitrate, sampleRate, resolvedMimeType)
return DetailSong.Properties(bitrate, sampleRate, resolvedMimeType)
}
private fun refreshAlbumData(album: Album) {
private fun refreshAlbumList(album: Album) {
logD("Refreshing album data")
val data = mutableListOf<Item>(album)
data.add(SortHeader(R.string.lbl_songs))
// To create a good user experience regarding disc numbers, we intersperse
// items that show the disc number throughout the album's songs. In the case
// that the album does not have distinct disc numbers, we omit such a header.
// To create a good user experience regarding disc numbers, we group the album's
// songs up by disc and then delimit the groups by a disc header.
val songs = albumSort.songs(album.songs)
// Songs without disc tags become part of Disc 1.
val byDisc = songs.groupBy { it.disc ?: 1 }
if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) {
val disc = entry.key
val discSongs = entry.value
data.add(DiscHeader(disc)) // Ensure ID uniqueness
data.addAll(discSongs)
data.add(DiscHeader(entry.key))
data.addAll(entry.value)
}
} else {
// Album only has one disc, don't add any redundant headers
data.addAll(songs)
}
_albumData.value = data
_albumList.value = data
}
private fun refreshArtistData(artist: Artist) {
private fun refreshArtistList(artist: Artist) {
logD("Refreshing artist data")
val data = mutableListOf<Item>(artist)
val albums = Sort(Sort.Mode.ByYear, false).albums(artist.albums)
val albums = Sort(Sort.Mode.ByDate, false).albums(artist.albums)
val byReleaseGroup =
albums.groupBy {
when (it.releaseType.refinement) {
ReleaseType.Refinement.LIVE -> R.string.lbl_live_group
ReleaseType.Refinement.REMIX -> R.string.lbl_remix_group
// Remap the complicated Album.Type data structure into an easier
// "AlbumGrouping" enum that will automatically group and sort
// the artist's albums.
when (it.type.refinement) {
Album.Type.Refinement.LIVE -> AlbumGrouping.LIVE
Album.Type.Refinement.REMIX -> AlbumGrouping.REMIXES
null ->
when (it.releaseType) {
is ReleaseType.Album -> R.string.lbl_albums
is ReleaseType.EP -> R.string.lbl_eps
is ReleaseType.Single -> R.string.lbl_singles
is ReleaseType.Compilation -> R.string.lbl_compilations
is ReleaseType.Soundtrack -> R.string.lbl_soundtracks
is ReleaseType.Mixtape -> R.string.lbl_mixtapes
when (it.type) {
is Album.Type.Album -> AlbumGrouping.ALBUMS
is Album.Type.EP -> AlbumGrouping.EPS
is Album.Type.Single -> AlbumGrouping.SINGLES
is Album.Type.Compilation -> AlbumGrouping.COMPILATIONS
is Album.Type.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is Album.Type.Mix -> AlbumGrouping.MIXES
is Album.Type.Mixtape -> AlbumGrouping.MIXTAPES
}
}
}
logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
data.add(Header(entry.key))
data.add(Header(entry.key.headerTitleRes))
data.addAll(entry.value)
}
data.add(SortHeader(R.string.lbl_songs))
data.addAll(artistSort.songs(artist.songs))
_artistData.value = data.toList()
// Artists may not be linked to any songs, only include a header entry if we have any.
if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header")
data.add(SortHeader(R.string.lbl_songs))
data.addAll(artistSort.songs(artist.songs))
}
_artistList.value = data.toList()
}
private fun refreshGenreData(genre: Genre) {
private fun refreshGenreList(genre: Genre) {
logD("Refreshing genre data")
val data = mutableListOf<Item>(genre)
// Genre is guaranteed to always have artists and songs.
data.add(Header(R.string.lbl_artists))
data.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSort.songs(genre.songs))
_genreData.value = data
_genreList.value = data
}
// --- CALLBACKS ---
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
val song = currentSong.value
if (song != null) {
logD("Song changed, refreshing data")
val newSong = library.sanitize(song.song)
if (newSong != null) {
generateDetailSong(newSong)
} else {
_currentSong.value = null
}
}
val album = currentAlbum.value
if (album != null) {
logD("Album changed, refreshing data")
val newAlbum = library.sanitize(album).also { _currentAlbum.value = it }
if (newAlbum != null) {
refreshAlbumData(newAlbum)
}
}
val artist = currentArtist.value
if (artist != null) {
logD("Artist changed, refreshing data")
val newArtist = library.sanitize(artist).also { _currentArtist.value = it }
if (newArtist != null) {
refreshArtistData(newArtist)
}
}
val genre = currentGenre.value
if (genre != null) {
logD("Genre changed, refreshing data")
val newGenre = library.sanitize(genre).also { _currentGenre.value = it }
if (newGenre != null) {
refreshGenreData(newGenre)
}
}
}
}
override fun onCleared() {
musicStore.removeCallback(this)
/**
* A simpler mapping of [Album.Type] used for grouping and sorting songs.
* @param headerTitleRes The title string resource to use for a header created out of an
* instance of this enum.
*/
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
ALBUMS(R.string.lbl_albums),
EPS(R.string.lbl_eps),
SINGLES(R.string.lbl_singles),
COMPILATIONS(R.string.lbl_compilations),
SOUNDTRACKS(R.string.lbl_soundtracks),
MIXES(R.string.lbl_mixes),
MIXTAPES(R.string.lbl_mixtapes),
LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group),
}
}
data class SortHeader(@StringRes val string: Int) : Item() {
override val id: Long
get() = string.toLong()
}
data class DiscHeader(val disc: Int) : Item() {
override val id: Long
get() = disc.toLong()
}

View file

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

View file

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

View file

@ -21,23 +21,24 @@ import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.formatDurationMs
/**
* A dialog displayed when "View properties" is selected on a song, showing more information about
* the properties of the audio file itself.
* @author OxygenCobalt
* A [ViewBindingDialogFragment] that shows information about a Song.
* @author Alexander Capehart (OxygenCobalt)
*/
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
private val detailModel: DetailViewModel by androidActivityViewModels()
// Information about what song to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an song.
private val args: SongDetailDialogArgs by navArgs()
override fun onCreateBinding(inflater: LayoutInflater) =
@ -50,46 +51,48 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
detailModel.setSongId(args.songId)
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.itemUid)
collectImmediately(detailModel.currentSong, ::updateSong)
}
override fun onDestroy() {
super.onDestroy()
detailModel.clearSong()
}
private fun updateSong(song: DetailSong?) {
if (song == null) {
// Song we were showing no longer exists.
findNavController().navigateUp()
return
}
private fun updateSong(song: DetailViewModel.DetailSong?) {
val binding = requireBinding()
if (song.properties != null) {
// Finished loading Song properties, populate and show the list of Song information.
binding.detailLoading.isInvisible = true
binding.detailContainer.isInvisible = false
if (song != null) {
if (song.info != null) {
val context = requireContext()
binding.detailContainer.isGone = false
binding.detailFileName.setText(song.song.path.name)
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
binding.detailFormat.setText(song.info.resolvedMimeType.resolveName(context))
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
val context = requireContext()
binding.detailFileName.setText(song.song.path.name)
binding.detailRelativeDir.setText(song.song.path.parent.resolveName(context))
binding.detailFormat.setText(song.properties.resolvedMimeType.resolveName(context))
binding.detailSize.setText(Formatter.formatFileSize(context, song.song.size))
binding.detailDuration.setText(song.song.durationMs.formatDurationMs(true))
if (song.info.bitrateKbps != null) {
binding.detailBitrate.setText(
getString(R.string.fmt_bitrate, song.info.bitrateKbps))
} else {
binding.detailBitrate.setText(R.string.def_bitrate)
}
if (song.info.sampleRate != null) {
binding.detailSampleRate.setText(
getString(R.string.fmt_sample_rate, song.info.sampleRate))
} else {
binding.detailSampleRate.setText(R.string.def_sample_rate)
}
if (song.properties.bitrateKbps != null) {
binding.detailBitrate.setText(
getString(R.string.fmt_bitrate, song.properties.bitrateKbps))
} else {
binding.detailContainer.isGone = true
binding.detailBitrate.setText(R.string.def_bitrate)
}
if (song.properties.sampleRateHz != null) {
binding.detailSampleRate.setText(
getString(R.string.fmt_sample_rate, song.properties.sampleRateHz))
} else {
binding.detailSampleRate.setText(R.string.def_sample_rate)
}
} else {
findNavController().navigateUp()
// Loading is still on-going, don't show anything yet.
binding.detailLoading.isInvisible = false
binding.detailContainer.isInvisible = true
}
}
}

View file

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

View file

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

View file

@ -26,26 +26,30 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.detail.SortHeader
import org.oxycblt.auxio.ui.recycler.AuxioRecyclerView
import org.oxycblt.auxio.ui.recycler.Header
import org.oxycblt.auxio.ui.recycler.HeaderViewHolder
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SimpleItemCallback
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.recycler.*
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
abstract class DetailAdapter<L : DetailAdapter.Listener>(
private val listener: L,
diffCallback: DiffUtil.ItemCallback<Item>
) : IndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
private var isPlaying = false
@Suppress("LeakingThis") override fun getItemCount() = differ.currentList.size
/**
* A [RecyclerView.Adapter] that implements behavior shared across each detail view's adapters.
* @param listener A [Listener] to bind interactions to.
* @param itemCallback A [DiffUtil.ItemCallback] to use with [AsyncListDiffer] when updating the
* internal list.
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailAdapter(
private val listener: Listener,
itemCallback: DiffUtil.ItemCallback<Item>
) : SelectionIndicatorAdapter<RecyclerView.ViewHolder>(), AuxioRecyclerView.SpanSizeLookup {
// Safe to leak this since the listener will not fire during initialization
@Suppress("LeakingThis") protected val differ = AsyncListDiffer(this, itemCallback)
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
// Implement support for headers and sort headers
is Header -> HeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -58,82 +62,109 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
else -> error("Invalid item type $viewType")
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) =
throw IllegalStateException()
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: List<Any>
) {
val item = differ.currentList[position]
if (payloads.isEmpty()) {
when (item) {
is Header -> (holder as HeaderViewHolder).bind(item)
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = differ.currentList[position]) {
is Header -> (holder as HeaderViewHolder).bind(item)
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
}
super.onBindViewHolder(holder, position, payloads)
}
override fun isItemFullWidth(position: Int): Boolean {
// Headers should be full-width in all configurations.
val item = differ.currentList[position]
return item is Header || item is SortHeader
}
protected val differ = AsyncListDiffer(this, diffCallback)
override val currentList: List<Item>
get() = differ.currentList
fun submitList(list: List<Item>) {
differ.submitList(list)
/**
* Asynchronously update the list with new items. Assumes that the list only contains data
* supported by the concrete [DetailAdapter] implementation.
* @param newList The new [Item]s for the adapter to display.
*/
fun submitList(newList: List<Item>) {
differ.submitList(newList)
}
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
interface Listener : SelectableListListener {
// TODO: Split off into sub-listeners if a collapsing toolbar is implemented.
/**
* Called when the play button in a detail header is pressed, requesting that the current
* item should be played.
*/
fun onPlay()
/**
* Called when the shuffle button in a detail header is pressed, requesting that the current
* item should be shuffled
*/
fun onShuffle()
/**
* Called when the button in a [SortHeader] item is pressed, requesting that the sort menu
* should be opened.
*/
fun onOpenSortMenu(anchor: View)
}
companion object {
val DIFFER =
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Header && newItem is Header ->
HeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFFER.areItemsTheSame(oldItem, newItem)
SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> false
}
}
}
}
interface Listener : MenuItemListener {
fun onPlayParent()
fun onShuffleParent()
fun onShowSortMenu(anchor: View)
}
}
class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
/**
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a
* button opening a menu for sorting. Use [new] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: SortHeader, listener: DetailAdapter.Listener) {
binding.headerTitle.text = binding.context.getString(item.string)
/**
* Bind new data to this instance.
* @param sortHeader The new [SortHeader] to bind.
* @param listener An [DetailAdapter.Listener] to bind interactions to.
*/
fun bind(sortHeader: SortHeader, listener: DetailAdapter.Listener) {
binding.headerTitle.text = binding.context.getString(sortHeader.titleRes)
binding.headerButton.apply {
// Add a Tooltip based on the content description so that the purpose of this
// button can be clear.
TooltipCompat.setTooltipText(this, contentDescription)
setOnClickListener(listener::onShowSortMenu)
setOnClickListener(listener::onOpenSortMenu)
}
}
companion object {
/** A unique ID for this [RecyclerView.ViewHolder] type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_SORT_HEADER
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
SortHeaderViewHolder(ItemSortHeaderBinding.inflate(parent.context.inflater))
val DIFFER =
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleItemCallback<SortHeader>() {
override fun areItemsTheSame(oldItem: SortHeader, newItem: SortHeader) =
oldItem.string == newItem.string
override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) =
oldItem.titleRes == newItem.titleRes
}
}
}

View file

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

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.
* @author OxygenCobalt
* @author Alexander Capehart (OxygenCobalt)
*/
class EdgeFrameLayout
@JvmOverloads
@ -37,7 +37,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Save a layout by simply moving the view bounds upwards
// Prevent excessive layouts by using translation instead of padding.
translationY = -insets.systemBarInsetsCompat.bottom.toFloat()
return insets
}

View file

@ -23,12 +23,13 @@ import android.view.MenuItem
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.core.view.isVisible
import androidx.core.view.iterator
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
@ -39,37 +40,43 @@ import com.google.android.material.transition.MaterialSharedAxis
import java.lang.reflect.Field
import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.home.list.AlbumListFragment
import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.list.selection.SelectionFragment
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.*
/**
* The main "Launching Point" fragment of Auxio, allowing navigation to the detail views for each
* respective item.
* @author OxygenCobalt
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
* to other views.
* @author Alexander Capehart (OxygenCobalt)
*/
class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuItemClickListener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
private val homeModel: HomeViewModel by androidActivityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
// lifecycleObject builds this in the creation step, so doing this is okay.
private val storagePermissionLauncher: ActivityResultLauncher<String> by lifecycleObject {
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
musicModel.reindex()
musicModel.refresh()
}
}
@ -86,28 +93,21 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
// our transitions.
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_AXIS, -1)
if (axis > -1) {
initAxisTransitions(axis)
setupAxisTransitions(axis)
}
}
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentHomeBinding) = binding.homeSelectionToolbar
override fun onBindingCreated(binding: FragmentHomeBinding, savedInstanceState: Bundle?) {
binding.homeAppbar.apply {
addOnOffsetChangedListener { _, offset ->
val range = binding.homeAppbar.totalScrollRange
super.onBindingCreated(binding, savedInstanceState)
binding.homeToolbar.alpha = 1f - (abs(offset.toFloat()) / (range.toFloat() / 2))
binding.homeContent.updatePadding(
bottom = binding.homeAppbar.totalScrollRange + offset)
}
}
binding.homeToolbar.setOnMenuItemClickListener(this@HomeFragment)
updateTabConfiguration()
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(this)
// Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources
@ -115,39 +115,49 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
requireContext().getColorCompat(R.color.sel_track).defaultColor
binding.homePager.apply {
adapter = HomePagerAdapter()
// Update HomeViewModel whenever the user swipes through the ViewPager.
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
// is an object for some reason.
registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
homeModel.synchronizeTabPosition(position)
}
})
// ViewPager2 will nominally consume window insets, which will then break the window
// insets applied to the indexing view before API 30. Fix this by overriding the
// listener with a non-consuming listener.
setOnApplyWindowInsetsListener { _, insets -> insets }
// We know that there will only be a fixed amount of tabs, so we manually set this
// limit to that. This also prevents the appbar lift state from being confused during
// page transitions.
offscreenPageLimit = homeModel.tabs.size
offscreenPageLimit = homeModel.currentTabModes.size
reduceSensitivity(3)
registerOnPageChangeCallback(
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) =
homeModel.updateCurrentTab(position)
})
TabLayoutMediator(binding.homeTabs, this, AdaptiveTabStrategy(context, homeModel))
.attach()
// ViewPager2 will nominally consume window insets, which will then break the window
// insets applied to the indexing view before API 30. Fix this by overriding the
// callback with a non-consuming listener.
setOnApplyWindowInsetsListener { _, insets -> insets }
// By default, ViewPager2's sensitivity is high enough to result in vertical scroll
// events being registered as horizontal scroll events. Reflect into the internal
// RecyclerView and change the touch slope so that touch actions will act more as a
// scroll than as a swipe. Derived from:
// https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
val recycler = VP_RECYCLER_FIELD.get(this@apply)
val slop = RV_TOUCH_SLOP_FIELD.get(recycler) as Int
RV_TOUCH_SLOP_FIELD.set(recycler, slop * 3)
}
// Further initialization must be done in the function that also handles
// re-creating the ViewPager.
setupPager(binding)
binding.homeFab.setOnClickListener { playbackModel.shuffleAll() }
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs, ::handleRecreateTabs)
collectImmediately(homeModel.currentTab, ::updateCurrentTab)
collectImmediately(musicModel.libraryExists, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexerState, ::handleIndexerState)
collect(homeModel.shouldRecreate, ::handleRecreate)
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
collectImmediately(homeModel.songLists, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexerState, ::updateIndexerState)
collect(navModel.exploreNavigationItem, ::handleNavigation)
collectImmediately(selectionModel.selected, ::updateSelection)
}
override fun onSaveInstanceState(outState: Bundle) {
@ -161,111 +171,77 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding)
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeToolbar.setOnMenuItemClickListener(null)
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
val binding = requireBinding()
val range = appBarLayout.totalScrollRange
// Fade out the toolbar as the AppBarLayout collapses. To prevent status bar overlap,
// the alpha transition is shifted such that the Toolbar becomes fully transparent
// when the AppBarLayout is only at half-collapsed.
binding.homeSelectionToolbar.alpha =
1f - (abs(verticalOffset.toFloat()) / (range.toFloat() / 2))
binding.homeContent.updatePadding(
bottom = binding.homeAppbar.totalScrollRange + verticalOffset)
}
override fun onMenuItemClick(item: MenuItem): Boolean {
if (super.onMenuItemClick(item)) {
return true
}
when (item.itemId) {
// Handle main actions (Search, Settings, About)
R.id.action_search -> {
logD("Navigating to search")
initAxisTransitions(MaterialSharedAxis.Z)
setupAxisTransitions(MaterialSharedAxis.Z)
findNavController().navigate(HomeFragmentDirections.actionShowSearch())
}
R.id.action_settings -> {
logD("Navigating to settings")
navModel.mainNavigateTo(MainNavigationAction.Settings)
navModel.mainNavigateTo(
MainNavigationAction.Directions(MainFragmentDirections.actionShowSettings()))
}
R.id.action_about -> {
logD("Navigating to about")
navModel.mainNavigateTo(MainNavigationAction.About)
navModel.mainNavigateTo(
MainNavigationAction.Directions(MainFragmentDirections.actionShowAbout()))
}
// Handle sort menu
R.id.submenu_sorting -> {
// Junk click event when opening the menu
}
R.id.option_sort_asc -> {
item.isChecked = !item.isChecked
homeModel.updateCurrentSort(
homeModel.setSortForCurrentTab(
homeModel
.getSortForDisplay(homeModel.currentTab.value)
.getSortForTab(homeModel.currentTabMode.value)
.withAscending(item.isChecked))
}
else -> {
// Sorting option was selected, mark it as selected and update the mode
item.isChecked = true
homeModel.updateCurrentSort(
homeModel.setSortForCurrentTab(
homeModel
.getSortForDisplay(homeModel.currentTab.value)
.getSortForTab(homeModel.currentTabMode.value)
.withMode(requireNotNull(Sort.Mode.fromItemId(item.itemId))))
}
}
// Always handling it one way or another, so always return true
return true
}
private fun updateCurrentTab(tab: DisplayMode) {
// Make sure that we update the scrolling view and allowed menu items whenever
// the tab changes.
val binding = requireBinding()
when (tab) {
DisplayMode.SHOW_SONGS -> {
updateSortMenu(tab) { id -> id != R.id.option_sort_count }
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_song_list
}
DisplayMode.SHOW_ALBUMS -> {
updateSortMenu(tab) { id -> id != R.id.option_sort_album }
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_album_list
}
DisplayMode.SHOW_ARTISTS -> {
updateSortMenu(tab) { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_artist_list
}
DisplayMode.SHOW_GENRES -> {
updateSortMenu(tab) { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
binding.homeAppbar.liftOnScrollTargetViewId = R.id.home_genre_list
}
}
}
private fun setupPager(binding: FragmentHomeBinding) {
binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabModes, childFragmentManager, viewLifecycleOwner)
private fun updateSortMenu(displayMode: DisplayMode, isVisible: (Int) -> Boolean) {
val sortMenu = requireNotNull(sortItem.subMenu)
val toHighlight = homeModel.getSortForDisplay(displayMode)
for (option in sortMenu) {
if (option.itemId == toHighlight.mode.itemId) {
option.isChecked = true
}
if (option.itemId == R.id.option_sort_asc) {
option.isChecked = toHighlight.isAscending
}
option.isVisible = isVisible(option.itemId)
}
}
private fun handleRecreateTabs(recreate: Boolean) {
if (recreate) {
requireBinding().homePager.recreate()
updateTabConfiguration()
homeModel.finishRecreateTabs()
}
}
private fun updateTabConfiguration() {
val binding = requireBinding()
val toolbarParams = binding.homeToolbar.layoutParams as AppBarLayout.LayoutParams
if (homeModel.tabs.size == 1) {
// A single tag makes the tab layout redundant, hide it and disable the collapsing
val toolbarParams = binding.homeSelectionToolbar.layoutParams as AppBarLayout.LayoutParams
if (homeModel.currentTabModes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior.
binding.homeTabs.isVisible = false
binding.homeAppbar.setExpanded(true, false)
@ -276,13 +252,79 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL or
AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
}
// Set up the mapping between the ViewPager and TabLayout.
TabLayoutMediator(
binding.homeTabs,
binding.homePager,
AdaptiveTabStrategy(requireContext(), homeModel.currentTabModes))
.attach()
}
private fun handleIndexerState(state: Indexer.State?) {
private fun updateCurrentTab(tabMode: MusicMode) {
// Update the sort options to align with those allowed by the tab
val isVisible: (Int) -> Boolean =
when (tabMode) {
// Disallow sorting by count for songs
MusicMode.SONGS -> { id -> id != R.id.option_sort_count }
// Disallow sorting by album for albums
MusicMode.ALBUMS -> { id -> id != R.id.option_sort_album }
// Only allow sorting by name, count, and duration for artists
MusicMode.ARTISTS -> { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
// Only allow sorting by name, count, and duration for genres
MusicMode.GENRES -> { id ->
id == R.id.option_sort_asc ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
}
}
val sortMenu = requireNotNull(sortItem.subMenu)
val toHighlight = homeModel.getSortForTab(tabMode)
for (option in sortMenu) {
// Check the ascending option and corresponding sort option to align with
// the current sort of the tab.
if (option.itemId == toHighlight.mode.itemId ||
(option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
option.isChecked = true
}
// Disable options that are not allowed by the isVisible lambda
option.isVisible = isVisible(option.itemId)
}
// Update the scrolling view in AppBarLayout to align with the current tab's
// scrolling state. This prevents the lift state from being confused as one
// goes between different tabs.
requireBinding().homeAppbar.liftOnScrollTargetViewId = getTabRecyclerId(tabMode)
}
private fun handleRecreate(recreate: Boolean) {
if (!recreate) {
// Nothing to do
return
}
val binding = requireBinding()
// Move back to position zero, as there must be a tab there.
binding.homePager.currentItem = 0
// Make sure tabs are set up to also follow the new ViewPager configuration.
setupPager(binding)
homeModel.finishRecreate()
}
private fun updateIndexerState(state: Indexer.State?) {
val binding = requireBinding()
when (state) {
is Indexer.State.Complete -> handleIndexerResponse(binding, state.response)
is Indexer.State.Indexing -> handleIndexingState(binding, state.indexing)
is Indexer.State.Complete -> setupCompleteState(binding, state.response)
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
null -> {
logD("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
@ -290,39 +332,44 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun handleIndexerResponse(binding: FragmentHomeBinding, response: Indexer.Response) {
private fun setupCompleteState(binding: FragmentHomeBinding, response: Indexer.Response) {
if (response is Indexer.Response.Ok) {
logD("Received ok response")
binding.homeFab.show()
binding.homeIndexingContainer.visibility = View.INVISIBLE
} else {
logD("Received non-ok response")
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
logD("Received non-ok response $response")
binding.homeIndexingProgress.visibility = View.INVISIBLE
when (response) {
is Indexer.Response.Err -> {
binding.homeIndexingProgress.visibility = View.INVISIBLE
logD("Updating UI to Response.Err state")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.reindex() }
setOnClickListener { musicModel.refresh() }
}
}
is Indexer.Response.NoMusic -> {
binding.homeIndexingProgress.visibility = View.INVISIBLE
logD("Updating UI to Response.NoMusic state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.reindex() }
setOnClickListener { musicModel.refresh() }
}
}
is Indexer.Response.NoPerms -> {
binding.homeIndexingProgress.visibility = View.INVISIBLE
logD("Updating UI to Response.NoPerms state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_grant)
@ -336,21 +383,22 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun handleIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingAction.visibility = View.INVISIBLE
val context = requireContext()
when (indexing) {
is Indexer.Indexing.Indeterminate -> {
binding.homeIndexingStatus.text = context.getString(R.string.lng_indexing)
// In a query/initialization state, show a generic loading status.
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
binding.homeIndexingProgress.isIndeterminate = true
}
is Indexer.Indexing.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingStatus.text =
context.getString(R.string.fmt_indexing, indexing.current, indexing.total)
getString(R.string.fmt_indexing, indexing.current, indexing.total)
binding.homeIndexingProgress.apply {
isIndeterminate = false
max = indexing.total
@ -360,9 +408,12 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
}
private fun updateFab(hasLoaded: Boolean, isFastScrolling: Boolean) {
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
val binding = requireBinding()
if (!hasLoaded || isFastScrolling) {
// If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (songs.isEmpty() || isFastScrolling) {
binding.homeFab.hide()
} else {
binding.homeFab.show()
@ -372,23 +423,32 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
private fun handleNavigation(item: Music?) {
val action =
when (item) {
is Song -> HomeFragmentDirections.actionShowAlbum(item.album.id)
is Album -> HomeFragmentDirections.actionShowAlbum(item.id)
is Artist -> HomeFragmentDirections.actionShowArtist(item.id)
is Genre -> HomeFragmentDirections.actionShowGenre(item.id)
is Song -> HomeFragmentDirections.actionShowAlbum(item.album.uid)
is Album -> HomeFragmentDirections.actionShowAlbum(item.uid)
is Artist -> HomeFragmentDirections.actionShowArtist(item.uid)
is Genre -> HomeFragmentDirections.actionShowGenre(item.uid)
else -> return
}
initAxisTransitions(MaterialSharedAxis.X)
setupAxisTransitions(MaterialSharedAxis.X)
findNavController().navigate(action)
}
private fun initAxisTransitions(axis: Int) {
// Sanity check
if (axis != MaterialSharedAxis.X && axis != MaterialSharedAxis.Z) {
logW("Invalid axis provided")
return
private fun updateSelection(selected: List<Music>) {
val binding = requireBinding()
if (binding.homeSelectionToolbar.updateSelectionAmount(selected.size) &&
selected.isNotEmpty()) {
logD("Significant selection occurred, expanding AppBar")
// Significant enough change where we want to expand the RecyclerView
binding.homeAppbar.expandWithRecycler(
binding.homePager.findViewById(getTabRecyclerId(homeModel.currentTabMode.value)))
}
}
private fun setupAxisTransitions(axis: Int) {
// Sanity check to avoid in-correct axis transitions
check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) {
"Not expecting Y axis transition"
}
enterTransition = MaterialSharedAxis(axis, true)
@ -398,42 +458,45 @@ class HomeFragment : ViewBindingFragment<FragmentHomeBinding>(), Toolbar.OnMenuI
}
/**
* By default, ViewPager2's sensitivity is high enough to result in vertical scroll events being
* registered as horizontal scroll events. Reflect into the internal recyclerview and change the
* touch slope so that touch actions will act more as a scroll than as a swipe. Derived from:
* https://al-e-shevelev.medium.com/how-to-reduce-scroll-sensitivity-of-viewpager2-widget-87797ad02414
* Get the ID of the RecyclerView contained by [ViewPager2] tab represented with the given
* [MusicMode].
* @param tabMode The [MusicMode] of the tab.
* @return The ID of the RecyclerView contained by the given tab.
*/
private fun ViewPager2.reduceSensitivity(by: Int) {
val recycler = VIEW_PAGER_RECYCLER_FIELD.get(this@reduceSensitivity)
val slop = VIEW_PAGER_TOUCH_SLOP_FIELD.get(recycler) as Int
VIEW_PAGER_TOUCH_SLOP_FIELD.set(recycler, slop * by)
}
/** Forces the view to recreate all fragments contained within it. */
private fun ViewPager2.recreate() {
currentItem = 0
adapter = HomePagerAdapter()
}
private inner class HomePagerAdapter :
FragmentStateAdapter(childFragmentManager, viewLifecycleOwner.lifecycle) {
override fun getItemCount(): Int = homeModel.tabs.size
override fun createFragment(position: Int): Fragment {
return when (homeModel.tabs[position]) {
DisplayMode.SHOW_SONGS -> SongListFragment()
DisplayMode.SHOW_ALBUMS -> AlbumListFragment()
DisplayMode.SHOW_ARTISTS -> ArtistListFragment()
DisplayMode.SHOW_GENRES -> GenreListFragment()
}
private fun getTabRecyclerId(tabMode: MusicMode) =
when (tabMode) {
MusicMode.SONGS -> R.id.home_song_recycler
MusicMode.ALBUMS -> R.id.home_album_recycler
MusicMode.ARTISTS -> R.id.home_artist_recycler
MusicMode.GENRES -> R.id.home_genre_recycler
}
/**
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
* @param tabs The current tab configuration. This will define the [Fragment]s created.
* @param fragmentManager The [FragmentManager] required by [FragmentStateAdapter].
* @param lifecycleOwner The [LifecycleOwner], whose Lifecycle is required by
* [FragmentStateAdapter].
*/
private class HomePagerAdapter(
private val tabs: List<MusicMode>,
fragmentManager: FragmentManager,
lifecycleOwner: LifecycleOwner
) : FragmentStateAdapter(fragmentManager, lifecycleOwner.lifecycle) {
override fun getItemCount() = tabs.size
override fun createFragment(position: Int): Fragment =
when (tabs[position]) {
MusicMode.SONGS -> SongListFragment()
MusicMode.ALBUMS -> AlbumListFragment()
MusicMode.ARTISTS -> ArtistListFragment()
MusicMode.GENRES -> GenreListFragment()
}
}
companion object {
private val VIEW_PAGER_RECYCLER_FIELD: Field by
private val VP_RECYCLER_FIELD: Field by
lazyReflectedField(ViewPager2::class, "mRecyclerView")
private val VIEW_PAGER_TOUCH_SLOP_FIELD: Field by
private val RV_TOUCH_SLOP_FIELD: Field by
lazyReflectedField(RecyclerView::class, "mTouchSlop")
private const val KEY_LAST_TRANSITION_AXIS =
BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"

View file

@ -26,135 +26,184 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.application
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
/**
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
* @author OxygenCobalt
* The ViewModel for managing the tab data and lists of the home view.
* @author Alexander Capehart (OxygenCobalt)
*/
class HomeViewModel(application: Application) :
AndroidViewModel(application), Settings.Callback, MusicStore.Callback {
private val musicStore = MusicStore.getInstance()
private val settings = Settings(application, this)
private val _songs = MutableStateFlow(listOf<Song>())
val songs: StateFlow<List<Song>>
get() = _songs
private val _songsList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songLists: StateFlow<List<Song>>
get() = _songsList
private val _albums = MutableStateFlow(listOf<Album>())
val albums: StateFlow<List<Album>>
get() = _albums
private val _albumsLists = MutableStateFlow(listOf<Album>())
/** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */
val albumsList: StateFlow<List<Album>>
get() = _albumsLists
private val _artists = MutableStateFlow(listOf<Artist>())
val artists: MutableStateFlow<List<Artist>>
get() = _artists
private val _artistsList = MutableStateFlow(listOf<Artist>())
/**
* A list of [Artist]s, sorted by the preferred [Sort], to be shown in the home view. Note that
* if "Hide collaborators" is on, this list will not include [Artist]s where
* [Artist.isCollaborator] is true.
*/
val artistsList: MutableStateFlow<List<Artist>>
get() = _artistsList
private val _genres = MutableStateFlow(listOf<Genre>())
val genres: StateFlow<List<Genre>>
get() = _genres
var tabs: List<DisplayMode> = visibleTabs
private set
/** Internal getter for getting the visible library tabs */
private val visibleTabs: List<DisplayMode>
get() = settings.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
private val _currentTab = MutableStateFlow(tabs[0])
val currentTab: StateFlow<DisplayMode> = _currentTab
private val _genresList = MutableStateFlow(listOf<Genre>())
/** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */
val genresList: StateFlow<List<Genre>>
get() = _genresList
/**
* Marker to recreate all library tabs, usually initiated by a settings change. When this flag
* is set, all tabs (and their respective viewpager fragments) will be recreated from scratch.
* A list of [MusicMode] corresponding to the current [Tab] configuration, excluding invisible
* [Tab]s.
*/
private val _shouldRecreateTabs = MutableStateFlow(false)
val recreateTabs: StateFlow<Boolean> = _shouldRecreateTabs
var currentTabModes: List<MusicMode> = makeTabModes()
private set
private val _currentTabMode = MutableStateFlow(currentTabModes[0])
/** The [MusicMode] of the currently shown [Tab]. */
val currentTabMode: StateFlow<MusicMode> = _currentTabMode
private val _shouldRecreate = MutableStateFlow(false)
/**
* A marker to re-create all library tabs, usually initiated by a settings change. When this
* flag is true, all tabs (and their respective ViewPager2 fragments) will be re-created from
* scratch.
*/
val shouldRecreate: StateFlow<Boolean> = _shouldRecreate
private val _isFastScrolling = MutableStateFlow(false)
/** A marker for whether the user is fast-scrolling in the home view or not. */
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
init {
musicStore.addCallback(this)
}
/** Update the current tab based off of the new ViewPager position. */
fun updateCurrentTab(pos: Int) {
logD("Updating current tab to ${tabs[pos]}")
_currentTab.value = tabs[pos]
}
fun finishRecreateTabs() {
_shouldRecreateTabs.value = false
}
/** Get the specific sort for the given [DisplayMode]. */
fun getSortForDisplay(displayMode: DisplayMode) =
when (displayMode) {
DisplayMode.SHOW_SONGS -> settings.libSongSort
DisplayMode.SHOW_ALBUMS -> settings.libAlbumSort
DisplayMode.SHOW_ARTISTS -> settings.libArtistSort
DisplayMode.SHOW_GENRES -> settings.libGenreSort
}
/** Update the currently displayed item's [Sort]. */
fun updateCurrentSort(sort: Sort) {
logD("Updating ${_currentTab.value} sort to $sort")
when (_currentTab.value) {
DisplayMode.SHOW_SONGS -> {
settings.libSongSort = sort
_songs.value = sort.songs(_songs.value)
}
DisplayMode.SHOW_ALBUMS -> {
settings.libAlbumSort = sort
_albums.value = sort.albums(_albums.value)
}
DisplayMode.SHOW_ARTISTS -> {
settings.libArtistSort = sort
_artists.value = sort.artists(_artists.value)
}
DisplayMode.SHOW_GENRES -> {
settings.libGenreSort = sort
_genres.value = sort.genres(_genres.value)
}
}
}
/**
* Update the fast scroll state. This is used to control the FAB visibility whenever the user
* begins to fast scroll.
*/
fun updateFastScrolling(scrolling: Boolean) {
logD("Updating fast scrolling state: $scrolling")
_isFastScrolling.value = scrolling
}
// --- OVERRIDES ---
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
_songs.value = settings.libSongSort.songs(library.songs)
_albums.value = settings.libAlbumSort.albums(library.albums)
_artists.value = settings.libArtistSort.artists(library.artists)
_genres.value = settings.libGenreSort.genres(library.genres)
}
}
override fun onSettingChanged(key: String) {
if (key == application.getString(R.string.set_key_lib_tabs)) {
tabs = visibleTabs
_shouldRecreateTabs.value = true
}
}
override fun onCleared() {
super.onCleared()
musicStore.removeCallback(this)
settings.release()
}
override fun onLibraryChanged(library: MusicStore.Library?) {
if (library != null) {
logD("Library changed, refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
_songsList.value = settings.libSongSort.songs(library.songs)
_albumsLists.value = settings.libAlbumSort.albums(library.albums)
_artistsList.value =
settings.libArtistSort.artists(
if (settings.shouldHideCollaborators) {
// Hide Collaborators is enabled, filter out collaborators.
library.artists.filter { !it.isCollaborator }
} else {
library.artists
})
_genresList.value = settings.libGenreSort.genres(library.genres)
}
}
override fun onSettingChanged(key: String) {
when (key) {
context.getString(R.string.set_key_lib_tabs) -> {
// Tabs changed, update the current tabs and set up a re-create event.
currentTabModes = makeTabModes()
_shouldRecreate.value = true
}
context.getString(R.string.set_key_hide_collaborators) -> {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
onLibraryChanged(musicStore.library)
}
}
}
/**
* Update [currentTabMode] to reflect a new ViewPager2 position
* @param pagerPos The new position of the ViewPager2 instance.
*/
fun synchronizeTabPosition(pagerPos: Int) {
logD("Updating current tab to ${currentTabModes[pagerPos]}")
_currentTabMode.value = currentTabModes[pagerPos]
}
/**
* Mark the recreation process as complete.
* @see shouldRecreate
*/
fun finishRecreate() {
_shouldRecreate.value = false
}
/**
* Get the preferred [Sort] for a given [Tab].
* @param tabMode The [MusicMode] of the [Tab] desired.
* @return The [Sort] preferred for that [Tab]
*/
fun getSortForTab(tabMode: MusicMode) =
when (tabMode) {
MusicMode.SONGS -> settings.libSongSort
MusicMode.ALBUMS -> settings.libAlbumSort
MusicMode.ARTISTS -> settings.libArtistSort
MusicMode.GENRES -> settings.libGenreSort
}
/**
* Update the preferred [Sort] for the current [Tab]. Will update corresponding list.
* @param sort The new [Sort] to apply. Assumed to be an allowed sort for the current [Tab].
*/
fun setSortForCurrentTab(sort: Sort) {
logD("Updating ${_currentTabMode.value} sort to $sort")
// Can simply re-sort the current list of items without having to access the library.
when (_currentTabMode.value) {
MusicMode.SONGS -> {
settings.libSongSort = sort
_songsList.value = sort.songs(_songsList.value)
}
MusicMode.ALBUMS -> {
settings.libAlbumSort = sort
_albumsLists.value = sort.albums(_albumsLists.value)
}
MusicMode.ARTISTS -> {
settings.libArtistSort = sort
_artistsList.value = sort.artists(_artistsList.value)
}
MusicMode.GENRES -> {
settings.libGenreSort = sort
_genresList.value = sort.genres(_genresList.value)
}
}
}
/**
* Update whether the user is fast scrolling or not in the home view.
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun setFastScrolling(isFastScrolling: Boolean) {
logD("Updating fast scrolling state: $isFastScrolling")
_isFastScrolling.value = isFastScrolling
}
/**
* Create a list of [MusicMode]s representing a simpler version of the [Tab] configuration.
* @return A list of the [MusicMode]s for each visible [Tab] in the configuration, ordered in
* the same way as the configuration.
*/
private fun makeTabModes() = settings.libTabs.filterIsInstance<Tab.Visible>().map { it.mode }
}

View file

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

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.ui.fastscroll
package org.oxycblt.auxio.home.fastscroll
import android.content.Context
import android.graphics.Canvas
@ -35,12 +35,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.recycler.AuxioRecyclerView
import org.oxycblt.auxio.util.getDimenSize
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
import org.oxycblt.auxio.util.*
/**
* A [RecyclerView] that enables better fast-scrolling. This is fundamentally a implementation of
@ -65,12 +61,36 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Added drag listener
* - Added documentation
*
* @author Hai Zhang, OxygenCobalt
* TODO: Add vibration when popup changes
*
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/
class FastScrollRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) {
/** An interface to provide text to use in the popup when fast-scrolling. */
interface PopupProvider {
/**
* Get text to use in the popup at the specified position.
* @param pos The position in the list.
* @return A [String] to use in the popup. Null if there is no applicable text for the popup
* at [pos].
*/
fun getPopup(pos: Int): String?
}
/** A listener for fast scroller interactions. */
interface Listener {
/**
* Called when the fast scrolling state changes.
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun onFastScrollingChanged(isFastScrolling: Boolean)
}
// Thumb
private val thumbView =
View(context).apply {
@ -98,7 +118,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
marginEnd = context.getDimenSize(R.dimen.spacing_small)
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
}
}
@ -106,7 +126,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Touch
private val minTouchTargetSize =
context.getDimenSize(R.dimen.fast_scroll_thumb_touch_target_size)
context.getDimenPixels(R.dimen.fast_scroll_thumb_touch_target_size)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
@ -133,33 +153,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
removeCallbacks(hideThumbRunnable)
showScrollbar()
showPopup()
listener?.onFastScrollStart()
} else {
postAutoHideScrollbar()
hidePopup()
listener?.onFastScrollStop()
}
listener?.onFastScrollingChanged(field)
}
private val tRect = Rect()
interface PopupProvider {
fun getPopup(pos: Int): String?
}
/** Callback to provide a string to be shown on the popup when an item is passed */
var popupProvider: PopupProvider? = null
interface OnFastScrollListener {
fun onFastScrollStart()
fun onFastScrollStop()
}
/**
* A listener for when a drag event occurs. The value will be true if a drag has begun, and
* false if a drag ended.
*/
var listener: OnFastScrollListener? = null
var listener: Listener? = null
init {
overlay.add(thumbView)
@ -208,13 +213,20 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
val firstPos = firstAdapterPos
val child = getChildAt(0)
val firstAdapterPos =
if (child != null) {
layoutManager?.getPosition(child) ?: NO_POSITION
} else {
NO_POSITION
}
val popupText: String
val provider = popupProvider
if (firstPos != NO_POSITION && provider != null) {
if (firstAdapterPos != NO_POSITION && provider != null) {
popupView.isInvisible = false
// Get the popup text. If there is none, we default to "?".
popupText = provider.getPopup(firstPos) ?: "?"
popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = true
@ -298,6 +310,14 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Combine the previous item dimensions with the current item top to find our scroll
// position
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
val child = getChildAt(0)
val firstAdapterPos =
when (val mgr = layoutManager) {
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
is LinearLayoutManager -> mgr.getPosition(child)
else -> 0
}
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
// Then calculate the thumb position, which is just:
@ -332,7 +352,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (!dragging &&
thumbView.isUnder(downX, thumbView.top.toFloat(), minTouchTargetSize) &&
abs(eventY - downY) > touchSlop) {
if (thumbView.isUnder(downX, downY, minTouchTargetSize)) {
dragStartY = lastY
dragStartThumbOffset = thumbOffset
@ -413,7 +432,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
showingThumb = true
animateView(thumbView, 1f)
animateViewIn(thumbView)
}
private fun hideScrollbar() {
@ -422,7 +441,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
showingThumb = false
animateView(thumbView, 0f)
animateViewOut(thumbView)
}
private fun showPopup() {
@ -431,7 +450,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
showingPopup = true
animateView(popupView, 1f)
animateViewIn(popupView)
}
private fun hidePopup() {
@ -440,11 +459,23 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
showingPopup = false
animateView(popupView, 0f)
animateViewOut(popupView)
}
private fun animateView(view: View, alpha: Float) {
view.animate().alpha(alpha).setDuration(ANIM_MILLIS).start()
private fun animateViewIn(view: View) {
view
.animate()
.alpha(1f)
.setDuration(context.getInteger(R.integer.anim_fade_enter_duration).toLong())
.start()
}
private fun animateViewOut(view: View) {
view
.animate()
.alpha(0f)
.setDuration(context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.start()
}
// --- LAYOUT STATE ---
@ -474,21 +505,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val scrollOffsetRange: Int
get() = scrollRange - height
private val firstAdapterPos: Int
get() {
if (childCount == 0) {
return NO_POSITION
}
val child = getChildAt(0)
return when (val mgr = layoutManager) {
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
is LinearLayoutManager -> mgr.getPosition(child)
else -> 0
}
}
private val itemHeight: Int
get() {
if (childCount == 0) {
@ -509,7 +525,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
companion object {
private const val ANIM_MILLIS = 150L
private const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
}
}

View file

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

View file

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

View file

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

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.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import java.util.Formatter
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.recycler.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.recycler.SyncListDiffer
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.recycler.IndicatorAdapter
import org.oxycblt.auxio.ui.recycler.Item
import org.oxycblt.auxio.ui.recycler.MenuItemListener
import org.oxycblt.auxio.ui.recycler.SongViewHolder
import org.oxycblt.auxio.ui.recycler.SyncListDiffer
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.formatDurationMs
import org.oxycblt.auxio.util.secsToMs
/**
* A [HomeListFragment] for showing a list of [Song]s.
* @author OxygenCobalt
* A [ListFragment] that shows a list of [Song]s.
* @author Alexander Capehart (OxygenCobalt)
*/
class SongListFragment : HomeListFragment<Song>() {
class SongListFragment :
ListFragment<FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
private val homeAdapter = SongAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val formatterSb = StringBuilder(50)
// Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb)
override fun onCreateBinding(inflater: LayoutInflater) =
FragmentHomeListBinding.inflate(inflater)
override fun onBindingCreated(binding: FragmentHomeListBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
binding.homeRecycler.apply {
id = R.id.home_song_list
id = R.id.home_song_recycler
adapter = homeAdapter
popupProvider = this@SongListFragment
listener = this@SongListFragment
}
collectImmediately(homeModel.songs, homeAdapter::replaceList)
collectImmediately(homeModel.songLists, homeAdapter::replaceList)
collectImmediately(selectionModel.selected, homeAdapter::setSelectedItems)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::handlePlayback)
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
}
override fun onDestroyBinding(binding: FragmentHomeListBinding) {
super.onDestroyBinding(binding)
binding.homeRecycler.apply {
adapter = null
popupProvider = null
listener = null
}
}
override fun getPopup(pos: Int): String? {
val song = homeModel.songs.value[pos]
// Change how we display the popup depending on the mode.
val song = homeModel.songLists.value[pos]
// Change how we display the popup depending on the current sort mode.
// Note: We don't use the more correct individual artist name here, as sorts are largely
// based off the names of the parent objects and not the child objects.
return when (homeModel.getSortForDisplay(DisplayMode.SHOW_SONGS).mode) {
return when (homeModel.getSortForTab(MusicMode.SONGS).mode) {
// Name -> Use name
is Sort.Mode.ByName -> song.sortName?.run { first().uppercase() }
is Sort.Mode.ByName -> song.collationKey?.run { sourceString.first().uppercase() }
// Artist -> Use Artist Name
is Sort.Mode.ByArtist -> song.album.artist.sortName?.run { first().uppercase() }
// Artist -> Use name of first artist
is Sort.Mode.ByArtist ->
song.album.artists[0].collationKey?.run { sourceString.first().uppercase() }
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.sortName?.run { first().uppercase() }
is Sort.Mode.ByAlbum ->
song.album.collationKey?.run { sourceString.first().uppercase() }
// Year -> Use Full Year
is Sort.Mode.ByYear -> song.album.date?.resolveYear(requireContext())
is Sort.Mode.ByDate -> song.album.date?.resolveDate(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
@ -102,47 +126,56 @@ class SongListFragment : HomeListFragment<Song>() {
}
}
override fun onItemClick(item: Item) {
check(item is Song)
playbackModel.play(item, settings.libPlaybackMode)
override fun onFastScrollingChanged(isFastScrolling: Boolean) {
homeModel.setFastScrolling(isFastScrolling)
}
override fun onRealClick(music: Music) {
check(music is Song) { "Unexpected datatype: ${music::class.java}" }
when (Settings(requireContext()).libPlaybackMode) {
MusicMode.SONGS -> playbackModel.playFromAll(music)
MusicMode.ALBUMS -> playbackModel.playFromAlbum(music)
MusicMode.ARTISTS -> playbackModel.playFromArtist(music)
MusicMode.GENRES -> playbackModel.playFromGenre(music)
}
}
override fun onOpenMenu(item: Item, anchor: View) {
when (item) {
is Song -> musicMenu(anchor, R.menu.menu_song_actions, item)
else -> error("Unexpected datatype when opening menu: ${item::class.java}")
}
check(item is Song) { "Unexpected datatype: ${item::class.java}" }
openMusicMenu(anchor, R.menu.menu_song_actions, item)
}
private fun handlePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
private fun updatePlayback(song: Song?, parent: MusicParent?, isPlaying: Boolean) {
if (parent == null) {
homeAdapter.updateIndicator(song, isPlaying)
homeAdapter.setPlayingItem(song, isPlaying)
} else {
// Ignore playback that is not from all songs
homeAdapter.updateIndicator(null, isPlaying)
homeAdapter.setPlayingItem(null, isPlaying)
}
}
private class SongAdapter(private val listener: MenuItemListener) :
IndicatorAdapter<SongViewHolder>() {
private val differ = SyncListDiffer(this, SongViewHolder.DIFFER)
/**
* A [SelectionIndicatorAdapter] that shows a list of [Song]s using [SongViewHolder].
* @param listener An [SelectableListListener] to bind interactions to.
*/
private class SongAdapter(private val listener: SelectableListListener) :
SelectionIndicatorAdapter<SongViewHolder>() {
private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK)
override val currentList: List<Item>
get() = differ.currentList
override fun getItemCount() = differ.currentList.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
SongViewHolder.new(parent)
override fun onBindViewHolder(holder: SongViewHolder, position: Int, payloads: List<Any>) {
super.onBindViewHolder(holder, position, payloads)
if (payloads.isEmpty()) {
holder.bind(differ.currentList[position], listener)
}
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
holder.bind(differ.currentList[position], listener)
}
/**
* Asynchronously update the list with new [Song]s.
* @param newList The new [Song]s for the adapter to display.
*/
fun replaceList(newList: List<Song>) {
differ.replaceList(newList)
}

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
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.logE
/**
* A data representation of a library tab. A tab can come in two moves, [Visible] or [Invisible].
* Invisibility means that the tab will still be present in the customization menu, but will not be
* shown on the home UI.
*
* Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs cannot
* be serialized on their own. Instead, they are saved as a sequence of tabs as shown below:
*
* 0bTAB1_TAB2_TAB3_TAB4_TAB5
*
* Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists. Each
* chunk in a sequence is represented as:
*
* VTTT
*
* Where V is a bit representing the visibility and T is a 3-bit integer representing the
* [DisplayMode] ordinal for this tab.
*
* To serialize and deserialize a tab sequence, [toSequence] and [fromSequence] can be used
* respectively.
*
* By default, the tab order will be SONGS, ALBUMS, ARTISTS, GENRES, PLAYLISTS
* A representation of a library tab suitable for configuration.
* @param mode The type of list in the home view this instance corresponds to.
* @author Alexander Capehart (OxygenCobalt)
*/
sealed class Tab(open val mode: DisplayMode) {
data class Visible(override val mode: DisplayMode) : Tab(mode)
data class Invisible(override val mode: DisplayMode) : Tab(mode)
sealed class Tab(open val mode: MusicMode) {
/**
* A visible tab. This will be visible in the home and tab configuration views.
* @param mode The type of list in the home view this instance corresponds to.
*/
data class Visible(override val mode: MusicMode) : Tab(mode)
/**
* A visible tab. This will be visible in the tab configuration view, but not in the home view.
* @param mode The type of list in the home view this instance corresponds to.
*/
data class Invisible(override val mode: MusicMode) : Tab(mode)
companion object {
/** The length a well-formed tab sequence should be */
// Like other IO-bound datatypes in Auxio, tabs are stored in a binary format. However, tabs
// cannot be serialized on their own. Instead, they are saved as a sequence of tabs as shown
// below:
//
// 0bTAB1_TAB2_TAB3_TAB4_TAB5
//
// Where TABN is a chunk representing a tab at position N. TAB5 is reserved for playlists.
// Each chunk in a sequence is represented as:
//
// VTTT
//
// Where V is a bit representing the visibility and T is a 3-bit integer representing the
// MusicMode for this tab.
/** The length a well-formed tab sequence should be. */
private const val SEQUENCE_LEN = 4
/** The default tab sequence, represented in integer form */
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
/**
* Maps between the integer code in the tab sequence and the actual [DisplayMode] instance.
* The default tab sequence, in integer form. This represents a set of four visible tabs
* ordered as "Song", "Album", "Artist", and "Genre".
*/
private val MODE_TABLE =
arrayOf(
DisplayMode.SHOW_SONGS,
DisplayMode.SHOW_ALBUMS,
DisplayMode.SHOW_ARTISTS,
DisplayMode.SHOW_GENRES)
const val SEQUENCE_DEFAULT = 0b1000_1001_1010_1011_0100
/** Convert an array [tabs] into a sequence of tabs. */
fun toSequence(tabs: Array<Tab>): Int {
/** Maps between the integer code in the tab sequence and it's [MusicMode]. */
private val MODE_TABLE =
arrayOf(MusicMode.SONGS, MusicMode.ALBUMS, MusicMode.ARTISTS, MusicMode.GENRES)
/**
* Convert an array of [Tab]s into it's integer representation.
* @param tabs The array of [Tab]s to convert
* @return An integer representation of the [Tab] array
*/
fun toIntCode(tabs: Array<Tab>): Int {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.mode }
var sequence = 0b0100
var shift = SEQUENCE_LEN * 4
for (tab in distinct) {
val bin =
when (tab) {
@ -85,14 +91,18 @@ sealed class Tab(open val mode: DisplayMode) {
return sequence
}
/** Convert a [sequence] into an array of tabs. */
fun fromSequence(sequence: Int): Array<Tab>? {
/**
* Convert a [Tab] integer representation into it's corresponding array of [Tab]s.
* @param intCode The integer representation of the [Tab]s.
* @return An array of [Tab]s corresponding to the sequence.
*/
fun fromIntCode(intCode: Int): Array<Tab>? {
val tabs = mutableListOf<Tab>()
// Try to parse a mode for each chunk in the sequence.
// If we can't parse one, just skip it.
for (shift in (0..4 * SEQUENCE_LEN).reversed() step 4) {
val chunk = sequence.shr(shift) and 0b1111
val chunk = intCode.shr(shift) and 0b1111
val mode = MODE_TABLE.getOrNull(chunk and 7) ?: continue

View file

@ -22,74 +22,127 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemTabBinding
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.recycler.DialogViewHolder
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.util.inflater
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
* @param listener A [Listener] for tab interactions.
*/
class TabAdapter(private val listener: Listener) : RecyclerView.Adapter<TabViewHolder>() {
/** The current array of [Tab]s. */
var tabs = arrayOf<Tab>()
private set
override fun getItemCount() = tabs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TabViewHolder.new(parent)
override fun onBindViewHolder(holder: TabViewHolder, position: Int) {
holder.bind(tabs[position], listener)
}
@Suppress("NotifyDatasetChanged")
/**
* Immediately update the tab array. This should be used when initializing the list.
* @param newTabs The new array of tabs to show.
*/
fun submitTabs(newTabs: Array<Tab>) {
tabs = newTabs
notifyDataSetChanged()
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
}
/**
* Update a specific tab to the given value.
* @param at The position of the tab to update.
* @param tab The new tab.
*/
fun setTab(at: Int, tab: Tab) {
tabs[at] = tab
// Use a payload to avoid an item change animation.
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
}
fun moveItems(from: Int, to: Int) {
val t = tabs[to]
val f = tabs[from]
tabs[from] = t
tabs[to] = f
notifyItemMoved(from, to)
/**
* Swap two tabs with each other.
* @param a The position of the first tab to swap.
* @param b The position of the second tab to swap.
*/
fun swapTabs(a: Int, b: Int) {
val tmp = tabs[b]
tabs[b] = tabs[a]
tabs[a] = tmp
notifyItemMoved(a, b)
}
/** A listener for interactions specific to tab configuration. */
interface Listener {
fun onVisibilityToggled(displayMode: DisplayMode)
fun onPickUpTab(viewHolder: RecyclerView.ViewHolder)
/**
* Called when a tab is clicked, requesting that the visibility should be inverted (i.e
* Visible -> Invisible and vice versa).
* @param tabMode The [MusicMode] of the tab clicked.
*/
fun onToggleVisibility(tabMode: MusicMode)
/**
* Called when the drag handle on a [RecyclerView.ViewHolder] is clicked, requesting that a
* drag should be started.
* @param viewHolder The [RecyclerView.ViewHolder] to start dragging.
*/
fun onPickUp(viewHolder: RecyclerView.ViewHolder)
}
companion object {
val PAYLOAD_TAB_CHANGED = Any()
private val PAYLOAD_TAB_CHANGED = Any()
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Tab]. Use [new] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class TabViewHolder private constructor(private val binding: ItemTabBinding) :
DialogViewHolder(binding.root) {
DialogRecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
* @param tab The new [Tab] to bind.
* @param listener A [TabAdapter.Listener] to bind interactions to.
*/
@SuppressLint("ClickableViewAccessibility")
fun bind(item: Tab, listener: TabAdapter.Listener) {
binding.root.setOnClickListener { listener.onVisibilityToggled(item.mode) }
fun bind(tab: Tab, listener: TabAdapter.Listener) {
binding.root.setOnClickListener { listener.onToggleVisibility(tab.mode) }
binding.tabIcon.apply {
setText(item.mode.string)
isChecked = item is Tab.Visible
binding.tabCheckBox.apply {
// Update the CheckBox name to align with the mode
setText(
when (tab.mode) {
MusicMode.SONGS -> R.string.lbl_songs
MusicMode.ALBUMS -> R.string.lbl_albums
MusicMode.ARTISTS -> R.string.lbl_artists
MusicMode.GENRES -> R.string.lbl_genres
})
// Unlike in other adapters, we update the checked state alongside
// the tab data since they are in the same data structure (Tab)
isChecked = tab is Tab.Visible
}
// Roll our own drag handlers as the default ones suck
// Set up the drag handle to start a drag whenever it is touched.
binding.tabDragHandle.setOnTouchListener { _, motionEvent ->
binding.tabDragHandle.performClick()
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onPickUpTab(this)
listener.onPickUp(this)
true
} else false
}
}
companion object {
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) = TabViewHolder(ItemTabBinding.inflate(parent.context.inflater))
}
}

View file

@ -25,19 +25,20 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.logD
/**
* The dialog for customizing library tabs.
* @author OxygenCobalt
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAdapter.Listener {
private val tabAdapter = TabAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val tabAdapter = TabAdapter(this)
private val touchHelper: ItemTouchHelper by lifecycleObject {
ItemTouchHelper(TabDragCallback(tabAdapter))
}
@ -55,14 +56,17 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
}
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
val savedTabs = findSavedTabState(savedInstanceState)
if (savedTabs != null) {
logD("Found saved tab state")
tabAdapter.submitTabs(savedTabs)
} else {
tabAdapter.submitTabs(settings.libTabs)
var tabs = settings.libTabs
// Try to restore a pending tab configuration that was saved prior.
if (savedInstanceState != null) {
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
if (savedTabs != null) {
tabs = savedTabs
}
}
// Set up the tab RecyclerView
tabAdapter.submitTabs(tabs)
binding.tabRecycler.apply {
adapter = tabAdapter
touchHelper.attachToRecyclerView(this)
@ -71,7 +75,8 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(KEY_TABS, Tab.toSequence(tabAdapter.tabs))
// Save any pending tab configurations to restore if this dialog is re-created.
outState.putInt(KEY_TABS, Tab.toIntCode(tabAdapter.tabs))
}
override fun onDestroyBinding(binding: DialogTabsBinding) {
@ -79,40 +84,31 @@ class TabCustomizeDialog : ViewBindingDialogFragment<DialogTabsBinding>(), TabAd
binding.tabRecycler.adapter = null
}
override fun onVisibilityToggled(displayMode: DisplayMode) {
// Tab viewholders bind with the initial tab state, which will drift from the actual
// state of the tabs over editing. So, this callback simply provides the displayMode
// for us to locate within the data and then update.
val index = tabAdapter.tabs.indexOfFirst { it.mode == displayMode }
if (index > -1) {
val tab = tabAdapter.tabs[index]
tabAdapter.setTab(
index,
when (tab) {
is Tab.Visible -> Tab.Invisible(tab.mode)
is Tab.Invisible -> Tab.Visible(tab.mode)
})
}
override fun onToggleVisibility(tabMode: MusicMode) {
logD("Toggling tab $tabMode")
// We will need the exact index of the tab to update on in order to
// notify the adapter of the change.
val index = tabAdapter.tabs.indexOfFirst { it.mode == tabMode }
val tab = tabAdapter.tabs[index]
tabAdapter.setTab(
index,
when (tab) {
// Invert the visibility of the tab
is Tab.Visible -> Tab.Invisible(tab.mode)
is Tab.Invisible -> Tab.Visible(tab.mode)
})
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.
(requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).isEnabled =
tabAdapter.tabs.filterIsInstance<Tab.Visible>().isNotEmpty()
}
override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) {
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(viewHolder)
}
private fun findSavedTabState(savedInstanceState: Bundle?): Array<Tab>? {
if (savedInstanceState != null) {
// Restore any pending tab configurations
return Tab.fromSequence(savedInstanceState.getInt(KEY_TABS))
}
return null
}
companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.TAB_CUSTOMIZE"
const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
private const val KEY_TABS = BuildConfig.APPLICATION_ID + ".key.PENDING_TABS"
}
}

View file

@ -22,14 +22,15 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
/**
* A simple [ItemTouchHelper.Callback] that handles dragging items in the tab customization menu.
* Unlike QueueAdapter's ItemTouchHelper, this one is bare and simple.
* An [ItemTouchHelper.Callback] that implements dragging in the [TabAdapter].
* @author Alexander Capehart (OxygenCobalt)
*/
class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int = makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN)
) = // Allow dragging up and down only
makeFlag(ItemTouchHelper.ACTION_STATE_DRAG, ItemTouchHelper.UP or ItemTouchHelper.DOWN)
override fun onChildDraw(
c: Canvas,
@ -40,8 +41,6 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
actionState: Int,
isCurrentlyActive: Boolean
) {
// No fancy UI magic here. This is a dialog, we don't need to give it as much attention.
// Just make sure the built-in androidx code doesn't get in our way.
viewHolder.itemView.translationX = dX
viewHolder.itemView.translationY = dY
}
@ -56,12 +55,14 @@ class TabDragCallback(private val adapter: TabAdapter) : ItemTouchHelper.Callbac
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
adapter.moveItems(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
// I don't think it's possible to jump more than one position at a time, so a swap
// will work just fine.
adapter.swapTabs(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
// We use a custom drag handle, so disable the long press action.
override fun isLongPressDragEnabled(): Boolean = false
override fun isLongPressDragEnabled() = false
}

View file

@ -24,79 +24,99 @@ import coil.imageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Size
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.TaskGuard
/**
* A utility to provide bitmaps in a manner less prone to race conditions.
* A utility to provide bitmaps in a race-less manner.
*
* Pretty much each service component needs to load bitmaps of some kind, but doing a blind image
* request with some target callbacks could result in overlapping requests causing incorrect
* updates. This class (to an extent) resolves this by adding several guards
* When it comes to components that load images manually as [Bitmap] instances, queued
* [ImageRequest]s may cause a race condition that results in the incorrect image being drawn. This
* utility resolves this by keeping track of the current request, and disposing it as soon as a new
* request is queued or if another, competing request is newer.
*
* @author OxygenCobalt
* @param context [Context] required to load images.
* @author Alexander Capehart (OxygenCobalt)
*/
class BitmapProvider(private val context: Context) {
/**
* An extension of [Disposable] with an additional [Target] to deliver the final [Bitmap] to.
*/
private data class Request(val disposable: Disposable, val callback: Target)
/** The target that will receive the requested [Bitmap]. */
interface Target {
/**
* Configure the [ImageRequest.Builder] to enable [Target]-specific configuration.
* @param builder The [ImageRequest.Builder] that will be used to request the desired
* [Bitmap].
* @return The same [ImageRequest.Builder] in order to easily chain configuration methods.
*/
fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
/**
* Called when the loading process is completed.
* @param bitmap The loaded bitmap, or null if the bitmap could not be loaded.
*/
fun onCompleted(bitmap: Bitmap?)
}
private var currentRequest: Request? = null
private var guard = TaskGuard()
private var currentHandle = 0L
/** If this provider is currently attempting to load something. */
val isBusy: Boolean
get() = currentRequest?.run { !disposable.isDisposed } ?: false
/**
* Load a bitmap from [song]. [target] should be a new object, not a reference to an existing
* callback.
* Load the Album cover [Bitmap] from a [Song].
* @param song The song to load a [Bitmap] of it's album cover from.
* @param target The [Target] to deliver the [Bitmap] to asynchronously.
*/
@Synchronized
fun load(song: Song, target: Target) {
val handle = guard.newHandle()
// Increment the handle, indicating a newer request has been created
val handle = ++currentHandle
currentRequest?.run { disposable.dispose() }
currentRequest = null
val request =
target.onConfigRequest(
ImageRequest.Builder(context)
.data(song)
.size(Size.ORIGINAL)
.target(
onSuccess = {
if (guard.check(handle)) {
val imageRequest =
target
.onConfigRequest(
ImageRequest.Builder(context)
.data(song)
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform.INSTANCE))
// Override the target in order to deliver the bitmap to the given
// listener.
.target(
onSuccess = {
synchronized(this) {
if (currentHandle == handle) {
// Has not been superceded by a new request, can deliver
// this result.
target.onCompleted(it.toBitmap())
}
},
onError = {
if (guard.check(handle)) {
}
},
onError = {
synchronized(this) {
if (currentHandle == handle) {
// Has not been superceded by a new request, can deliver
// this result.
target.onCompleted(null)
}
})
.transformations(SquareFrameTransform.INSTANCE))
currentRequest = Request(context.imageLoader.enqueue(request.build()), target)
}
})
currentRequest = Request(context.imageLoader.enqueue(imageRequest.build()), target)
}
/**
* Release this instance, canceling all image load jobs. This should be ran when the object is
* no longer used.
*/
/** Release this instance, cancelling any currently running operations. */
@Synchronized
fun release() {
++currentHandle
currentRequest?.run { disposable.dispose() }
currentRequest = null
}
private data class Request(val disposable: Disposable, val callback: Target)
/** Represents the target for a request. */
interface Target {
/** Modify the default request with custom attributes. */
fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder = builder
/**
* Called when the loading process is completed. [bitmap] will be null if there was an
* error.
*/
fun onCompleted(bitmap: Bitmap?)
}
}

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
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.AttrRes
import androidx.core.view.updateMarginsRelative
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getInteger
/**
* Effectively a super-charged [StyledImageView].
*
* This class enables the following features alongside the base features pf [StyledImageView]:
* - Activation indicator
* - (Eventually) selection indicator
* A super-charged [StyledImageView]. This class enables the following features in addition to
* [StyledImageView]:
* - A selection indicator
* - An activation (playback) indicator
* - Support for ONE custom view
*
* This class is primarily intended for list items. For most uses, the simpler [StyledImageView] is
* more efficient and suitable.
* This class is primarily intended for list items. For other uses, [StyledImageView] is more
* suitable.
*
* @author OxygenCobalt
* TODO: Rework content descriptions here
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ImageGroup
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private val cornerRadius: Float
private val inner: StyledImageView
private val innerImageView: StyledImageView
private var customView: View? = null
private val indicator: IndicatorView
private val playbackIndicatorView: PlaybackIndicatorView
private val selectionIndicatorView: ImageView
private var fadeAnimator: ValueAnimator? = null
private val cornerRadius: Float
init {
// Android wants you to make separate attributes for each view type, but will
// then throw an error if you do because of duplicate attribute names.
// Obtain some StyledImageView attributes to use later when theming the cusotm view.
@SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
// Keep track of our corner radius so that we can apply the same attributes to the custom
// view.
cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
styledAttrs.recycle()
inner = StyledImageView(context, attrs)
indicator = IndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius }
// Initialize what views we can here.
innerImageView = StyledImageView(context, attrs)
playbackIndicatorView =
PlaybackIndicatorView(context).apply { cornerRadius = this@ImageGroup.cornerRadius }
selectionIndicatorView =
ImageView(context).apply {
imageTintList = context.getAttrColorCompat(R.attr.colorOnPrimary)
setImageResource(R.drawable.ic_check_20)
setBackgroundResource(R.drawable.ui_selection_badge_bg)
}
addView(inner)
// The inner StyledImageView should be at the bottom and hidden by any other elements
// if they become visible.
addView(innerImageView)
}
override fun onFinishInflate() {
super.onFinishInflate()
// Due to innerImageView, the max child count is actually 2 and not 1.
check(childCount < 3) { "Only one custom view is allowed" }
if (childCount > 2) {
error("Only one custom view is allowed")
}
// Get the second inflated child, making sure we customize it to align with
// the rest of this view.
customView =
getChildAt(1)?.apply {
background =
@ -83,65 +105,144 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
addView(indicator)
// Playback indicator should sit above the inner StyledImageView and custom view/
addView(playbackIndicatorView)
// Selction indicator should never be obscured, so place it at the top.
addView(
selectionIndicatorView,
LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
// Override the layout params of the indicator so that it's in the
// bottom left corner.
gravity = Gravity.BOTTOM or Gravity.END
val spacing = context.getDimenPixels(R.dimen.spacing_tiny)
updateMarginsRelative(bottom = spacing, end = spacing)
})
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
invalidateIndicator()
// Initialize each component before this view is drawn.
invalidateImageAlpha()
invalidatePlayingIndicator()
invalidateSelectionIndicator()
}
override fun setActivated(activated: Boolean) {
super.setActivated(activated)
invalidateIndicator()
invalidateSelectionIndicator()
}
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
invalidateIndicator()
invalidateImageAlpha()
invalidatePlayingIndicator()
}
override fun setSelected(selected: Boolean) {
super.setSelected(selected)
invalidateImageAlpha()
invalidatePlayingIndicator()
}
/**
* Bind a [Song] to the internal [StyledImageView].
* @param song The [Song] to bind to the view.
* @see StyledImageView.bind
*/
fun bind(song: Song) = innerImageView.bind(song)
/**
* Bind a [Album] to the internal [StyledImageView].
* @param album The [Album] to bind to the view.
* @see StyledImageView.bind
*/
fun bind(album: Album) = innerImageView.bind(album)
/**
* Bind a [Genre] to the internal [StyledImageView].
* @param artist The [Artist] to bind to the view.
* @see StyledImageView.bind
*/
fun bind(artist: Artist) = innerImageView.bind(artist)
/**
* Bind a [Genre] to the internal [StyledImageView].
* @param genre The [Genre] to bind to the view.
* @see StyledImageView.bind
*/
fun bind(genre: Genre) = innerImageView.bind(genre)
/**
* Whether this view should be indicated to have ongoing playback or not. See
* PlaybackIndicatorView for more information on what occurs here. Note: It's expected for this
* view to already be marked as playing with setSelected (not the same thing) before this is set
* to true.
*/
var isPlaying: Boolean
get() = indicator.isPlaying
get() = playbackIndicatorView.isPlaying
set(value) {
indicator.isPlaying = value
playbackIndicatorView.isPlaying = value
}
private fun invalidateIndicator() {
if (isActivated) {
alpha = 1f
private fun invalidateImageAlpha() {
// If this view is disabled, show it at half-opacity, *unless* it is also marked
// as playing, in which we still want to show it at full-opacity.
alpha = if (isSelected || isEnabled) 1f else 0.5f
}
private fun invalidatePlayingIndicator() {
if (isSelected) {
// View is "selected" (actually marked as playing), so show the playing indicator
// and hide all other elements except for the selection indicator.
// TODO: Animate the other indicators?
customView?.alpha = 0f
inner.alpha = 0f
indicator.alpha = 1f
innerImageView.alpha = 0f
playbackIndicatorView.alpha = 1f
} else {
alpha = if (isEnabled) 1f else 0.5f
// View is not "selected", hide the playing indicator.
customView?.alpha = 1f
inner.alpha = 1f
indicator.alpha = 0f
innerImageView.alpha = 1f
playbackIndicatorView.alpha = 0f
}
}
fun bind(song: Song) {
inner.bind(song)
contentDescription =
context.getString(R.string.desc_album_cover, song.album.resolveName(context))
}
private fun invalidateSelectionIndicator() {
// Set up a target transition for the selection indicator.
val targetAlpha: Float
val targetDuration: Long
fun bind(album: Album) {
inner.bind(album)
contentDescription =
context.getString(R.string.desc_album_cover, album.resolveName(context))
}
if (isActivated) {
// View is "activated" (i.e marked as selected), so show the selection indicator.
targetAlpha = 1f
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
// View is not "activated", hide the selection indicator.
targetAlpha = 0f
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
fun bind(artist: Artist) {
inner.bind(artist)
contentDescription =
context.getString(R.string.desc_artist_image, artist.resolveName(context))
}
if (selectionIndicatorView.alpha == targetAlpha) {
// Nothing to do.
return
}
fun bind(genre: Genre) {
inner.bind(genre)
contentDescription =
context.getString(R.string.desc_genre_image, genre.resolveName(context))
if (!isLaidOut) {
// Not laid out, initialize it without animation before drawing.
selectionIndicatorView.alpha = targetAlpha
return
}
if (fadeAnimator != null) {
// Cancel any previous animation.
fadeAnimator?.cancel()
fadeAnimator = null
}
fadeAnimator =
ValueAnimator.ofFloat(selectionIndicatorView.alpha, targetAlpha).apply {
duration = targetDuration
addUpdateListener { selectionIndicatorView.alpha = it.animatedValue as Float }
start()
}
}
}

View file

@ -33,27 +33,31 @@ import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
/**
* View that displays the playback indicator. Nominally emulates [StyledImageView], but is much
* different internally as an animated icon can't be wrapped within StyledDrawable without causing
* insane issues.
* @author OxygenCobalt
* A view that displays an activation (i.e playback) indicator, with an accented styling and an
* animated equalizer icon.
*
* This is only meant for use with [ImageGroup]. Due to limitations with [AnimationDrawable]
* instances within custom views, this cannot be merged with [ImageGroup].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class IndicatorView
class PlaybackIndicatorView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) {
private val playingIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_playing_indicator_24) as AnimationDrawable
private val pausedIndicatorDrawable =
context.getDrawableCompat(R.drawable.ic_paused_indicator_24)
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
private val settings = Settings(context)
/**
* The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius
* to this view without any attribute hacks.
*/
var cornerRadius = 0f
set(value) {
field = value
@ -66,7 +70,29 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
/**
* Whether this view should be indicated to have ongoing playback or not. If true, the animated
* playing icon will be shown. If false, the static paused icon will be shown.
*/
var isPlaying: Boolean
get() = drawable == playingIndicatorDrawable
set(value) {
if (value) {
playingIndicatorDrawable.start()
setImageDrawable(playingIndicatorDrawable)
} else {
playingIndicatorDrawable.stop()
setImageDrawable(pausedIndicatorDrawable)
}
}
init {
// We will need to manually re-scale the playing/paused drawables to align with
// StyledDrawable, so use the matrix scale type.
scaleType = ScaleType.MATRIX
// Tint the playing/paused drawables so they are harmonious with the background.
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
// Use clipToOutline and a background drawable to crop images. While Coil's transformation
// could theoretically be used to round corners, the corner radius is dependent on the
// dimensions of the image, which will result in inconsistent corners across different
@ -79,23 +105,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
fillColor = context.getColorCompat(R.color.sel_cover_bg)
setCornerSize(cornerRadius)
}
scaleType = ScaleType.MATRIX
ImageViewCompat.setImageTintList(this, context.getColorCompat(R.color.sel_on_cover_bg))
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// Emulate StyledDrawable scaling with matrix scaling.
val iconSize = max(measuredWidth, measuredHeight) / 2
imageMatrix =
indicatorMatrix.apply {
reset()
drawable?.let { drawable ->
// Android is too good to allow us to set a fixed image size, so we instead need
// to define a matrix to scale an image directly.
// First scale the icon up to the desired size.
indicatorMatrixSrc.set(
0f,
@ -106,23 +126,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
indicatorMatrix.setRectToRect(
indicatorMatrixSrc, indicatorMatrixDst, Matrix.ScaleToFit.CENTER)
// Then actually center it into the icon, which the previous call does not
// actually do.
// Then actually center it into the icon.
indicatorMatrix.postTranslate(
(measuredWidth - iconSize) / 2f, (measuredHeight - iconSize) / 2f)
}
}
}
var isPlaying: Boolean
get() = drawable == playingIndicatorDrawable
set(value) {
if (value) {
playingIndicatorDrawable.start()
setImageDrawable(playingIndicatorDrawable)
} else {
playingIndicatorDrawable.stop()
setImageDrawable(pausedIndicatorDrawable)
}
}
}

View file

@ -33,6 +33,7 @@ import coil.dispose
import coil.load
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
@ -43,41 +44,33 @@ import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDrawableCompat
/**
* An [AppCompatImageView] that applies many of the stylistic choices that Auxio uses regarding
* images.
* An [AppCompatImageView] with some additional styling, including:
*
* Default behavior includes the addition of a tonal background, automatic sizing of icons to half
* of the view size, and corner radius application depending on user preference.
* - Tonal background
* - Rounded corners based on user preferences
* - Built-in support for binding image data or using a static icon with the same styling as
* placeholder drawables.
*
* @author OxygenCobalt
* @author Alexander Capehart (OxygenCobalt)
*/
class StyledImageView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) {
private val settings = Settings(context)
var cornerRadius = 0f
set(value) {
field = value
(background as? MaterialShapeDrawable)?.let { bg ->
if (settings.roundMode) {
bg.setCornerSize(value)
} else {
bg.setCornerSize(0f)
}
}
}
var staticIcon: Drawable? = null
set(value) {
field = value?.let { StyledDrawable(context, it) }
setImageDrawable(field)
}
private var useLargeIcon: Boolean = false
init {
// Load view attributes
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
val staticIcon =
styledAttrs.getResourceId(
R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL)
val cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
styledAttrs.recycle()
if (staticIcon != ResourcesCompat.ID_NULL) {
// Use the static icon if specified for this image.
setImageDrawable(StyledDrawable(context, context.getDrawableCompat(staticIcon)))
}
// Use clipToOutline and a background drawable to crop images. While Coil's transformation
// could theoretically be used to round corners, the corner radius is dependent on the
// dimensions of the image, which will result in inconsistent corners across different
@ -88,71 +81,90 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background =
MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg)
setCornerSize(cornerRadius)
if (Settings(context).roundMode) {
// Only use the specified corner radius when round mode is enabled.
setCornerSize(cornerRadius)
}
}
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
val staticIcon =
styledAttrs.getResourceId(
R.styleable.StyledImageView_staticIcon, ResourcesCompat.ID_NULL)
if (staticIcon != ResourcesCompat.ID_NULL) {
this.staticIcon = context.getDrawableCompat(staticIcon)
}
useLargeIcon = styledAttrs.getBoolean(R.styleable.StyledImageView_useLargeIcon, false)
cornerRadius = styledAttrs.getDimension(R.styleable.StyledImageView_cornerRadius, 0f)
styledAttrs.recycle()
}
/** Bind the album cover for a [song]. */
fun bind(song: Song) = loadImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
/**
* Bind a [Song]'s album cover to this view, also updating the content description.
* @param song The [Song] to bind.
*/
fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
/** Bind the album cover for an [album]. */
fun bind(album: Album) = loadImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
/**
* Bind an [Album]'s cover to this view, also updating the content description.
* @param album the [Album] to bind.
*/
fun bind(album: Album) = bindImpl(album, R.drawable.ic_album_24, R.string.desc_album_cover)
/** Bind the image for an [artist] */
fun bind(artist: Artist) = loadImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
/**
* Bind an [Artist]'s image to this view, also updating the content description.
* @param artist the [Artist] to bind.
*/
fun bind(artist: Artist) = bindImpl(artist, R.drawable.ic_artist_24, R.string.desc_artist_image)
/** Bind the image for a [genre] */
fun bind(genre: Genre) = loadImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
private fun <T : Music> loadImpl(music: T, @DrawableRes error: Int, @StringRes desc: Int) {
if (staticIcon != null) {
error("Static StyledImageViews cannot bind new images")
}
contentDescription = context.getString(desc, music.resolveName(context))
/**
* Bind an [Genre]'s image to this view, also updating the content description.
* @param genre the [Genre] to bind.
*/
fun bind(genre: Genre) = bindImpl(genre, R.drawable.ic_genre_24, R.string.desc_genre_image)
/**
* Internally bind a [Music]'s image to this view.
* @param music The music to find.
* @param errorRes The error drawable resource to use if the music cannot be loaded.
* @param descRes The content description string resource to use. The resource must have one
* field for the name of the [Music].
*/
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
// Dispose of any previous image request and load a new image.
dispose()
load(music) {
error(StyledDrawable(context, context.getDrawableCompat(error)))
error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
transformations(SquareFrameTransform.INSTANCE)
}
// Update the content description to the specified resource.
contentDescription = context.getString(descRes, music.resolveName(context))
}
private class StyledDrawable(context: Context, private val src: Drawable) : Drawable() {
/**
* A [Drawable] wrapper that re-styles the drawable to better align with the style of
* [StyledImageView].
* @param context [Context] required for initialization.
* @param inner The [Drawable] to wrap.
*/
private class StyledDrawable(context: Context, private val inner: Drawable) : Drawable() {
init {
DrawableCompat.setTintList(src, context.getColorCompat(R.color.sel_on_cover_bg))
// Re-tint the drawable to use the analogous "on surface" color for
// StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
}
override fun draw(canvas: Canvas) {
// Resize the drawable such that it's always 1/4 the size of the image and
// centered in the middle of the canvas.
val adjustWidth = bounds.width() / 4
val adjustHeight = bounds.height() / 4
src.bounds.set(
inner.bounds.set(
adjustWidth,
adjustHeight,
bounds.width() - adjustWidth,
bounds.height() - adjustHeight)
src.draw(canvas)
inner.draw(canvas)
}
// Required drawable overrides. Just forward to the wrapped drawable.
override fun setAlpha(alpha: Int) {
src.alpha = alpha
inner.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
src.colorFilter = colorFilter
inner.colorFilter = colorFilter
}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image
package org.oxycblt.auxio.image.extractor
import android.content.Context
import coil.ImageLoader
@ -35,41 +35,44 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.music.Sort
/** A basic keyer for music data. */
/**
* A [Keyer] implementation for [Music] data.
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicKeyer : Keyer<Music> {
override fun key(data: Music, options: Options): String {
return if (data is Song) {
override fun key(data: Music, options: Options) =
if (data is Song) {
// Group up song covers with album covers for better caching
key(data.album, options)
data.album.uid.toString()
} else {
"${data::class.simpleName}: ${data.id}"
data.uid.toString()
}
}
}
/**
* Fetcher that returns the album cover for a given [Album] or [Song], depending on the factory
* used.
* @author OxygenCobalt
* Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
* [AlbumFactory] for instantiation.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumCoverFetcher
private constructor(private val context: Context, private val album: Album) : BaseFetcher() {
private constructor(private val context: Context, private val album: Album) : Fetcher {
override suspend fun fetch(): FetchResult? =
fetchArt(context, album)?.let { stream ->
Covers.fetch(context, album)?.run {
SourceResult(
source = ImageSource(stream.source().buffer(), context),
source = ImageSource(source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
/** A [Fetcher.Factory] implementation that works with [Song]s. */
class SongFactory : Fetcher.Factory<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader): Fetcher {
return AlbumCoverFetcher(options.context, data.album)
}
override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, data.album)
}
/** A [Fetcher.Factory] implementation that works with [Album]s. */
class AlbumFactory : Fetcher.Factory<Album> {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, data)
@ -77,21 +80,23 @@ private constructor(private val context: Context, private val album: Album) : Ba
}
/**
* Fetcher that fetches the image for an [Artist]
* @author OxygenCobalt
* [Fetcher] for [Artist] images. Use [Factory] for instantiation.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImageFetcher
private constructor(
private val context: Context,
private val size: Size,
private val artist: Artist,
) : BaseFetcher() {
private val artist: Artist
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val albums = Sort(Sort.Mode.ByName, true).albums(artist.albums)
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
return createMosaic(context, results, size)
// Pick the "most prominent" albums (i.e albums with the most songs) to show in the image.
val albums = Sort(Sort.Mode.ByCount, false).albums(artist.albums)
val results = albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, album) }
return Images.createMosaic(context, results, size)
}
/** [Fetcher.Factory] implementation. */
class Factory : Fetcher.Factory<Artist> {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
ArtistImageFetcher(options.context, options.size, data)
@ -99,35 +104,21 @@ private constructor(
}
/**
* Fetcher that fetches the image for a [Genre]
* @author OxygenCobalt
* [Fetcher] for [Genre] images. Use [Factory] for instantiation.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImageFetcher
private constructor(
private val context: Context,
private val size: Size,
private val genre: Genre,
) : BaseFetcher() {
private val genre: Genre
) : Fetcher {
override suspend fun fetch(): FetchResult? {
// Genre logic is the most complicated, as we want to ensure album cover variation (i.e
// all four covers shouldn't be from the same artist) while also still leveraging mosaics
// whenever possible. So, if there are more than four distinct artists in a genre, make
// it so that one artist only adds one album cover to the mosaic. Otherwise, use order
// albums normally.
val artists = genre.songs.groupBy { it.album.artist.id }.keys
val albums =
Sort(Sort.Mode.ByName, true).albums(genre.songs.groupBy { it.album }.keys).run {
if (artists.size > 4) {
distinctBy { it.artist.rawName }
} else {
this
}
}
val results = albums.mapAtMost(4) { album -> fetchArt(context, album) }
return createMosaic(context, results, size)
val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, it) }
return Images.createMosaic(context, results, size)
}
/** [Fetcher.Factory] implementation. */
class Factory : Fetcher.Factory<Genre> {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
GenreImageFetcher(options.context, options.size, data)
@ -135,10 +126,14 @@ private constructor(
}
/**
* Map at most [n] items from a collection. [transform] is called for each item that is eligible. If
* null is returned, then that item will be skipped.
* Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be
* transformed into [R].
* @param n The maximum amount of items to map.
* @param transform The function that transforms data [T] from the original list into data [R] in
* the new list. Can return null if the [T] cannot be transformed into an [R].
* @return A new list of at most N non-null [R] items.
*/
private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(
private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull(
n: Int,
transform: (T) -> R?
): List<R> {
@ -146,11 +141,12 @@ private inline fun <T : Any, R : Any> Collection<T>.mapAtMost(
val out = mutableListOf<R>()
for (item in this) {
if (out.size < until) {
transform(item)?.let(out::add)
} else {
if (out.size >= until) {
break
}
// Still have more data we can transform.
transform(item)?.let(out::add)
}
return out

View file

@ -15,24 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image
package org.oxycblt.auxio.image.extractor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.media.MediaMetadataRetriever
import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.MetadataRetriever
@ -42,36 +28,32 @@ import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
/**
* The base implementation for all image fetchers in Auxio.
* @author OxygenCobalt
*
* TODO: File-system derived images [cover.jpg, Artist Images]
* Internal utilities for loading album covers.
* @author Alexander Capehart (OxygenCobalt).
*/
abstract class BaseFetcher : Fetcher {
object Covers {
/**
* Fetch the artwork of an [album]. This call respects user configuration and has proper
* redundancy in the case that metadata fails to load.
* Fetch an album cover, respecting the current cover configuration.
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null if the cover
* loading failed or should not occur.
*/
protected suspend fun fetchArt(context: Context, album: Album): InputStream? {
suspend fun fetch(context: Context, album: Album): InputStream? {
val settings = Settings(context)
if (!settings.showCovers) {
return null
}
return try {
if (settings.useQualityCovers) {
fetchQualityCovers(context, album)
} else {
fetchMediaStoreCovers(context, album)
when (settings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
CoverMode.QUALITY -> fetchQualityCovers(context, album)
}
} catch (e: Exception) {
logW("Unable to extract album cover due to an error: $e")
@ -79,20 +61,28 @@ abstract class BaseFetcher : Fetcher {
}
}
/**
* Load an [Album] cover directly from one of it's Song files. This attempts the following in
* order:
* - [MediaMetadataRetriever], as it has the best support and speed.
* - ExoPlayer's [MetadataRetriever], as some devices (notably Samsung) can have broken
* [MediaMetadataRetriever] implementations.
* - MediaStore, as a last-ditch fallback if the format is really obscure.
*
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
private suspend fun fetchQualityCovers(context: Context, album: Album) =
// Loading quality covers basically means to parse the file metadata ourselves
// and then extract the cover.
// First try MediaMetadataRetriever. We will always do this first, as it supports
// a variety of formats, has multiple levels of fault tolerance, and is pretty fast
// for a manual parser.
// However, Samsung seems to cripple this class as to force people to use their ad-infested
// music app which relies on proprietary OneUI extensions instead of AOSP. That means
// we have to add even more layers of redundancy to make sure we can extract a cover.
// Thanks Samsung. Prick.
fetchAospMetadataCovers(context, album)
?: fetchExoplayerCover(context, album) ?: fetchMediaStoreCovers(context, album)
/**
* Loads an album cover with [MediaMetadataRetriever].
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
private fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? {
MediaMetadataRetriever().apply {
// This call is time-consuming but it also doesn't seem to hold up the main thread,
@ -106,18 +96,24 @@ abstract class BaseFetcher : Fetcher {
}
}
/**
* Loads an [Album] cover with ExoPlayer's [MetadataRetriever].
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri
val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri))
// future.get is a blocking call that makes us spin until the future is done.
// This is bad for a co-routine, as it prevents cancellation and by extension
// messes with the image loading process and causes frustrating bugs.
// messes with the image loading process and causes annoying bugs.
// To fix this we wrap this around in a withContext call to make it suspend and make
// sure that the runner can do other coroutines.
@Suppress("BlockingMethodInNonBlockingContext")
val tracks =
withContext(Dispatchers.IO) {
withContext(Dispatchers.Default) {
try {
future.get()
} catch (e: Exception) {
@ -172,80 +168,15 @@ abstract class BaseFetcher : Fetcher {
return stream
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? {
val uri = data.coverUri
// Eliminate any chance that this blocking call might mess up the loading process
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
}
/**
* Create a mosaic image from multiple streams of image data, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph
* Loads an [Album] cover from MediaStore.
* @param context [Context] required to load the image.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null otherwise.
*/
protected suspend fun createMosaic(
context: Context,
streams: List<InputStream>,
size: Size
): FetchResult? {
if (streams.size < 4) {
return streams.firstOrNull()?.let { stream ->
return SourceResult(
source = ImageSource(stream.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
}
// Use whatever size coil gives us to create the mosaic, rounding it to even so that we
// get a symmetrical mosaic [and to prevent bugs]. If there is no size, default to a
// 512x512 mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap =
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Run the bitmap through a transform to make sure it's a square of the desired
// resolution.
val bitmap =
SquareFrameTransform.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun fetchMediaStoreCovers(context: Context, album: Album): InputStream? {
// Eliminate any chance that this blocking call might mess up the loading process
return withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
}
}

View file

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

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

View file

@ -15,9 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.dirs
package org.oxycblt.auxio.list
import org.oxycblt.auxio.music.Directory
import androidx.annotation.StringRes
/** Represents a the configuration for the "Folder Management" setting */
data class MusicDirs(val dirs: List<Directory>, val shouldInclude: Boolean)
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
interface Item
/**
* A "header" used for delimiting groups of data.
* @param titleRes The string resource used for the header's title.
*/
data class Header(@StringRes val titleRes: Int) : Item

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/>.
*/
package org.oxycblt.auxio.ui.recycler
package org.oxycblt.auxio.list.recycler
import android.content.Context
import android.graphics.Rect
import android.util.AttributeSet
import android.view.WindowInsets
import androidx.annotation.AttrRes
@ -27,30 +26,36 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.systemBarInsetsCompat
/** A [RecyclerView] that enables some extra functionality for Auxio's use-case. */
/**
* A [RecyclerView] with a few QoL extensions, such as:
* - Automatic edge-to-edge support
* - Adapter-based [SpanSizeLookup] implementation
* - Automatic [setHasFixedSize] setup
* @author Alexander Capehart (OxygenCobalt)
*/
open class AuxioRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
RecyclerView(context, attrs, defStyleAttr) {
private val initialPadding = Rect(paddingLeft, paddingTop, paddingRight, paddingBottom)
private val initialPaddingBottom = paddingBottom
init {
// Prevent children from being clipped by window insets
clipToPadding = false
// Auxio's non-dialog RecyclerViews never change their size based on adapter contents,
// so we can enable fixed-size optimizations.
setHasFixedSize(true)
}
final override fun setHasFixedSize(hasFixedSize: Boolean) {
// Prevent a this leak by marking setHasFixedSize as final.
super.setHasFixedSize(hasFixedSize)
}
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
updatePadding(
initialPadding.left,
initialPadding.top,
initialPadding.right,
initialPadding.bottom + insets.systemBarInsetsCompat.bottom)
// Update the RecyclerView's padding such that the bottom insets are applied
// while still preserving bottom padding.
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
return insets
}
@ -58,16 +63,28 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.setAdapter(adapter)
if (adapter is SpanSizeLookup) {
// This adapter has support for special span sizes, hook it up to the
// GridLayoutManager.
val glm = (layoutManager as GridLayoutManager)
val fullWidthSpanCount = glm.spanCount
glm.spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
// Using the adapter implementation, if the adapter specifies that
// an item is full width, it will take up all of the spans, using a
// single span otherwise.
override fun getSpanSize(position: Int) =
if (adapter.isItemFullWidth(position)) glm.spanCount else 1
if (adapter.isItemFullWidth(position)) fullWidthSpanCount else 1
}
}
}
/** An [RecyclerView.Adapter]-specific hook to [GridLayoutManager.SpanSizeLookup]. */
interface SpanSizeLookup {
/**
* Get if the item at a position takes up the whole width of the [RecyclerView] or not.
* @param position The position of the item.
* @return true if the item is full-width, false otherwise.
*/
fun isItemFullWidth(position: Int): Boolean
}
}

View file

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

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/>.
*/
package org.oxycblt.auxio.ui.recycler
package org.oxycblt.auxio.list.recycler
import android.view.View
import androidx.annotation.StringRes
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
/**
* The base for all items in Auxio. Any datatype can derive this type and gain some behavior not
* provided for free by the normal adapter implementations, such as certain types of diffing.
*/
abstract class Item {
/** A unique ID for this item. ***THIS IS NOT A MEDIASTORE ID!** */
abstract val id: Long
}
/** A data object used solely for the "Header" UI element. */
data class Header(
/** The string resource used for the header. */
@StringRes val string: Int
) : Item() {
override val id: Long
get() = string.toLong()
}
/** An interface for detecting if an item has been clicked once. */
interface ItemClickListener {
/** Called when an item is clicked once. */
fun onItemClick(item: Item)
}
/** An interface for detecting if an item has had it's menu opened. */
interface MenuItemListener : ItemClickListener {
/** Called when an item desires to open a menu relating to it. */
fun onOpenMenu(item: Item, anchor: View)
}
/**
* Like [AsyncListDiffer], but synchronous. This may seem like it would be inefficient, but in
* practice Auxio's lists tend to be small enough to the point where this does not matter, and
* situations that would be inefficient are ruled out with [replaceList].
* A list differ that operates synchronously. This can help resolve some shortcomings with
* AsyncListDiffer, at the cost of performance. Derived from Material Files:
* https://github.com/zhanghai/MaterialFiles
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/
class SyncListDiffer<T>(
adapter: RecyclerView.Adapter<*>,
@ -141,17 +110,28 @@ class SyncListDiffer<T>(
result.dispatchUpdatesTo(updateCallback)
}
/** Submit a list normally, doing a diff synchronously. Only use this for trivial changes. */
/**
* Submit a list like AsyncListDiffer. This is exceedingly slow for large diffs, so only use it
* if the changes are trivial.
* @param newList The list to update to.
*/
fun submitList(newList: List<T>) {
if (newList == currentList) {
// Nothing to do.
return
}
currentList = newList
}
/**
* Replace this list with a new list. This is useful for very large list diffs that would
* generally be too chaotic and slow to provide a good UX.
* Replace this list with a new list. This is good for large diffs that are too slow to update
* synchronously, but too chaotic to update asynchronously.
* @param newList The list to update to.
*/
fun replaceList(newList: List<T>) {
if (newList == currentList) {
// Nothing to do.
return
}
@ -159,14 +139,3 @@ class SyncListDiffer<T>(
currentList = newList
}
}
/**
* A base [DiffUtil.ItemCallback] that automatically provides an implementation of
* [areContentsTheSame] any object that is derived from [Item].
*/
abstract class SimpleItemCallback<T : Item> : DiffUtil.ItemCallback<T>() {
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
if (oldItem.javaClass != newItem.javaClass) return false
return oldItem.id == newItem.id
}
}

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.net.Uri
import android.provider.OpenableColumns
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.useQuery
/**
* The main storage for music items.
* A repository granting access to the music library..
*
* Whereas other apps load music from MediaStore as it is shown, Auxio does not do that, as it
* cripples any kind of advanced metadata functionality. Instead, Auxio loads all music into a
* in-memory relational data-structure called [Library]. This costs more memory-wise, but is also
* much more sensible.
* This can be used to obtain certain music items, or await changes to the music library. It is
* generally recommended to use this over Indexer to keep track of the library state, as the
* interface will be less volatile.
*
* The only other, memory-efficient option is to create our own hybrid database that leverages both
* a typical DB and a mem-cache, like Vinyl. But why would we do that when I've encountered no real
* issues with the current system.
*
* [Library] may not be available at all times, so leveraging [Callback] is recommended. Consumers
* should also be aware that [Library] may change while they are running, and design their work
* accordingly.
*
* @author OxygenCobalt
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicStore private constructor() {
private val callbacks = mutableListOf<Callback>()
/**
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
* can change, so it's highly recommended to not access this directly and instead rely on
* [Callback].
*/
var library: Library? = null
private set
set(value) {
field = value
for (callback in callbacks) {
callback.onLibraryChanged(library)
}
}
/** Add a callback to this instance. Make sure to remove it when done. */
/**
* Add a [Callback] to this instance. This can be used to receive changes in the music library.
* Will invoke all [Callback] methods to initialize the instance with the current state.
* @param callback The [Callback] to add.
* @see Callback
*/
@Synchronized
fun addCallback(callback: Callback) {
callback.onLibraryChanged(library)
callbacks.add(callback)
}
/** Remove a callback from this instance. */
/**
* Remove a [Callback] from this instance, preventing it from recieving any further updates.
* @param callback The [Callback] to remove. Does nothing if the [Callback] was never added in
* the first place.
* @see Callback
*/
@Synchronized
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}
/** Update the library in this instance. This is only meant for use by the internal indexer. */
@Synchronized
fun updateLibrary(newLibrary: Library?) {
library = newLibrary
for (callback in callbacks) {
callback.onLibraryChanged(library)
}
}
/** Represents a library of music owned by [MusicStore]. */
/**
* A library of [Music] instances.
* @param songs All [Song]s loaded from the device.
* @param albums All [Album]s that could be created.
* @param artists All [Artist]s that could be created.
* @param genres All [Genre]s that could be created.
*/
data class Library(
val genres: List<Genre>,
val artists: List<Artist>,
val songs: List<Song>,
val albums: List<Album>,
val songs: List<Song>
val artists: List<Artist>,
val genres: List<Genre>,
) {
private val genreIdMap = HashMap<Long, Genre>().apply { genres.forEach { put(it.id, it) } }
private val artistIdMap =
HashMap<Long, Artist>().apply { artists.forEach { put(it.id, it) } }
private val albumIdMap = HashMap<Long, Album>().apply { albums.forEach { put(it.id, it) } }
private val songIdMap = HashMap<Long, Song>().apply { songs.forEach { put(it.id, it) } }
private val uidMap = HashMap<Music.UID, Music>()
/** Find a [Song] by it's ID. Null if no song exists with that ID. */
fun findSongById(songId: Long) = songIdMap[songId]
init {
// The data passed to Library initially are complete, but are still volitaile.
// Finalize them to ensure they are well-formed. Also initialize the UID map in
// the same loop for efficiency.
for (song in songs) {
song._finalize()
uidMap[song.uid] = song
}
/** Find a [Album] by it's ID. Null if no album exists with that ID. */
fun findAlbumById(albumId: Long) = albumIdMap[albumId]
for (album in albums) {
album._finalize()
uidMap[album.uid] = album
}
/** Find a [Artist] by it's ID. Null if no artist exists with that ID. */
fun findArtistById(artistId: Long) = artistIdMap[artistId]
for (artist in artists) {
artist._finalize()
uidMap[artist.uid] = artist
}
/** Find a [Genre] by it's ID. Null if no genre exists with that ID. */
fun findGenreById(genreId: Long) = genreIdMap[genreId]
for (genre in genres) {
genre._finalize()
uidMap[genre.uid] = genre
}
}
/** Sanitize an old item to find the corresponding item in a new library. */
fun sanitize(song: Song) = findSongById(song.id)
/** Sanitize an old item to find the corresponding item in a new library. */
fun sanitize(songs: List<Song>) = songs.mapNotNull { sanitize(it) }
/** Sanitize an old item to find the corresponding item in a new library. */
fun sanitize(album: Album) = findAlbumById(album.id)
/** Sanitize an old item to find the corresponding item in a new library. */
fun sanitize(artist: Artist) = findArtistById(artist.id)
/** Sanitize an old item to find the corresponding item in a new library. */
fun sanitize(genre: Genre) = findGenreById(genre.id)
/**
* Finds a [Music] item [T] in the library by it's [Music.UID].
* @param uid The [Music.UID] to search for.
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found
* or the [Music.UID] did not correspond to a [T].
*/
@Suppress("UNCHECKED_CAST") fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
/** Find a song for a [uri]. */
/**
* Convert a [Song] from an another library into a [Song] in this [Library].
* @param song The [Song] to convert.
* @return The analogous [Song] in this [Library], or null if it does not exist.
*/
fun sanitize(song: Song) = find<Song>(song.uid)
/**
* Convert a [Album] from an another library into a [Album] in this [Library].
* @param album The [Album] to convert.
* @return The analogous [Album] in this [Library], or null if it does not exist.
*/
fun sanitize(album: Album) = find<Album>(album.uid)
/**
* Convert a [Artist] from an another library into a [Artist] in this [Library].
* @param artist The [Artist] to convert.
* @return The analogous [Artist] in this [Library], or null if it does not exist.
*/
fun sanitize(artist: Artist) = find<Artist>(artist.uid)
/**
* Convert a [Genre] from an another library into a [Genre] in this [Library].
* @param genre The [Genre] to convert.
* @return The analogous [Genre] in this [Library], or null if it does not exist.
*/
fun sanitize(genre: Genre) = find<Genre>(genre.uid)
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(uri, arrayOf(OpenableColumns.DISPLAY_NAME)) {
cursor ->
context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst()
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
// song. Do what we can to hopefully find the song the user wanted to open.
val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
songs.find { it.path.name == displayName }
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
songs.find { it.path.name == displayName && it.size == size }
}
}
/** A callback for awaiting the loading of music. */
/** A listener for changes in the music library. */
interface Callback {
/**
* Called when the current [Library] has changed.
* @param library The new [Library], or null if no [Library] has been loaded yet.
*/
fun onLibraryChanged(library: Library?)
}
companion object {
@Volatile private var INSTANCE: MusicStore? = null
/** Get the process-level instance of [MusicStore] */
/**
* Get a singleton instance.
* @return The (possibly newly-created) singleton instance.
*/
fun getInstance(): MusicStore {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
}

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
/**
* A ViewModel representing the current indexing state.
* @author OxygenCobalt
* A [ViewModel] providing data specific to the music loading process.
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicViewModel : ViewModel(), Indexer.Callback {
private val indexer = Indexer.getInstance()
private val _indexerState = MutableStateFlow<Indexer.State?>(null)
/** The current music indexing state. */
/** The current music loading state, or null if no loading is going on. */
val indexerState: StateFlow<Indexer.State?> = _indexerState
private val _libraryExists = MutableStateFlow(false)
/** Whether a music library has successfully been loaded. */
val libraryExists: StateFlow<Boolean> = _libraryExists
private val _statistics = MutableStateFlow<Statistics?>(null)
/** [Statistics] about the last completed music load. */
val statistics: StateFlow<Statistics?>
get() = _statistics
init {
indexer.registerCallback(this)
}
fun reindex() {
indexer.requestReindex()
override fun onCleared() {
indexer.unregisterCallback(this)
}
override fun onIndexerStateChanged(state: Indexer.State?) {
_indexerState.value = state
if (state is Indexer.State.Complete && state.response is Indexer.Response.Ok) {
_libraryExists.value = true
// New state is a completed library, update the statistics values.
val library = state.response.library
_statistics.value =
Statistics(
library.songs.size,
library.albums.size,
library.artists.size,
library.genres.size,
library.songs.sumOf { it.durationMs })
}
}
override fun onCleared() {
indexer.unregisterCallback(this)
/** Requests that the music library should be re-loaded while leveraging the cache. */
fun refresh() {
indexer.requestReindex(true)
}
/** Requests that the music library be re-loaded without the cache. */
fun rescan() {
indexer.requestReindex(false)
}
/**
* Non-manipulated statistics bound the last successful music load.
* @param songs The amount of [Song]s that were loaded.
* @param albums The amount of [Album]s that were created.
* @param artists The amount of [Artist]s that were created.
* @param genres The amount of [Genre]s that were created.
* @param durationMs The total duration of all songs in the library, in milliseconds.
*/
data class Statistics(
val songs: Int,
val albums: Int,
val artists: Int,
val genres: Int,
val durationMs: Long
)
}

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/>.
*/
package org.oxycblt.auxio.music.dirs
package org.oxycblt.auxio.music.storage
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemMusicDirBinding
import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.ui.recycler.DialogViewHolder
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
/**
* Adapter that shows the list of music folder and their "Clear" button.
* @author OxygenCobalt
* [RecyclerView.Adapter] that manages a list of [Directory] instances.
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<MusicDirViewHolder>() {
class DirectoryAdapter(private val listener: Listener) :
RecyclerView.Adapter<MusicDirViewHolder>() {
private val _dirs = mutableListOf<Directory>()
/**
* The current list of [Directory]s, may not line up with [MusicDirectories] due to removals.
*/
val dirs: List<Directory> = _dirs
override fun getItemCount() = dirs.size
@ -42,6 +46,10 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<Mus
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
holder.bind(dirs[position], listener)
/**
* Add a [Directory] to the end of the list.
* @param dir The [Directory] to add.
*/
fun add(dir: Directory) {
if (_dirs.contains(dir)) {
return
@ -51,32 +59,54 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter<Mus
notifyItemInserted(_dirs.lastIndex)
}
/**
* Add a list of [Directory] instances to the end of the list.
* @param dirs The [Directory instances to add.
*/
fun addAll(dirs: List<Directory>) {
val oldLastIndex = dirs.lastIndex
_dirs.addAll(dirs)
notifyItemRangeInserted(oldLastIndex, dirs.size)
}
/**
* Remove a [Directory] from the list.
* @param dir The [Directory] to remove. Must exist in the list.
*/
fun remove(dir: Directory) {
val idx = _dirs.indexOf(dir)
_dirs.removeAt(idx)
notifyItemRemoved(idx)
}
/** A Listener for [DirectoryAdapter] interactions. */
interface Listener {
fun onRemoveDirectory(dir: Directory)
}
}
/** The viewholder for [MusicDirAdapter]. Not intended for use in other adapters. */
/**
* A [RecyclerView.Recycler] that displays a [Directory]. Use [new] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
DialogViewHolder(binding.root) {
fun bind(item: Directory, listener: MusicDirAdapter.Listener) {
binding.dirPath.text = item.resolveName(binding.context)
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) }
DialogRecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
* @param dir The new [Directory] to bind.
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
*/
fun bind(dir: Directory, listener: DirectoryAdapter.Listener) {
binding.dirPath.text = dir.resolveName(binding.context)
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(dir) }
}
companion object {
/**
* Create a new instance.
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun new(parent: View) =
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.dirs
package org.oxycblt.auxio.music.storage
import android.net.Uri
import android.os.Bundle
@ -28,9 +28,8 @@ import androidx.core.view.isVisible
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
@ -38,11 +37,11 @@ import org.oxycblt.auxio.util.showToast
/**
* Dialog that manages the music dirs setting.
* @author OxygenCobalt
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicDirsDialog :
ViewBindingDialogFragment<DialogMusicDirsBinding>(), MusicDirAdapter.Listener {
private val dirAdapter = MusicDirAdapter(this)
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
private val storageManager: StorageManager by lifecycleObject { binding ->
binding.context.getSystemServiceCompat(StorageManager::class)
@ -59,8 +58,7 @@ class MusicDirsDialog :
.setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ ->
val dirs = settings.getMusicDirs(storageManager)
val newDirs =
MusicDirs(dirs = dirAdapter.dirs, shouldInclude = isInclude(requireBinding()))
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
if (dirs != newDirs) {
logD("Committing changes")
settings.setMusicDirs(newDirs)
@ -70,7 +68,8 @@ class MusicDirsDialog :
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
val launcher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
// Now that the dialog exists, we get the view manually when the dialog is shown
// and override its click listener so that the dialog does not auto-dismiss when we
@ -78,7 +77,6 @@ class MusicDirsDialog :
// and the app from crashing in the latter.
requireDialog().setOnShowListener {
val dialog = it as AlertDialog
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
logD("Opening launcher")
launcher.launch(null)
@ -94,11 +92,12 @@ class MusicDirsDialog :
if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
if (pendingDirs != null) {
dirs =
MusicDirs(
pendingDirs.mapNotNull { Directory.fromDocumentUri(storageManager, it) },
MusicDirectories(
pendingDirs.mapNotNull {
Directory.fromDocumentTreeUri(storageManager, it)
},
savedInstanceState.getBoolean(KEY_PENDING_MODE))
}
}
@ -123,7 +122,7 @@ class MusicDirsDialog :
super.onSaveInstanceState(outState)
outState.putStringArrayList(
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map { it.toString() }))
outState.putBoolean(KEY_PENDING_MODE, isInclude(requireBinding()))
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
}
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
@ -136,14 +135,26 @@ class MusicDirsDialog :
requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty()
}
private fun addDocTreePath(uri: Uri?) {
/**
* Add a Document Tree [Uri] chosen by the user to the current [MusicDirectories] instance.
* @param uri The document tree [Uri] to add, chosen by the user. Will do nothing if the [Uri]
* is null or not valid.
*/
private fun addDocumentTreeUriToDirs(uri: Uri?) {
if (uri == null) {
// A null URI means that the user left the file picker without picking a directory
logD("No URI given (user closed the dialog)")
return
}
val dir = parseExcludedUri(uri)
// Convert the document tree URI into it's relative path form, which can then be
// parsed into a Directory instance.
val docUri =
DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri))
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
val dir = Directory.fromDocumentTreeUri(storageManager, treeUri)
if (dir != null) {
dirAdapter.add(dir)
requireBinding().dirsEmpty.isVisible = false
@ -152,33 +163,20 @@ class MusicDirsDialog :
}
}
private fun parseExcludedUri(uri: Uri): Directory? {
// Turn the raw URI into a document tree URI
val docUri =
DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri))
// Turn it into a semi-usable path
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
// Parsing handles the rest
return Directory.fromDocumentUri(storageManager, treeUri)
}
private fun updateMode() {
val binding = requireBinding()
if (isInclude(binding)) {
if (isUiModeInclude(binding)) {
binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc)
} else {
binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc)
}
}
private fun isInclude(binding: DialogMusicDirsBinding) =
/** Get if the UI has currently configured [MusicDirectories.shouldInclude] to be true. */
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED"
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
}

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.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.Sort
import org.oxycblt.auxio.music.extractor.*
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.TaskGuard
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.requireBackgroundThread
/**
* Auxio's media indexer.
* Core music loading state class.
*
* Auxio's media indexer is somewhat complicated, as it has grown to support a variety of use cases
* (and hacky garbage) in order to produce the best possible experience. It is split into three
* distinct steps:
* This class provides low-level access into the exact state of the music loading process. **This
* class should not be used in most cases.** It is highly volatile and provides far more information
* than is usually needed. Use [MusicStore] instead if you do not need to work with the exact music
* loading state.
*
* 1. Finding a [Backend] to use and then querying the media database with it.
* 2. Using the [Backend] and the media data to create songs
* 3. Using the songs to build the library, which primarily involves linking up all data objects
* with their corresponding parents/children.
*
* This class in particular handles 3 primarily. For the code that handles 1 and 2, see the
* [Backend] implementations.
*
* This class also fulfills the role of maintaining the current music loading state, which seems
* like a job for [MusicStore] but in practice is only really leveraged by the components that
* directly work with music loading, making such redundant.
*
* @author OxygenCobalt
* @author Alexander Capehart (OxygenCobalt)
*/
class Indexer {
class Indexer private constructor() {
private var lastResponse: Response? = null
private var indexingState: Indexing? = null
private var guard = TaskGuard()
private var controller: Controller? = null
private var callback: Callback? = null
/** Whether music loading is occurring or not. */
val isIndexing: Boolean
get() = indexingState != null
/**
* Whether this instance is in an indeterminate state or not, where nothing has been previously
* loaded, yet no loading is going on.
* Whether this instance has not completed a loading process and is not currently loading music.
* This often occurs early in an app's lifecycle, and consumers should try to avoid showing any
* state when this flag is true.
*/
val isIndeterminate: Boolean
get() = lastResponse == null && indexingState == null
/** Whether this instance is actively indexing or not. */
val isIndexing: Boolean
get() = indexingState != null
/** Register a [Controller] with this instance. */
/**
* Register a [Controller] for this instance. This instance will handle any commands to start
* the music loading process. There can be only one [Controller] at a time. Will invoke all
* [Callback] methods to initialize the instance with the current state.
* @param controller The [Controller] to register. Will do nothing if already registered.
*/
@Synchronized
fun registerController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller != null) {
@ -84,10 +81,19 @@ class Indexer {
return
}
// Initialize the controller with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller.onIndexerStateChanged(currentState)
this.controller = controller
}
/** Unregister a [Controller] with this instance. */
/**
* Unregister the [Controller] from this instance, prevent it from recieving any further
* commands.
* @param controller The [Controller] to unregister. Must be the current [Controller]. Does
* nothing if invoked by another [Controller] implementation.
*/
@Synchronized
fun unregisterController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) {
@ -98,21 +104,32 @@ class Indexer {
this.controller = null
}
/**
* Register the [Callback] for this instance. This can be used to receive rapid-fire updates to
* the current music loading state. There can be only one [Callback] at a time. Will invoke all
* [Callback] methods to initialize the instance with the current state.
* @param callback The [Callback] to add.
*/
@Synchronized
fun registerCallback(callback: Callback) {
if (BuildConfig.DEBUG && this.callback != null) {
logW("Callback is already registered")
logW("Listener is already registered")
return
}
// Initialize the listener with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
callback.onIndexerStateChanged(currentState)
this.callback = callback
}
/**
* Unregister a [Callback] from this instance, preventing it from recieving any further updates.
* @param callback The [Callback] to unregister. Must be the current [Callback]. Does nothing if
* invoked by another [Callback] implementation.
* @see Callback
*/
@Synchronized
fun unregisterCallback(callback: Callback) {
if (BuildConfig.DEBUG && this.callback !== callback) {
@ -124,275 +141,265 @@ class Indexer {
}
/**
* Start the indexing process. This should be done by [Controller] in a background thread. When
* complete, a new completion state will be pushed to each callback.
* Start the indexing process. This should be done from in the background from [Controller]'s
* context after a command has been received to start the process.
* @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
* be written, but no cache entries will be loaded into the new library.
*/
suspend fun index(context: Context) {
requireBackgroundThread()
val handle = guard.newHandle()
val notGranted =
ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED
if (notGranted) {
emitCompletion(Response.NoPerms, handle)
suspend fun index(context: Context, withCache: Boolean) {
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
// No permissions, signal that we can't do anything.
emitCompletion(Response.NoPerms)
return
}
val response =
try {
val start = System.currentTimeMillis()
val library = indexImpl(context, handle)
val library = indexImpl(context, withCache)
if (library != null) {
// Successfully loaded a library.
logD(
"Music indexing completed successfully in " +
"${System.currentTimeMillis() - start}ms")
Response.Ok(library)
} else {
// Loaded a library, but it contained no music.
logE("No music found")
Response.NoMusic
}
} catch (e: CancellationException) {
// Got cancelled, propagate upwards
// Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled")
throw e
} catch (e: Exception) {
// Music loading process failed due to something we have not handled.
logE("Music indexing failed")
logE(e.stackTraceToString())
Response.Err(e)
}
emitCompletion(response, handle)
emitCompletion(response)
}
/**
* Request that re-indexing should be done. This should be used by components that do not manage
* the indexing process to re-index music.
* Request that the music library should be reloaded. This should be used by components that do
* not manage the indexing process in order to signal that the [Controller] should call [index]
* eventually.
* @param withCache Whether to use the cache when loading music. Does nothing if there is no
* [Controller].
*/
@Synchronized
fun requestReindex() {
fun requestReindex(withCache: Boolean) {
logD("Requesting reindex")
controller?.onStartIndexing()
controller?.onStartIndexing(withCache)
}
/**
* "Cancel" the last job by making it unable to send further state updates. This will cause the
* worker operating the job for that specific handle to cancel as soon as it tries to send a
* state update.
* Reset the current loading state to signal that the instance is not loading. This should be
* called by [Controller] after it's indexing co-routine was cancelled.
*/
@Synchronized
fun cancelLast() {
fun reset() {
logD("Cancelling last job")
val handle = guard.newHandle()
emitIndexing(null, handle)
emitIndexing(null)
}
/**
* Run the proper music loading process. [handle] must be a truthful handle of the task calling
* this function.
* Internal implementation of the music loading process.
* @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
* be written, but no cache entries will be loaded into the new library.
* @return A newly-loaded [MusicStore.Library], or null if nothing was loaded.
*/
private fun indexImpl(context: Context, handle: Long): MusicStore.Library? {
emitIndexing(Indexing.Indeterminate, handle)
// Since we have different needs for each version, we determine a "Backend" to use
// when loading music and then leverage that to create the initial song list.
// This is technically dependency injection. Except it doesn't increase your compile
// times by 3x. Isn't that nice.
val mediaStoreBackend =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreBackend()
else -> Api21MediaStoreBackend()
}
val settings = Settings(context)
val backend =
if (settings.useQualityTags) {
ExoPlayerBackend(mediaStoreBackend)
private suspend fun indexImpl(context: Context, withCache: Boolean): MusicStore.Library? {
// Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music
// experience.
val cacheDatabase =
if (withCache) {
ReadWriteCacheExtractor(context)
} else {
mediaStoreBackend
WriteOnlyCacheExtractor(context)
}
val songs = buildSongs(context, backend, handle)
val mediaStoreExtractor =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
Api30MediaStoreExtractor(context, cacheDatabase)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Api29MediaStoreExtractor(context, cacheDatabase)
else -> Api21MediaStoreExtractor(context, cacheDatabase)
}
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val songs = buildSongs(metadataExtractor, Settings(context))
if (songs.isEmpty()) {
// No songs, nothing else to do.
return null
}
// Build the rest of the music library from the song list. This is much more powerful
// and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis()
val albums = buildAlbums(songs)
val artists = buildArtists(albums)
val artists = buildArtists(songs, albums)
val genres = buildGenres(songs)
// Sanity check: Ensure that all songs are linked up to albums/artists/genres.
for (song in songs) {
if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
error(
"Found unlinked song: ${song.rawName} [" +
"missing album: ${song._isMissingAlbum} " +
"missing artist: ${song._isMissingArtist} " +
"missing genre: ${song._isMissingGenre}]")
}
}
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return MusicStore.Library(genres, artists, albums, songs)
return MusicStore.Library(songs, albums, artists, genres)
}
/**
* Does the initial query over the song database using [backend]. The songs returned by this
* function are **not** well-formed. The companion [buildAlbums], [buildArtists], and
* [buildGenres] functions must be called with the returned list so that all songs are properly
* linked up.
* Load a list of [Song]s from the device.
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
* instances.
* @param settings [Settings] required to create [Song] instances.
* @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked
* with parent [Album], [Artist], and [Genre] items in order to be usable.
*/
private fun buildSongs(context: Context, backend: Backend, handle: Long): List<Song> {
private suspend fun buildSongs(
metadataExtractor: MetadataExtractor,
settings: Settings
): List<Song> {
logD("Starting indexing process")
val start = System.currentTimeMillis()
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
// how long a media database query will take.
emitIndexing(Indexing.Indeterminate)
val total = metadataExtractor.init()
yield()
var songs =
backend.query(context).use { cursor ->
logD(
"Successfully queried media database " +
"in ${System.currentTimeMillis() - start}ms")
// Note: We use a set here so we can eliminate song duplicates.
val songs = mutableSetOf<Song>()
val rawSongs = mutableListOf<Song.Raw>()
metadataExtractor.parse { rawSong ->
songs.add(Song(rawSong, settings))
rawSongs.add(rawSong)
backend.buildSongs(context, cursor) { emitIndexing(it, handle) }
}
// Deduplicate songs to prevent (most) deformed music clones
songs =
songs
.distinctBy {
it.rawName to
it._albumName to
it._artistName to
it._albumArtistName to
it._genreName to
it.track to
it.disc to
it.durationMs
}
.toMutableList()
// Ensure that sorting order is consistent so that grouping is also consistent.
Sort(Sort.Mode.ByName, true).songsInPlace(songs)
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
return songs
}
/**
* Group songs up into their respective albums. Instead of using the unreliable album or artist
* databases, we instead group up songs by their *lowercase* artist and album name to create
* albums. This serves two purposes:
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
* makes sure both of those are resolved into a single artist called "Rammstein"
* 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
* that all songs are unified under a single album.
*
* This does come with some costs, it's far slower than using the album ID itself, and it may
* result in an unrelated album cover being selected depending on the song chosen as the
* template, but it seems to work pretty well.
*/
private fun buildAlbums(songs: List<Song>): List<Album> {
val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { it._albumGroupingId }
for (entry in songsByAlbum) {
val albumSongs = entry.value
// Use the song with the latest year as our metadata song.
// This allows us to replicate the LAST_YEAR field, which is useful as it means that
// weird years like "0" wont show up if there are alternatives.
val templateSong =
albumSongs.maxWith(compareBy(Sort.Mode.NullableComparator.DATE) { it._date })
albums.add(
Album(
rawName = templateSong._albumName,
rawSortName = templateSong._albumSortName,
date = templateSong._date,
releaseType = templateSong._albumReleaseType ?: ReleaseType.Album(null),
coverUri = templateSong._albumCoverUri,
songs = entry.value,
_artistGroupingName = templateSong._artistGroupingName,
_artistGroupingSortName = templateSong._artistGroupingSortName))
// Now we can signal a defined progress by showing how many songs we have
// loaded, and the projected amount of songs we found in the library
// (obtained by the extractors)
yield()
emitIndexing(Indexing.Songs(songs.size, total))
}
logD("Successfully built ${albums.size} albums")
// Finalize the extractors with the songs we have now loaded. There is no ETA
// on this process, so go back to an indeterminate state.
emitIndexing(Indexing.Indeterminate)
metadataExtractor.finalize(rawSongs)
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
// Ensure that sorting order is consistent so that grouping is also consistent.
// Rolling this into the set is not an option, as songs with the same sort result
// would be lost.
return Sort(Sort.Mode.ByName, true).songs(songs)
}
/**
* Build a list of [Album]s from the given [Song]s.
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
* [Album]s when created.
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
* with parent [Artist] instances in order to be usable.
*/
private fun buildAlbums(songs: List<Song>): List<Album> {
// Group songs by their singular raw album, then map the raw instances and their
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
val songsByAlbum = songs.groupBy { it._rawAlbum }
val albums = songsByAlbum.map { Album(it.key, it.value) }
logD("Successfully built ${albums.size} albums")
return albums
}
/**
* Group up albums into artists. This also requires a de-duplication step due to some edge cases
* where [buildAlbums] could not detect duplicates.
* Group up [Song]s and [Album]s into [Artist] instances. Both of these items are required as
* they group into [Artist] instances much differently, with [Song]s being grouped primarily by
* artist names, and [Album]s being grouped primarily by album artist names.
* @param songs The [Song]s to build [Artist]s from. One [Song] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created.
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created.
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
* of [Song]s and [Album]s.
*/
private fun buildArtists(albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it._artistGroupingId }
private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
// Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
for (entry in albumsByArtist) {
// The first album will suffice for template metadata.
val templateAlbum = entry.value[0]
artists.add(
Artist(
rawName = templateAlbum._artistGroupingName,
rawSortName = templateAlbum._artistGroupingSortName,
albums = entry.value))
for (song in songs) {
for (rawArtist in song._rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
}
}
logD("Successfully built ${artists.size} artists")
for (album in albums) {
for (rawArtist in album._rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
}
}
// Convert the combined mapping into artist instances.
val artists = musicByArtist.map { Artist(it.key, it.value) }
logD("Successfully built ${artists.size} artists")
return artists
}
/**
* Group up songs into genres. This is a relatively simple step compared to the other library
* steps, as there is no demand to deduplicate genres by a lowercase name.
* Group up [Song]s into [Genre] instances.
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
* created.
* @return A non-empty list of [Genre]s.
*/
private fun buildGenres(songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>()
val songsByGenre = songs.groupBy { it._genreGroupingId }
for (entry in songsByGenre) {
// The first song fill suffice for template metadata.
val templateSong = entry.value[0]
genres.add(Genre(rawName = templateSong._genreName, songs = entry.value))
// Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
for (song in songs) {
for (rawGenre in song._rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
}
}
// Convert the mapping into genre instances.
val genres = songsByGenre.map { Genre(it.key, it.value) }
logD("Successfully built ${genres.size} genres")
return genres
}
/**
* Emit a new [State.Indexing] state. This can be used to signal the current state of the music
* loading process to external code. Assumes that the callee has already checked if they have
* not been canceled and thus have the ability to emit a new state.
* @param indexing The new [Indexing] state to emit, or null if no loading process is occurring.
*/
@Synchronized
private fun emitIndexing(indexing: Indexing?, handle: Long) {
guard.yield(handle)
if (indexing == indexingState) {
// Ignore redundant states used when the backends just want to check for
// a cancellation
return
}
private fun emitIndexing(indexing: Indexing?) {
indexingState = indexing
// If we have canceled the loading process, we want to revert to a previous completion
// whenever possible to prevent state inconsistency.
val state =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller?.onIndexerStateChanged(state)
callback?.onIndexerStateChanged(state)
}
private suspend fun emitCompletion(response: Response, handle: Long) {
guard.yield(handle)
/**
* Emit a new [State.Complete] state. This can be used to signal the completion of the music
* loading process to external code. Will check if the callee has not been canceled and thus has
* the ability to emit a new state
* @param response The new [Response] to emit, representing the outcome of the music loading
* process.
*/
private suspend fun emitCompletion(response: Response) {
yield()
// Swap to the Main thread so that downstream callbacks don't crash from being on
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
withContext(Dispatchers.Main) {
@ -401,39 +408,76 @@ class Indexer {
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
lastResponse = response
indexingState = null
// Signal that the music loading process has been completed.
val state = State.Complete(response)
controller?.onIndexerStateChanged(state)
callback?.onIndexerStateChanged(state)
}
}
}
/** Represents the current indexer state. */
/** Represents the current state of [Indexer]. */
sealed class State {
/**
* Music loading is ongoing.
* @param indexing The current music loading progress..
* @see Indexer.Indexing
*/
data class Indexing(val indexing: Indexer.Indexing) : State()
/**
* Music loading has completed.
* @param response The outcome of the music loading process.
* @see Response
*/
data class Complete(val response: Response) : State()
}
/**
* Represents the current progress of the music loader. Usually encapsulated in a [State].
* @see State.Indexing
*/
sealed class Indexing {
/**
* Music loading is occurring, but no definite estimate can be put on the current progress.
*/
object Indeterminate : Indexing()
/**
* Music loading has a definite progress.
* @param current The current amount of songs that have been loaded.
* @param total The projected total amount of songs that will be loaded.
*/
class Songs(val current: Int, val total: Int) : Indexing()
}
/** Represents the possible outcomes of a loading process. */
/** Represents the possible outcomes of the music loading process. */
sealed class Response {
/**
* Music load was successful and produced a [MusicStore.Library].
* @param library The loaded [MusicStore.Library].
*/
data class Ok(val library: MusicStore.Library) : Response()
/**
* Music loading encountered an unexpected error.
* @param throwable The error thrown.
*/
data class Err(val throwable: Throwable) : Response()
/** Music loading occurred, but resulted in no music. */
object NoMusic : Response()
/** Music loading could not occur due to a lack of storage permissions. */
object NoPerms : Response()
}
/**
* A callback to use when the indexing state changes.
* A listener for rapid-fire changes in the music loading state.
*
* This callback is low-level and not guaranteed to be single-thread. For that,
* [MusicStore.Callback] is recommended instead.
* This is only useful for code that absolutely must show the current loading process.
* Otherwise, [MusicStore.Callback] is highly recommended due to it's updates only consisting of
* the [MusicStore.Library].
*/
interface Callback {
/**
@ -447,38 +491,43 @@ class Indexer {
fun onIndexerStateChanged(state: State?)
}
/**
* Context that runs the music loading process. Implementations should be capable of running the
* background for long periods of time without android killing the process.
*/
interface Controller : Callback {
fun onStartIndexing()
}
/** Represents a backend that metadata can be extracted from. */
interface Backend {
/** Query the media database for a basic cursor. */
fun query(context: Context): Cursor
/** Create a list of songs from the [Cursor] queried in [query]. */
fun buildSongs(
context: Context,
cursor: Cursor,
emitIndexing: (Indexing) -> Unit
): List<Song>
/**
* Called when a new music loading process was requested. Implementations should forward
* this to [index].
* @param withCache Whether to use the cache or not when loading. If false, the cache should
* still be written, but no cache entries will be loaded into the new library.
* @see index
*/
fun onStartIndexing(withCache: Boolean)
}
companion object {
@Volatile private var INSTANCE: Indexer? = null
/**
* A version-compatible identifier for the read external storage permission required by the
* system to load audio.
*/
val PERMISSION_READ_AUDIO =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// TODO: Move elsewhere.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
/** Get the process-level instance of [Indexer]. */
/**
* Get a singleton instance.
* @return The (possibly newly-created) singleton instance.
*/
fun getInstance(): Indexer {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
}

View file

@ -18,17 +18,24 @@
package org.oxycblt.auxio.music.system
import android.content.Context
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.system.ServiceNotification
import org.oxycblt.auxio.service.ForegroundServiceNotification
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.newMainPendingIntent
/** The notification responsible for showing the indexer state. */
/**
* A dynamic [ForegroundServiceNotification] that shows the current music loading state.
* @param context [Context] required to create the notification.
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexingNotification(private val context: Context) :
ServiceNotification(context, INDEXER_CHANNEL) {
ForegroundServiceNotification(context, INDEXER_CHANNEL) {
private var lastUpdateTime = -1L
init {
setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_PROGRESS)
@ -44,32 +51,50 @@ class IndexingNotification(private val context: Context) :
override val code: Int
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
/**
* Update this notification with the new music loading state.
* @param indexing The new music loading state to display in the notification.
* @return true if the notification updated, false otherwise
*/
fun updateIndexingState(indexing: Indexer.Indexing): Boolean {
when (indexing) {
is Indexer.Indexing.Indeterminate -> {
// Indeterminate state, use a vaguer description and in-determinate progress.
// These events are not very frequent, and thus we don't need to safeguard
// against rate limiting.
logD("Updating state to $indexing")
lastUpdateTime = -1
setContentText(context.getString(R.string.lng_indexing))
setProgress(0, 0, true)
return true
}
is Indexer.Indexing.Songs -> {
// Only update the notification every 50 songs to prevent excessive updates.
if (indexing.current % 50 == 0) {
logD("Updating state to $indexing")
setContentText(
context.getString(R.string.fmt_indexing, indexing.current, indexing.total))
setProgress(indexing.total, indexing.current, false)
return true
// Determinate state, show an active progress meter. Since these updates arrive
// highly rapidly, only update every 1.5 seconds to prevent notification rate
// limiting.
// TODO: Can I port this to the playback notification somehow?
val now = SystemClock.elapsedRealtime()
if (lastUpdateTime > -1 && (now - lastUpdateTime) < 1500) {
return false
}
lastUpdateTime = SystemClock.elapsedRealtime()
logD("Updating state to $indexing")
setContentText(
context.getString(R.string.fmt_indexing, indexing.current, indexing.total))
setProgress(indexing.total, indexing.current, false)
return true
}
}
return false
}
}
/** The notification responsible for showing the indexer state. */
class ObservingNotification(context: Context) : ServiceNotification(context, INDEXER_CHANNEL) {
/**
* A static [ForegroundServiceNotification] that signals to the user that the app is currently
* monitoring the music library for changes.
* @author Alexander Capehart (OxygenCobalt)
*/
class ObservingNotification(context: Context) :
ForegroundServiceNotification(context, INDEXER_CHANNEL) {
init {
setSmallIcon(R.drawable.ic_indexer_24)
setCategory(NotificationCompat.CATEGORY_SERVICE)
@ -85,6 +110,7 @@ class ObservingNotification(context: Context) : ServiceNotification(context, IND
get() = IntegerTable.INDEXER_NOTIFICATION_CODE
}
/** Notification channel shared by [IndexingNotification] and [ObservingNotification]. */
private val INDEXER_CHANNEL =
ServiceNotification.ChannelInfo(
ForegroundServiceNotification.ChannelInfo(
id = BuildConfig.APPLICATION_ID + ".channel.INDEXER", nameRes = R.string.lbl_indexer)

View file

@ -20,7 +20,10 @@ package org.oxycblt.auxio.music.system
import android.app.Service
import android.content.Intent
import android.database.ContentObserver
import android.os.*
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.provider.MediaStore
import coil.imageLoader
import kotlinx.coroutines.CoroutineScope
@ -30,60 +33,61 @@ import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.system.ForegroundManager
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
/**
* A [Service] that handles the music loading process.
* A [Service] that manages the background music loading process.
*
* Loading music is actually somewhat time-consuming, to the point where it's likely better suited
* to a service that is less likely to be killed by the OS.
* Loading music is a time-consuming process that would likely be killed by the system before it
* could complete if ran anywhere else. So, this [Service] manages the music loading process as an
* instance of [Indexer.Controller].
*
* You could probably do the same using WorkManager and the GooberQueue library or whatever, but the
* boilerplate you skip is not worth the insanity of androidx.
* This [Service] also handles automatic rescanning, as that is a similarly long-running background
* operation that would be unsuitable elsewhere in the app.
*
* @author OxygenCobalt
* TODO: Unify with PlaybackService as part of the service independence project
*
* @author Alexander Capehart (OxygenCobalt)
*/
class IndexerService : Service(), Indexer.Controller, Settings.Callback {
private val indexer = Indexer.getInstance()
private val musicStore = MusicStore.getInstance()
private val playbackManager = PlaybackStateManager.getInstance()
private val serviceJob = Job()
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
private val playbackManager = PlaybackStateManager.getInstance()
private var currentIndexJob: Job? = null
private lateinit var foregroundManager: ForegroundManager
private lateinit var indexingNotification: IndexingNotification
private lateinit var observingNotification: ObservingNotification
private lateinit var settings: Settings
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var indexerContentObserver: SystemContentObserver
private lateinit var settings: Settings
override fun onCreate() {
super.onCreate()
// Initialize the core service components first.
foregroundManager = ForegroundManager(this)
indexingNotification = IndexingNotification(this)
observingNotification = ObservingNotification(this)
wakeLock =
getSystemServiceCompat(PowerManager::class)
.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, BuildConfig.APPLICATION_ID + ":IndexerService")
settings = Settings(this, this)
// Initialize any listener-dependent components last as we wouldn't want a listener race
// condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver()
settings = Settings(this, this)
indexer.registerController(this)
// An indeterminate indexer and a missing library implies we are extremely early
// in app initialization so start loading music.
if (musicStore.library == null && indexer.isIndeterminate) {
logD("No library present and no previous response, indexing music now")
onStartIndexing()
onStartIndexing(true)
}
logD("Service created.")
@ -95,28 +99,29 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onDestroy() {
super.onDestroy()
// De-initialize core service components first.
foregroundManager.release()
wakeLock.releaseSafe()
// De-initialize the components first to prevent stray reloading events
settings.release()
// Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur.
indexerContentObserver.release()
settings.release()
indexer.unregisterController(this)
// Then cancel the other components.
indexer.cancelLast()
// Then cancel any remaining music loading jobs.
serviceJob.cancel()
indexer.reset()
}
// --- CONTROLLER CALLBACKS ---
override fun onStartIndexing() {
override fun onStartIndexing(withCache: Boolean) {
if (indexer.isIndexing) {
indexer.cancelLast()
// Cancel the previous music loading job.
currentIndexJob?.cancel()
indexer.reset()
}
indexScope.launch { indexer.index(this@IndexerService) }
// Start a new music loading job on a co-routine.
currentIndexJob = indexScope.launch { indexer.index(this@IndexerService, withCache) }
}
override fun onIndexerStateChanged(state: Indexer.State?) {
@ -125,28 +130,23 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
if (state.response is Indexer.Response.Ok &&
state.response.library != musicStore.library) {
logD("Applying new library")
val newLibrary = state.response.library
// We only care if the newly-loaded library is going to replace a previously
// loaded library.
if (musicStore.library != null) {
// This is a new library to replace an existing one.
// Wipe possibly-invalidated album covers
// Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected
// to a callback as it is bad practice for a shared object to attach to
// the callback system of another.
// to a listener as it is bad practice for a shared object to attach to
// the listener system of another.
playbackManager.sanitize(newLibrary)
}
musicStore.updateLibrary(newLibrary)
// Forward the new library to MusicStore to continue the update process.
musicStore.library = newLibrary
}
// On errors, while we would want to show a notification that displays the
// error, in practice that comes into conflict with the upcoming Android 13
// notification permission, and there is no point implementing permission
// on-boarding for such when it will only be used for this.
// error, that requires the Android 13 notification permission, which is not
// handled right now.
updateIdleSession()
}
is Indexer.State.Indexing -> {
@ -163,21 +163,29 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// --- INTERNAL ---
/**
* Update the current state to "Active", in which the service signals that music loading is
* on-going.
* @param state The current music loading state.
*/
private fun updateActiveSession(state: Indexer.Indexing) {
// When loading, we want to enter the foreground state so that android does
// not shut off the loading process. Note that while we will always post the
// notification when initially starting, we will not update the notification
// unless it indicates that we have changed it.
// unless it indicates that it has changed.
val changed = indexingNotification.updateIndexingState(state)
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
logD("Notification changed, re-posting notification")
indexingNotification.post()
}
// Make sure we can keep the CPU on while loading music
wakeLock.acquireSafe()
}
/**
* Update the current state to "Idle", in which it either does nothing or signals that it's
* currently monitoring the music library for changes.
*/
private fun updateIdleSession() {
if (settings.shouldBeObserving) {
// There are a few reasons why we stay in the foreground with automatic rescanning:
@ -186,27 +194,34 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
// we can go foreground later.
// 2. If a non-foreground service is killed, the app will probably still be alive,
// and thus the music library will not be updated at all.
// TODO: Assuming I unify this with PlaybackService, it's possible that I won't need
// this anymore.
if (!foregroundManager.tryStartForeground(observingNotification)) {
observingNotification.post()
}
} else {
// Not observing and done loading, exit foreground.
foregroundManager.tryStopForeground()
}
// Release our wake lock (if we were using it)
wakeLock.releaseSafe()
}
/** Utility to safely acquire a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.acquireSafe() {
// Avoid unnecessary acquire calls.
if (!wakeLock.isHeld) {
logD("Acquiring wake lock")
// We always drop the wakelock eventually. Timeout is not needed.
@Suppress("WakelockTimeout") acquire()
// Time out after a minute, which is the average music loading time for a medium-sized
// library. If this runs out, we will re-request the lock, and if music loading is
// shorter than the timeout, it will be released early.
acquire(WAKELOCK_TIMEOUT_MS)
}
}
/** Utility to safely release a [PowerManager.WakeLock] without crashes/inefficiency. */
private fun PowerManager.WakeLock.releaseSafe() {
// Avoid unnecessary release calls.
if (wakeLock.isHeld) {
logD("Releasing wake lock")
release()
@ -217,10 +232,16 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onSettingChanged(key: String) {
when (key) {
// Hook changes in music settings to a new music loading event.
getString(R.string.set_key_exclude_non_music),
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_quality_tags) -> onStartIndexing()
getString(R.string.set_key_separators) -> onStartIndexing(true)
getString(R.string.set_key_observing) -> {
// Make sure we don't override the service state with the observing
// notification if we were actively loading when the automatic rescanning
// setting changed. In such a case, the state will still be updated when
// the music loading process ends.
if (!indexer.isIndexing) {
updateIdleSession()
}
@ -228,35 +249,46 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
}
}
/** Internal content observer intended to work with the automatic reloading system. */
private inner class SystemContentObserver(
private val handler: Handler = Handler(Looper.getMainLooper())
) : ContentObserver(handler), Runnable {
/**
* A [ContentObserver] that observes the [MediaStore] music database for changes, a behavior
* known to the user as automatic rescanning. The active (and not passive) nature of observing
* the database is what requires [IndexerService] to stay foreground when this is enabled.
*/
private inner class SystemContentObserver :
ContentObserver(Handler(Looper.getMainLooper())), Runnable {
private val handler = Handler(Looper.getMainLooper())
init {
contentResolverSafe.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, this)
}
/**
* Release this instance, preventing it from further observing the database and cancelling
* any pending update events.
*/
fun release() {
handler.removeCallbacks(this)
contentResolverSafe.unregisterContentObserver(this)
}
override fun onChange(selfChange: Boolean) {
// Batch rapid-fire updates to the library into a single call to run after 500ms
handler.removeCallbacks(this)
handler.postDelayed(this, REINDEX_DELAY)
handler.postDelayed(this, REINDEX_DELAY_MS)
}
override fun run() {
// Check here if we should even start a reindex. This is much less bug-prone than
// registering and de-registering this component as this setting changes.
if (settings.shouldBeObserving) {
onStartIndexing()
onStartIndexing(true)
}
}
}
companion object {
const val REINDEX_DELAY = 500L
private const val WAKELOCK_TIMEOUT_MS = 60 * 1000L
private const val REINDEX_DELAY_MS = 500L
}
}

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.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
import org.oxycblt.auxio.music.Song
@ -29,17 +27,15 @@ import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.msToDs
/**
* A fragment showing the current playback state in a compact manner. Used as the bar for the
* playback sheet.
* @author OxygenCobalt
* A [ViewBindingFragment] that shows the current playback state in a compact manner.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
@ -52,30 +48,47 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
binding: FragmentPlaybackBarBinding,
savedInstanceState: Bundle?
) {
super.onBindingCreated(binding, savedInstanceState)
val context = requireContext()
// --- UI SETUP ---
binding.root.apply {
setOnClickListener { navModel.mainNavigateTo(MainNavigationAction.Expand) }
setOnLongClickListener {
playbackModel.song.value?.let(navModel::exploreNavigateTo)
true
}
}
// Set up marquee on song information
binding.playbackSong.isSelected = true
binding.playbackInfo.isSelected = true
// Set up actions
binding.playbackPlayPause.setOnClickListener { playbackModel.toggleIsPlaying() }
setupSecondaryActions(binding, Settings(context))
// Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources
// using a ColorStateList in the resources.
binding.playbackProgressBar.trackColor =
context.getColorCompat(R.color.sel_track).defaultColor
binding.playbackPlayPause.setOnClickListener { playbackModel.invertPlaying() }
// -- VIEWMODEL SETUP ---
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.isPlaying, ::updatePlaying)
collectImmediately(playbackModel.positionDs, ::updatePosition)
}
// Update the secondary action to match the setting.
override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) {
super.onDestroyBinding(binding)
// Marquee elements leak if they are not disabled when the views are destroyed.
binding.playbackSong.isSelected = false
binding.playbackInfo.isSelected = false
}
when (Settings(context).barAction) {
BarAction.NEXT -> {
private fun setupSecondaryActions(binding: FragmentPlaybackBarBinding, settings: Settings) {
when (settings.playbackBarAction) {
ActionMode.NEXT -> {
binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.ic_skip_next_24)
contentDescription = getString(R.string.desc_skip_next)
@ -83,35 +96,24 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
setOnClickListener { playbackModel.next() }
}
}
BarAction.REPEAT -> {
ActionMode.REPEAT -> {
binding.playbackSecondaryAction.apply {
contentDescription = getString(R.string.desc_change_repeat)
iconTint = context.getColorCompat(R.color.sel_accented)
setOnClickListener { playbackModel.incrementRepeatMode() }
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
setOnClickListener { playbackModel.toggleRepeatMode() }
collectImmediately(playbackModel.repeatMode, ::updateRepeat)
}
}
BarAction.SHUFFLE -> {
ActionMode.SHUFFLE -> {
binding.playbackSecondaryAction.apply {
setIconResource(R.drawable.sel_shuffle_state_24)
contentDescription = getString(R.string.desc_shuffle)
iconTint = context.getColorCompat(R.color.sel_accented)
iconTint = context.getColorCompat(R.color.sel_activatable_icon)
setOnClickListener { playbackModel.invertShuffled() }
collectImmediately(playbackModel.isShuffled, ::updateShuffled)
}
}
}
// -- VIEWMODEL SETUP ---
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.isPlaying, ::updateIsPlaying)
collectImmediately(playbackModel.positionDs, ::updatePosition)
}
override fun onDestroyBinding(binding: FragmentPlaybackBarBinding) {
super.onDestroyBinding(binding)
binding.playbackSong.isSelected = false
}
private fun updateSong(song: Song?) {
@ -120,18 +122,19 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
val binding = requireBinding()
binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context)
binding.playbackInfo.text = song.resolveIndividualArtistName(context)
binding.playbackInfo.text = song.resolveArtistContents(context)
binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
}
}
private fun updateIsPlaying(isPlaying: Boolean) {
private fun updatePlaying(isPlaying: Boolean) {
requireBinding().playbackPlayPause.isActivated = isPlaying
}
private fun updateRepeat(repeatMode: RepeatMode) {
requireBinding().playbackSecondaryAction.apply {
setIconResource(repeatMode.icon)
// Icon tinting is controlled through isActivated, so update that flag as well.
isActivated = repeatMode != RepeatMode.NONE
}
}
@ -144,21 +147,3 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
requireBinding().playbackProgressBar.progress = positionDs.toInt()
}
}
/** Represents the action that should be shown on the playback bar. */
enum class BarAction {
NEXT,
REPEAT,
SHUFFLE;
companion object {
/** Convert an int [code] into an instance, or null if it isn't valid. */
fun fromIntCode(code: Int) =
when (code) {
IntegerTable.BAR_ACTION_NEXT -> NEXT
IntegerTable.BAR_ACTION_REPEAT -> REPEAT
IntegerTable.BAR_ACTION_SHUFFLE -> SHUFFLE
else -> null
}
}
}

View file

@ -25,17 +25,16 @@ import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AuxioSheetBehavior
import org.oxycblt.auxio.ui.BaseBottomSheetBehavior
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
/**
* The coordinator layout behavior used for the playback sheet, hacking in the many fixes required
* to make bottom sheets like this work.
* @author OxygenCobalt
* The [BaseBottomSheetBehavior] for the playback bottom sheet. This bottom sheet
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
AuxioSheetBehavior<V>(context, attributeSet) {
class PlaybackBottomSheetBehavior<V : View>(context: Context, attributeSet: AttributeSet?) :
BaseBottomSheetBehavior<V>(context, attributeSet) {
val sheetBackgroundDrawable =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(R.attr.colorSurface)
@ -57,6 +56,8 @@ class PlaybackSheetBehavior<V : View>(context: Context, attributeSet: AttributeS
override fun createBackground(context: Context) =
LayerDrawable(
arrayOf(
// Add another colored background so that there is always an obscuring
// element even as the actual "background" element is faded out.
MaterialShapeDrawable(sheetBackgroundDrawable.shapeAppearanceModel).apply {
fillColor = sheetBackgroundDrawable.fillColor
},

View file

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

Some files were not shown because too many files have changed in this diff Show more