diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 32e9ccd9a..76abe25e7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.md b/.github/ISSUE_TEMPLATE/bug-crash-report.md deleted file mode 100644 index 2c93a498a..000000000 --- a/.github/ISSUE_TEMPLATE/bug-crash-report.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: Bug/Crash Report -about: Report an issue with Auxio -title: '' -labels: bug -assignees: '' - ---- - -#### Describe the bug/crash: - - -#### Expected behavior - - -#### Steps To Reproduce the bug/crash: - - -#### Logs/Stack Traces: - - -#### Screenshots: - - -#### Phone Information: - - -#### 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). \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-crash-report.yml b/.github/ISSUE_TEMPLATE/bug-crash-report.yml new file mode 100644 index 000000000..614c45a41 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-crash-report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index 24e890b14..000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: Feature Request -about: Propose an idea for Auxio -title: '' -labels: enhancement -assignees: '' - ---- - - - -#### Describe the feature you want to implement: - - -#### Is your feature request related to a problem? Please describe: - - -#### Do other music players handle this? If so, how? - - -#### Why do you think this will improve everyone's usage of Auxio? - - -#### 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). \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..2b2c779a8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1b639fe4b..766444e74 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,7 +3,6 @@ #### What is it? - [ ] Bugfix (user facing) - [ ] Feature (user facing) -- [ ] Translation to: (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. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c8fc2d1b..7cc7f6db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,57 @@ ## dev +## 3.0.0 + +#### What's New +- Added multi-value tags support + - Added support for multiple artists + - Added support for multiple genres +- Artists and album artists are now both given UI entires + - Added setting to hide "collaborator" artists +- Upgraded music ID management: + - Added support for MusicBrainz IDs (MBIDs) + - Use the more unique MD5 hash of metadata when MBIDs can't be used +- Genres now display a list of artists +- Added toggle to load non-music (Such as podcasts) +- Music loader now caches parsed metadata for faster load times +- Redesigned icon + - Added animated splash screen on Android 12+ +- Added support for MP4 ReplayGain (`----`) atoms + +#### What's Improved +- Sorting now takes accented characters into account +- Added support for compilation sub-release-types like (DJ) Mix +- Album dates now start from the earliest date instead of latest date +- Reshuffling the queue will no longer drop any songs you have added/removed +- Allowed light/dark theme to be customized on Android 12+ +- All information now scrolls in the playback view +- A month is now shown for song/album dates when available +- Added loading indicator to song properties view +- List items have been made more compact + +#### What's Fixed +- Fixed issue where the scroll popup would not display correctly in landscape mode [#230] +- Fixed issue where the playback progress would continue in the notification when +audio focus was lost +- Fixed issue where the artist name would not be shown in the OS audio switcher menu +- Fixed issue where the search view would not update if the library changed +- Fixed visual bug with transitions in the black theme +- Fixed toolbar flickering when fast-scrolling in the home UI + +#### What's Changed +- Ignore MediaStore tags is now Auxio's default and unchangeable behavior. The option has been removed. +- Removed the "Play from genre" option in the library/detail playback mode settings+ +- "Use alternate notification action" is now "Custom notification action" +- "Show covers" and "Ignore MediaStore covers" have been unified into "Album covers" + +#### Dev/Meta +- Created new wiki with more information about app functionality +- Switched to issue forms +- Completed migration to reactive playback system +- Refactor music backends into a unified chain of extractors +- Add bluetooth connection receiver (No functionality in app yet) + ## 2.6.4 #### What's Fixed diff --git a/README.md b/README.md index b431e1a3d..d1b95cc36 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases @@ -13,7 +13,7 @@ Minimum SDK Version

-

Changelog | FAQ | Licenses | Contributing | Architecture

+

Changelog | Wiki

