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.
-
-
+
+
@@ -13,7 +13,7 @@
-
+
@@ -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