Translation status @@ -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 Exoplayer, 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 Exoplayer, 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). diff --git a/app/build.gradle b/app/build.gradle index 2c6afb0da..ef8f54ab5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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") } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 200b25bd5..f3c0f68db 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7f31072e4..26eacc16f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,11 @@ + + + + + - + + + + + + + + + + extends CoordinatorLayout.Behavior { - /** 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 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 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 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); diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt index 29827448d..1e645d506 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt @@ -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" } } diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 75b8a01e1..e2cbcb5ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt index e57bd5da1..f33f9d2b1 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt @@ -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" } diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index fdad06639..e66ef86cc 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -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(), ViewTreeObserver.OnPreDrawListener { + ViewBindingFragment(), + 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 } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index a1ddf6b68..1d3d4f9f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -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(), - Toolbar.OnMenuItemClickListener, - AlbumDetailAdapter.Listener { +class AlbumDetailFragment : ListFragment(), 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) { + detailAdapter.setSelectedItems(selected) + requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 501d218ff..e1d103861 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -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(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener { +class ArtistDetailFragment : ListFragment(), 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) { + detailAdapter.setSelectedItems(selected) + requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt new file mode 100644 index 000000000..639687f45 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt @@ -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 . + */ + +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 + ) +} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt index de6ebe883..8e4a4ed6d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt @@ -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(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(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") } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index b024729ea..f350fcba1 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -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(null) + /** + * The current [DetailSong] to display. Null if there is nothing to show. + * + * TODO: De-couple Song and Properties? + */ val currentSong: StateFlow get() = _currentSong + // --- ALBUM --- + private val _currentAlbum = MutableStateFlow(null) + /** The current [Album] to display. Null if there is nothing to show. */ val currentAlbum: StateFlow get() = _currentAlbum - private val _albumData = MutableStateFlow(listOf()) - val albumData: StateFlow> - get() = _albumData + private val _albumList = MutableStateFlow(listOf()) + /** The current list data derived from [currentAlbum]. */ + val albumList: StateFlow> + 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(null) + /** The current [Artist] to display. Null if there is nothing to show. */ val currentArtist: StateFlow get() = _currentArtist - private val _artistData = MutableStateFlow(listOf()) - val artistData: StateFlow> = _artistData + private val _artistList = MutableStateFlow(listOf()) + /** The current list derived from [currentArtist]. */ + val artistList: StateFlow> = _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(null) + /** The current [Genre] to display. Null if there is nothing to show. */ val currentGenre: StateFlow get() = _currentGenre - private val _genreData = MutableStateFlow(listOf()) - val genreData: StateFlow> = _genreData + private val _genreList = MutableStateFlow(listOf()) + /** The current list data derived from [currentGenre]. */ + val genreList: StateFlow> = _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(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(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(uid).also { refreshGenreList(it) } + } + + private fun 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(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(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(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() -} diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 920bdec3f..bfeca52c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -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(), Toolbar.OnMenuItemClickListener, DetailAdapter.Listener { +class GenreDetailFragment : ListFragment(), 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) { + detailAdapter.setSelectedItems(selected) + requireBinding().detailSelectionToolbar.updateSelectionAmount(selected.size) } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt index 3a87850d1..327038255 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index a49d94374..e7444ab16 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -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() { 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() { 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 } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt index 4070f4950..8d1eb25ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt @@ -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(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 - ) { - 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() { - 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() { - 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() { - 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() { - override fun areItemsTheSame(oldItem: Song, newItem: Song) = + override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.durationMs == newItem.durationMs } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt index 9c22ec80d..12b9b2fd4 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt @@ -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(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 - ) { - 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() { - 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() { + 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() { - 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() { - override fun areItemsTheSame(oldItem: Song, newItem: Song) = + override fun areContentsTheSame(oldItem: Song, newItem: Song) = oldItem.rawName == newItem.rawName && oldItem.album.rawName == newItem.album.rawName } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt index 5fd7c8f82..dcb21b67a 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt @@ -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( - private val listener: L, - diffCallback: DiffUtil.ItemCallback -) : IndicatorAdapter(), 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 +) : SelectionIndicatorAdapter(), 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( 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 - ) { - 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 get() = differ.currentList - fun submitList(list: List) { - 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) { + 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() { - 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() { - override fun areItemsTheSame(oldItem: SortHeader, newItem: SortHeader) = - oldItem.string == newItem.string + override fun areContentsTheSame(oldItem: SortHeader, newItem: SortHeader) = + oldItem.titleRes == newItem.titleRes } } } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt index 1121874a6..6d6326dc7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/GenreDetailAdapter.kt @@ -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(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 - ) { - 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() { - 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() { - 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 diff --git a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt deleted file mode 100644 index 9538bbe95..000000000 --- a/app/src/main/java/org/oxycblt/auxio/home/AdaptiveTabStrategy.kt +++ /dev/null @@ -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 . - */ - -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) - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt b/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt index ab26b0967..87032bfe6 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index af7135d72..3e6795c5a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -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(), Toolbar.OnMenuItemClickListener { - private val playbackModel: PlaybackViewModel by androidActivityViewModels() - private val navModel: NavigationViewModel by activityViewModels() +class HomeFragment : + SelectionFragment(), 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 by lifecycleObject { registerForActivityResult(ActivityResultContracts.RequestPermission()) { - musicModel.reindex() + musicModel.refresh() } } @@ -86,28 +93,21 @@ class HomeFragment : ViewBindingFragment(), 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(), 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(), 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(), 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(), 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(), 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(), Toolbar.OnMenuI } } - private fun updateFab(hasLoaded: Boolean, isFastScrolling: Boolean) { + private fun updateFab(songs: List, 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(), 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) { + 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(), 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, + 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" diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index 5d3154333..d6be75fd4 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -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()) - val songs: StateFlow> - get() = _songs + private val _songsList = MutableStateFlow(listOf()) + /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ + val songLists: StateFlow> + get() = _songsList - private val _albums = MutableStateFlow(listOf()) - val albums: StateFlow> - get() = _albums + private val _albumsLists = MutableStateFlow(listOf()) + /** A list of [Album]s, sorted by the preferred [Sort], to be shown in the home view. */ + val albumsList: StateFlow> + get() = _albumsLists - private val _artists = MutableStateFlow(listOf()) - val artists: MutableStateFlow> - get() = _artists + private val _artistsList = MutableStateFlow(listOf()) + /** + * 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> + get() = _artistsList - private val _genres = MutableStateFlow(listOf()) - val genres: StateFlow> - get() = _genres - - var tabs: List = visibleTabs - private set - - /** Internal getter for getting the visible library tabs */ - private val visibleTabs: List - get() = settings.libTabs.filterIsInstance().map { it.mode } - - private val _currentTab = MutableStateFlow(tabs[0]) - val currentTab: StateFlow = _currentTab + private val _genresList = MutableStateFlow(listOf()) + /** A list of [Genre]s, sorted by the preferred [Sort], to be shown in the home view. */ + val genresList: StateFlow> + 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 = _shouldRecreateTabs + var currentTabModes: List = makeTabModes() + private set + + private val _currentTabMode = MutableStateFlow(currentTabModes[0]) + /** The [MusicMode] of the currently shown [Tab]. */ + val currentTabMode: StateFlow = _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 = _shouldRecreate private val _isFastScrolling = MutableStateFlow(false) + /** A marker for whether the user is fast-scrolling in the home view or not. */ val isFastScrolling: StateFlow = _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().map { it.mode } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/fastscroll/FastScrollPopupView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt similarity index 88% rename from app/src/main/java/org/oxycblt/auxio/ui/fastscroll/FastScrollPopupView.kt rename to app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt index e5b07584d..ba1de4483 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/fastscroll/FastScrollPopupView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollPopupView.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/ui/fastscroll/FastScrollRecyclerView.kt rename to app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index 1721fde0e..4add92475 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index 6be4243b3..f37309f84 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -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() { - private val homeAdapter = AlbumAdapter(this) - private val formatterSb = StringBuilder(32) +class AlbumListFragment : + ListFragment(), + 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() { } } - 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() { - 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() { + private val differ = SyncListDiffer(this, AlbumViewHolder.DIFF_CALLBACK) override val currentList: List 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) { - 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) { differ.replaceList(newList) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 4c5a77945..29fecfd17 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -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() { +class ArtistListFragment : + ListFragment(), + 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() { - 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() { + private val differ = SyncListDiffer(this, ArtistViewHolder.DIFF_CALLBACK) override val currentList: List 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 - ) { - 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) { differ.replaceList(newList) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index c51c1ab3a..1019a85eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -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() { +class GenreListFragment : + ListFragment(), + 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() { } } - 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() { - 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() { + private val differ = SyncListDiffer(this, GenreViewHolder.DIFF_CALLBACK) override val currentList: List 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) { - 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) { differ.replaceList(newList) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt deleted file mode 100644 index c95ccfed2..000000000 --- a/app/src/main/java/org/oxycblt/auxio/home/list/HomeListFragment.kt +++ /dev/null @@ -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 . - */ - -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 : - MenuFragment(), - 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) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 37f0f3a72..c040761fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -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() { +class SongListFragment : + ListFragment(), + 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() { } } - 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() { - 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() { + private val differ = SyncListDiffer(this, SongViewHolder.DIFF_CALLBACK) override val currentList: List 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) { - 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) { differ.replaceList(newList) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt new file mode 100644 index 000000000..d1a97ba58 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/AdaptiveTabStrategy.kt @@ -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 . + */ + +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) : + 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) + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt index 3108bcd4b..76f7cf95d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt @@ -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): 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): 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? { + /** + * 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? { val tabs = mutableListOf() // 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 diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt index 7ab2c9df0..10a459d5c 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabAdapter.kt @@ -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() { + /** The current array of [Tab]s. */ var tabs = arrayOf() 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) { 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)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt index 387250530..0bb712767 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt @@ -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(), 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(), 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(), 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(), 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().isNotEmpty() } - override fun onPickUpTab(viewHolder: RecyclerView.ViewHolder) { + override fun onPickUp(viewHolder: RecyclerView.ViewHolder) { touchHelper.startDrag(viewHolder) } - private fun findSavedTabState(savedInstanceState: Bundle?): Array? { - 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" } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt index bdf71f3b6..0eeb29b1e 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabDragCallback.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt index d76a2dc0f..8bb6d83a6 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -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?) - } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt new file mode 100644 index 000000000..d1a656f4c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt @@ -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 . + */ + +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 + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt index 07313b57d..5b9e814a9 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt @@ -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() + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/IndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt similarity index 80% rename from app/src/main/java/org/oxycblt/auxio/image/IndicatorView.kt rename to app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt index f2cdceada..f5df8f9bb 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/IndicatorView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt @@ -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) - } - } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt index ed3783104..590404a6a 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -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 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 diff --git a/app/src/main/java/org/oxycblt/auxio/image/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt similarity index 55% rename from app/src/main/java/org/oxycblt/auxio/image/Components.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 00d06fe67..8c9acd412 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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 { - 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 { - 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 { 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 { 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 { 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 Collection.mapAtMost( +private inline fun Collection.mapAtMostNotNull( n: Int, transform: (T) -> R? ): List { @@ -146,11 +141,12 @@ private inline fun Collection.mapAtMost( val out = mutableListOf() 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 diff --git a/app/src/main/java/org/oxycblt/auxio/image/BaseFetcher.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt similarity index 52% rename from app/src/main/java/org/oxycblt/auxio/image/BaseFetcher.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt index e4869b70a..6b703e37f 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BaseFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt @@ -15,24 +15,10 @@ * along with this program. If not, see . */ -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, - 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) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CrossfadeTransitionFactory.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/ErrorCrossfadeTransitionFactory.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/image/CrossfadeTransitionFactory.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/ErrorCrossfadeTransitionFactory.kt index 7d15697d6..676cc53bb 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CrossfadeTransitionFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ErrorCrossfadeTransitionFactory.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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) { diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt new file mode 100644 index 000000000..df9f4ba19 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt @@ -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 . + */ + +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, + 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 + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/SquareFrameTransform.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/image/SquareFrameTransform.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt index f81fd962f..1e31a237e 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/SquareFrameTransform.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/SquareFrameTransform.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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() } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirs.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt similarity index 66% rename from app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirs.kt rename to app/src/main/java/org/oxycblt/auxio/list/Data.kt index 748bd43c1..878a6a9d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirs.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt @@ -15,9 +15,15 @@ * along with this program. If not, see . */ -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, 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 diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt new file mode 100644 index 000000000..f8071816e --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt @@ -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 . + */ + +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 : SelectionFragment(), 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() + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt new file mode 100644 index 000000000..926fb6904 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/Listeners.kt @@ -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 . + */ + +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) } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/AuxioRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt similarity index 56% rename from app/src/main/java/org/oxycblt/auxio/ui/recycler/AuxioRecyclerView.kt rename to app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt index 3edd521d5..b29e8cb7f 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/AuxioRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/AuxioRecyclerView.kt @@ -15,10 +15,9 @@ * along with this program. If not, see . */ -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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/DialogRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt similarity index 63% rename from app/src/main/java/org/oxycblt/auxio/ui/recycler/DialogRecyclerView.kt rename to app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt index 77813e9cc..6984f1c93 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/DialogRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/DialogRecyclerView.kt @@ -15,10 +15,11 @@ * along with this program. If not, see . */ -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) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt new file mode 100644 index 000000000..17b016575 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/PlayingIndicatorAdapter.kt @@ -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 . + */ + +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 : RecyclerView.Adapter() { + // 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 + + override fun getItemCount() = currentList.size + + override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { + // 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() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt new file mode 100644 index 000000000..29ecc2582 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SelectionIndicatorAdapter.kt @@ -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 . + */ + +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 : + PlayingIndicatorAdapter() { + private var selectedItems = setOf() + + override fun onBindViewHolder(holder: VH, position: Int, payloads: List) { + 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) { + 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() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt new file mode 100644 index 000000000..9a289fc88 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SimpleItemCallback.kt @@ -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 . + */ + +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 : DiffUtil.ItemCallback() { + final override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem == newItem +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/recycler/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt similarity index 69% rename from app/src/main/java/org/oxycblt/auxio/ui/recycler/Data.kt rename to app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt index d8f5b05ee..4b47c22c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/recycler/Data.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/SyncListDiffer.kt @@ -15,48 +15,17 @@ * along with this program. If not, see . */ -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( adapter: RecyclerView.Adapter<*>, @@ -141,17 +110,28 @@ class SyncListDiffer( 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) { + 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) { if (newList == currentList) { + // Nothing to do. return } @@ -159,14 +139,3 @@ class SyncListDiffer( currentList = newList } } - -/** - * A base [DiffUtil.ItemCallback] that automatically provides an implementation of - * [areContentsTheSame] any object that is derived from [Item]. - */ -abstract class SimpleItemCallback : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { - if (oldItem.javaClass != newItem.javaClass) return false - return oldItem.id == newItem.id - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt new file mode 100644 index 000000000..9f3d07805 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt @@ -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 . + */ + +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() { + 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() { + 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() { + 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() { + 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

() { + override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean = + oldItem.titleRes == newItem.titleRes + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt new file mode 100644 index 000000000..a5d762c42 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt @@ -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 . + */ + +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 : + ViewBindingFragment(), 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 + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt new file mode 100644 index 000000000..106244edd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt @@ -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 . + */ + +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 + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt new file mode 100644 index 000000000..ae6b0bb60 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -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 . + */ + +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()) + /** the currently selected items. These are ordered in earliest selected and latest selected. */ + val selected: StateFlow> + 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() } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index af0770036..aa424003c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -20,361 +20,1262 @@ package org.oxycblt.auxio.music import android.content.Context -import android.net.Uri +import android.os.Parcelable +import java.security.MessageDigest +import java.text.CollationKey +import java.text.Collator +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.UUID import kotlin.math.max -import kotlin.math.min -import org.oxycblt.auxio.BuildConfig +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.R -import org.oxycblt.auxio.ui.Sort -import org.oxycblt.auxio.ui.recycler.Item +import org.oxycblt.auxio.list.Item +import org.oxycblt.auxio.music.extractor.parseId3GenreNames +import org.oxycblt.auxio.music.extractor.parseMultiValue +import org.oxycblt.auxio.music.extractor.toUuidOrNull +import org.oxycblt.auxio.music.storage.* +import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.util.inRangeOrNull import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.unlikelyToBeNull // --- MUSIC MODELS --- -/** [Item] variant that represents a music item. */ -sealed class Music : Item() { - /** The raw name of this item. Null if unknown. */ +/** + * Abstract music data. This contains universal information about all concrete music + * implementations, such as identification information and names. + * @author Alexander Capehart (OxygenCobalt) + */ +sealed class Music : Item { + /** + * A unique identifier for this music item. + * @see UID + */ + abstract val uid: UID + + /** + * The raw name of this item as it was extracted from the file-system. Will be null if the + * item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName]. + */ abstract val rawName: String? - /** The raw sorting name of this item. Null if not present. */ + /** + * Returns a name suitable for use in the app UI. This should be favored over [rawName] in + * nearly all cases. + * @param context [Context] required to obtain placeholder text or formatting information. + * @return A human-readable string representing the name of this music. In the case that the + * item does not have a name, an analogous "Unknown X" name is returned. + */ + abstract fun resolveName(context: Context): String + + /** + * The raw sort name of this item as it was extracted from the file-system. This can be used not + * only when sorting music, but also trying to locate music based on a fuzzy search by the user. + * Will be null if the item has no known sort name. + */ abstract val rawSortName: String? /** - * The name of this item used for sorting.This should not be used outside of sorting and - * fast-scrolling. + * A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a + * semantically-correct manner. Will be null if the item has no name. + * + * The key will have the following attributes: + * - If [rawSortName] is present, this key will be derived from it. Otherwise [rawName] is used. + * - If the string begins with an article, such as "the", it will be stripped, as is usually + * convention for sorting media. This is not internationalized. */ - val sortName: String? - get() = rawSortName ?: rawName?.parseSortName() + abstract val collationKey: CollationKey? /** - * Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would - * become Unknown Artist, (124) would become its proper genre name, etc. + * Finalize this item once the music library has been fully constructed. This is where any final + * ordering or sanity checking should occur. **This function is internal to the music package. + * Do not use it elsewhere.** */ - abstract fun resolveName(context: Context): String + abstract fun _finalize() + + /** + * Provided implementation to create a [CollationKey] in the way described by [collationKey]. + * This should be used in all overrides of all [CollationKey]. + * @return A [CollationKey] that follows the specification described by [collationKey]. + */ + protected fun makeCollationKeyImpl(): CollationKey? { + val sortName = + (rawSortName ?: rawName)?.run { + 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 + } + } + + return COLLATOR.getCollationKey(sortName) + } + + // Note: We solely use the UID in comparisons so that certain items that differ in all + // but UID are treated differently. + + override fun hashCode() = uid.hashCode() + + override fun equals(other: Any?) = + other is Music && javaClass == other.javaClass && uid == other.uid + + /** + * A unique identifier for a piece of music. + * + * [UID] enables a much cheaper and more reliable form of differentiating music, derived from + * either a hash of meaningful metadata or the MusicBrainz ID spec. Using this enables several + * improvements to music management in this app, including: + * + * - Proper differentiation of identical music. It's common for large, well-tagged libraries to + * have functionally duplicate items that are differentiated with MusicBrainz IDs, and so [UID] + * allows us to properly differentiate between these in the app. + * - Better music persistence between restarts. Whereas directly storing song names would be + * prone to collisions, and storing MediaStore IDs would drift rapidly as the music library + * changes, [UID] enables a much stronger form of persistence given it's unique link to a + * specific files metadata configuration, which is unlikely to collide with another item or + * drift as the music library changes. + * + * Note: Generally try to use [UID] as a black box that can only be read, written, and compared. + * It will not be fun if you try to manipulate it in any other manner. + * + * @author Alexander Capehart (OxygenCobalt) + */ + @Parcelize + class UID + private constructor( + private val format: Format, + private val mode: MusicMode, + private val uuid: UUID + ) : Parcelable { + // Cache the hashCode for HashMap efficiency. + @IgnoredOnParcel private var hashCode = format.hashCode() + + init { + hashCode = 31 * hashCode + mode.hashCode() + hashCode = 31 * hashCode + uuid.hashCode() + } + + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is UID && format == other.format && mode == other.mode && uuid == other.uuid + + override fun toString() = "${format.namespace}:${mode.intCode.toString(16)}-$uuid" + + /** + * Internal marker of [Music.UID] format type. + * @param namespace Namespace to use in the [Music.UID]'s string representation. + */ + private enum class Format(val namespace: String) { + /** @see auxio */ + AUXIO("org.oxycblt.auxio"), + /** @see musicBrainz */ + MUSICBRAINZ("org.musicbrainz") + } + + companion object { + /** + * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, + * unlikely-to-change metadata of the music. + * @param mode The analogous [MusicMode] of the item that created this [UID]. + * @param updates Block to update the [MessageDigest] hash with the metadata of the + * item. Make sure the metadata hashed semantically aligns with the format + * specification. + * @return A new auxio-style [UID]. + */ + fun auxio(mode: MusicMode, updates: MessageDigest.() -> Unit): UID { + val digest = + MessageDigest.getInstance("SHA-256").run { + updates() + digest() + } + // Convert the digest to a UUID. This does cleave off some of the hash, but this + // is considered okay. + val uuid = + UUID( + digest[0] + .toLong() + .shl(56) + .or(digest[1].toLong().and(0xFF).shl(48)) + .or(digest[2].toLong().and(0xFF).shl(40)) + .or(digest[3].toLong().and(0xFF).shl(32)) + .or(digest[4].toLong().and(0xFF).shl(24)) + .or(digest[5].toLong().and(0xFF).shl(16)) + .or(digest[6].toLong().and(0xFF).shl(8)) + .or(digest[7].toLong().and(0xFF)), + digest[8] + .toLong() + .shl(56) + .or(digest[9].toLong().and(0xFF).shl(48)) + .or(digest[10].toLong().and(0xFF).shl(40)) + .or(digest[11].toLong().and(0xFF).shl(32)) + .or(digest[12].toLong().and(0xFF).shl(24)) + .or(digest[13].toLong().and(0xFF).shl(16)) + .or(digest[14].toLong().and(0xFF).shl(8)) + .or(digest[15].toLong().and(0xFF))) + return UID(Format.AUXIO, mode, uuid) + } + + /** + * Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID + * extracted from a file. + * @param mode The analogous [MusicMode] of the item that created this [UID]. + * @param mbid The analogous MusicBrainz ID for this item that was extracted from a + * file. + * @return A new MusicBrainz-style [UID]. + */ + fun musicBrainz(mode: MusicMode, mbid: UUID): UID = UID(Format.MUSICBRAINZ, mode, mbid) + + /** + * Convert a [UID]'s string representation back into a concrete [UID] instance. + * @param uid The [UID]'s string representation, formatted as + * `format_namespace:music_mode_int-uuid`. + * @return A [UID] converted from the string representation, or null if the string + * representation was invalid. + */ + fun fromString(uid: String): UID? { + val split = uid.split(':', limit = 2) + if (split.size != 2) { + return null + } + + val format = + when (split[0]) { + Format.AUXIO.namespace -> Format.AUXIO + Format.MUSICBRAINZ.namespace -> Format.MUSICBRAINZ + else -> return null + } + + val ids = split[1].split('-', limit = 2) + if (ids.size != 2) { + return null + } + + val mode = + MusicMode.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null + val uuid = ids[1].toUuidOrNull() ?: return null + return UID(format, mode, uuid) + } + } + } + + companion object { + /** Cached collator instance re-used with [makeCollationKeyImpl]. */ + private val COLLATOR = Collator.getInstance().apply { strength = Collator.PRIMARY } + } } /** - * [Music] variant that denotes that this object is a parent of other data objects, such as an - * [Album] or [Artist] + * An abstract grouping of [Song]s and other [Music] data. + * @author Alexander Capehart (OxygenCobalt) */ sealed class MusicParent : Music() { - /** The songs that this parent owns. */ + /** The [Song]s in this this group. */ abstract val songs: List + + // Note: Append song contents to MusicParent equality so that Groups with + // the same UID but different contents are not equal. + + override fun hashCode() = 31 * uid.hashCode() + songs.hashCode() + + override fun equals(other: Any?) = + other is MusicParent && + javaClass == other.javaClass && + uid == other.uid && + songs == other.songs } -/** The data object for a song. */ -data class Song( - override val rawName: String, - override val rawSortName: String?, - /** The path of this song. */ - val path: Path, - /** The URI linking to this song's file. */ - val uri: Uri, - /** The mime type of this song. */ - val mimeType: MimeType, - /** The size of this song (in bytes) */ - val size: Long, - /** The datetime at which this media item was added, represented as a unix timestamp. */ - val dateAdded: Long, - /** The total duration of this song, in millis. */ - val durationMs: Long, - /** The track number of this song, null if there isn't any. */ - val track: Int?, - /** The disc number of this song, null if there isn't any. */ - val disc: Int?, - /** Internal field. Do not use. */ - val _date: Date?, - /** Internal field. Do not use. */ - val _albumName: String, - /** Internal field. Do not use. */ - val _albumSortName: String?, - /** Internal field. Do not use. */ - val _albumReleaseType: ReleaseType?, - /** Internal field. Do not use. */ - val _albumCoverUri: Uri, - /** Internal field. Do not use. */ - val _artistName: String?, - /** Internal field. Do not use. */ - val _artistSortName: String?, - /** Internal field. Do not use. */ - val _albumArtistName: String?, - /** Internal field. Do not use. */ - val _albumArtistSortName: String?, - /** Internal field. Do not use. */ - val _genreName: String? -) : Music() { - override val id: Long - get() { - var result = rawName.toMusicId() - result = 31 * result + album.rawName.toMusicId() - result = 31 * result + album.artist.rawName.toMusicId() - result = 31 * result + (track ?: 0) - result = 31 * result + (disc ?: 0) - result = 31 * result + durationMs - return result - } +/** + * A song. Perhaps the foundation of the entirety of Auxio. + * @param raw The [Song.Raw] to derive the member data from. + * @param settings [Settings] to determine the artist configuration. + * @author Alexander Capehart (OxygenCobalt) + */ +class Song constructor(raw: Raw, settings: Settings) : Music() { + override val uid = + // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. + raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) } + ?: UID.auxio(MusicMode.SONGS) { + // Song UIDs are based on the raw data without parsing so that they remain + // consistent across music setting changes. Parents are not held up to the + // same standard since grouping is already inherently linked to settings. + update(raw.name) + update(raw.albumName) + update(raw.date) + update(raw.track) + update(raw.disc) + + update(raw.artistNames) + update(raw.albumArtistNames) + } + override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" } + override val rawSortName = raw.sortName + override val collationKey = makeCollationKeyImpl() override fun resolveName(context: Context) = rawName + /** The track number. Will be null if no valid track number was present in the metadata. */ + val track = raw.track + + /** The disc number. Will be null if no valid disc number was present in the metadata. */ + val disc = raw.disc + + /** The release [Date]. Will be null if no valid date was present in the metadata. */ + val date = raw.date + + /** + * The URI to the audio file that this instance was created from. This can be used to access the + * audio file in a way that is scoped-storage-safe. + */ + val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri() + + /** + * The [Path] to this audio file. This is only intended for display, [uri] should be favored + * instead for accessing the audio file. + */ + val path = + Path( + name = requireNotNull(raw.fileName) { "Invalid raw: No display name" }, + parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" }) + + /** The [MimeType] of the audio file. Only intended for display. */ + val mimeType = + MimeType( + fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" }, + fromFormat = null) + + /** The size of the audio file, in bytes. */ + val size = requireNotNull(raw.size) { "Invalid raw: No size" } + + /** The duration of the audio file, in milliseconds. */ + val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" } + + /** The date the audio file was added to the device, as a unix epoch timestamp. */ + val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" } + private var _album: Album? = null - /** The album of this song. */ + /** + * The parent [Album]. If the metadata did not specify an album, it's parent directory is used + * instead. + */ val album: Album get() = unlikelyToBeNull(_album) - private var _genre: Genre? = null - /** The genre of this song. Will be an "unknown genre" if the song does not have any. */ - val genre: Genre - get() = unlikelyToBeNull(_genre) - - /** - * The raw artist name for this song in particular. First uses the artist tag, and then falls - * back to the album artist tag (i.e parent artist name). Null if name is unknown. - */ - val individualArtistRawName: String? - get() = _artistName ?: album.artist.rawName - - /** - * Resolve the artist name for this song in particular. First uses the artist tag, and then - * falls back to the album artist tag (i.e parent artist name) - */ - fun resolveIndividualArtistName(context: Context) = - _artistName ?: album.artist.resolveName(context) - - /** Internal field. Do not use. */ - val _albumGroupingId: Long - get() { - var result = _artistGroupingName.toMusicId() - result = 31 * result + _albumName.toMusicId() - return result + private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(settings) + private val artistNames = raw.artistNames.parseMultiValue(settings) + private val artistSortNames = raw.artistSortNames.parseMultiValue(settings) + private val rawArtists = + artistNames.mapIndexed { i, name -> + Artist.Raw( + artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + artistSortNames.getOrNull(i)) } - /** Internal field. Do not use. */ - val _genreGroupingId: Long - get() = _genreName.toMusicId() + private val albumArtistMusicBrainzIds = raw.albumArtistMusicBrainzIds.parseMultiValue(settings) + private val albumArtistNames = raw.albumArtistNames.parseMultiValue(settings) + private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(settings) + private val rawAlbumArtists = + albumArtistNames.mapIndexed { i, name -> + Artist.Raw( + albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + albumArtistSortNames.getOrNull(i)) + } - /** Internal field. Do not use. */ - val _artistGroupingName: String? - get() = _albumArtistName ?: _artistName + private val _artists = mutableListOf() + /** + * The parent [Artist]s of this [Song]. Is often one, but there can be multiple if more than one + * [Artist] name was specified in the metadata. Unliked [Album], artists are prioritized for + * this field. + */ + val artists: List + get() = _artists - /** Internal field. Do not use. */ - val _artistGroupingSortName: String? - get() = - when { - _albumArtistName != null -> _albumArtistSortName - _artistName != null -> _artistSortName - else -> null + /** + * Resolves one or more [Artist]s into a single piece of human-readable names. + * @param context [Context] required for [resolveName]. formatter. + */ + fun resolveArtistContents(context: Context) = + // TODO Internationalize the list + artists.joinToString { it.resolveName(context) } + + /** + * Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only + * compare surface-level names, and not [Music.UID]s. + * @param other The [Song] to compare to. + * @return True if the [Artist] displays are equal, false otherwise + */ + fun areArtistContentsTheSame(other: Song): Boolean { + for (i in 0 until max(artists.size, other.artists.size)) { + val a = artists.getOrNull(i) ?: return false + val b = other.artists.getOrNull(i) ?: return false + if (a.rawName != b.rawName) { + return false } + } - /** Internal field. Do not use. */ - val _isMissingAlbum: Boolean - get() = _album == null - /** Internal field. Do not use. */ - val _isMissingArtist: Boolean - get() = _album?._isMissingArtist ?: true - /** Internal field. Do not use. */ - val _isMissingGenre: Boolean - get() = _genre == null + return true + } - /** Internal method. Do not use. */ + private val _genres = mutableListOf() + /** + * The parent [Genre]s of this [Song]. Is often one, but there can be multiple if more than one + * [Genre] name was specified in the metadata. + */ + val genres: List + get() = _genres + + /** + * Resolves one or more [Genre]s into a single piece human-readable names. + * @param context [Context] required for [resolveName]. + */ + fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) } + + // --- INTERNAL FIELDS --- + + /** + * The [Album.Raw] instances collated by the [Song]. This can be used to group [Song]s into an + * [Album]. **This is only meant for use within the music package.** + */ + val _rawAlbum = + Album.Raw( + mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" }, + musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(), + name = requireNotNull(raw.albumName) { "Invalid raw: No album name" }, + sortName = raw.albumSortName, + type = Album.Type.parse(raw.albumTypes.parseMultiValue(settings)), + rawArtists = + rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) }) + + /** + * The [Artist.Raw] instances collated by the [Song]. The artists of the song take priority, + * followed by the album artists. If there are no artists, this field will be a single "unknown" + * [Artist.Raw]. This can be used to group up [Song]s into an [Artist]. **This is only meant for + * use within the music package.** + */ + val _rawArtists = rawArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(Artist.Raw()) } + + /** + * The [Genre.Raw] instances collated by the [Song]. This can be used to group up [Song]s into a + * [Genre]. ID3v2 Genre names are automatically converted to their resolved names. **This is + * only meant for use within the music package.** + */ + val _rawGenres = + raw.genreNames + .parseId3GenreNames(settings) + .map { Genre.Raw(it) } + .ifEmpty { listOf(Genre.Raw()) } + + /** + * Links this [Song] with a parent [Album]. + * @param album The parent [Album] to link to. **This is only meant for use within the music + * package.** + */ fun _link(album: Album) { _album = album } - /** Internal method. Do not use. */ - fun _link(genre: Genre) { - _genre = genre + /** + * Links this [Song] with a parent [Artist]. + * @param artist The parent [Artist] to link to. **This is only meant for use within the music + * package.** + */ + fun _link(artist: Artist) { + _artists.add(artist) } + + /** + * Links this [Song] with a parent [Genre]. + * @param genre The parent [Genre] to link to. **This is only meant for use within the music + * package.** + */ + fun _link(genre: Genre) { + _genres.add(genre) + } + + override fun _finalize() { + checkNotNull(_album) { "Malformed song: No album" } + + check(_artists.isNotEmpty()) { "Malformed song: No artists" } + for (i in _artists.indices) { + // Non-destructively reorder the linked artists so that they align with + // the artist ordering within the song metadata. + // TODO: Make sure this works for artists only derived from album artists. + val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists) + val other = _artists[newIdx] + _artists[newIdx] = _artists[i] + _artists[i] = other + } + + check(_genres.isNotEmpty()) { "Malformed song: No genres" } + for (i in _genres.indices) { + // Non-destructively reorder the linked genres so that they align with + // the genre ordering within the song metadata. + val newIdx = _genres[i]._getOriginalPositionIn(_rawGenres) + val other = _genres[newIdx] + _genres[newIdx] = _genres[i] + _genres[i] = other + } + } + + /** + * Raw information about a [Song] obtained from the filesystem/Extractor instances. **This is + * only meant for use within the music package.** + */ + class Raw + constructor( + /** + * The ID of the [Song]'s audio file, obtained from MediaStore. Note that this ID is highly + * unstable and should only be used for accessing the audio file. + */ + var mediaStoreId: Long? = null, + /** @see Song.dateAdded */ + var dateAdded: Long? = null, + /** The latest date the [Song]'s audio file was modified, as a unix epoch timestamp. */ + var dateModified: Long? = null, + /** @see Song.path */ + var fileName: String? = null, + /** @see Song.path */ + var directory: Directory? = null, + /** @see Song.size */ + var size: Long? = null, + /** @see Song.durationMs */ + var durationMs: Long? = null, + /** @see Song.mimeType */ + var extensionMimeType: String? = null, + /** @see Music.UID */ + var musicBrainzId: String? = null, + /** @see Music.rawName */ + var name: String? = null, + /** @see Music.rawSortName */ + var sortName: String? = null, + /** @see Song.track */ + var track: Int? = null, + /** @see Song.disc */ + var disc: Int? = null, + /** @see Song.date */ + var date: Date? = null, + /** @see Album.Raw.mediaStoreId */ + var albumMediaStoreId: Long? = null, + /** @see Album.Raw.musicBrainzId */ + var albumMusicBrainzId: String? = null, + /** @see Album.Raw.name */ + var albumName: String? = null, + /** @see Album.Raw.sortName */ + var albumSortName: String? = null, + /** @see Album.Raw.type */ + var albumTypes: List = listOf(), + /** @see Artist.Raw.musicBrainzId */ + var artistMusicBrainzIds: List = listOf(), + /** @see Artist.Raw.name */ + var artistNames: List = listOf(), + /** @see Artist.Raw.sortName */ + var artistSortNames: List = listOf(), + /** @see Artist.Raw.musicBrainzId */ + var albumArtistMusicBrainzIds: List = listOf(), + /** @see Artist.Raw.name */ + var albumArtistNames: List = listOf(), + /** @see Artist.Raw.sortName */ + var albumArtistSortNames: List = listOf(), + /** @see Genre.Raw.name */ + var genreNames: List = listOf() + ) } -/** The data object for an album. */ -data class Album( - override val rawName: String, - override val rawSortName: String?, - /** The date this album was released. */ - val date: Date?, - /** The type of release this album has. */ - val releaseType: ReleaseType, - /** The URI for the cover image corresponding to this album. */ - val coverUri: Uri, - /** The songs of this album. */ - override val songs: List, - /** Internal field. Do not use. */ - val _artistGroupingName: String?, - /** Internal field. Do not use. */ - val _artistGroupingSortName: String? -) : MusicParent() { +/** + * An abstract release group. While it may be called an album, it encompasses other types of + * releases like singles, EPs, and compilations. + * @param raw The [Album.Raw] to derive the member data from. + * @param songs The [Song]s that are a part of this [Album]. These items will be linked to this + * [Album]. + * @author Alexander Capehart (OxygenCobalt) + */ +class Album constructor(raw: Raw, override val songs: List) : MusicParent() { + override val uid = + // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. + raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ALBUMS, it) } + ?: UID.auxio(MusicMode.ALBUMS) { + // Hash based on only names despite the presence of a date to increase stability. + // I don't know if there is any situation where an artist will have two albums with + // the exact same name, but if there is, I would love to know. + update(raw.name) + update(raw.rawArtists.map { it.name }) + } + override val rawName = raw.name + override val rawSortName = raw.sortName + override val collationKey = makeCollationKeyImpl() + override fun resolveName(context: Context) = rawName + + /** + * The earliest [Date] this album was released. Will be null if no valid date was present in the + * metadata of any [Song] + */ + val date: Date? // TODO: Date ranges? + + /** + * The [Type] of this album, signifying the type of release it actually is. Defaults to + * [Type.Album]. + */ + val type = raw.type ?: Type.Album(null) + /** + * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the + * cost of image quality. + */ + val coverUri = raw.mediaStoreId.toCoverUri() + + /** The duration of all songs in the album, in milliseconds. */ + val durationMs: Long + + /** The earliest date a song in this album was added, as a unix epoch timestamp. */ + val dateAdded: Long + init { + var earliestDate: Date? = null + var totalDuration: Long = 0 + var earliestDateAdded: Long = Long.MAX_VALUE + + // Do linking and value generation in the same loop for efficiency. for (song in songs) { song._link(this) + + if (song.date != null) { + // Since we can't really assign a maximum value for dates, we instead + // just check if the current earliest date doesn't exist and fill it + // in with the current song if that's the case. + if (earliestDate == null || song.date < earliestDate) { + earliestDate = song.date + } + } + + if (song.dateAdded < earliestDateAdded) { + earliestDateAdded = song.dateAdded + } + + totalDuration += song.durationMs + } + + date = earliestDate + durationMs = totalDuration + dateAdded = earliestDateAdded + } + + private val _artists = mutableListOf() + /** + * The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than + * one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists + * are prioritized for this field. + */ + val artists: List + get() = _artists + + /** + * Resolves one or more [Artist]s into a single piece of human-readable names. + * @param context [Context] required for [resolveName]. + */ + fun resolveArtistContents(context: Context) = artists.joinToString { it.resolveName(context) } + + /** + * Checks if the [Artist] *display* of this [Album] and another [Album] are equal. This will + * only compare surface-level names, and not [Music.UID]s. + * @param other The [Album] to compare to. + * @return True if the [Artist] displays are equal, false otherwise + */ + fun areArtistContentsTheSame(other: Album): Boolean { + for (i in 0 until max(artists.size, other.artists.size)) { + val a = artists.getOrNull(i) ?: return false + val b = other.artists.getOrNull(i) ?: return false + if (a.rawName != b.rawName) { + return false + } + } + + return true + } + + // --- INTERNAL FIELDS --- + + /** + * The [Artist.Raw] instances collated by the [Album]. The album artists of the song take + * priority, followed by the artists. If there are no artists, this field will be a single + * "unknown" [Artist.Raw]. This can be used to group up [Album]s into an [Artist]. **This is + * only meant for use within the music package.** + */ + val _rawArtists = raw.rawArtists + + /** + * Links this [Album] with a parent [Artist]. + * @param artist The parent [Artist] to link to. **This is only meant for use within the music + * package.** + */ + fun _link(artist: Artist) { + _artists.add(artist) + } + + override fun _finalize() { + check(songs.isNotEmpty()) { "Malformed album: Empty" } + check(_artists.isNotEmpty()) { "Malformed album: No artists" } + for (i in _artists.indices) { + // Non-destructively reorder the linked artists so that they align with + // the artist ordering within the song metadata. + val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists) + val other = _artists[newIdx] + _artists[newIdx] = _artists[i] + _artists[i] = other } } - override val id: Long - get() { - var result = rawName.toMusicId() - result = 31 * result + artist.rawName.toMusicId() - result = 31 * result + (date?.year ?: 0) - return result + /** + * The type of release an [Album] is considered. This includes EPs, Singles, Compilations, etc. + * + * This class is derived from the MusicBrainz Release Group Type specification. It can be found + * at: https://musicbrainz.org/doc/Release_Group/Type + * @author Alexander Capehart (OxygenCobalt) + */ + sealed class Type { + /** + * A specification of what kind of performance this release is. If null, the release is + * considered "Plain". + */ + abstract val refinement: Refinement? + + /** The string resource corresponding to the name of this release type to show in the UI. */ + abstract val stringRes: Int + + /** + * A plain album. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class Album(override val refinement: Refinement?) : Type() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_album + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_album_live + Refinement.REMIX -> R.string.lbl_album_remix + } } - override fun resolveName(context: Context) = rawName + /** + * A "Extended Play", or EP. Usually a smaller release consisting of 4-5 songs. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class EP(override val refinement: Refinement?) : Type() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_ep + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_ep_live + Refinement.REMIX -> R.string.lbl_ep_remix + } + } - private var _artist: Artist? = null - /** The parent artist of this album. */ - val artist: Artist - get() = unlikelyToBeNull(_artist) + /** + * A single. Usually a release consisting of 1-2 songs. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class Single(override val refinement: Refinement?) : Type() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_single + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_single_live + Refinement.REMIX -> R.string.lbl_single_remix + } + } - /** The earliest date a song in this album was added. */ - val dateAdded = songs.minOf { it.dateAdded } + /** + * A compilation. Usually consists of many songs from a variety of artists. + * @param refinement A specification of what kind of performance this release is. If null, + * the release is considered "Plain". + */ + data class Compilation(override val refinement: Refinement?) : Type() { + override val stringRes: Int + get() = + when (refinement) { + null -> R.string.lbl_compilation + // If present, include the refinement in the name of this release type. + Refinement.LIVE -> R.string.lbl_compilation_live + Refinement.REMIX -> R.string.lbl_compilation_remix + } + } - val durationMs: Long - get() = songs.sumOf { it.durationMs } + /** + * A soundtrack. Similar to a [Compilation], but created for a specific piece of (usually + * visual) media. + */ + object Soundtrack : Type() { + override val refinement: Refinement? + get() = null - /** Internal field. Do not use. */ - val _artistGroupingId: Long - get() = _artistGroupingName.toMusicId() + override val stringRes: Int + get() = R.string.lbl_soundtrack + } - /** Internal field. Do not use. */ - val _isMissingArtist: Boolean - get() = _artist == null + /** + * A (DJ) Mix. These are usually one large track consisting of the artist playing several + * sub-tracks with smooth transitions between them. + */ + object Mix : Type() { + override val refinement: Refinement? + get() = null - /** Internal method. Do not use. */ - fun _link(artist: Artist) { - _artist = artist + override val stringRes: Int + get() = R.string.lbl_mix + } + + /** + * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or + * a future release. + */ + object Mixtape : Type() { + override val refinement: Refinement? + get() = null + + override val stringRes: Int + get() = R.string.lbl_mixtape + } + + /** A specification of what kind of performance a particular release is. */ + enum class Refinement { + /** A release consisting of a live performance */ + LIVE, + + /** A release consisting of another [Artist]s remix of a prior performance. */ + REMIX + } + + companion object { + /** + * Parse a [Type] from a string formatted with the MusicBrainz Release Group Type + * specification. + * @param types A list of values consisting of valid release type values. + * @return A [Type] consisting of the given types, or null if the types were not valid. + */ + fun parse(types: List): Type? { + val primary = types.getOrNull(0) ?: return null + return when { + // Primary types should be the first types in the sequence. + primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) } + primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) } + primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) } + // The spec makes no mention of whether primary types are a pre-requisite for + // secondary types, so we assume that it's not and map oprhan secondary types + // to Album release types. + else -> types.parseSecondaryTypes(0) { Album(it) } + } + } + + /** + * Parse "secondary" types (i.e not [Album], [EP], or [Single]) from a string formatted + * with the MusicBrainz Release Group Type specification. + * @param index The index of the release type to parse. + * @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding + * to the callee's context. This is used in order to handle secondary times that are + * actually [Refinement]s. + * @return A [Type] corresponding to the secondary type found at that index. + */ + private inline fun List.parseSecondaryTypes( + index: Int, + convertRefinement: (Refinement?) -> Type + ): Type { + val secondary = getOrNull(index) + return if (secondary.equals("compilation", true)) { + // Secondary type is a compilation, actually parse the third type + // and put that into a compilation if needed. + parseSecondaryTypeImpl(getOrNull(index + 1)) { Compilation(it) } + } else { + // Secondary type is a plain value, use the original values given. + parseSecondaryTypeImpl(secondary, convertRefinement) + } + } + + /** + * Parse "secondary" types (i.e not [Album], [EP], [Single]) that do not correspond to + * any child values. + * @param type The release type value to parse. + * @param convertRefinement Code to convert a [Refinement] into a [Type] corresponding + * to the callee's context. This is used in order to handle secondary times that are + * actually [Refinement]s. + */ + private inline fun parseSecondaryTypeImpl( + type: String?, + convertRefinement: (Refinement?) -> Type + ) = + when { + // Parse all the types that have no children + type.equals("soundtrack", true) -> Soundtrack + type.equals("mixtape/street", true) -> Mixtape + type.equals("dj-mix", true) -> Mix + type.equals("live", true) -> convertRefinement(Refinement.LIVE) + type.equals("remix", true) -> convertRefinement(Refinement.REMIX) + else -> convertRefinement(null) + } + } + } + + /** + * Raw information about an [Album] obtained from the component [Song] instances. **This is only + * meant for use within the music package.** + */ + class Raw( + /** + * The ID of the [Album]'s grouping, obtained from MediaStore. Note that this ID is highly + * unstable and should only be used for accessing the system-provided cover art. + */ + val mediaStoreId: Long, + /** @see Music.uid */ + val musicBrainzId: UUID?, + /** @see Music.rawName */ + val name: String, + /** @see Music.rawSortName */ + val sortName: String?, + /** @see Album.type */ + val type: Type?, + /** @see Artist.Raw.name */ + val rawArtists: List + ) { + // Albums are grouped as follows: + // - If we have a MusicBrainz ID, only group by it. This allows different Albums with the + // same name to be differentiated, which is common in large libraries. + // - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase + // artist name. This allows for case-insensitive artist/album grouping, which can be common + // for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein"). + + // Cache the hash-code for HashMap efficiency. + private val hashCode = + musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode()) + + override fun hashCode() = hashCode + + override fun equals(other: Any?): Boolean { + if (other !is Raw) return false + if (musicBrainzId != null && + other.musicBrainzId != null && + musicBrainzId == other.musicBrainzId) { + return true + } + + return name.equals(other.name, true) && rawArtists == other.rawArtists + } } } /** - * The [MusicParent] for an *album* artist. This reflects a group of songs with the same(ish) album - * artist or artist field, not the individual performers of an artist. + * An abstract artist. These are actually a combination of the artist and album artist tags from + * within the library, derived from [Song]s and [Album]s respectively. + * @param raw The [Artist.Raw] to derive the member data from. + * @param songAlbums A list of the [Song]s and [Album]s that are a part of this [Artist], either + * through artist or album artist tags. Providing [Song]s to the artist is optional. These instances + * will be linked to this [Artist]. + * @author Alexander Capehart (OxygenCobalt) */ -data class Artist( - override val rawName: String?, - override val rawSortName: String?, - /** The albums of this artist. */ +class Artist constructor(private val raw: Raw, songAlbums: List) : MusicParent() { + override val uid = + // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. + raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ARTISTS, it) } + ?: UID.auxio(MusicMode.ARTISTS) { update(raw.name) } + override val rawName = raw.name + override val rawSortName = raw.sortName + override val collationKey = makeCollationKeyImpl() + override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) + override val songs: List + + /** + * All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist + * will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus + * included in this list. + */ val albums: List -) : MusicParent() { + + /** + * The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no + * songs. + */ + val durationMs: Long? + + /** + * Whether this artist is considered a "collaborator", i.e it is not directly credited on any + * [Album]. + */ + val isCollaborator: Boolean + init { - for (album in albums) { - album._link(this) + val distinctSongs = mutableSetOf() + val distinctAlbums = mutableSetOf() + + var noAlbums = true + + for (music in songAlbums) { + when (music) { + is Song -> { + music._link(this) + distinctSongs.add(music) + distinctAlbums.add(music.album) + } + is Album -> { + music._link(this) + distinctAlbums.add(music) + noAlbums = false + } + else -> error("Unexpected input music ${music::class.simpleName}") + } } + + songs = distinctSongs.toList() + albums = distinctAlbums.toList() + durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull() + isCollaborator = noAlbums } - override val id: Long - get() = rawName.toMusicId() + private lateinit var genres: List - override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) + /** + * Resolves one or more [Genre]s into a single piece of human-readable names. + * @param context [Context] required for [resolveName]. + */ + fun resolveGenreContents(context: Context) = genres.joinToString { it.resolveName(context) } - /** The songs of this artist. */ - override val songs = albums.flatMap { it.songs } + /** + * Checks if the [Genre] *display* of this [Artist] and another [Artist] are equal. This will + * only compare surface-level names, and not [Music.UID]s. + * @param other The [Artist] to compare to. + * @return True if the [Genre] displays are equal, false otherwise + */ + fun areGenreContentsTheSame(other: Artist): Boolean { + for (i in 0 until max(genres.size, other.genres.size)) { + val a = genres.getOrNull(i) ?: return false + val b = other.genres.getOrNull(i) ?: return false + if (a.rawName != b.rawName) { + return false + } + } - val durationMs: Long - get() = songs.sumOf { it.durationMs } + return true + } + + // --- INTERNAL METHODS --- + + /** + * Returns the original position of this [Artist]'s [Artist.Raw] within the given [Artist.Raw] + * list. This can be used to create a consistent ordering within child [Artist] lists based on + * the original tag order. + * @param rawArtists The [Artist.Raw] instances to check. It is assumed that this [Artist]'s + * [Artist.Raw] will be within the list. + * @return The index of the [Artist]'s [Artist.Raw] within the list. **This is only meant for + * use within the music package.** + */ + fun _getOriginalPositionIn(rawArtists: List) = rawArtists.indexOf(raw) + + override fun _finalize() { + check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" } + genres = + Sort(Sort.Mode.ByName, true) + .genres(songs.flatMapTo(mutableSetOf()) { it.genres }) + .sortedByDescending { genre -> songs.count { it.genres.contains(genre) } } + } + + /** + * Raw information about an [Artist] obtained from the component [Song] and [Album] instances. + * **This is only meant for use within the music package.** + */ + class Raw( + /** @see Music.UID */ + val musicBrainzId: UUID? = null, + /** @see Music.rawName */ + val name: String? = null, + /** @see Music.rawSortName */ + val sortName: String? = null + ) { + // Artists are grouped as follows: + // - If we have a MusicBrainz ID, only group by it. This allows different Artists with the + // same name to be differentiated, which is common in large libraries. + // - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist + // grouping to be case-insensitive. + + // Cache the hashCode for HashMap efficiency. + private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode() + + // Compare names and MusicBrainz IDs in order to differentiate artists with the + // same name in large libraries. + + override fun hashCode() = hashCode + + override fun equals(other: Any?): Boolean { + if (other !is Raw) return false + + if (musicBrainzId != null && + other.musicBrainzId != null && + musicBrainzId == other.musicBrainzId) { + return true + } + + return when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } + } + } } -/** The data object for a genre. */ -data class Genre(override val rawName: String?, override val songs: List) : MusicParent() { - init { - for (song in songs) { - song._link(this) - } - } - - // Sort tags don't make sense on genres - override val rawSortName: String? - get() = rawName - - override val id: Long - get() = rawName.toMusicId() - +/** + * A genre of [Song]s. + * @author Alexander Capehart (OxygenCobalt) + */ +class Genre constructor(private val raw: Raw, override val songs: List) : MusicParent() { + override val uid = UID.auxio(MusicMode.GENRES) { update(raw.name) } + override val rawName = raw.name + override val rawSortName = rawName + override val collationKey = makeCollationKeyImpl() override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) + /** The albums indirectly linked to by the [Song]s of this [Genre]. */ + val albums: List + + /** The artists indirectly linked to by the [Artist]s of this [Genre]. */ + val artists: List + + /** The total duration of the songs in this genre, in milliseconds. */ val durationMs: Long - get() = songs.sumOf { it.durationMs } -} -private fun String?.toMusicId(): Long { - if (this == null) { - // Pre-calculated hash of MediaStore.UNKNOWN_STRING - return 54493231833456 + init { + val distinctAlbums = mutableSetOf() + val distinctArtists = mutableSetOf() + var totalDuration = 0L + + for (song in songs) { + song._link(this) + distinctAlbums.add(song.album) + distinctArtists.addAll(song.artists) + totalDuration += song.durationMs + } + + albums = + Sort(Sort.Mode.ByName, true).albums(distinctAlbums).sortedByDescending { album -> + album.songs.count { it.genres.contains(this) } + } + artists = Sort(Sort.Mode.ByName, true).artists(distinctArtists) + durationMs = totalDuration } - var result = 0L - for (ch in lowercase()) { - result = 31 * result + ch.code + // --- INTERNAL METHODS --- + + /** + * Returns the original position of this [Genre]'s [Genre.Raw] within the given [Genre.Raw] + * list. This can be used to create a consistent ordering within child [Genre] lists based on + * the original tag order. + * @param rawGenres The [Genre.Raw] instances to check. It is assumed that this [Genre]'s + * [Genre.Raw] will be within the list. + * @return The index of the [Genre]'s [Genre.Raw] within the list. **This is only meant for use + * within the music package.** + */ + fun _getOriginalPositionIn(rawGenres: List) = rawGenres.indexOf(raw) + + override fun _finalize() { + check(songs.isNotEmpty()) { "Malformed genre: Empty" } + } + + /** + * Raw information about a [Genre] obtained from the component [Song] instances. **This is only + * meant for use within the music package.** + */ + class Raw( + /** @see Music.rawName */ + val name: String? = null + ) { + // Only group by the lowercase genre name. This allows Genre grouping to be + // case-insensitive, which may be helpful in some libraries with different ways of + // formatting genres. + + // Cache the hashCode for HashMap efficiency. + private val hashCode = name?.lowercase().hashCode() + + override fun hashCode() = hashCode + + override fun equals(other: Any?) = + other is Raw && + when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } } - return result } /** * An ISO-8601/RFC 3339 Date. * - * Unlike a typical Date within the standard library, this class just represents the ID3v2/Vorbis - * date format, which is largely assumed to be a subset of ISO-8601. No validation outside of format - * validation is done. + * This class only encodes the timestamp spec and it's conversion to a human-readable date, without + * any other time management or validation. In general, this should only be used for display. * - * The reasoning behind Date is that Auxio cannot trust any kind of metadata date to actually make - * sense in a calendar, due to bad tagging, locale-specific issues, or simply from the limited - * nature of tag formats. Thus, it's better to use an analogous data structure that will not mangle - * or reject valid-ish dates. - * - * Date instances are immutable and their internal implementation is hidden. To instantiate one, use - * [from]. The string representation of a Date is RFC 3339, with granular position depending on the - * presence of particular tokens. - * - * Please, **Do not use this for anything important related to time.** I cannot stress this enough. - * This code will blow up if you try to do that. - * - * @author OxygenCobalt + * @author Alexander Capehart (OxygenCobalt) */ class Date private constructor(private val tokens: List) : Comparable { - init { - if (BuildConfig.DEBUG) { - // Last-ditch sanity check to catch format bugs that might slip through - check(tokens.size in 1..6) { "There must be 1-6 date tokens" } - check(tokens.slice(0..min(tokens.lastIndex, 2)).all { it > 0 }) { - "All date tokens must be non-zero " - } - check(tokens.slice(1..tokens.lastIndex).all { it < 100 }) { - "All non-year tokens must be two digits" + private val year = tokens[0] + private val month = tokens.getOrNull(1) + private val day = tokens.getOrNull(2) + private val hour = tokens.getOrNull(3) + private val minute = tokens.getOrNull(4) + private val second = tokens.getOrNull(5) + + /** + * Resolve this instance into a human-readable date. + * @param context [Context] required to get human-readable names. + * @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan + * 2020") will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will + * be properly localized. + */ + fun resolveDate(context: Context): String { + if (month != null) { + // Parse a date format from an ISO-ish format + val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat) + format.applyPattern("yyyy-MM") + val date = + try { + format.parse("$year-$month") + } catch (e: ParseException) { + null + } + + if (date != null) { + // Reformat as a readable month and year + format.applyPattern("MMM yyyy") + return format.format(date) } } + + // Unable to create fine-grained date, just format as a year. + return context.getString(R.string.fmt_number, year) } - val year: Int - get() = tokens[0] - - /** Resolve the year field in a way suitable for the UI. */ - fun resolveYear(context: Context) = context.getString(R.string.fmt_number, year) - - private val month: Int? - get() = tokens.getOrNull(1) - - private val day: Int? - get() = tokens.getOrNull(2) - - private val hour: Int? - get() = tokens.getOrNull(3) - - private val minute: Int? - get() = tokens.getOrNull(4) - - private val second: Int? - get() = tokens.getOrNull(5) - override fun hashCode() = tokens.hashCode() override fun equals(other: Any?) = other is Date && tokens == other.tokens override fun compareTo(other: Date): Int { - val comparator = Sort.Mode.NullableComparator.INT - - for (i in 0..(max(tokens.lastIndex, other.tokens.lastIndex))) { - val result = comparator.compare(tokens.getOrNull(i), other.tokens.getOrNull(i)) - if (result != 0) { - return result + for (i in 0 until max(tokens.size, other.tokens.size)) { + val ai = tokens.getOrNull(i) + val bi = other.tokens.getOrNull(i) + when { + ai != null && bi != null -> { + val result = ai.compareTo(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 } } @@ -384,49 +1285,95 @@ class Date private constructor(private val tokens: List) : Comparable override fun toString() = StringBuilder().appendDate().toString() private fun StringBuilder.appendDate(): StringBuilder { - append(year.toFixedString(4)) - append("-${(month ?: return this).toFixedString(2)}") - append("-${(day ?: return this).toFixedString(2)}") - append("T${(hour ?: return this).toFixedString(2)}") - append(":${(minute ?: return this.append('Z')).toFixedString(2)}") - append(":${(second ?: return this.append('Z')).toFixedString(2)}") + // Construct an ISO-8601 date, dropping precision that doesn't exist. + append(year.toStringFixed(4)) + append("-${(month ?: return this).toStringFixed(2)}") + append("-${(day ?: return this).toStringFixed(2)}") + append("T${(hour ?: return this).toStringFixed(2)}") + append(":${(minute ?: return this.append('Z')).toStringFixed(2)}") + append(":${(second ?: return this.append('Z')).toStringFixed(2)}") return this.append('Z') } - private fun Int.toFixedString(len: Int) = toString().padStart(len, '0') + private fun Int.toStringFixed(len: Int) = toString().padStart(len, '0').substring(0 until len) companion object { + /** + * A [Regex] that can parse a variable-precision ISO-8601 timestamp. Derived from + * https://github.com/quodlibet/mutagen + */ private val ISO8601_REGEX = Regex( - """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2}))?)?)?)?)?$""") + """^(\d{4,})([-.](\d{2})([-.](\d{2})([T ](\d{2})([:.](\d{2})([:.](\d{2})(Z)?)?)?)?)?)?$""") + /** + * Create a [Date] from a year component. + * @param year The year component. + * @return A new [Date] of the given component, or null if the component is invalid. + */ fun from(year: Int) = fromTokens(listOf(year)) + /** + * Create a [Date] from a date component. + * @param year The year component. + * @param month The month component. + * @param day The day component. + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid. + */ fun from(year: Int, month: Int, day: Int) = fromTokens(listOf(year, month, day)) + /** + * Create [Date] from a datetime component. + * @param year The year component. + * @param month The month component. + * @param day The day component. + * @param hour The hour component + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid. + */ fun from(year: Int, month: Int, day: Int, hour: Int, minute: Int) = fromTokens(listOf(year, month, day, hour, minute)) + /** + * Create a [Date] from a [String] timestamp. + * @param timestamp The ISO-8601 timestamp to parse. Can have reduced precision. + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid or + * if the timestamp is invalid. + */ fun from(timestamp: String): Date? { - val groups = - (ISO8601_REGEX.matchEntire(timestamp) ?: return null) - .groupValues.mapIndexedNotNull { index, s -> - if (index % 2 != 0) s.toIntOrNull() else null - } - - return fromTokens(groups) + val tokens = + // Match the input with the timestamp regex + (ISO8601_REGEX.matchEntire(timestamp) ?: return null) + .groupValues + // Filter to the specific tokens we want and convert them to integer tokens. + .mapIndexedNotNull { index, s -> if (index % 2 != 0) s.toIntOrNull() else null } + return fromTokens(tokens) } + /** + * Create a [Date] from the given non-validated tokens. + * @param tokens The tokens to use for each date component, in order of precision. + * @return A new [Date] consisting of the given components. May have reduced precision if + * the components were partially invalid, and will be null if all components are invalid. + */ private fun fromTokens(tokens: List): Date? { - val out = mutableListOf() - validateTokens(tokens, out) - if (out.isEmpty()) { + val validated = mutableListOf() + validateTokens(tokens, validated) + if (validated.isEmpty()) { + // No token was valid, return null. return null } - - return Date(out) + return Date(validated) } + /** + * Validate a list of tokens provided by [src], and add the valid ones to [dst]. Will stop + * as soon as an invalid token is found. + * @param src The input tokens to validate. + * @param dst The destination list to add valid tokens to. + */ private fun validateTokens(src: List, dst: MutableList) { dst.add(src.getOrNull(0)?.nonZeroOrNull() ?: return) dst.add(src.getOrNull(1)?.inRangeOrNull(1..12) ?: return) @@ -438,119 +1385,48 @@ class Date private constructor(private val tokens: List) : Comparable } } +// --- MUSIC UID CREATION UTILITIES --- + /** - * Represents the type of release a particular album is. - * - * This can be used to differentiate between album sub-types like Singles, EPs, Compilations, and - * others. Internally, it operates on a reduced version of the MusicBrainz release type - * specification. It can be extended if there is demand. - * - * @author OxygenCobalt + * Update a [MessageDigest] with a lowercase [String]. + * @param string The [String] to hash. If null, it will not be hashed. */ -sealed class ReleaseType { - abstract val refinement: Refinement? - abstract val stringRes: Int - - data class Album(override val refinement: Refinement?) : ReleaseType() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_album - Refinement.LIVE -> R.string.lbl_album_live - Refinement.REMIX -> R.string.lbl_album_remix - } - } - - data class EP(override val refinement: Refinement?) : ReleaseType() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_ep - Refinement.LIVE -> R.string.lbl_ep_live - Refinement.REMIX -> R.string.lbl_ep_remix - } - } - - data class Single(override val refinement: Refinement?) : ReleaseType() { - override val stringRes: Int - get() = - when (refinement) { - null -> R.string.lbl_single - Refinement.LIVE -> R.string.lbl_single_live - Refinement.REMIX -> R.string.lbl_single_remix - } - } - - object Compilation : ReleaseType() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_compilation - } - - object Soundtrack : ReleaseType() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_soundtrack - } - - object Mixtape : ReleaseType() { - override val refinement: Refinement? - get() = null - - override val stringRes: Int - get() = R.string.lbl_mixtape - } - - /** - * Roughly analogous to the MusicBrainz "live" and "remix" secondary types. Unlike the main - * types, these only modify an existing, primary type. They are not implemented for secondary - * types, however they may be expanded to compilations in the future. - */ - enum class Refinement { - LIVE, - REMIX - } - - companion object { - fun parse(type: String) = parse(type.split('+')) - - fun parse(types: List): ReleaseType { - val primary = types[0].trim() - - // Primary types should be the first one in sequence. The spec makes no mention of - // whether primary types are a pre-requisite for secondary types, so we assume that - // it isn't. There are technically two other types, but those are unrelated to music - // and thus we don't support them. - return when { - primary.equals("album", true) -> types.parseSecondaryTypes(1) { Album(it) } - primary.equals("ep", true) -> types.parseSecondaryTypes(1) { EP(it) } - primary.equals("single", true) -> types.parseSecondaryTypes(1) { Single(it) } - else -> types.parseSecondaryTypes(0) { Album(it) } - } - } - - private inline fun List.parseSecondaryTypes( - secondaryIdx: Int, - target: (Refinement?) -> ReleaseType - ): ReleaseType { - val secondary = (getOrNull(secondaryIdx) ?: return target(null)).trim() - - return when { - // Compilation is the only weird secondary release type, as it could - // theoretically have additional modifiers including soundtrack, remix, - // live, dj-mix, etc. However, since there is no real demand for me to - // respond to those, I don't implement them simply for internal simplicity. - secondary.equals("compilation", true) -> Compilation - secondary.equals("soundtrack", true) -> Soundtrack - secondary.equals("mixtape/street", true) -> Mixtape - secondary.equals("live", true) -> target(Refinement.LIVE) - secondary.equals("remix", true) -> target(Refinement.REMIX) - else -> target(null) - } - } +private fun MessageDigest.update(string: String?) { + if (string != null) { + update(string.lowercase().toByteArray()) + } else { + update(0) + } +} + +/** + * Update a [MessageDigest] with the string representation of a [Date]. + * @param date The [Date] to hash. If null, nothing will be done. + */ +private fun MessageDigest.update(date: Date?) { + if (date != null) { + update(date.toString().toByteArray()) + } else { + update(0) + } +} + +/** + * Update a [MessageDigest] with the lowercase versions of all of the input [String]s. + * @param strings The [String]s to hash. If a [String] is null, it will not be hashed. + */ +private fun MessageDigest.update(strings: List) { + strings.forEach(::update) +} + +/** + * Update a [MessageDigest] with the little-endian bytes of a [Int]. + * @param n The [Int] to write. If null, nothing will be done. + */ +private fun MessageDigest.update(n: Int?) { + if (n != null) { + update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte())) + } else { + update(0) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt new file mode 100644 index 000000000..42a86f25a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicMode.kt @@ -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 . + */ + +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 + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index 9e3ce034d..185cbb6ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -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() + /** + * 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, - val artists: List, + val songs: List, val albums: List, - val songs: List + val artists: List, + val genres: List, ) { - private val genreIdMap = HashMap().apply { genres.forEach { put(it.id, it) } } - private val artistIdMap = - HashMap().apply { artists.forEach { put(it.id, it) } } - private val albumIdMap = HashMap().apply { albums.forEach { put(it.id, it) } } - private val songIdMap = HashMap().apply { songs.forEach { put(it.id, it) } } + private val uidMap = HashMap() - /** 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) = 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 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.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.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.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.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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt deleted file mode 100644 index 1a56c957e..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt +++ /dev/null @@ -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 . - */ - -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, - selector: String? = null, - args: Array? = null -) = query(uri, projection, selector, args, null) - -/** Shortcut for making a [ContentResolver] query and using the particular cursor with [use]. */ -inline fun ContentResolver.useQuery( - uri: Uri, - projection: Array, - selector: String? = null, - args: Array? = 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.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() - - // 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") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index ffdc38736..c115d080b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -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(null) - /** The current music indexing state. */ + /** The current music loading state, or null if no loading is going on. */ val indexerState: StateFlow = _indexerState - private val _libraryExists = MutableStateFlow(false) - /** Whether a music library has successfully been loaded. */ - val libraryExists: StateFlow = _libraryExists + private val _statistics = MutableStateFlow(null) + /** [Statistics] about the last completed music load. */ + val statistics: StateFlow + 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 + ) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Sort.kt b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt new file mode 100644 index 000000000..7ce2248bd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/Sort.kt @@ -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 . + */ + +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): List { + 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): List { + 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): List { + 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): List { + 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) { + 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) { + 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) { + 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) { + 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 { + 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 { + 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 { + 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 { + 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 = + 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 = + 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 = + 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 = + 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 = + 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 = + MultiComparator( + compareByDynamic(isAscending) { it.durationMs }, + compareBy(BasicComparator.SONG)) + + override fun getAlbumComparator(isAscending: Boolean): Comparator = + MultiComparator( + compareByDynamic(isAscending) { it.durationMs }, + compareBy(BasicComparator.ALBUM)) + + override fun getArtistComparator(isAscending: Boolean): Comparator = + MultiComparator( + compareByDynamic(isAscending, NullableComparator.LONG) { it.durationMs }, + compareBy(BasicComparator.ARTIST)) + + override fun getGenreComparator(isAscending: Boolean): Comparator = + 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 = + MultiComparator( + compareByDynamic(isAscending) { it.songs.size }, + compareBy(BasicComparator.ALBUM)) + + override fun getArtistComparator(isAscending: Boolean): Comparator = + MultiComparator( + compareByDynamic(isAscending, NullableComparator.INT) { it.songs.size }, + compareBy(BasicComparator.ARTIST)) + + override fun getGenreComparator(isAscending: Boolean): Comparator = + 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 = + 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 = + 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 = + MultiComparator( + compareByDynamic(isAscending) { it.dateAdded }, compareBy(BasicComparator.SONG)) + + override fun getAlbumComparator(isAscending: Boolean): Comparator = + 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 > 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 compareByDynamic( + isAscending: Boolean, + comparator: Comparator + ): Comparator = 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 compareByDynamic( + isAscending: Boolean, + comparator: Comparator, + 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 compareBy(comparator: Comparator): Comparator = + 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(vararg comparators: Comparator) : Comparator { + 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(private val inner: Comparator) : Comparator> { + override fun compare(a: List, b: List): 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> = 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 private constructor() : Comparator { + 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 = BasicComparator() + /** A re-usable instance configured for [Album]s. */ + val ALBUM: Comparator = BasicComparator() + /** A re-usable instance configured for [Artist]s. */ + val ARTIST: Comparator = BasicComparator() + /** A re-usable instance configured for [Genre]s. */ + val GENRE: Comparator = 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> private constructor() : Comparator { + 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() + /** A re-usable instance configured for [Long]s. */ + val LONG = NullableComparator() + /** A re-usable instance configured for [Date]s. */ + val DATE = NullableComparator() + } + } + + 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) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/StorageFramework.kt b/app/src/main/java/org/oxycblt/auxio/music/StorageFramework.kt deleted file mode 100644 index b29e4beea..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/StorageFramework.kt +++ /dev/null @@ -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 . - */ - -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 - 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).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) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt new file mode 100644 index 000000000..e9159a0db --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt @@ -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 . + */ + +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) + + /** + * 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) { + 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? = 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) { + 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 { + requireBackgroundThread() + val start = System.currentTimeMillis() + val map = mutableMapOf() + 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) { + 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.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 + } + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt new file mode 100644 index 000000000..6b56f8c70 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt @@ -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 . + */ + +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 +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt new file mode 100644 index 000000000..8ec2adadd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -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 . + */ + +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() + + /** + * 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() + 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() + 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) { + // 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 + 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): 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 , 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 + 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): 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 + 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): 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 + 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 + 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 } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt new file mode 100644 index 000000000..6603850e4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -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 . + */ + +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 = 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) = 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>() + val vorbisTags = mutableMapOf>() + + // 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>) { + // 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>): 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>) { + // 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()) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt new file mode 100644 index 000000000..05a2deeb7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/ParsingUtil.kt @@ -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 . + */ + +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 { + val split = mutableListOf() + 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.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 { + // 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.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? { + val groups = (ID3V2_GENRE_RE.matchEntire(this) ?: return null).groupValues + val genres = mutableSetOf() + + // 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") diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/SeparatorsDialog.kt new file mode 100644 index 000000000..c7e2ed55f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/SeparatorsDialog.kt @@ -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 . + */ + +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() { + 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 = '&' + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt new file mode 100644 index 000000000..89c502d04 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt @@ -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 . + */ + +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() { + private var artists = listOf() + + 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) { + 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)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt new file mode 100644 index 000000000..a8ea49687 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt @@ -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 . + */ + +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) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt new file mode 100644 index 000000000..30b5dd996 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt @@ -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 . + */ + +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(), 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() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt new file mode 100644 index 000000000..b81e2604a --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt @@ -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 . + */ + +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) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt new file mode 100644 index 000000000..57f8322ce --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt @@ -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() { + private var genres = listOf() + + 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) { + 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)) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt new file mode 100644 index 000000000..a5bba8d48 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt @@ -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(), 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt new file mode 100644 index 000000000..0fee83bcd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt @@ -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 . + */ + +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(null) + /** The current item whose artists should be shown in the picker. Null if there is no item. */ + val currentItem: StateFlow get() = _currentItem + + private val _artistChoices = MutableStateFlow>(listOf()) + /** The current [Artist] choices. Empty if no item is shown in the picker. */ + val artistChoices: StateFlow> + get() = _artistChoices + + private val _genreChoices = MutableStateFlow>(listOf()) + /** The current [Genre] choices. Empty if no item is shown in the picker. */ + val genreChoices: StateFlow> + 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 -> {} + } + } + +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt similarity index 57% rename from app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt index 8b3156dab..f0474c364 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt @@ -15,23 +15,27 @@ * along with this program. If not, see . */ -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() { +class DirectoryAdapter(private val listener: Listener) : + RecyclerView.Adapter() { private val _dirs = mutableListOf() + /** + * The current list of [Directory]s, may not line up with [MusicDirectories] due to removals. + */ val dirs: List = _dirs override fun getItemCount() = dirs.size @@ -42,6 +46,10 @@ class MusicDirAdapter(private val listener: Listener) : RecyclerView.Adapter) { 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)) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt similarity index 79% rename from app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt index 30465aa99..90507e915 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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(), MusicDirAdapter.Listener { - private val dirAdapter = MusicDirAdapter(this) + ViewBindingDialogFragment(), 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" } diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt new file mode 100644 index 000000000..ebe8a0ae2 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt @@ -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 . + */ + +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, 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) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt new file mode 100644 index 000000000..60bc797e9 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt @@ -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 . + */ + +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, + selector: String? = null, + args: Array? = 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 ContentResolver.useQuery( + uri: Uri, + projection: Array, + selector: String? = null, + args: Array? = 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 + 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).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() + } + } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt deleted file mode 100644 index 3fe77837c..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/system/ExoPlayerBackend.kt +++ /dev/null @@ -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 . - */ - -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 = 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 { - // 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() - 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() - val vorbisTags = mutableMapOf>() - - // 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) { - // (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): 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>) { - // (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()) -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index 81539f0f2..028fee788 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -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 { + private suspend fun buildSongs( + metadataExtractor: MetadataExtractor, + settings: Settings + ): List { + 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() + val rawSongs = mutableListOf() + 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): List { - val albums = mutableListOf() - 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): List { + // 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): List { - val artists = mutableListOf() - val albumsByArtist = albums.groupBy { it._artistGroupingId } + private fun buildArtists(songs: List, albums: List): List { + // 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>() - 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): List { - val genres = mutableListOf() - 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>() + 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 + /** + * 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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt index f2acf3c43..d9d34fa02 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index c116e5df5..b024890eb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -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 } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt deleted file mode 100644 index 6b3fdd56b..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/system/MediaStoreBackend.kt +++ /dev/null @@ -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 . - */ - -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() - - 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() - 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 { - val audios = mutableListOf