Compare commits

..

1 commit
dev ... music2

Author SHA1 Message Date
Alexander Capehart
a784f73c5e
in-progress interpreter refactor
Will force-rewrite at several points.
2024-11-09 20:06:53 -07:00
430 changed files with 12130 additions and 14783 deletions

View file

@ -34,7 +34,6 @@ body:
attributes: attributes:
label: What android version do you use? label: What android version do you use?
options: options:
- Android 15
- Android 14 - Android 14
- Android 13 - Android 13
- Android 12L - Android 12L

View file

@ -25,10 +25,8 @@ jobs:
cache: gradle cache: gradle
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Check formatting with spotless - name: Test app with Gradle
run: ./gradlew spotlessCheck run: ./gradlew app:testDebug
- name: Test musikr with Gradle
run: ./gradlew musikr:testDebug
- name: Build debug APK with Gradle - name: Build debug APK with Gradle
run: ./gradlew app:packageDebug run: ./gradlew app:packageDebug
- name: Upload debug APK artifact - name: Upload debug APK artifact

2
.gitignore vendored
View file

@ -14,5 +14,3 @@ captures/
*.iml *.iml
.cxx .cxx
.kotlin .kotlin
.aider*
.env

5
.gitmodules vendored
View file

@ -1,8 +1,3 @@
[submodule "media"] [submodule "media"]
path = media path = media
url = https://github.com/OxygenCobalt/media.git url = https://github.com/OxygenCobalt/media.git
[submodule "musikr/src/main/cpp/taglib"]
path = musikr/src/main/cpp/taglib
url = https://github.com/taglib/taglib.git
tag = ee1931b

View file

@ -1,100 +1,30 @@
# Changelog # Changelog
## 4.0.3
#### What's Improved
- Improved music loader pipeline efficiency
- Made cover.png support more flexible
- Albums with the same name but different album artists are now split
if fully tagged with album artists
#### What's Fixed
- Possibly fixed cache failures on large libraries
- Possibly fixed playback state saving failing on some devices
- Fixed issue where artists w/o songs would not have a cover
- Fixed music not being reloaded when music locations changed
- Fixed tasker media control not working
- Fixed tasker playback start command never finishing
#### Dev/Meta
- Removed useless storage permissions
- Internal cleanup/simplification of musikr API
- Removed unused resources
#### What's Fixed
## 4.0.2
#### What's New
- Added back in support for cover art from cover.png/cover.jpg
- Added "As is" cover art setting
- Option to include hidden files or not (off by default)
#### What's Improved
- Reduced elevation contrast in black theme
#### What's Fixed
- Fixed incorrect extension stripping on some files
- Fixed various errors in new branding
- Fixed MTE segfault from improper string handling
#### What's Changed
- Hidden files no longer loaded by default
## 4.0.1
#### What's Fixed
- Fixed music loading hanging on files without tags
- Fixed playlists being destroyed in poorly tagged libraries
## 4.0.0 ## 4.0.0
#### What's New #### What's New
- A total user interface refresh based on the latest Material Design specs
- New theme palettes
- Improved designs for playback and detail views
- New app branding and icon
- Refreshed round mode
- Less intrusive music loading indicators
- **Musikr**, a brand new music loading system
- Directly accesses user files rather than unreliable media database
- Uses faster and more capable native tag parsing
- Stores cover data on-device for fast and high-quality access
- New interpretation system with many quality-of-life improvements
- Android 15 support - Android 15 support
- New app branding and icon
- Refreshed playback design
- Live widget preview on Android 15+
- Added GitHub/email feedback forms to about page
#### What's Improved #### What's Improved
- Initial music loading is signifigantly faster and less resource intensive - Album grouping no longer done with artist in mind by default
- Album grouping no longer done with artist
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries - MusicBrainz IDs will no longer split albums/artists in less tagged libraries
- M3U playlist file name is now proposed if one cannot be found within the file - M3U playlist file name is now proposed if one cannot be found within the file
- Duration is now parsed from certain files that previously could not be parsed
- ID3v2 tags are now parsed from WAV files
- NN/TT tracks/discs are now handled in Vorbis
- Music library will is less likely to fail to respond to updates
- Hidden audio files can now be loaded
- Sorting songs by date now uses songs date first, before the earliest album date - Sorting songs by date now uses songs date first, before the earliest album date
- Added working layouts for small split-screen form factors - Added working layouts for small split-screen form factors
- Added fast scrolling in detail views
- Added ability to make issues and make feedback e-mails in-app
#### What's Fixed #### What's Fixed
- Music loader no longer spawns thousands of threads when scanning
- Excessive CPU no longer spent showing music loading process
- Fixed playback sheet flickering on warm start - Fixed playback sheet flickering on warm start
- No longer possible to save a sort with no direction specified - No longer possible to save a sort with no direction specified
- Fixed inconsistent corner radii in widget - Fixed inconsistent corner radii in widget
- Possibly fixed foreground start music loading failures
- Fixed playlist view not exiting on deletion
#### What's Changed
- Date added is now local to when the app discovers the file and will not
persist long-term
- Songs with no album are now "Unknown album" rather than folder name
- Tab layout no longer changes depending on device configuration
- Round mode is now on by default
#### Dev/Meta #### Dev/Meta
- No longer using custom logging setup - No longer using custom logging setup
- Music loading split off into separate musikr module
## 3.6.3 ## 3.6.3

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.4"> <a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.6.3">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.6.3&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -15,12 +15,7 @@
</p> </p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4> <h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
<p align="center"> <p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a> <a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
<a href="https://accrescent.app/app/org.oxycblt.auxio">
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
</a>
</p>
<p align="center">
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a> <a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
</p> </p>
@ -33,12 +28,14 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
## Screenshots ## Screenshots
<p align="center"> <p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png" width=200>
</p> </p>
@ -64,39 +61,29 @@ precise/original dates, sort tags, and more
- Headset autoplay - Headset autoplay
- Stylish widgets that automatically adapt to their size - Stylish widgets that automatically adapt to their size
- Completely private and offline - Completely private and offline
- No rounded album covers (if you want them) - No rounded album covers (by default)
## Permissions ## Permissions
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files - Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background - Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading - Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
## Donate ## Donate
You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself! You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
<p align="center"><b>$16/month supporters:</b></p>
<p align="center">
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></a>
<br/>
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
</p>
<p align="center"><b>$8/month supporters:</b></p> <p align="center"><b>$8/month supporters:</b></p>
<p align="center"> <p align="center">
<a href="https://github.com/alanorth"><img src="https://avatars.githubusercontent.com/u/191754?v=4" width=50 /></a> <a href="https://github.com/alanorth"><img src="https://avatars.githubusercontent.com/u/191754?v=4" width=50 /></a>
<a href="https://github.com/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a> <a href="https://github.com/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
<a href="https://github.com/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a> <a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=50 /></a>
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
</p> </p>
## Building ## Building
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
parsing. This adds some caveats to the build process:
1. `cmake` and `ninja-build` must be installed before building the project. 1. `cmake` and `ninja-build` must be installed before building the project.
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly 2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download the external code. download the external code.

View file

View file

@ -2,6 +2,7 @@ plugins {
id "com.android.application" id "com.android.application"
id "kotlin-android" id "kotlin-android"
id "androidx.navigation.safeargs.kotlin" id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize" id "kotlin-parcelize"
id "dagger.hilt.android.plugin" id "dagger.hilt.android.plugin"
id "kotlin-kapt" id "kotlin-kapt"
@ -11,18 +12,20 @@ plugins {
android { android {
compileSdk 35 compileSdk 35
// Auxio implicitly depends on the native modules, explicitly specify it // NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
// here so the libraries are still stripped. // it here so that binary stripping will work.
ndkVersion ndk_version // TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
// NDK use is unified
ndkVersion "26.3.11579264"
namespace "org.oxycblt.auxio" namespace "org.oxycblt.auxio"
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "4.0.4" versionName "3.6.3"
versionCode 63 versionCode 53
minSdk min_sdk minSdk 24
targetSdk target_sdk targetSdk 35
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
@ -77,13 +80,14 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version" def coroutines_version = '1.7.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
// --- SUPPORT --- // --- SUPPORT ---
// General // General
implementation "androidx.core:core-ktx:$core_version" implementation "androidx.core:core-ktx:1.15.0"
implementation "androidx.appcompat:appcompat:1.7.0" implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3" implementation "androidx.activity:activity-ktx:1.9.3"
// noinspection GradleDependency // noinspection GradleDependency
@ -121,26 +125,20 @@ dependencies {
implementation "androidx.preference:preference-ktx:1.2.1" implementation "androidx.preference:preference-ktx:1.2.1"
// Database // Database
def room_version = '2.6.1'
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version" ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version" implementation "androidx.room:room-ktx:$room_version"
// Build
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugaring_version"
// --- SECOND PARTY ---
// Musikr
implementation project(":musikr")
// --- THIRD PARTY --- // --- THIRD PARTY ---
// Exoplayer (Vendored) // Exoplayer (Vendored)
implementation project(":media-lib-exoplayer") implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.3"
// Image loading // Image loading
implementation 'io.coil-kt.coil3:coil-core:3.0.2' implementation 'io.coil-kt:coil-base:2.4.0'
// Material // Material
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
@ -164,4 +162,25 @@ dependencies {
// Fuzzy search // Fuzzy search
implementation 'org.apache.commons:commons-text:1.9' implementation 'org.apache.commons:commons-text:1.9'
// Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:1.13.7"
testImplementation "org.robolectric:robolectric:4.11"
testImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
spotless {
kotlin {
target "src/**/*.kt"
ktfmt().dropboxStyle()
licenseHeaderFile("NOTICE")
}
}
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
} }

View file

@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="info_app_name" translatable="false">Auxio Debug</string> <string name="info_app_name" translatable="false">Auxio Debug</string>
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
</resources> </resources>

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Android 13 uses READ_MEDIA_AUDIO instead of READ_EXTERNAL_STORAGE -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@ -97,15 +100,6 @@
</intent-filter> </intent-filter>
</service> </service>
<!--
Expose Auxio's cover data to the android system
-->
<provider
android:name=".image.CoverProvider"
android:authorities="@string/pkg_authority_cover"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<!-- <!--
Work around apps that blindly query for ACTION_MEDIA_BUTTON working. Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info. See the class for more info.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1309,6 +1309,7 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
+ " should not be set externally."); + " should not be set externally.");
} }
if (!hideable && state == STATE_HIDDEN) { if (!hideable && state == STATE_HIDDEN) {
Log.w(TAG, "Cannot set state: " + state);
return; return;
} }
final int finalState; final int finalState;
@ -1632,13 +1633,12 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return; return;
} }
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked(); BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
boolean canActuallyHide = hideable && isHideableWhenDragging();
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) {
// If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet. // If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet.
setState(canActuallyHide ? STATE_HIDDEN : STATE_COLLAPSED); setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED);
return; return;
} }
if (canActuallyHide) { if (hideable && isHideableWhenDragging()) {
bottomContainerBackHelper.finishBackProgressNotPersistent( bottomContainerBackHelper.finishBackProgressNotPersistent(
backEvent, backEvent,
new AnimatorListenerAdapter() { new AnimatorListenerAdapter() {

View file

@ -36,7 +36,6 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class AuxioService : class AuxioService :
@ -54,30 +53,24 @@ class AuxioService :
musicFragment = musicFragmentFactory.create(this, this, this) musicFragment = musicFragmentFactory.create(this, this, this)
sessionToken = playbackFragment.attach() sessionToken = playbackFragment.attach()
musicFragment.attach() musicFragment.attach()
Timber.d("Service Created")
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached // TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here. // service, we might need more handling here.
super.onStartCommand(intent, flags, startId)
onHandleForeground(intent) onHandleForeground(intent)
// If we die we want to not restart, we will immediately try to foreground in and just return super.onStartCommand(intent, flags, startId)
// fail to start again since the activity will be dead too. This is not the semantically
// "correct" flag (normally you want START_STICKY for playback) but we need this to avoid
// weird foreground errors.
return START_NOT_STICKY
} }
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
val binder = super.onBind(intent)
onHandleForeground(intent) onHandleForeground(intent)
return binder return super.onBind(intent)
} }
private fun onHandleForeground(intent: Intent?) { private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
musicFragment.start() musicFragment.start()
playbackFragment.start(intent) playbackFragment.start(startId)
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
@ -141,7 +134,6 @@ class AuxioService :
} }
// Nothing changed, but don't show anything music related since we can always // Nothing changed, but don't show anything music related since we can always
// index during playback. // index during playback.
isForeground = true
} else { } else {
musicFragment.createNotification { musicFragment.createNotification {
if (it != null) { if (it != null) {

View file

@ -65,8 +65,6 @@ object IntegerTable {
const val START_ID_ACTIVITY = 0xA050 const val START_ID_ACTIVITY = 0xA050
/** Tasker AuxioService Start ID */ /** Tasker AuxioService Start ID */
const val START_ID_TASKER = 0xA051 const val START_ID_TASKER = 0xA051
/** MediaButtonReceiver AuxioService Start ID */
const val START_ID_MEDIA_BUTTON = 0xA052
/** RepeatMode.NONE */ /** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100 const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */ /** RepeatMode.ALL */
@ -125,10 +123,10 @@ object IntegerTable {
const val ACTION_MODE_SHUFFLE = 0xA11B const val ACTION_MODE_SHUFFLE = 0xA11B
/** CoverMode.Off */ /** CoverMode.Off */
const val COVER_MODE_OFF = 0xA11C const val COVER_MODE_OFF = 0xA11C
/** CoverMode.Balanced */ /** CoverMode.MediaStore */
const val COVER_MODE_BALANCED = 0xA11D const val COVER_MODE_MEDIA_STORE = 0xA11D
/** CoverMode.Quality */ /** CoverMode.Quality */
const val COVER_MODE_HIGH_QUALITY = 0xA11E const val COVER_MODE_QUALITY = 0xA11E
/** PlaySong.FromAll */ /** PlaySong.FromAll */
const val PLAY_SONG_FROM_ALL = 0xA11F const val PLAY_SONG_FROM_ALL = 0xA11F
/** PlaySong.FromAlbum */ /** PlaySong.FromAlbum */
@ -141,8 +139,4 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123 const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */ /** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124 const val PLAY_SONG_BY_ITSELF = 0xA124
/** CoverMode.SaveSpace */
const val COVER_MODE_SAVE_SPACE = 0xA125
/** CoverMode.AsIs */
const val COVER_MODE_AS_IS = 0xA126
} }

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.animation.ValueAnimator
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
@ -26,7 +27,6 @@ import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -50,8 +50,10 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.Outer import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.OpenPanel import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -68,8 +70,6 @@ import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -257,9 +257,9 @@ class MainFragment :
} }
override fun onPreDraw(): Boolean { override fun onPreDraw(): Boolean {
// This is where I shove literally all the UI logic that won't behave any callback // TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
// or "normal" method I've tried. Surely running this on every frame will actually cause // sheets continually getting stuck. I need something with even more frequent updates,
// it to work properly! // or otherwise bottom sheets get stuck.
// We overload CoordinatorLayout far too much to rely on any of it's typical // We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should // listener functionality. Just update all transitions before every draw. Should
@ -367,10 +367,6 @@ class MainFragment :
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" } requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled() .invalidateEnabled()
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
binding.mainFabContainer.isVisible =
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
return true return true
} }
@ -408,6 +404,9 @@ class MainFragment :
} }
private fun updateIndexerState(state: IndexingState?) { private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
if (state is IndexingState.Completed && state.error == null) { if (state is IndexingState.Completed && state.error == null) {
L.d("Received ok response") L.d("Received ok response")
val binding = requireBinding() val binding = requireBinding()
@ -513,6 +512,8 @@ class MainFragment :
} }
} }
private var scrimAnimator: ValueAnimator? = null
private fun updateSpeedDial(open: Boolean) { private fun updateSpeedDial(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" } requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open) .invalidateEnabled(open)

View file

@ -22,7 +22,6 @@ import android.os.Bundle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
@ -30,9 +29,12 @@ import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
@ -42,10 +44,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -117,7 +115,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
binding.detailToolbarTitle.text = name binding.detailToolbarTitle.text = name
binding.detailCover.bind(album) binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.) // The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = album.releaseType.resolve(context) binding.detailType.text = getString(album.releaseType.stringRes)
binding.detailName.text = name binding.detailName.text = name
// Artist name maps to the subhead text // Artist name maps to the subhead text
binding.detailSubhead.apply { binding.detailSubhead.apply {
@ -133,7 +131,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// Date, song count, and duration map to the info text // Date, song count, and duration map to the info text
binding.detailInfo.apply { binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information // Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date) val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size) val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true) val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration) text = context.getString(R.string.fmt_three, date, songCount, duration)
@ -142,15 +140,9 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
binding.detailPlayButton?.setOnClickListener { binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
} }
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailShuffleButton?.setOnClickListener { binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
} }
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }
@ -299,11 +291,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// RecyclerView will scroll assuming it has the total height of the screen (i.e a // RecyclerView will scroll assuming it has the total height of the screen (i.e a
// collapsed appbar), so we need to collapse the appbar if that's the case. // collapsed appbar), so we need to collapse the appbar if that's the case.
binding.detailAppbar.setExpanded(false) binding.detailAppbar.setExpanded(false)
if (!binding.detailRecycler.canScroll()) {
// Don't scroll if the RecyclerView goes off screen. If we go anyway, overscroll
// kicks in and creates a weird bounce effect.
return
}
binding.detailRecycler.post { binding.detailRecycler.post {
// Use a custom smooth scroller that will settle the item in the middle of // Use a custom smooth scroller that will settle the item in the middle of
// the screen rather than the end. // the screen rather than the end.
@ -329,6 +316,4 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
} }
} }
} }
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
} }

View file

@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
@ -40,11 +44,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -164,15 +163,9 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
binding.detailPlayButton?.setOnClickListener { binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
} }
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailShuffleButton?.setOnClickListener { binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
} }
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }

View file

@ -35,13 +35,13 @@ import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.PlainDivider import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
abstract class DetailFragment<P : MusicParent, C : Music> : abstract class DetailFragment<P : MusicParent, C : Music> :
ListFragment<C, FragmentDetailBinding>(), ListFragment<C, FragmentDetailBinding>(),
@ -123,9 +123,6 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
val detailContent = binding.detailToolbarContent val detailContent = binding.detailToolbarContent
detailContent.alpha = inRatio detailContent.alpha = inRatio
detailContent.translationY = spacingSmall * (1 - inRatio) detailContent.translationY = spacingSmall * (1 - inRatio)
// Enable fast scrolling once fully collapsed
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
} }
abstract fun onOpenParentMenu() abstract fun onOpenParentMenu()

View file

@ -23,17 +23,17 @@ import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
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.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Playlist
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.ReleaseType
import timber.log.Timber as L import timber.log.Timber as L
interface DetailGenerator { interface DetailGenerator {
@ -121,7 +121,7 @@ private class DetailGeneratorImpl(
} }
override fun album(uid: Music.UID): Detail<Album>? { override fun album(uid: Music.UID): Detail<Album>? {
val album = musicRepository.library?.findAlbum(uid) ?: return null val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null
val songs = listSettings.albumSongSort.songs(album.songs) val songs = listSettings.albumSongSort.songs(album.songs)
val discs = songs.groupBy { it.disc } val discs = songs.groupBy { it.disc }
val section = val section =
@ -134,7 +134,7 @@ private class DetailGeneratorImpl(
} }
override fun artist(uid: Music.UID): Detail<Artist>? { override fun artist(uid: Music.UID): Detail<Artist>? {
val artist = musicRepository.library?.findArtist(uid) ?: return null val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null
val grouping = val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) { artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into detail sections // Remap the complicated ReleaseType data structure into detail sections
@ -173,14 +173,14 @@ private class DetailGeneratorImpl(
} }
override fun genre(uid: Music.UID): Detail<Genre>? { override fun genre(uid: Music.UID): Detail<Genre>? {
val genre = musicRepository.library?.findGenre(uid) ?: return null val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists)) val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs)) val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
return Detail(genre, listOf(artists, songs)) return Detail(genre, listOf(artists, songs))
} }
override fun playlist(uid: Music.UID): Detail<Playlist>? { override fun playlist(uid: Music.UID): Detail<Playlist>? {
val playlist = musicRepository.library?.findPlaylist(uid) ?: return null val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null
if (playlist.songs.isNotEmpty()) { if (playlist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(playlist.songs) val songs = DetailSection.Songs(playlist.songs)
return Detail(playlist, listOf(songs)) return Detail(playlist, listOf(songs))

View file

@ -22,14 +22,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.DiscDivider import org.oxycblt.auxio.detail.list.DiscDivider
import org.oxycblt.auxio.detail.list.DiscHeader import org.oxycblt.auxio.detail.list.DiscHeader
import org.oxycblt.auxio.detail.list.EditHeader import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SortHeader import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
@ -38,20 +40,21 @@ import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
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.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -66,11 +69,11 @@ class DetailViewModel
constructor( constructor(
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val audioPropertiesFactory: AudioProperties.Factory,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
detailGeneratorFactory: DetailGenerator.Factory detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator { ) : ViewModel(), DetailGenerator.Invalidator {
private val _toShow = MutableEvent<Show>() private val _toShow = MutableEvent<Show>()
/** /**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently. * A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
*/ */
@ -79,34 +82,30 @@ constructor(
// --- SONG --- // --- SONG ---
private val _currentSong = MutableStateFlow<Song?>(null) private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow<Song?>(null)
/** The current [Song] to display. Null if there is nothing to show. */ /** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?> val currentSong: StateFlow<Song?>
get() = _currentSong get() = _currentSong
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf()) private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
/** The current properties of [currentSong]. Empty if nothing to show. */ val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
val currentSongProperties: StateFlow<List<SongProperty>>
get() = _currentSongProperties
// --- ALBUM --- // --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null) private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */ /** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?> val currentAlbum: StateFlow<Album?>
get() = _currentAlbum get() = _currentAlbum
private val _albumSongList = MutableStateFlow(listOf<Item>()) private val _albumSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentAlbum]. */ /** The current list data derived from [currentAlbum]. */
val albumSongList: StateFlow<List<Item>> val albumSongList: StateFlow<List<Item>>
get() = _albumSongList get() = _albumSongList
private val _albumSongInstructions = MutableEvent<UpdateInstructions>() private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumSongList] in the UI. */ /** Instructions for updating [albumSongList] in the UI. */
val albumSongInstructions: Event<UpdateInstructions> val albumSongInstructions: Event<UpdateInstructions>
get() = _albumSongInstructions get() = _albumSongInstructions
@ -122,18 +121,15 @@ constructor(
// --- ARTIST --- // --- ARTIST ---
private val _currentArtist = MutableStateFlow<Artist?>(null) private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */ /** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?> val currentArtist: StateFlow<Artist?>
get() = _currentArtist get() = _currentArtist
private val _artistSongList = MutableStateFlow(listOf<Item>()) private val _artistSongList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */ /** The current list derived from [currentArtist]. */
val artistSongList: StateFlow<List<Item>> = _artistSongList val artistSongList: StateFlow<List<Item>> = _artistSongList
private val _artistSongInstructions = MutableEvent<UpdateInstructions>() private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */ /** Instructions for updating [artistSongList] in the UI. */
val artistSongInstructions: Event<UpdateInstructions> val artistSongInstructions: Event<UpdateInstructions>
get() = _artistSongInstructions get() = _artistSongInstructions
@ -149,18 +145,15 @@ constructor(
// --- GENRE --- // --- GENRE ---
private val _currentGenre = MutableStateFlow<Genre?>(null) private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */ /** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?> val currentGenre: StateFlow<Genre?>
get() = _currentGenre get() = _currentGenre
private val _genreSongList = MutableStateFlow(listOf<Item>()) private val _genreSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */ /** The current list data derived from [currentGenre]. */
val genreSongList: StateFlow<List<Item>> = _genreSongList val genreSongList: StateFlow<List<Item>> = _genreSongList
private val _genreSongInstructions = MutableEvent<UpdateInstructions>() private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */ /** Instructions for updating [artistSongList] in the UI. */
val genreSongInstructions: Event<UpdateInstructions> val genreSongInstructions: Event<UpdateInstructions>
get() = _genreSongInstructions get() = _genreSongInstructions
@ -176,24 +169,20 @@ constructor(
// --- PLAYLIST --- // --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null) private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */ /** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?> val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist get() = _currentPlaylist
private val _playlistSongList = MutableStateFlow(listOf<Item>()) private val _playlistSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */ /** The current list data derived from [currentPlaylist] */
val playlistSongList: StateFlow<List<Item>> = _playlistSongList val playlistSongList: StateFlow<List<Item>> = _playlistSongList
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>() private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistSongList] in the UI. */ /** Instructions for updating [playlistSongList] in the UI. */
val playlistSongInstructions: Event<UpdateInstructions> val playlistSongInstructions: Event<UpdateInstructions>
get() = _playlistSongInstructions get() = _playlistSongInstructions
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null) private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/** /**
* The new playlist songs created during the current editing session. Null if no editing session * The new playlist songs created during the current editing session. Null if no editing session
* is occurring. * is occurring.
@ -319,14 +308,14 @@ constructor(
} }
/** /**
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with * Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* the new [Song]. * be updated to align with the new [Song].
* *
* @param uid The UID of the [Song] to load. Must be valid. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSong(uid: Music.UID) { fun setSong(uid: Music.UID) {
L.d("Opening song $uid") L.d("Opening song $uid")
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo) _currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
if (_currentSong.value == null) { if (_currentSong.value == null) {
L.w("Given song UID was invalid") L.w("Given song UID was invalid")
} }
@ -522,32 +511,16 @@ constructor(
} }
private fun refreshAudioInfo(song: Song) { private fun refreshAudioInfo(song: Song) {
_currentSongProperties.value = buildList { L.d("Refreshing audio info")
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song))) // Clear any previous job in order to avoid stale data from appearing in the UI.
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album))) currentSongJob?.cancel()
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists))) _songAudioProperties.value = null
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres))) currentSongJob =
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) } viewModelScope.launch(Dispatchers.IO) {
song.track?.let { val info = audioPropertiesFactory.extract(song)
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null))) yield()
} L.d("Updating audio info to $info")
song.disc?.let { _songAudioProperties.value = info
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
}
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
add(
SongProperty(
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, SongProperty.Value.Decibels(it)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, SongProperty.Value.Decibels(it)))
}
} }
} }

View file

@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
@ -39,11 +43,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -133,15 +132,9 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
binding.detailPlayButton?.setOnClickListener { binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
} }
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailShuffleButton?.setOnClickListener { binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
} }
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }

View file

@ -35,9 +35,13 @@ import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.DialogAwareNavigationListener
@ -48,11 +52,6 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -232,24 +231,12 @@ class PlaylistDetailFragment :
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
} }
} }
binding.detailToolbarPlay.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailShuffleButton?.apply { binding.detailShuffleButton?.apply {
isEnabled = playable isEnabled = playable
setOnClickListener { setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
} }
} }
binding.detailToolbarShuffle.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }

View file

@ -18,7 +18,9 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
@ -30,9 +32,16 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.list.SongProperty import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Song import org.oxycblt.auxio.util.concatLocalized
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -62,19 +71,74 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSong(args.songUid) detailModel.setSong(args.songUid)
detailModel.toShow.consume() detailModel.toShow.consume()
collectImmediately(detailModel.currentSong, ::updateSong) collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
} }
private fun updateSong(song: Song?) { private fun updateSong(song: Song?, info: AudioProperties?) {
L.d("No song to show, navigating away")
if (song == null) { if (song == null) {
L.d("No song to show, navigating away")
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
if (info != null) {
val context = requireContext()
detailAdapter.update(
buildList {
add(SongProperty(R.string.lbl_name, song.zipName(context)))
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
song.track?.let {
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
}
song.disc?.let {
val formattedNumber = getString(R.string.fmt_number, it.number)
val zipped =
if (it.name != null) {
getString(R.string.fmt_zipped_names, formattedNumber, it.name)
} else {
formattedNumber
}
add(SongProperty(R.string.lbl_disc, zipped))
}
add(SongProperty(R.string.lbl_path, song.path.resolve(context)))
info.resolvedMimeType.resolveName(context)?.let {
add(SongProperty(R.string.lbl_format, it))
}
add(
SongProperty(
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
info.bitrateKbps?.let {
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
}
info.sampleRateHz?.let {
add(
SongProperty(
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
}
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, it.formatDb(context)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, it.formatDb(context)))
}
},
UpdateInstructions.Replace(0))
}
} }
private fun updateSongProperties(songProperties: List<SongProperty>) { private fun <T : Music> T.zipName(context: Context): String {
detailAdapter.update(songProperties, UpdateInstructions.Replace(0)) val name = name
return if (name is Name.Known && name.sort != null) {
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else {
name.resolve(context)
} }
}
private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }
} }

View file

@ -25,10 +25,9 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist
/** /**
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with * A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with

View file

@ -23,12 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -56,9 +56,9 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return if (!changes.deviceLibrary) return
val library = musicRepository.library ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
// Need to sanitize different items depending on the current set of choices. // Need to sanitize different items depending on the current set of choices.
_artistChoices.value = _artistChoices.value?.sanitize(library) _artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
L.d("Updated artist choices: ${_artistChoices.value}") L.d("Updated artist choices: ${_artistChoices.value}")
} }
@ -98,15 +98,16 @@ sealed interface ArtistShowChoices {
val uid: Music.UID val uid: Music.UID
/** The current [Artist] choices. */ /** The current [Artist] choices. */
val choices: List<Artist> val choices: List<Artist>
/** Sanitize this instance with a [Library]. */ /** Sanitize this instance with a [DeviceLibrary]. */
fun sanitize(newLibrary: Library): ArtistShowChoices? fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */ /** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
class FromSong(val song: Song) : ArtistShowChoices { class FromSong(val song: Song) : ArtistShowChoices {
override val uid = song.uid override val uid = song.uid
override val choices = song.artists override val choices = song.artists
override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) } override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findSong(uid)?.let { FromSong(it) }
} }
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */ /** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
@ -114,7 +115,7 @@ sealed interface ArtistShowChoices {
override val uid = album.uid override val uid = album.uid
override val choices = album.artists override val choices = album.artists
override fun sanitize(newLibrary: Library) = override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findAlbum(uid)?.let { FromAlbum(it) } newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
} }
} }

View file

@ -32,9 +32,9 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -35,14 +35,14 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.resolveNumber
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
/** /**
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view. * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
@ -121,7 +121,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
*/ */
fun bind(discHeader: DiscHeader) { fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner val disc = discHeader.inner
binding.discNumber.text = disc.resolve(binding.context) binding.discNumber.text = disc.resolveNumber(binding.context)
binding.discName.apply { binding.discName.apply {
text = disc?.name text = disc?.name
isGone = disc?.name == null isGone = disc?.name == null

View file

@ -29,13 +29,12 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
/** /**
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view. * A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
@ -105,7 +104,8 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
binding.parentName.text = album.name.resolve(binding.context) binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information // Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date) album.dates?.resolveDate(binding.context)
?: binding.context.getString(R.string.def_date)
} }
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {

View file

@ -35,9 +35,9 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
import org.oxycblt.auxio.list.recycler.DividerViewHolder import org.oxycblt.auxio.list.recycler.DividerViewHolder
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
/** /**
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the * A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the

View file

@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.musikr.Song import org.oxycblt.auxio.music.Song
/** /**
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view. * A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.

View file

@ -40,13 +40,12 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -18,26 +18,17 @@
package org.oxycblt.auxio.detail.list package org.oxycblt.auxio.detail.list
import android.text.format.Formatter
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date
/** /**
* An adapter for [SongProperty] instances. * An adapter for [SongProperty] instances.
@ -62,31 +53,7 @@ class SongPropertyAdapter :
* @param value The value of the property. * @param value The value of the property.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class SongProperty(@StringRes val name: Int, val value: Value) { data class SongProperty(@StringRes val name: Int, val value: String) : Item
sealed interface Value {
data class MusicName(val music: Music) : Value
data class MusicNames(val name: List<Music>) : Value
data class Number(val value: Int, val subtitle: String?) : Value
data class ItemDate(val date: Date) : Value
data class ItemPath(val path: Path) : Value
data class Size(val sizeBytes: Long) : Value
data class Duration(val durationMs: Long) : Value
data class ItemFormat(val format: Format) : Value
data class Bitrate(val kbps: Int) : Value
data class SampleRate(val hz: Int) : Value
data class Decibels(val value: Float) : Value
}
}
/** /**
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
@ -98,58 +65,7 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
fun bind(property: SongProperty) { fun bind(property: SongProperty) {
val context = binding.context val context = binding.context
binding.propertyName.hint = context.getString(property.name) binding.propertyName.hint = context.getString(property.name)
when (property.value) { binding.propertyValue.setText(property.value)
is SongProperty.Value.MusicName -> {
val music = property.value.music
binding.propertyValue.setText(music.name.resolve(context))
}
is SongProperty.Value.MusicNames -> {
val names = property.value.name.resolveNames(context)
binding.propertyValue.setText(names)
}
is SongProperty.Value.Number -> {
val value = context.getString(R.string.fmt_number, property.value.value)
val subtitle = property.value.subtitle
binding.propertyValue.setText(
if (subtitle != null) {
context.getString(R.string.fmt_zipped_names, value, subtitle)
} else {
value
})
}
is SongProperty.Value.ItemDate -> {
val date = property.value.date
binding.propertyValue.setText(date.resolve(context))
}
is SongProperty.Value.ItemPath -> {
val path = property.value.path
binding.propertyValue.setText(path.resolve(context))
}
is SongProperty.Value.Size -> {
val size = property.value.sizeBytes
binding.propertyValue.setText(Formatter.formatFileSize(context, size))
}
is SongProperty.Value.Duration -> {
val duration = property.value.durationMs
binding.propertyValue.setText(duration.formatDurationMs(true))
}
is SongProperty.Value.ItemFormat -> {
val format = property.value.format
binding.propertyValue.setText(format.resolve(context))
}
is SongProperty.Value.Bitrate -> {
val kbps = property.value.kbps
binding.propertyValue.setText(context.getString(R.string.fmt_bitrate, kbps))
}
is SongProperty.Value.SampleRate -> {
val hz = property.value.hz
binding.propertyValue.setText(context.getString(R.string.fmt_sample_rate, hz))
}
is SongProperty.Value.Decibels -> {
val value = property.value.value
binding.propertyValue.setText(value.formatDb(context))
}
}
} }
companion object { companion object {

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.ui package org.oxycblt.auxio.home
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
@ -40,6 +40,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Prevent excessive layouts by using translation instead of padding.
updatePadding(bottom = insets.systemBarInsetsCompat.bottom) updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets return insets
} }

View file

@ -24,11 +24,9 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.openInBrowser import org.oxycblt.auxio.util.openInBrowser
@ -44,12 +42,10 @@ import org.oxycblt.auxio.util.showToast
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() { class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
private val args: ErrorDetailsDialogArgs by navArgs() private val args: ErrorDetailsDialogArgs by navArgs()
private var clipboardManager: ClipboardManager? = null private var clipboardManager: ClipboardManager? = null
private val musicModel: MusicViewModel by viewModels()
override fun onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
builder builder
.setTitle(R.string.lbl_error_info) .setTitle(R.string.lbl_error_info)
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
.setPositiveButton(R.string.lbl_report) { _, _ -> .setPositiveButton(R.string.lbl_report) { _, _ ->
requireContext().openInBrowser(LINK_ISSUES) requireContext().openInBrowser(LINK_ISSUES)
} }

View file

@ -22,10 +22,10 @@ import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuCompat import androidx.core.view.MenuCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -37,10 +37,12 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding import org.oxycblt.auxio.databinding.FragmentHomeBinding
@ -51,27 +53,31 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.PlaylistListFragment import org.oxycblt.auxio.home.list.PlaylistListFragment
import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.NamedTabStrategy import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectionFragment import org.oxycblt.auxio.list.SelectionFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.NoAudioPermissionException
import org.oxycblt.auxio.music.NoMusicException
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -172,7 +178,6 @@ class HomeFragment :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate) collect(homeModel.recreateTabs.flow, ::handleRecreate)
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
@ -266,7 +271,9 @@ class HomeFragment :
// Set up the mapping between the ViewPager and TabLayout. // Set up the mapping between the ViewPager and TabLayout.
TabLayoutMediator( TabLayoutMediator(
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes)) binding.homeTabs,
binding.homePager,
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
.attach() .attach()
} }
@ -297,49 +304,98 @@ class HomeFragment :
homeModel.recreateTabs.consume() homeModel.recreateTabs.consume()
} }
private fun handleChooseFolders(unit: Unit?) {
if (unit == null) {
return
}
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
homeModel.chooseMusicLocations.consume()
}
private fun updateIndexerState(state: IndexingState?) { private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding() val binding = requireBinding()
when (state) { when (state) {
is IndexingState.Completed -> { is IndexingState.Completed -> setupCompleteState(binding, state.error)
binding.homeIndexingContainer.isInvisible = state.error == null is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
binding.homeIndexingProgress.isInvisible = state.error != null
binding.homeIndexingError.isInvisible = state.error == null
if (state.error != null) {
binding.homeIndexingContainer.setOnClickListener {
findNavController()
.navigateSafe(HomeFragmentDirections.reportError(state.error))
}
} else {
binding.homeIndexingContainer.setOnClickListener(null)
}
}
is IndexingState.Indexing -> {
binding.homeIndexingContainer.isInvisible = false
binding.homeIndexingProgress.apply {
isInvisible = false
when (state.progress) {
is IndexingProgress.Songs -> {
isIndeterminate = false
progress = state.progress.loaded
max = state.progress.explored
}
is IndexingProgress.Indeterminate -> {
isIndeterminate = true
}
}
}
binding.homeIndexingError.isInvisible = true
}
null -> { null -> {
binding.homeIndexingContainer.isInvisible = true L.d("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
}
}
}
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) {
L.d("Received ok response")
binding.homeIndexingContainer.visibility = View.INVISIBLE
return
}
L.d("Received non-ok response")
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
binding.homeIndexingActions.visibility = View.VISIBLE
when (error) {
is NoAudioPermissionException -> {
L.d("Showing permission prompt")
binding.homeIndexingStatus.setText(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingTry.apply {
text = context.getString(R.string.lbl_grant)
setOnClickListener {
requireNotNull(storagePermissionLauncher) {
"Permission launcher was not available"
}
.launch(PERMISSION_READ_AUDIO)
}
}
binding.homeIndexingMore.visibility = View.GONE
}
is NoMusicException -> {
L.d("Showing no music error")
binding.homeIndexingStatus.setText(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
binding.homeIndexingMore.visibility = View.GONE
}
else -> {
L.d("Showing generic error")
binding.homeIndexingStatus.setText(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
binding.homeIndexingMore.apply {
visibility = View.VISIBLE
setOnClickListener {
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
}
}
}
}
}
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingActions.visibility = View.INVISIBLE
binding.homeIndexingStatus.setText(R.string.lng_indexing)
when (progress) {
is IndexingProgress.Indeterminate -> {
// In a query/initialization state, show a generic loading status.
binding.homeIndexingProgress.isIndeterminate = true
}
is IndexingProgress.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingProgress.apply {
isIndeterminate = false
max = progress.total
this.progress = progress.current
}
} }
} }
} }
@ -508,5 +564,11 @@ class HomeFragment :
private companion object { private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop") val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
} }
} }

View file

@ -22,13 +22,13 @@ import javax.inject.Inject
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Playlist
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
interface HomeGenerator { interface HomeGenerator {
@ -36,8 +36,6 @@ interface HomeGenerator {
fun release() fun release()
fun empty(): Boolean
fun songs(): List<Song> fun songs(): List<Song>
fun albums(): List<Album> fun albums(): List<Album>
@ -51,8 +49,6 @@ interface HomeGenerator {
fun tabs(): List<MusicType> fun tabs(): List<MusicType>
interface Invalidator { interface Invalidator {
fun invalidateEmpty() {}
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
fun invalidateTabs() fun invalidateTabs()
@ -123,10 +119,8 @@ private class HomeGeneratorImpl(
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
invalidator.invalidateEmpty() val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
val library = musicRepository.library
if (changes.deviceLibrary && library != null) {
L.d("Refreshing library") L.d("Refreshing library")
// Get the each list of items in the library to use as our list data. // Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them. // Applying the preferred sorting to them.
@ -136,7 +130,8 @@ private class HomeGeneratorImpl(
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff) invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
} }
if (changes.userLibrary && library != null) { val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
L.d("Refreshing playlists") L.d("Refreshing playlists")
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
} }
@ -148,16 +143,15 @@ private class HomeGeneratorImpl(
homeSettings.unregisterListener(this) homeSettings.unregisterListener(this)
} }
override fun empty() = musicRepository.library?.empty() ?: true
override fun songs() = override fun songs() =
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
override fun albums() = override fun albums() =
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList() musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) }
?: emptyList()
override fun artists() = override fun artists() =
musicRepository.library?.let { deviceLibrary -> musicRepository.deviceLibrary?.let { deviceLibrary ->
val sorted = listSettings.artistSort.artists(deviceLibrary.artists) val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
if (homeSettings.shouldHideCollaborators) { if (homeSettings.shouldHideCollaborators) {
sorted.filter { it.explicitAlbums.isNotEmpty() } sorted.filter { it.explicitAlbums.isNotEmpty() }
@ -167,10 +161,11 @@ private class HomeGeneratorImpl(
} ?: emptyList() } ?: emptyList()
override fun genres() = override fun genres() =
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList() musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) }
?: emptyList()
override fun playlists() = override fun playlists() =
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) } musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) }
?: emptyList() ?: emptyList()
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type } override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }

View file

@ -27,16 +27,16 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -120,10 +120,6 @@ constructor(
val playlistList: StateFlow<List<Playlist>> val playlistList: StateFlow<List<Playlist>>
get() = _playlistList get() = _playlistList
private val _empty = MutableStateFlow(false)
val empty: StateFlow<Boolean>
get() = _empty
private val _playlistInstructions = MutableEvent<UpdateInstructions>() private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genreList] in the UI. */ /** Instructions for how to update [genreList] in the UI. */
val playlistInstructions: Event<UpdateInstructions> val playlistInstructions: Event<UpdateInstructions>
@ -163,10 +159,6 @@ constructor(
val showOuter: Event<Outer> val showOuter: Event<Outer>
get() = _showOuter get() = _showOuter
private val _chooseMusicLocations = MutableEvent<Unit>()
val chooseMusicLocations: Event<Unit>
get() = _chooseMusicLocations
init { init {
homeGenerator.attach() homeGenerator.attach()
} }
@ -176,10 +168,6 @@ constructor(
homeGenerator.release() homeGenerator.release()
} }
override fun invalidateEmpty() {
_empty.value = homeGenerator.empty()
}
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
when (type) { when (type) {
MusicType.SONGS -> { MusicType.SONGS -> {
@ -275,10 +263,6 @@ constructor(
_isFastScrolling.value = isFastScrolling _isFastScrolling.value = isFastScrolling
} }
fun startChooseMusicLocations() {
_chooseMusicLocations.put(Unit)
}
fun showSettings() { fun showSettings() {
_showOuter.put(Outer.Settings) _showOuter.put(Outer.Settings)
} }

View file

@ -190,8 +190,6 @@ class ThemedSpeedDialView : SpeedDialView {
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f) val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor) overlayLayout.setBackgroundColor(overlayColor)
} }
// Fix default margins added by library
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
} }
private fun Int.withModulatedAlpha( private fun Int.withModulatedAlpha(
@ -232,24 +230,13 @@ class ThemedSpeedDialView : SpeedDialView {
return super.addActionItem(actionItem, position, animate)?.apply { return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply { fab.apply {
updateLayoutParams<MarginLayoutParams> { updateLayoutParams<MarginLayoutParams> {
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny) val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
if (position == actionItems.lastIndex) { setMargins(horizontalMargin, 0, horizontalMargin, 0)
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
} else {
setMargins(0, 0, rightMargin, 0)
}
} }
useCompatPadding = false useCompatPadding = false
} }
labelBackground.apply { labelBackground.apply {
updateLayoutParams<MarginLayoutParams> {
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
}
}
useCompatPadding = false useCompatPadding = false
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall) setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
background = background =

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
@ -38,16 +36,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/** /**
* A [ListFragment] that shows a list of [Album]s. * A [ListFragment] that shows a list of [Album]s.
@ -82,16 +79,7 @@ class AlbumListFragment :
listener = this@AlbumListFragment listener = this@AlbumListFragment
} }
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_album_48)
contentDescription = getString(R.string.lbl_albums)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_albums)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.albumList, ::updateAlbums) collectImmediately(homeModel.albumList, ::updateAlbums)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -111,10 +99,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.albumSort.mode) { return when (homeModel.albumSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> album.name.thumb() is Sort.Mode.ByName -> album.name.thumb
// By Artist -> Use name of first artist // By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> album.artists[0].name.thumb() is Sort.Mode.ByArtist -> album.artists[0].name.thumb
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd) // Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) } is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
@ -127,7 +115,7 @@ class AlbumListFragment :
// Last added -> Format as date // Last added -> Format as date
is Sort.Mode.ByDateAdded -> { is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = album.addedMs val dateAddedMillis = album.dateAdded.secsToMs()
formatterSb.setLength(0) formatterSb.setLength(0)
DateUtils.formatDateRange( DateUtils.formatDateRange(
context, context,
@ -159,14 +147,6 @@ class AlbumListFragment :
albumAdapter.update(albums, homeModel.albumInstructions.consume()) albumAdapter.update(albums, homeModel.albumInstructions.consume())
} }
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -36,16 +34,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.positiveOrNull import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/** /**
* A [ListFragment] that shows a list of [Artist]s. * A [ListFragment] that shows a list of [Artist]s.
@ -77,16 +74,7 @@ class ArtistListFragment :
listener = this@ArtistListFragment listener = this@ArtistListFragment
} }
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_artist_48)
contentDescription = getString(R.string.lbl_artists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_artists)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.artistList, ::updateArtists) collectImmediately(homeModel.artistList, ::updateArtists)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -106,7 +94,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.artistSort.mode) { return when (homeModel.artistSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> artist.name.thumb() is Sort.Mode.ByName -> artist.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -135,14 +123,6 @@ class ArtistListFragment :
artistAdapter.update(artists, homeModel.artistInstructions.consume()) artistAdapter.update(artists, homeModel.artistInstructions.consume())
} }
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -36,15 +34,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.GenreViewHolder import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/** /**
* A [ListFragment] that shows a list of [Genre]s. * A [ListFragment] that shows a list of [Genre]s.
@ -76,16 +73,7 @@ class GenreListFragment :
listener = this@GenreListFragment listener = this@GenreListFragment
} }
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_genre_48)
contentDescription = getString(R.string.lbl_genres)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_genres)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.genreList, ::updateGenres) collectImmediately(homeModel.genreList, ::updateGenres)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -105,7 +93,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.genreSort.mode) { return when (homeModel.genreSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> genre.name.thumb() is Sort.Mode.ByName -> genre.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -134,14 +122,6 @@ class GenreListFragment :
genreAdapter.update(genres, homeModel.genreInstructions.consume()) genreAdapter.update(genres, homeModel.genreInstructions.consume())
} }
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* ListUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.list
import androidx.core.text.isDigitsOnly
import org.oxycblt.musikr.tag.Name
fun Name.thumb() =
when (this) {
is Name.Known ->
tokens.firstOrNull()?.let {
if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase()
}
is Name.Unknown -> "?"
}

View file

@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
@ -35,15 +33,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/** /**
* A [ListFragment] that shows a list of [Playlist]s. * A [ListFragment] that shows a list of [Playlist]s.
@ -74,18 +71,7 @@ class PlaylistListFragment :
listener = this@PlaylistListFragment listener = this@PlaylistListFragment
} }
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_playlist_48)
contentDescription = getString(R.string.lbl_playlists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_playlists)
collectImmediately(homeModel.playlistList, ::updatePlaylists) collectImmediately(homeModel.playlistList, ::updatePlaylists)
collectImmediately(
homeModel.empty,
homeModel.playlistList,
musicModel.indexingState,
::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -105,7 +91,7 @@ class PlaylistListFragment :
// Change how we display the popup depending on the current sort mode. // Change how we display the popup depending on the current sort mode.
return when (homeModel.playlistSort.mode) { return when (homeModel.playlistSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> playlist.name.thumb() is Sort.Mode.ByName -> playlist.name.thumb
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
@ -134,26 +120,6 @@ class PlaylistListFragment :
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume()) playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
} }
private fun updateNoMusicIndicator(
empty: Boolean,
playlists: List<Playlist>,
indexingState: IndexingState?
) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty && playlists.isNotEmpty()
if (!empty && playlists.isEmpty()) {
binding.homeNoMusicAction.isVisible = true
binding.homeNoMusicAction.text = getString(R.string.lbl_new_playlist)
binding.homeNoMusicAction.setOnClickListener { musicModel.createPlaylist() }
} else {
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
binding.homeNoMusicAction.text = getString(R.string.lbl_music_sources)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
}
}
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -22,8 +22,6 @@ import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
@ -37,15 +35,14 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/** /**
* A [ListFragment] that shows a list of [Song]s. * A [ListFragment] that shows a list of [Song]s.
@ -62,7 +59,6 @@ class SongListFragment :
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
private val songAdapter = SongAdapter(this) private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text // Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64) private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb) private val formatter = Formatter(formatterSb)
@ -80,16 +76,7 @@ class SongListFragment :
listener = this@SongListFragment listener = this@SongListFragment
} }
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_song_48)
contentDescription = getString(R.string.lbl_songs)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_songs)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.songList, ::updateSongs) collectImmediately(homeModel.songList, ::updateSongs)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -111,23 +98,23 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects. // based off the names of the parent objects and not the child objects.
return when (homeModel.songSort.mode) { return when (homeModel.songSort.mode) {
// Name -> Use name // Name -> Use name
is Sort.Mode.ByName -> song.name.thumb() is Sort.Mode.ByName -> song.name.thumb
// Artist -> Use name of first artist // Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb() is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
// Album -> Use Album Name // Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.name.thumb() is Sort.Mode.ByAlbum -> song.album.name.thumb
// Year -> Use Full Year // Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolve(requireContext()) is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
// Last added -> Format as date // Last added -> Format as date
is Sort.Mode.ByDateAdded -> { is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = song.addedMs val dateAddedMillis = song.dateAdded.secsToMs()
formatterSb.setLength(0) formatterSb.setLength(0)
DateUtils.formatDateRange( DateUtils.formatDateRange(
context, context,
@ -159,14 +146,6 @@ class SongListFragment :
songAdapter.update(songs, homeModel.songInstructions.consume()) songAdapter.update(songs, homeModel.songInstructions.consume())
} }
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) { private fun updateSelection(selection: List<Music>) {
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2022 Auxio Project
* AdaptiveTabStrategy.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.tabs
import android.content.Context
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicType
/**
* 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<MusicType>) :
TabLayoutMediator.TabConfigurationStrategy {
private val width = context.resources.configuration.smallestScreenWidthDp
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val homeTab = tabs[position]
val icon =
when (homeTab) {
MusicType.SONGS -> R.drawable.ic_song_24
MusicType.ALBUMS -> R.drawable.ic_album_24
MusicType.ARTISTS -> R.drawable.ic_artist_24
MusicType.GENRES -> R.drawable.ic_genre_24
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
}
// Use expected sw* size thresholds when choosing a configuration.
when {
// On small screens, only display an icon.
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
// On large screens, display an icon and text.
width < 600 -> tab.setText(homeTab.nameRes)
// On medium-size screens, display text.
else -> tab.setIcon(icon).setText(homeTab.nameRes)
}
}
}

View file

@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* NamedTabStrategy.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.tabs
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import org.oxycblt.auxio.music.MusicType
class NamedTabStrategy(private val homeTabs: List<MusicType>) : TabConfigurationStrategy {
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(homeTabs[position].nameRes)
}
}

View file

@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import coil3.ImageLoader import androidx.core.graphics.drawable.toBitmap
import coil3.request.Disposable import coil.ImageLoader
import coil3.request.ImageRequest import coil.request.Disposable
import coil3.size.Size import coil.request.ImageRequest
import coil3.toBitmap import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.musikr.Song import org.oxycblt.auxio.music.Song
/** /**
* A utility to provide bitmaps in a race-less manner. * A utility to provide bitmaps in a race-less manner.
@ -94,7 +94,7 @@ constructor(
target target
.onConfigRequest( .onConfigRequest(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(song.cover) .data(listOf(song.cover))
// Use ORIGINAL sizing, as we are not loading into any View-like component. // Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)) .size(Size.ORIGINAL))
.target( .target(

View file

@ -26,11 +26,12 @@ import org.oxycblt.auxio.IntegerTable
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
enum class CoverMode { enum class CoverMode {
/** Do not load album covers ("Off"). */
OFF, OFF,
SAVE_SPACE, /** Load covers from the fast, but lower-quality media store database ("Fast"). */
BALANCED, MEDIA_STORE,
HIGH_QUALITY, /** Load high-quality covers directly from music files ("Quality"). */
AS_IS; QUALITY;
/** /**
* The integer representation of this instance. * The integer representation of this instance.
@ -41,10 +42,8 @@ enum class CoverMode {
get() = get() =
when (this) { when (this) {
OFF -> IntegerTable.COVER_MODE_OFF OFF -> IntegerTable.COVER_MODE_OFF
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
BALANCED -> IntegerTable.COVER_MODE_BALANCED QUALITY -> IntegerTable.COVER_MODE_QUALITY
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
AS_IS -> IntegerTable.COVER_MODE_AS_IS
} }
companion object { companion object {
@ -58,10 +57,8 @@ enum class CoverMode {
fun fromIntCode(intCode: Int) = fun fromIntCode(intCode: Int) =
when (intCode) { when (intCode) {
IntegerTable.COVER_MODE_OFF -> OFF IntegerTable.COVER_MODE_OFF -> OFF
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
IntegerTable.COVER_MODE_BALANCED -> BALANCED IntegerTable.COVER_MODE_QUALITY -> QUALITY
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
IntegerTable.COVER_MODE_AS_IS -> AS_IS
else -> null else -> null
} }
} }

View file

@ -1,86 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* CoverProvider.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image
import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.runBlocking
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.musikr.covers.CoverResult
class CoverProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (mode != "r" || uriMatcher.match(uri) != 1) {
return null
}
val id = uri.lastPathSegment ?: return null
return runBlocking {
when (val result = SettingCovers.immutable(requireNotNull(context)).obtain(id)) {
is CoverResult.Hit -> result.cover.fd()
else -> null
}
}
}
override fun getType(uri: Uri): String {
check(uriMatcher.match(uri) == 1) { "Unknown URI: $uri" }
return "image/*"
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor = throw UnsupportedOperationException()
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
companion object {
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.image.CoverProvider"
private const val IMAGES_PATH = "covers"
private val uriMatcher =
UriMatcher(UriMatcher.NO_MATCH).apply { addURI(AUTHORITY, "$IMAGES_PATH/*", 1) }
val CONTENT_URI: Uri =
Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(IMAGES_PATH)
.build()
}
}

View file

@ -37,35 +37,31 @@ import androidx.annotation.DrawableRes
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isEmpty
import androidx.core.view.updateMarginsRelative import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat import androidx.core.widget.ImageViewCompat
import coil3.ImageLoader import coil.ImageLoader
import coil3.asImage import coil.request.ImageRequest
import coil3.request.ImageRequest import coil.util.CoilUtils
import coil3.request.target
import coil3.request.transformations
import coil3.util.CoilUtils
import com.google.android.material.R as MR import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.coil.RoundedRectTransformation import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.coil.SquareCropTransformation import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.MaterialFader import org.oxycblt.auxio.ui.MaterialFader
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.covers.CoverCollection
/** /**
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and * Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
@ -173,7 +169,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.onFinishInflate() super.onFinishInflate()
// The image isn't added if other children have populated the body. This is by design. // The image isn't added if other children have populated the body. This is by design.
if (isEmpty()) { if (childCount == 0) {
addView(image) addView(image)
} }
@ -317,7 +313,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(song: Song) = fun bind(song: Song) =
bindImpl( bindImpl(
song.cover, listOf(song.cover),
context.getString(R.string.desc_album_cover, song.album.name), context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -328,7 +324,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(album: Album) = fun bind(album: Album) =
bindImpl( bindImpl(
album.covers, album.cover.all,
context.getString(R.string.desc_album_cover, album.name), context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -339,7 +335,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(artist: Artist) = fun bind(artist: Artist) =
bindImpl( bindImpl(
artist.covers, artist.cover.all,
context.getString(R.string.desc_artist_image, artist.name), context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24) R.drawable.ic_artist_24)
@ -350,7 +346,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(genre: Genre) = fun bind(genre: Genre) =
bindImpl( bindImpl(
genre.covers, genre.cover.all,
context.getString(R.string.desc_genre_image, genre.name), context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24) R.drawable.ic_genre_24)
@ -361,7 +357,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist) =
bindImpl( bindImpl(
playlist.covers, playlist.cover?.all ?: emptyList(),
context.getString(R.string.desc_playlist_image, playlist.name), context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24) R.drawable.ic_playlist_24)
@ -373,15 +369,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded. * @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/ */
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) = fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes) bindImpl(Cover.order(songs), desc, errorRes)
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) { private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
val request = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(cover) .data(covers)
.error( .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize))
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
.asImage())
.target(image) .target(image)
val cornersTransformation = val cornersTransformation =
@ -410,7 +404,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
@Px val iconSize: Int? @Px val iconSize: Int?
) : Drawable() { ) : Drawable() {
init { init {
// Re-tint the drawable to use the analogous "on surface" color for // Re-tint the drawable to use the analogous "on surfaceg" color for
// StyledImageView. // StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
} }

View file

@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
get() = get() =
CoverMode.fromIntCode( CoverMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.BALANCED ?: CoverMode.MEDIA_STORE
override val forceSquareCovers: Boolean override val forceSquareCovers: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false) get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
@ -64,8 +64,8 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
when { when {
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) -> !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
CoverMode.BALANCED CoverMode.MEDIA_STORE
else -> CoverMode.BALANCED else -> CoverMode.QUALITY
} }
sharedPreferences.edit { sharedPreferences.edit {
@ -74,24 +74,6 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
remove(OLD_KEY_QUALITY_COVERS) remove(OLD_KEY_QUALITY_COVERS)
} }
} }
if (sharedPreferences.contains(OLD_KEY_COVER_MODE)) {
L.d("Migrating cover mode setting")
var mode =
CoverMode.fromIntCode(sharedPreferences.getInt(OLD_KEY_COVER_MODE, Int.MIN_VALUE))
?: CoverMode.BALANCED
if (mode == CoverMode.HIGH_QUALITY) {
// High quality now has space characteristics that could be
// undesirable, clamp to balanced.
mode = CoverMode.BALANCED
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_cover_mode), mode.intCode)
remove(OLD_KEY_COVER_MODE)
}
}
} }
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) { override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
@ -105,6 +87,5 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
private companion object { private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS" const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS" const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
} }
} }

View file

@ -1,140 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverCollectionFetcher.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Canvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.pxOrElse
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.withContext
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.CoverCollection
class CoverCollectionFetcher
private constructor(
private val context: Context,
private val covers: CoverCollection,
private val size: Size,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a
// mosaic.
return createMosaic(streams, size).also {
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceFetchResult(
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap = createBitmap(mosaicSize.width, mosaicSize.height)
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
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.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 ImageFetchResult(
image = mosaicBitmap.toDrawable(context.resources).asImage(),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
CoverCollectionFetcher(options.context, data, options.size)
}
}

View file

@ -1,47 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverFetcher.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import javax.inject.Inject
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.Cover
class CoverFetcher private constructor(private val cover: Cover) : Fetcher {
override suspend fun fetch(): FetchResult? {
val stream = cover.open() ?: return null
return SourceFetchResult(
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data)
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* Keyers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import coil3.key.Keyer
import coil3.request.Options
import javax.inject.Inject
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
override fun key(data: CoverCollection, options: Options) =
"multi:${data.hashCode()}&${options.size}"
}

View file

@ -1,42 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* NullCovers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
override suspend fun cleanup(excluding: Collection<Cover>) {
storage.ls(setOf()).map { storage.rm(it) }
}
}
data object NullCover : Cover {
override val id = "null"
override suspend fun open() = null
}

View file

@ -1,26 +0,0 @@
/*
* Copyright (c) 2025 Auxio Project
* RevisionedTranscoding.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import java.util.UUID
import org.oxycblt.musikr.covers.stored.Transcoding
class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
override val tag = "_$revision${inner.tag}"
}

View file

@ -1,73 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* SettingCovers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import android.content.Context
import android.graphics.Bitmap
import java.util.UUID
import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.FDCover
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.chained.ChainedCovers
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
import org.oxycblt.musikr.covers.fs.FSCovers
import org.oxycblt.musikr.covers.fs.MutableFSCovers
import org.oxycblt.musikr.covers.stored.Compress
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.covers.stored.MutableStoredCovers
import org.oxycblt.musikr.covers.stored.NoTranscoding
import org.oxycblt.musikr.covers.stored.StoredCovers
interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object {
suspend fun immutable(context: Context): Covers<FDCover> =
ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
}
}
class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings) :
SettingCovers {
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
val coverStorage = CoverStorage.at(context.coversDir())
val transcoding =
when (imageSettings.coverMode) {
CoverMode.OFF -> return NullCovers(coverStorage)
CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
CoverMode.AS_IS -> NoTranscoding
}
val revisionedTranscoding = RevisionedTranscoding(revision, transcoding)
val storedCovers =
MutableStoredCovers(
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
val fsCovers = MutableFSCovers(context)
return MutableChainedCovers(storedCovers, fsCovers)
}
}
private fun Context.coversDir() = filesDir.resolve("covers").apply { mkdirs() }

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2021 Auxio Project
* Components.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import coil.ImageLoader
import coil.fetch.Fetcher
import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
override fun key(data: Collection<Cover>, options: Options) =
"${data.map { it.key }.hashCode()}"
}
class CoverFetcher
private constructor(
private val covers: Collection<Cover>,
private val size: Size,
private val coverExtractor: CoverExtractor,
) : Fetcher {
override suspend fun fetch() = coverExtractor.extract(covers, size)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data, options.size, coverExtractor)
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2023 Auxio Project
* Cover.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.net.Uri
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Song
sealed interface Cover {
val key: String
val mediaStoreCoverUri: Uri
/**
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
*/
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
Cover {
override val mediaStoreCoverUri = songCoverUri
override val key = perceptualHash
}
/**
* We couldn't find any embedded cover art ourselves, but the android system might have some
* through a cover.jpg file or something similar.
*/
data class External(val albumCoverUri: Uri) : Cover {
override val mediaStoreCoverUri = albumCoverUri
override val key = albumCoverUri.toString()
}
companion object {
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
fun order(songs: Collection<Song>) =
FALLBACK_SORT.songs(songs)
.map { it.cover }
.groupBy { it.key }
.entries
.sortedByDescending { it.value.size }
.map { it.value.first() }
}
}
data class ParentCover(val single: Cover, val all: List<Cover>) {
companion object {
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
}
}

View file

@ -0,0 +1,253 @@
/*
* Copyright (c) 2023 Auxio Project
* CoverExtractor.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.annotation.SuppressLint
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 androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
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 dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Song
import timber.log.Timber as L
/**
* Provides functionality for extracting album cover information. Meant for internal use only.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class CoverExtractor
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
* @param covers The [Cover]s to load.
* @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of the first four loaded album covers. Otherwise, a
* [SourceResult] of one album cover will be returned.
*/
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val streams = mutableListOf<InputStream>()
for (cover in covers) {
openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a mosaic.
return createMosaic(streams, size).also {
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceResult(
source = ImageSource(first.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) {
// We can only extract pictures from two tags with this method, ID3v2's APIC or
// Vorbis picture comments.
val pic: ByteArray?
val type: Int
when (val entry = metadata.get(i)) {
is ApicFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
is PictureFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
else -> continue
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
private suspend fun openCoverInputStream(cover: Cover) =
try {
when (cover) {
is Cover.Embedded ->
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(cover)
}
is Cover.External -> {
extractMediaStoreCover(cover)
}
}
} catch (e: Exception) {
L.e("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(cover: Cover.Embedded) =
extractExoplayerCover(cover)
?: extractAospMetadataCover(cover)
?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
MediaMetadataRetriever().run {
// This call is time-consuming but it also doesn't seem to hold up the main thread,
// so it's probably fine not to wrap it.rmt
setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [i.e there is no embedded cover], than just ignore it and move on
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
return findCoverDataInMetadata(metadata)
}
@SuppressLint("Recycle")
private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
// Coil will recycle this InputStream, so we don't need to worry about it.
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// 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
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.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 {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 Auxio Project
* DHash.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
@Suppress("UNUSED")
fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}

View file

@ -16,15 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import coil3.decode.DataSource import coil.decode.DataSource
import coil3.request.ImageResult import coil.drawable.CrossfadeDrawable
import coil3.request.SuccessResult import coil.request.ImageResult
import coil3.transition.CrossfadeDrawable import coil.request.SuccessResult
import coil3.transition.CrossfadeTransition import coil.transition.CrossfadeTransition
import coil3.transition.Transition import coil.transition.Transition
import coil3.transition.TransitionTarget import coil.transition.TransitionTarget
/** /**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results. * A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* CoilModule.kt is part of Auxio. * ExtractorModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import android.content.Context import android.content.Context
import coil3.ImageLoader import coil.ImageLoader
import coil3.request.CachePolicy import coil.request.CachePolicy
import coil3.request.transitionFactory
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -31,22 +30,19 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class CoilModule { class ExtractorModule {
@Singleton @Singleton
@Provides @Provides
fun imageLoader( fun imageLoader(
@ApplicationContext context: Context, @ApplicationContext context: Context,
coverKeyer: CoverKeyer, keyer: CoverKeyer,
coverFactory: CoverFetcher.Factory, factory: CoverFetcher.Factory
coverCollectionKeyer: CoverCollectionKeyer,
coverCollectionFactory: CoverCollectionFetcher.Factory
) = ) =
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
add(coverKeyer) // Add fetchers for Music components to make them usable with ImageRequest
add(coverFactory) add(keyer)
add(coverCollectionKeyer) add(factory)
add(coverCollectionFactory)
} }
// Use our own crossfade with error drawable support // Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory()) .transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Bitmap.createBitmap import android.graphics.Bitmap.createBitmap
@ -30,16 +30,16 @@ import android.graphics.RectF
import android.graphics.Shader import android.graphics.Shader
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.graphics.applyCanvas import androidx.core.graphics.applyCanvas
import coil3.decode.DecodeUtils import coil.decode.DecodeUtils
import coil3.size.Scale import coil.size.Scale
import coil3.size.Size import coil.size.Size
import coil3.size.pxOrElse import coil.size.pxOrElse
import coil3.transform.Transformation import coil.transform.Transformation
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images * A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
* without cropping them. * images without cropping them.
* *
* @author Coil Team, Alexander Capehart (OxygenCobalt) * @author Coil Team, Alexander Capehart (OxygenCobalt)
*/ */
@ -48,7 +48,7 @@ class RoundedRectTransformation(
@Px private val topRight: Float = 0f, @Px private val topRight: Float = 0f,
@Px private val bottomLeft: Float = 0f, @Px private val bottomLeft: Float = 0f,
@Px private val bottomRight: Float = 0f @Px private val bottomRight: Float = 0f
) : Transformation() { ) : Transformation {
constructor(@Px radius: Float) : this(radius, radius, radius, radius) constructor(@Px radius: Float) : this(radius, radius, radius, radius)

View file

@ -16,13 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.image.coil package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.graphics.scale import coil.size.Size
import coil3.size.Size import coil.size.pxOrElse
import coil3.size.pxOrElse import coil.transform.Transformation
import coil3.transform.Transformation
import kotlin.math.min import kotlin.math.min
/** /**
@ -31,7 +30,7 @@ import kotlin.math.min
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SquareCropTransformation : Transformation() { class SquareCropTransformation : Transformation {
override val cacheKey: String override val cacheKey: String
get() = "SquareCropTransformation" get() = "SquareCropTransformation"
@ -47,7 +46,7 @@ class SquareCropTransformation : Transformation() {
val desiredHeight = size.height.pxOrElse { dstSize } val desiredHeight = size.height.pxOrElse { dstSize }
if (dstSize != desiredWidth || dstSize != desiredHeight) { if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Image is not the desired size, upscale it. // Image is not the desired size, upscale it.
return dst.scale(desiredWidth, desiredHeight) return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
} }
return dst return dst
} }

View file

@ -22,9 +22,9 @@ import androidx.annotation.StringRes
// TODO: Consider breaking this up into sealed classes for individual adapters // TODO: Consider breaking this up into sealed classes for individual adapters
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */ /** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
typealias Item = Any interface Item
interface Header interface Header : Item
/** /**
* A "header" used for delimiting groups of data. * A "header" used for delimiting groups of data.
@ -44,7 +44,7 @@ interface PlainHeader : Header {
*/ */
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
interface Divider<T> { interface Divider<T> : Item {
val anchor: T? val anchor: T?
} }

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.Music
/** /**
* A Fragment containing a selectable list. * A Fragment containing a selectable list.

View file

@ -25,17 +25,17 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
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.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -64,17 +64,18 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
val library = musicRepository.library ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return
// Sanitize the selection to remove items that no longer exist and thus // Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list. // won't appear in any list.
_selected.value = _selected.value =
_selected.value.mapNotNull { _selected.value.mapNotNull {
when (it) { when (it) {
is Song -> library.findSong(it.uid) is Song -> deviceLibrary.findSong(it.uid)
is Album -> library.findAlbum(it.uid) is Album -> deviceLibrary.findAlbum(it.uid)
is Artist -> library.findArtist(it.uid) is Artist -> deviceLibrary.findArtist(it.uid)
is Genre -> library.findGenre(it.uid) is Genre -> deviceLibrary.findGenre(it.uid)
is Playlist -> library.findPlaylist(it.uid) is Playlist -> userLibrary.findPlaylist(it.uid)
} }
} }
} }

View file

@ -21,7 +21,7 @@ package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.Music
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
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.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/** /**
* Command to navigate to a specific menu dialog configuration. * Command to navigate to a specific menu dialog configuration.

View file

@ -27,18 +27,17 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMenuBinding import org.oxycblt.auxio.databinding.DialogMenuBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.share import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/** /**
* [MenuDialogFragment] implementation for a [Song]. * [MenuDialogFragment] implementation for a [Song].
@ -113,7 +112,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) { override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
val context = requireContext() val context = requireContext()
binding.menuCover.bind(menu.album) binding.menuCover.bind(menu.album)
binding.menuType.text = menu.album.releaseType.resolve(context) binding.menuType.text = getString(menu.album.releaseType.stringRes)
binding.menuName.text = menu.album.name.resolve(context) binding.menuName.text = menu.album.name.resolve(context)
binding.menuInfo.text = menu.album.artists.resolveNames(context) binding.menuInfo.text = menu.album.artists.resolveNames(context)
} }

View file

@ -23,9 +23,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.MusicParent
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -70,35 +70,35 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
} }
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? { private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
val song = musicRepository.library?.findSong(parcel.songUid) ?: return null val song = musicRepository.deviceLibrary?.findSong(parcel.songUid) ?: return null
val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent? val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
return Menu.ForSong(parcel.res, song, playWith) return Menu.ForSong(parcel.res, song, playWith)
} }
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? { private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? {
val album = musicRepository.library?.findAlbum(parcel.albumUid) ?: return null val album = musicRepository.deviceLibrary?.findAlbum(parcel.albumUid) ?: return null
return Menu.ForAlbum(parcel.res, album) return Menu.ForAlbum(parcel.res, album)
} }
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? { private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? {
val artist = musicRepository.library?.findArtist(parcel.artistUid) ?: return null val artist = musicRepository.deviceLibrary?.findArtist(parcel.artistUid) ?: return null
return Menu.ForArtist(parcel.res, artist) return Menu.ForArtist(parcel.res, artist)
} }
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? { private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? {
val genre = musicRepository.library?.findGenre(parcel.genreUid) ?: return null val genre = musicRepository.deviceLibrary?.findGenre(parcel.genreUid) ?: return null
return Menu.ForGenre(parcel.res, genre) return Menu.ForGenre(parcel.res, genre)
} }
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? { private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? {
val playlist = musicRepository.library?.findPlaylist(parcel.playlistUid) ?: return null val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null
return Menu.ForPlaylist(parcel.res, playlist) return Menu.ForPlaylist(parcel.res, playlist)
} }
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? { private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
val library = musicRepository.library ?: return null val deviceLibrary = musicRepository.deviceLibrary ?: return null
val songs = parcel.songUids.mapNotNull(library::findSong) val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
return Menu.ForSelection(parcel.res, songs) return Menu.ForSelection(parcel.res, songs)
} }
} }

View file

@ -19,37 +19,21 @@
package org.oxycblt.auxio.list.recycler package org.oxycblt.auxio.list.recycler
import android.animation.Animator import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isEmpty
import androidx.core.view.isInvisible
import androidx.core.view.updatePaddingRelative
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.MaterialFadingSlider
import org.oxycblt.auxio.ui.MaterialSlider import org.oxycblt.auxio.ui.MaterialSlider
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.isRtl import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder import org.oxycblt.auxio.util.isUnder
@ -78,70 +62,33 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Added drag listener * - Added drag listener
* - Added documentation * - Added documentation
* - Completely new design * - Completely new design
* - New scroll position backend
* *
* @author Hai Zhang, Alexander Capehart (OxygenCobalt) * @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*
* TODO: Add vibration when popup changes
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
*/ */
class FastScrollRecyclerView class FastScrollRecyclerView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) { AuxioRecyclerView(context, attrs, defStyleAttr) {
// Thumb // Thumb
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium) private val thumbSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium) private val slider = MaterialSlider(context, thumbSize)
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
private var thumbAnimator: Animator? = null private var thumbAnimator: Animator? = null
@SuppressLint("InflateParams")
private val thumbView = private val thumbView =
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) }
thumbSlider.jumpOut(this)
}
private val thumbPadding = Rect(0, 0, 0, 0) private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0 private var thumbOffset = 0
private var showingThumb = false private var showingThumb = false
private val hideThumbRunnable = Runnable { private val hideThumbRunnable = Runnable {
if (!dragging) { if (!dragging) {
hideThumb() hideScrollbar()
} }
} }
private val popupView =
MaterialTextView(context).apply {
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
setTextColor(
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
elevation =
context
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
.toFloat()
background = context.getDrawableCompat(R.drawable.ui_popup)
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
updatePaddingRelative(start = paddingStart, end = paddingEnd)
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
}
}
private val popupSlider =
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
jumpOut(popupView)
}
private var popupAnimator: Animator? = null
private var showingPopup = false
// Touch // Touch
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small) private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
@ -152,24 +99,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private var dragStartY = 0f private var dragStartY = 0f
private var dragStartThumbOffset = 0 private var dragStartThumbOffset = 0
private var fastScrollingPossible = true
var fastScrollingEnabled = true
set(value) {
if (field == value) {
return
}
field = value
if (!value) {
removeCallbacks(hideThumbRunnable)
hideThumb()
hidePopup()
}
listener?.onFastScrollingChanged(field)
}
private var dragging = false private var dragging = false
set(value) { set(value) {
if (field == value) { if (field == value) {
@ -187,9 +116,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (field) { if (field) {
removeCallbacks(hideThumbRunnable) removeCallbacks(hideThumbRunnable)
showScrollbar() showScrollbar()
showPopup()
} else { } else {
hidePopup()
postAutoHideScrollbar() postAutoHideScrollbar()
} }
@ -201,7 +128,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
init { init {
overlay.add(thumbView) overlay.add(thumbView)
overlay.add(popupView)
addItemDecoration( addItemDecoration(
object : ItemDecoration() { object : ItemDecoration() {
@ -230,96 +156,26 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT --- // --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() { private fun onPreDraw() {
updateThumbState() updateScrollbarState()
thumbView.layoutDirection = layoutDirection thumbView.layoutDirection = layoutDirection
thumbView.measure( thumbView.measure(
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY)) MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY))
val thumbTop = thumbPadding.top + thumbOffset val thumbTop = thumbPadding.top + thumbOffset
val thumbLeft = val thumbLeft =
if (isRtl) { if (isRtl) {
thumbPadding.left thumbPadding.left
} else { } else {
width - thumbPadding.right - thumbWidth width - thumbPadding.right - thumbSize
} }
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight) thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize)
popupView.layoutDirection = layoutDirection
val child = getChildAt(0)
val firstAdapterPos =
if (child != null) {
layoutManager?.getPosition(child) ?: NO_POSITION
} else {
NO_POSITION
}
val popupText: String
val provider = popupProvider
if (firstAdapterPos != NO_POSITION && provider != null) {
popupView.isInvisible = false
// Get the popup text. If there is none, we default to "?".
popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = false
popupText = ""
}
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) {
popupView.text = popupText
val widthMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
thumbPadding.left +
thumbPadding.right +
thumbWidth +
popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin,
popupLayoutParams.width)
val heightMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
thumbPadding.top +
thumbPadding.bottom +
popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin,
popupLayoutParams.height)
popupView.measure(widthMeasureSpec, heightMeasureSpec)
if (showingPopup) {
doPopupVibration()
}
}
val popupWidth = popupView.measuredWidth
val popupHeight = popupView.measuredHeight
val popupLeft =
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin
} else {
width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
}
val popupAnchorY = popupHeight / 2
val thumbAnchorY = thumbView.height / 2
val popupTop =
(thumbTop + thumbAnchorY - popupAnchorY)
.coerceAtLeast(thumbPadding.top + popupLayoutParams.topMargin)
.coerceAtMost(
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
} }
override fun onScrolled(dx: Int, dy: Int) { override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy) super.onScrolled(dx, dy)
updateThumbState() updateScrollbarState()
// Measure or layout events result in a fake onScrolled call. Ignore those. // Measure or layout events result in a fake onScrolled call. Ignore those.
if (dx == 0 && dy == 0) { if (dx == 0 && dy == 0) {
@ -337,15 +193,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return insets return insets
} }
private fun updateThumbState() { private fun updateScrollbarState() {
// Then calculate the thumb position, which is just: // Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range] // [proportion of scroll position to scroll range] * [total thumb range]
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
val offsetY = computeVerticalScrollOffset() val offsetY = computeVerticalScrollOffset()
if (computeVerticalScrollRange() < height || isEmpty()) { if (computeVerticalScrollRange() < height || childCount == 0) {
fastScrollingPossible = false
hideThumb()
hidePopup()
return return
} }
val extentY = computeVerticalScrollExtent() val extentY = computeVerticalScrollExtent()
@ -354,10 +206,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun onItemTouch(event: MotionEvent): Boolean { private fun onItemTouch(event: MotionEvent): Boolean {
if (!fastScrollingEnabled || !fastScrollingPossible) {
dragging = false
return false
}
val eventX = event.x val eventX = event.x
val eventY = event.y val eventY = event.y
@ -371,9 +219,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) { if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else if (eventX > thumbView.right - thumbWidth / 4) { } else if (eventX > thumbView.right - thumbSize / 4) {
dragStartThumbOffset = dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} else { } else {
return false return false
@ -391,8 +238,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else {
dragStartY = eventY dragStartY = eventY
dragStartThumbOffset = dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} }
@ -436,65 +282,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun showScrollbar() { private fun showScrollbar() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingThumb) { if (showingThumb) {
return return
} }
showingThumb = true showingThumb = true
thumbAnimator?.cancel() thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() } thumbAnimator = slider.slideIn(thumbView).also { it.start() }
} }
private fun hideThumb() { private fun hideScrollbar() {
if (!showingThumb) { if (!showingThumb) {
return return
} }
showingThumb = false showingThumb = false
thumbAnimator?.cancel() thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() } thumbAnimator = slider.slideOut(thumbView).also { it.start() }
}
private fun showPopup() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingPopup) {
return
}
showingPopup = true
popupAnimator?.cancel()
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
}
private fun hidePopup() {
if (!showingPopup) {
return
}
showingPopup = false
popupAnimator?.cancel()
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
}
private fun doPopupVibration() {
performHapticFeedback(
if (Build.VERSION.SDK_INT >= 27) {
HapticFeedbackConstants.TEXT_HANDLE_MOVE
} else {
HapticFeedbackConstants.KEYBOARD_TAP
})
} }
// --- LAYOUT STATE --- // --- LAYOUT STATE ---
private val thumbOffsetRange: Int private val thumbOffsetRange: Int
get() { get() {
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight return height - thumbPadding.top - thumbPadding.bottom - thumbSize
} }
/** An interface to provide text to use in the popup when fast-scrolling. */ /** An interface to provide text to use in the popup when fast-scrolling. */

View file

@ -92,6 +92,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure // Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
// this is only done once when the item is initially picked up. // this is only done once when the item is initially picked up.
// TODO: I think this is possible to improve with a raw ValueAnimator.
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) { if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
L.d("Lifting ViewHolder") L.d("Lifting ViewHolder")

View file

@ -32,17 +32,16 @@ import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.areNamesTheSame import org.oxycblt.auxio.music.areNamesTheSame
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/** /**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance. * A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.

View file

@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.musikr.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.musikr.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.musikr.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.musikr.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.musikr.Song import org.oxycblt.auxio.music.Song
/** /**
* A sorting method. * A sorting method.
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun sortSongs(songs: MutableList<Song>, direction: Direction) { override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name } songs.sortBy { it.name }
when (direction) { when (direction) {
Direction.ASCENDING -> songs.sortBy { it.addedMs } Direction.ASCENDING -> songs.sortBy { it.dateAdded }
Direction.DESCENDING -> songs.sortByDescending { it.addedMs } Direction.DESCENDING -> songs.sortByDescending { it.dateAdded }
} }
} }
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) { override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name } albums.sortBy { it.name }
when (direction) { when (direction) {
Direction.ASCENDING -> albums.sortBy { it.addedMs } Direction.ASCENDING -> albums.sortBy { it.dateAdded }
Direction.DESCENDING -> albums.sortByDescending { it.addedMs } Direction.DESCENDING -> albums.sortByDescending { it.dateAdded }
} }
} }
} }

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 Auxio Project
* Indexing.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music
import android.os.Build
/** Version-aware permission identifier for reading audio files. */
val PERMISSION_READ_AUDIO =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_AUDIO
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Exception?) : IndexingState
}
/**
* Represents the current progress of music loading.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingProgress {
/** Other work is being done that does not have a defined progress. */
data object Indeterminate : IndexingProgress
/**
* Songs are currently being loaded.
*
* @param current The current amount of songs loaded.
* @param total The projected total amount of songs.
*/
data class Songs(val current: Int, val total: Int) : IndexingProgress
}
/**
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoAudioPermissionException : Exception() {
override val message = "Storage permissions are required to load music"
}
/**
* Thrown when no music was found.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoMusicException : Exception() {
override val message = "No music was found on the device"
}

View file

@ -16,25 +16,29 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.musikr package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.room.TypeConverter import androidx.room.TypeConverter
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.musikr.covers.Cover import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.musikr.covers.CoverCollection import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.musikr.fs.Format import org.oxycblt.auxio.list.Item
import org.oxycblt.musikr.fs.Path import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.musikr.tag.Date import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.musikr.tag.Disc import org.oxycblt.auxio.music.info.Date
import org.oxycblt.musikr.tag.Name import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.musikr.tag.ReleaseType import org.oxycblt.auxio.music.info.Name
import org.oxycblt.musikr.tag.ReplayGainAdjustment import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.musikr.util.toUuidOrNull import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.toUuidOrNull
/** /**
* Abstract music data. This contains universal information about all concrete music * Abstract music data. This contains universal information about all concrete music
@ -42,7 +46,7 @@ import org.oxycblt.musikr.util.toUuidOrNull
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed interface Music { sealed interface Music : Item {
/** /**
* A unique identifier for this music item. * A unique identifier for this music item.
* *
@ -77,34 +81,23 @@ sealed interface Music {
class UID class UID
private constructor( private constructor(
private val format: Format, private val format: Format,
private val item: Item, private val type: MusicType,
private val uuid: UUID private val uuid: UUID
) : Parcelable { ) : Parcelable {
// Cache the hashCode for HashMap efficiency. // Cache the hashCode for HashMap efficiency.
@IgnoredOnParcel private var hashCode = format.hashCode() @IgnoredOnParcel private var hashCode = format.hashCode()
init { init {
hashCode = 31 * hashCode + item.hashCode() hashCode = 31 * hashCode + type.hashCode()
hashCode = 31 * hashCode + uuid.hashCode() hashCode = 31 * hashCode + uuid.hashCode()
} }
override fun hashCode() = hashCode override fun hashCode() = hashCode
override fun equals(other: Any?) = override fun equals(other: Any?) =
other is UID && format == other.format && item == other.item && uuid == other.uuid other is UID && format == other.format && type == other.type && uuid == other.uuid
override fun toString() = "${format.namespace}:${item.intCode.toString(16)}-$uuid" override fun toString() = "${format.namespace}:${type.intCode.toString(16)}-$uuid"
internal enum class Item(val intCode: Int) {
// Item used to be MusicType back when the music module was
// part of Auxio, so these old integer codes remain.
// TODO: Introduce new UID format that removes these.
SONG(0xA10B),
ALBUM(0xA10A),
ARTIST(0xA109),
GENRE(0xA108),
PLAYLIST(0xA107)
}
/** /**
* Internal marker of [Music.UID] format type. * Internal marker of [Music.UID] format type.
@ -124,7 +117,7 @@ sealed interface Music {
@TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString() @TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString()
/** @see [Music.UID.fromString] */ /** @see [Music.UID.fromString] */
@TypeConverter fun toMusicUid(string: String?) = string?.let(Companion::fromString) @TypeConverter fun toMusicUid(string: String?) = string?.let(UID::fromString)
} }
companion object { companion object {
@ -132,23 +125,23 @@ sealed interface Music {
* Creates an Auxio-style [UID] of random composition. Used if there is no * Creates an Auxio-style [UID] of random composition. Used if there is no
* non-subjective, unlikely-to-change metadata of the music. * non-subjective, unlikely-to-change metadata of the music.
* *
* @param item The type of [Item] that created this [UID]. * @param type The analogous [MusicType] of the item that created this [UID].
*/ */
internal fun auxio(item: Item): UID { fun auxio(type: MusicType): UID {
return UID(Format.AUXIO, item, UUID.randomUUID()) return UID(Format.AUXIO, type, UUID.randomUUID())
} }
/** /**
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective, * Creates an Auxio-style [UID] with a [UUID] composed of a hash of the non-subjective,
* unlikely-to-change metadata of the music. * unlikely-to-change metadata of the music.
* *
* @param item The type of [Item] that created this [UID]. * @param type The analogous [MusicType] of the item that created this [UID].
* @param updates Block to update the [MessageDigest] hash with the metadata of the * @param updates Block to update the [MessageDigest] hash with the metadata of the
* item. Make sure the metadata hashed semantically aligns with the format * item. Make sure the metadata hashed semantically aligns with the format
* specification. * specification.
* @return A new auxio-style [UID]. * @return A new auxio-style [UID].
*/ */
internal fun auxio(item: Item, updates: MessageDigest.() -> Unit): UID { fun auxio(type: MusicType, updates: MessageDigest.() -> Unit): UID {
val digest = val digest =
MessageDigest.getInstance("SHA-256").run { MessageDigest.getInstance("SHA-256").run {
updates() updates()
@ -178,19 +171,19 @@ sealed interface Music {
.or(digest[13].toLong().and(0xFF).shl(16)) .or(digest[13].toLong().and(0xFF).shl(16))
.or(digest[14].toLong().and(0xFF).shl(8)) .or(digest[14].toLong().and(0xFF).shl(8))
.or(digest[15].toLong().and(0xFF))) .or(digest[15].toLong().and(0xFF)))
return UID(Format.AUXIO, item, uuid) return UID(Format.AUXIO, type, uuid)
} }
/** /**
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID * Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
* extracted from a file. * extracted from a file.
* *
* @param item The [Item] that created this [UID]. * @param type The analogous [MusicType] of the item that created this [UID].
* @param mbid The analogous MusicBrainz ID for this item that was extracted from a * @param mbid The analogous MusicBrainz ID for this item that was extracted from a
* file. * file.
* @return A new MusicBrainz-style [UID]. * @return A new MusicBrainz-style [UID].
*/ */
internal fun musicBrainz(item: Item, mbid: UUID) = UID(Format.MUSICBRAINZ, item, mbid) fun musicBrainz(type: MusicType, mbid: UUID) = UID(Format.MUSICBRAINZ, type, mbid)
/** /**
* Convert a [UID]'s string representation back into a concrete [UID] instance. * Convert a [UID]'s string representation back into a concrete [UID] instance.
@ -218,8 +211,8 @@ sealed interface Music {
return null return null
} }
val intCode = ids[0].toIntOrNull(16) ?: return null val type =
val type = Item.entries.firstOrNull { it.intCode == intCode } ?: return null MusicType.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null
val uuid = ids[1].toUuidOrNull() ?: return null val uuid = ids[1].toUuidOrNull() ?: return null
return UID(format, type, uuid) return UID(format, type, uuid)
} }
@ -243,7 +236,6 @@ sealed interface MusicParent : Music {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface Song : Music { interface Song : Music {
override val name: Name.Known
/** The track number. Will be null if no valid track number was present in the metadata. */ /** The track number. Will be null if no valid track number was present in the metadata. */
val track: Int? val track: Int?
/** The [Disc] number. Will be null if no valid disc number was present in the metadata. */ /** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
@ -255,32 +247,23 @@ interface Song : Music {
* audio file in a way that is scoped-storage-safe. * audio file in a way that is scoped-storage-safe.
*/ */
val uri: Uri val uri: Uri
/** Useful information to quickly obtain the album cover. */
val cover: Cover
/** /**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored * The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file. * instead for accessing the audio file.
*/ */
val path: Path val path: Path
/** The [Format] of the audio file. Only intended for display. */ /** The [MimeType] of the audio file. Only intended for display. */
val format: Format val mimeType: MimeType
/** The size of the audio file, in bytes. */ /** The size of the audio file, in bytes. */
val size: Long val size: Long
/** The duration of the audio file, in milliseconds. */ /** The duration of the audio file, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The bitrate of the audio file, in kbps. */
val bitrateKbps: Int
/** The sample rate of the audio file, in Hz. */
val sampleRateHz: Int
/** The ReplayGain adjustment to apply during playback. */ /** The ReplayGain adjustment to apply during playback. */
val replayGainAdjustment: ReplayGainAdjustment val replayGainAdjustment: ReplayGainAdjustment
/** /** The date the audio file was added to the device, as a unix epoch timestamp. */
* The date last modified the audio file was last modified, in milliseconds since the unix val dateAdded: Long
* epoch.
*/
val modifiedMs: Long
/** The time the audio file was added to the device, in milliseconds since the unix epoch. */
val addedMs: Long
/** Useful information to quickly obtain the album cover. */
val cover: Cover?
/** /**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used * The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead. * instead.
@ -313,12 +296,12 @@ interface Album : MusicParent {
* [ReleaseType.Album]. * [ReleaseType.Album].
*/ */
val releaseType: ReleaseType val releaseType: ReleaseType
/** Cover information from album's songs. */ /** Cover information from the template song used for the album. */
val covers: CoverCollection val cover: ParentCover
/** The duration of all songs in the album, in milliseconds. */ /** The duration of all songs in the album, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The earliest date a song in this album was added, in milliseconds since the unix epoch. */ /** The earliest date a song in this album was added, as a unix epoch timestamp. */
val addedMs: Long val dateAdded: Long
/** /**
* The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than * 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 * one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
@ -344,7 +327,7 @@ interface Artist : MusicParent {
*/ */
val durationMs: Long? val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val covers: CoverCollection val cover: ParentCover
/** The [Genre]s of this artist. */ /** The [Genre]s of this artist. */
val genres: List<Genre> val genres: List<Genre>
} }
@ -360,7 +343,7 @@ interface Genre : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val covers: CoverCollection val cover: ParentCover
} }
/** /**
@ -374,5 +357,34 @@ interface Playlist : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val covers: CoverCollection val cover: ParentCover?
}
/**
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
* localized manner.
*
* @param context [Context] required
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.name.resolve(context) }
/**
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.name]), false otherwise.
*/
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.name != b.name) {
return false
}
}
return true
} }

View file

@ -19,30 +19,31 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import android.content.pm.PackageManager
import java.util.UUID import androidx.core.content.ContextCompat
import java.util.LinkedList
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.image.covers.SettingCovers import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.musikr.IndexingProgress import org.oxycblt.auxio.music.fs.MediaStoreExtractor
import org.oxycblt.musikr.Interpretation import org.oxycblt.auxio.music.info.Name
import org.oxycblt.musikr.Library import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.metadata.TagExtractor
import org.oxycblt.musikr.Musikr import org.oxycblt.auxio.music.user.MutableUserLibrary
import org.oxycblt.musikr.MutableLibrary import org.oxycblt.auxio.music.user.UserLibrary
import org.oxycblt.musikr.Playlist import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
import org.oxycblt.musikr.Song import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -57,9 +58,10 @@ import timber.log.Timber as L
* configurations * configurations
*/ */
interface MusicRepository { interface MusicRepository {
/** The current library */ /** The current music information found on the device. */
val library: Library? val deviceLibrary: DeviceLibrary?
/** The current user-defined music information. */
val userLibrary: UserLibrary?
/** The current state of music loading. Null if no load has occurred yet. */ /** The current state of music loading. Null if no load has occurred yet. */
val indexingState: IndexingState? val indexingState: IndexingState?
@ -173,7 +175,7 @@ interface MusicRepository {
* @param withCache Whether to load with the music cache or not. * @param withCache Whether to load with the music cache or not.
* @return The top-level music loading [Job] started. * @return The top-level music loading [Job] started.
*/ */
suspend fun index(worker: IndexingWorker, withCache: Boolean) fun index(worker: IndexingWorker, withCache: Boolean): Job
/** A listener for changes to the stored music information. */ /** A listener for changes to the stored music information. */
interface UpdateListener { interface UpdateListener {
@ -188,8 +190,8 @@ interface MusicRepository {
/** /**
* Flags indicating which kinds of music information changed. * Flags indicating which kinds of music information changed.
* *
* @param deviceLibrary Whether the current songs/albums/artists/genres has changed. * @param deviceLibrary Whether the current [DeviceLibrary] has changed.
* @param userLibrary Whether the current playlists have changed. * @param userLibrary Whether the current [Playlist]s have changed.
*/ */
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean) data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
@ -201,6 +203,12 @@ interface MusicRepository {
/** A persistent worker that can load music in the background. */ /** A persistent worker that can load music in the background. */
interface IndexingWorker { interface IndexingWorker {
/** A [Context] required to read device storage */
val workerContext: Context
/** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope
/** /**
* Request that the music loading process ([index]) should be started. Any prior loads * Request that the music loading process ([index]) should be started. Any prior loads
* should be canceled. * should be canceled.
@ -211,42 +219,22 @@ interface MusicRepository {
} }
} }
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Exception?) : IndexingState
}
class MusicRepositoryImpl class MusicRepositoryImpl
@Inject @Inject
constructor( constructor(
@ApplicationContext private val context: Context, private val cacheRepository: CacheRepository,
private val cache: MutableCache, private val mediaStoreExtractor: MediaStoreExtractor,
private val storedPlaylists: StoredPlaylists, private val tagExtractor: TagExtractor,
private val settingCovers: SettingCovers, private val deviceLibraryFactory: DeviceLibrary.Factory,
private val userLibraryFactory: UserLibrary.Factory,
private val musicSettings: MusicSettings private val musicSettings: MusicSettings
) : MusicRepository { ) : MusicRepository {
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>() private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>() private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
@Volatile private var indexingWorker: IndexingWorker? = null @Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null
@Volatile override var library: MutableLibrary? = null @Volatile override var deviceLibrary: DeviceLibrary? = null
@Volatile override var userLibrary: MutableUserLibrary? = null
@Volatile private var previousCompletedState: IndexingState.Completed? = null @Volatile private var previousCompletedState: IndexingState.Completed? = null
@Volatile private var currentIndexingState: IndexingState? = null @Volatile private var currentIndexingState: IndexingState? = null
override val indexingState: IndexingState? override val indexingState: IndexingState?
@ -283,7 +271,7 @@ constructor(
} }
@Synchronized @Synchronized
override fun registerWorker(worker: IndexingWorker) { override fun registerWorker(worker: MusicRepository.IndexingWorker) {
if (indexingWorker != null) { if (indexingWorker != null) {
L.w("Worker is already registered") L.w("Worker is already registered")
return return
@ -293,7 +281,7 @@ constructor(
} }
@Synchronized @Synchronized
override fun unregisterWorker(worker: IndexingWorker) { override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
if (indexingWorker !== worker) { if (indexingWorker !== worker) {
L.w("Given worker did not match current worker") L.w("Given worker did not match current worker")
return return
@ -305,51 +293,41 @@ constructor(
@Synchronized @Synchronized
override fun find(uid: Music.UID) = override fun find(uid: Music.UID) =
(library?.run { (deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
findSong(uid) ?: userLibrary?.findPlaylist(uid))
?: findAlbum(uid)
?: findArtist(uid)
?: findGenre(uid)
?: findPlaylist(uid)
})
override suspend fun createPlaylist(name: String, songs: List<Song>) { override suspend fun createPlaylist(name: String, songs: List<Song>) {
val library = synchronized(this) { library ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Creating playlist $name with ${songs.size} songs") L.d("Creating playlist $name with ${songs.size} songs")
val newLibrary = library.createPlaylist(name, songs) userLibrary.createPlaylist(name, songs)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun renamePlaylist(playlist: Playlist, name: String) { override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val library = synchronized(this) { library ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Renaming $playlist to $name") L.d("Renaming $playlist to $name")
val newLibrary = library.renamePlaylist(playlist, name) userLibrary.renamePlaylist(playlist, name)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun deletePlaylist(playlist: Playlist) { override suspend fun deletePlaylist(playlist: Playlist) {
val library = synchronized(this) { library ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Deleting $playlist") L.d("Deleting $playlist")
val newLibrary = library.deletePlaylist(playlist) userLibrary.deletePlaylist(playlist)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) { override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val library = synchronized(this) { library ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Adding ${songs.size} songs to $playlist") L.d("Adding ${songs.size} songs to $playlist")
val newLibrary = library.addToPlaylist(playlist, songs) userLibrary.addToPlaylist(playlist, songs)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) { override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val library = synchronized(this) { library ?: return } val userLibrary = synchronized(this) { userLibrary ?: return }
L.d("Rewriting $playlist with ${songs.size} songs") L.d("Rewriting $playlist with ${songs.size} songs")
val newLibrary = library.rewritePlaylist(playlist, songs) userLibrary.rewritePlaylist(playlist, songs)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
@ -359,53 +337,241 @@ constructor(
indexingWorker?.requestIndex(withCache) indexingWorker?.requestIndex(withCache)
} }
override suspend fun index(worker: IndexingWorker, withCache: Boolean) { override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
L.d("Begin index [cache=$withCache]") worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
try { try {
indexImpl(withCache) indexImpl(context, scope, withCache)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine. // Got cancelled, propagate upwards to top-level co-routine.
L.d("Loading routine was cancelled") L.d("Loading routine was cancelled")
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
// Music loading process failed due to something we have not handled. // Music loading process failed due to something we have not handled.
// TODO: Still want to display this error eventually
L.e("Music indexing failed") L.e("Music indexing failed")
L.e(e.stackTraceToString()) L.e(e.stackTraceToString())
emitIndexingCompletion(e) emitIndexingCompletion(e)
} }
} }
private suspend fun indexImpl(withCache: Boolean) { private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) {
// TODO: Find a way to break up this monster of a method, preferably as another class.
val start = System.currentTimeMillis()
// Make sure we have permissions before going forward. Theoretically this would be better
// done at the UI level, but that intertwines logic and display too much.
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
L.e("Permissions were not granted")
throw NoAudioPermissionException()
}
// Obtain configuration information // Obtain configuration information
val constraints =
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
val separators = Separators.from(musicSettings.separators) val separators = Separators.from(musicSettings.separators)
val nameFactory = val nameFactory =
if (musicSettings.intelligentSorting) { if (musicSettings.intelligentSorting) {
Naming.intelligent() Name.Known.IntelligentFactory
} else { } else {
Naming.simple() Name.Known.SimpleFactory
} }
val locations = musicSettings.musicLocations
val withHidden = musicSettings.withHidden
val currentRevision = musicSettings.revision // Begin with querying MediaStore and the music cache. The former is needed for Auxio
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID() // to figure out what songs are (probably) on the device, and the latter will be needed
val cache = if (withCache) cache else WriteOnlyMutableCache(cache) // for discovery (described later). These have no shared state, so they are done in
val covers = settingCovers.mutate(context, newRevision) // parallel.
val storage = Storage(cache, covers, storedPlaylists) L.d("Starting MediaStore query")
val interpretation = Interpretation(nameFactory, separators, withHidden) emitIndexingProgress(IndexingProgress.Indeterminate)
val result =
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress) val mediaStoreQueryJob =
// Music loading completed, update the revision right now so we re-use this work scope.async {
// later. val query =
musicSettings.revision = newRevision try {
// Deliver the library to the rest of the app mediaStoreExtractor.query(constraints)
// This will more or less block until all required item translation and } catch (e: Exception) {
// cleanup finishes. // Normally, errors in an async call immediately bubble up to the Looper
emitLibrary(result.library) // and crash the app. Thus, we have to wrap any error into a Result
// Clean up old data that is now impossible for the app to be using. // and then manually forward it to the try block that indexImpl is
result.cleanup() // called from.
// Finish up loading. return@async Result.failure(e)
}
Result.success(query)
}
// Since this main thread is a co-routine, we can do operations in parallel in a way
// identical to calling async.
val cache =
if (withCache) {
L.d("Reading cache")
cacheRepository.readCache()
} else {
null
}
L.d("Awaiting MediaStore query")
val query = mediaStoreQueryJob.await().getOrThrow()
// We now have all the information required to start the "discovery" process. This
// is the point at which Auxio starts scanning each file given from MediaStore and
// transforming it into a music library. MediaStore normally
L.d("Starting discovery")
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
val processedSongs = Channel<RawSong>(Channel.UNLIMITED) // Transformed into SongImpl
// MediaStoreExtractor discovers all music on the device, and forwards them to either
// DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata
// does not exist. In the latter situation, it also applies it's own (inferior) metadata.
L.d("Starting MediaStore discovery")
val mediaStoreJob =
scope.async {
try {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
} catch (e: Exception) {
// To prevent a deadlock, we want to close the channel with an exception
// to cascade to and cancel all other routines before finally bubbling up
// to the main extractor loop.
L.e("MediaStore extraction failed: $e")
incompleteSongs.close(
Exception("MediaStore extraction failed: ${e.stackTraceToString()}"))
return@async
}
incompleteSongs.close()
}
// TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date
// metadata for them, and then forwards it to DeviceLibrary.
L.d("Starting tag extraction")
val tagJob =
scope.async {
try {
tagExtractor.consume(incompleteSongs, completeSongs)
} catch (e: Exception) {
L.e("Tag extraction failed: $e")
completeSongs.close(
Exception("Tag extraction failed: ${e.stackTraceToString()}"))
return@async
}
completeSongs.close()
}
// DeviceLibrary constructs music parent instances as song information is provided,
// and then forwards them to the primary loading loop.
L.d("Starting DeviceLibrary creation")
val deviceLibraryJob =
scope.async(Dispatchers.Default) {
val deviceLibrary =
try {
deviceLibraryFactory.create(
completeSongs, processedSongs, separators, nameFactory)
} catch (e: Exception) {
L.e("DeviceLibrary creation failed: $e")
processedSongs.close(
Exception("DeviceLibrary creation failed: ${e.stackTraceToString()}"))
return@async Result.failure(e)
}
processedSongs.close()
Result.success(deviceLibrary)
}
// We could keep track of a total here, but we also need to collate this RawSong information
// for when we write the cache later on in the finalization step.
val rawSongs = LinkedList<RawSong>()
// Use a longer timeout so that dependent components can timeout and throw errors that
// provide more context than if we timed out here.
processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
rawSongs.add(it)
// Since discovery takes up the bulk of the music loading process, we switch to
// indicating a defined amount of loaded songs in comparison to the projected amount
// of songs that were queried.
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
}
withTimeout(DEFAULT_TIMEOUT) {
mediaStoreJob.await()
tagJob.await()
}
// Deliberately done after the involved initialization step to make it less likely
// that the short-circuit occurs so quickly as to break the UI.
// TODO: Do not error, instead just wipe the entire library.
if (rawSongs.isEmpty()) {
L.e("Music library was empty")
throw NoMusicException()
}
// Now that the library is effectively loaded, we can start the finalization step, which
// involves writing new cache information and creating more music data that is derived
// from the library (e.g playlists)
L.d("Discovered ${rawSongs.size} songs, starting finalization")
// We have no idea how long the cache will take, and the playlist construction
// will be too fast to indicate, so switch back to an indeterminate state.
emitIndexingProgress(IndexingProgress.Indeterminate)
// The UserLibrary job is split into a query and construction step, a la MediaStore.
// This way, we can start working on playlists even as DeviceLibrary might still be
// working on parent information.
L.d("Starting UserLibrary query")
val userLibraryQueryJob =
scope.async {
val rawPlaylists =
try {
userLibraryFactory.query()
} catch (e: Exception) {
return@async Result.failure(e)
}
Result.success(rawPlaylists)
}
// The cache might not exist, or we might have encountered a song not present in it.
// Both situations require us to rewrite the cache in bulk. This is also done parallel
// since the playlist read will probably take some time.
// TODO: Read/write from the cache incrementally instead of in bulk?
if (cache == null || cache.invalidated) {
L.d("Writing cache [why=${cache?.invalidated}]")
cacheRepository.writeCache(rawSongs)
}
// Create UserLibrary once we finally get the required components for it.
L.d("Awaiting UserLibrary query")
val rawPlaylists = userLibraryQueryJob.await().getOrThrow()
L.d("Awaiting DeviceLibrary creation")
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
L.d("Starting UserLibrary creation")
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
// Loading process is functionally done, indicate such
L.d(
"Successfully indexed music library [device=$deviceLibrary " +
"user=$userLibrary time=${System.currentTimeMillis() - start}]")
emitIndexingCompletion(null) emitIndexingCompletion(null)
val deviceLibraryChanged: Boolean
val userLibraryChanged: Boolean
// We want to make sure that all reads and writes are synchronized due to the sheer
// amount of consumers of MusicRepository.
// TODO: Would Atomics not be a better fit here?
synchronized(this) {
// It's possible that this reload might have changed nothing, so make sure that
// hasn't happened before dispatching a change to all consumers.
deviceLibraryChanged = this.deviceLibrary != deviceLibrary
userLibraryChanged = this.userLibrary != userLibrary
if (!deviceLibraryChanged && !userLibraryChanged) {
L.d("Library has not changed, skipping update")
return
}
this.deviceLibrary = deviceLibrary
this.userLibrary = userLibrary
}
// Consumers expect their updates to be on the main thread (notably PlaybackService),
// so switch to it.
withContext(Dispatchers.Main) {
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
}
} }
private suspend fun emitIndexingProgress(progress: IndexingProgress) { private suspend fun emitIndexingProgress(progress: IndexingProgress) {
@ -418,39 +584,6 @@ constructor(
} }
} }
private suspend fun emitLibrary(newLibrary: MutableLibrary) {
val deviceLibraryChanged: Boolean
val userLibraryChanged: Boolean
// We want to make sure that all reads and writes are synchronized due to the sheer
// amount of consumers of MusicRepository.
synchronized(this) {
// It's possible that this reload might have changed nothing, so make sure that
// hasn't happened before dispatching a change to all consumers.
// This is an old compat shim back when device library and user library were different
// thinks. For the sake of avoiding drastic changes, it sticks around.
// TODO: Remove this once you start work on kindred.
deviceLibraryChanged =
this.library?.songs != newLibrary.songs ||
this.library?.albums != newLibrary.albums ||
this.library?.artists != newLibrary.artists ||
this.library?.genres != newLibrary.genres
userLibraryChanged = this.library?.playlists != newLibrary.playlists
if (!deviceLibraryChanged && !userLibraryChanged) {
L.d("Library has not changed, skipping update")
return
}
this.library = newLibrary
}
// Consumers expect their updates to be on the main thread (notably PlaybackService),
// so switch to it.
withContext(Dispatchers.Main) {
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
}
}
private suspend fun emitIndexingCompletion(error: Exception?) { private suspend fun emitIndexingCompletion(error: Exception?) {
yield() yield()
synchronized(this) { synchronized(this) {

View file

@ -21,11 +21,11 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.dirs.MusicDirectories
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.musikr.fs.MusicLocation
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -34,14 +34,10 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface MusicSettings : Settings<MusicSettings.Listener> { interface MusicSettings : Settings<MusicSettings.Listener> {
/** The current library revision. */ /** The configuration on how to handle particular directories in the music library. */
var revision: UUID? var musicDirs: MusicDirectories
/** The locations of music to load. */
var musicLocations: List<MusicLocation>
/** Whether to exclude non-music audio files from the music library. */ /** Whether to exclude non-music audio files from the music library. */
val excludeNonMusic: Boolean val excludeNonMusic: Boolean
/** Whether to ignore hidden files and directories during music loading. */
val withHidden: Boolean
/** Whether to be actively watching for changes in the music library. */ /** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */ /** A [String] of characters representing the desired characters to denote multi-value tags. */
@ -50,8 +46,6 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
val intelligentSorting: Boolean val intelligentSorting: Boolean
interface Listener { interface Listener {
/** Called when the current music locations changed. */
fun onMusicLocationsChanged() {}
/** Called when a setting controlling how music is loaded has changed. */ /** Called when a setting controlling how music is loaded has changed. */
fun onIndexingSettingChanged() {} fun onIndexingSettingChanged() {}
/** Called when the [shouldBeObserving] configuration has changed. */ /** Called when the [shouldBeObserving] configuration has changed. */
@ -59,45 +53,35 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
} }
} }
class MusicSettingsImpl @Inject constructor(@ApplicationContext private val context: Context) : class MusicSettingsImpl
Settings.Impl<MusicSettings.Listener>(context), MusicSettings { @Inject
constructor(
override var revision: UUID? @ApplicationContext context: Context,
get() = private val documentPathFactory: DocumentPathFactory
sharedPreferences ) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
.getString(getString(R.string.set_key_library_revision), null) override var musicDirs: MusicDirectories
?.let(UUID::fromString)
set(value) {
sharedPreferences.edit {
putString(getString(R.string.set_key_library_revision), value.toString())
apply()
}
}
override var musicLocations: List<MusicLocation>
get() { get() {
val locations = val dirs =
sharedPreferences.getString(getString(R.string.set_key_music_locations), null) (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
?: return emptyList() ?: emptySet())
return MusicLocation.existing(context, locations) .mapNotNull(documentPathFactory::fromDocumentId)
return MusicDirectories(
dirs,
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
} }
set(value) { set(value) {
sharedPreferences.edit { sharedPreferences.edit {
putString( putStringSet(
getString(R.string.set_key_music_locations), MusicLocation.toString(value)) getString(R.string.set_key_music_dirs),
commit() value.dirs.map(documentPathFactory::toDocumentId).toSet())
// Sometimes changing this setting just won't actually trigger the listener. putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
// Only this one. No idea why. apply()
listener?.onMusicLocationsChanged()
} }
} }
override val excludeNonMusic: Boolean override val excludeNonMusic: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true) get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
override val withHidden: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), false)
override val shouldBeObserving: Boolean override val shouldBeObserving: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
@ -119,14 +103,11 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads" // TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
// (just need to manipulate data) // (just need to manipulate data)
when (key) { when (key) {
getString(R.string.set_key_music_locations) -> { getString(R.string.set_key_exclude_non_music),
L.d("Dispatching music locations change") getString(R.string.set_key_music_dirs),
listener.onMusicLocationsChanged() getString(R.string.set_key_music_dirs_include),
}
getString(R.string.set_key_separators), getString(R.string.set_key_separators),
getString(R.string.set_key_auto_sort_names), getString(R.string.set_key_auto_sort_names) -> {
getString(R.string.set_key_with_hidden),
getString(R.string.set_key_exclude_non_music) -> {
L.d("Dispatching indexing setting change for $key") L.d("Dispatching indexing setting change for $key")
listener.onIndexingSettingChanged() listener.onIndexingSettingChanged()
} }

View file

@ -27,10 +27,15 @@ import org.oxycblt.auxio.R
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
enum class MusicType { enum class MusicType {
/** @see Song */
SONGS, SONGS,
/** @see Album */
ALBUMS, ALBUMS,
/** @see Artist */
ARTISTS, ARTISTS,
/** @see Genre */
GENRES, GENRES,
/** @see Playlist */
PLAYLISTS; PLAYLISTS;
/** /**

View file

@ -1,173 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* MusicUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music
import android.content.Context
import java.text.ParseException
import java.text.SimpleDateFormat
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.Placeholder
import org.oxycblt.musikr.tag.ReleaseType
import org.oxycblt.musikr.tag.ReleaseType.Refinement
import timber.log.Timber
fun Name.resolve(context: Context) =
when (this) {
is Name.Known -> raw
is Name.Unknown ->
when (placeholder) {
Placeholder.ALBUM -> context.getString(R.string.def_album)
Placeholder.ARTIST -> context.getString(R.string.def_artist)
Placeholder.GENRE -> context.getString(R.string.def_genre)
}
}
/**
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
* localized manner.
*
* @param context [Context] required
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.name.resolve(context) }
/**
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.name]), false otherwise.
*/
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.name != b.name) {
return false
}
}
return true
}
/**
* 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 Date.resolve(context: Context) =
// Unable to create fine-grained date, just format as a year.
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
private fun Date.resolveFineGrained(): String? {
// We can't directly load a date with our own
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
format.applyPattern("yyyy-MM")
val date =
try {
format.parse("$year-$month")
} catch (e: ParseException) {
Timber.e("Unable to parse fine-grained date: $e")
return null
}
// Reformat as a readable month and year
format.applyPattern("MMM yyyy")
return format.format(date)
}
fun Disc?.resolve(context: Context) =
this?.run { context.getString(R.string.fmt_disc_no, number) }
?: context.getString(R.string.def_disc)
/**
* Resolve this instance into a human-readable date range.
*
* @param context [Context] required to get human-readable names.
* @return If the date has a maximum value, then a `min - max` formatted string will be returned
* with the formatted [Date]s of the minimum and maximum dates respectively. Otherwise, the
* formatted name of the minimum [Date] will be returned.
*/
fun Date.Range.resolve(context: Context) =
if (min != max) {
context.getString(R.string.fmt_date_range, min.resolve(context), max.resolve(context))
} else {
min.resolve(context)
}
fun ReleaseType.resolve(context: Context) =
when (this) {
is ReleaseType.Album ->
when (refinement) {
null -> context.getString(R.string.lbl_album)
Refinement.LIVE -> context.getString(R.string.lbl_album_live)
Refinement.REMIX -> context.getString(R.string.lbl_album_remix)
}
is ReleaseType.EP ->
when (refinement) {
null -> context.getString(R.string.lbl_ep)
Refinement.LIVE -> context.getString(R.string.lbl_ep_live)
Refinement.REMIX -> context.getString(R.string.lbl_ep_remix)
}
is ReleaseType.Single ->
when (refinement) {
null -> context.getString(R.string.lbl_single)
Refinement.LIVE -> context.getString(R.string.lbl_single_live)
Refinement.REMIX -> context.getString(R.string.lbl_single_remix)
}
is ReleaseType.Compilation ->
when (refinement) {
null -> context.getString(R.string.lbl_compilation)
Refinement.LIVE -> context.getString(R.string.lbl_compilation_live)
Refinement.REMIX -> context.getString(R.string.lbl_compilation_remix)
}
is ReleaseType.Soundtrack -> context.getString(R.string.lbl_soundtrack)
is ReleaseType.Mix -> context.getString(R.string.lbl_mix)
is ReleaseType.Mixtape -> context.getString(R.string.lbl_mixtape)
is ReleaseType.Demo -> context.getString(R.string.lbl_demo)
}
fun Format.resolve(context: Context): String =
when (this) {
is Format.MPEG3 -> context.getString(R.string.cdc_mp3)
is Format.MPEG4 ->
containing?.let { context.getString(R.string.cnt_mp4, it.resolve(context)) }
?: context.getString(R.string.cdc_mp4)
is Format.AAC -> context.getString(R.string.cdc_aac)
is Format.ALAC -> context.getString(R.string.cdc_alac)
is Format.Ogg ->
containing?.let { context.getString(R.string.cnt_ogg, it.resolve(context)) }
?: context.getString(R.string.cdc_ogg)
is Format.Opus -> context.getString(R.string.cdc_opus)
is Format.Vorbis -> context.getString(R.string.cdc_vorbis)
is Format.FLAC -> context.getString(R.string.cdc_flac)
is Format.Wav -> context.getString(R.string.cdc_wav)
is Format.Unknown -> extension ?: context.getString(R.string.cdc_unknown)
}

View file

@ -18,12 +18,10 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -31,15 +29,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.ExternalPlaylistManager
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.ExportConfig
import org.oxycblt.musikr.playlist.ExternalPlaylistManager
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -51,11 +44,10 @@ import timber.log.Timber as L
class MusicViewModel class MusicViewModel
@Inject @Inject
constructor( constructor(
@ApplicationContext context: Context,
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository private val musicRepository: MusicRepository,
private val externalPlaylistManager: ExternalPlaylistManager
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { ) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
private val externalPlaylistManager = ExternalPlaylistManager.from(context)
private val _indexingState = MutableStateFlow<IndexingState?>(null) private val _indexingState = MutableStateFlow<IndexingState?>(null)
@ -93,14 +85,14 @@ constructor(
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return if (!changes.deviceLibrary) return
val library = musicRepository.library ?: return val deviceLibrary = musicRepository.deviceLibrary ?: return
_statistics.value = _statistics.value =
Statistics( Statistics(
library.songs.size, deviceLibrary.songs.size,
library.albums.size, deviceLibrary.albums.size,
library.artists.size, deviceLibrary.artists.size,
library.genres.size, deviceLibrary.genres.size,
library.songs.sumOf { it.durationMs }) deviceLibrary.songs.sumOf { it.durationMs })
L.d("Updated statistics: ${_statistics.value}") L.d("Updated statistics: ${_statistics.value}")
} }
@ -170,10 +162,10 @@ constructor(
return@launch return@launch
} }
val library = musicRepository.library ?: return@launch val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
val songs = val songs =
importedPlaylist.paths.mapNotNull { importedPlaylist.paths.mapNotNull {
it.firstNotNullOfOrNull(library::findSongByPath) it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
} }
if (songs.isEmpty()) { if (songs.isEmpty()) {

View file

@ -0,0 +1,187 @@
/*
* Copyright (c) 2023 Auxio Project
* CacheDatabase.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.cache
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped
@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao
}
@Dao
interface CachedSongsDao {
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
@Query("DELETE FROM CachedSong") suspend fun nukeSongs()
@Insert suspend fun insertSongs(songs: List<CachedSong>)
}
@Entity
@TypeConverters(CachedSong.Converters::class)
data class CachedSong(
/**
* The ID of the [RawSong]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file.
*/
@PrimaryKey var mediaStoreId: Long,
/** @see RawSong.dateAdded */
var dateAdded: Long,
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long,
/** @see RawSong.size */
var size: Long? = null,
/** @see RawSong */
var durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float? = null,
/** @see RawSong.replayGainAlbumAdjustment */
val replayGainAlbumAdjustment: Float? = null,
/** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null,
/** @see RawSong.name */
var name: String,
/** @see RawSong.sortName */
var sortName: String? = null,
/** @see RawSong.track */
var track: Int? = null,
/** @see RawSong.name */
var disc: Int? = null,
/** @See RawSong.subtitle */
var subtitle: String? = null,
/** @see RawSong.date */
var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */
var albumName: String,
/** @see RawSong.albumSortName */
var albumSortName: String? = null,
/** @see RawSong.releaseTypes */
var releaseTypes: List<String> = listOf(),
/** @see RawSong.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.artistNames */
var artistNames: List<String> = listOf(),
/** @see RawSong.artistSortNames */
var artistSortNames: List<String> = listOf(),
/** @see RawSong.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.albumArtistNames */
var albumArtistNames: List<String> = listOf(),
/** @see RawSong.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(),
/** @see RawSong.genreNames */
var genreNames: List<String> = listOf()
) {
fun copyToRaw(rawSong: RawSong) {
rawSong.musicBrainzId = musicBrainzId
rawSong.name = name
rawSong.sortName = sortName
rawSong.size = size
rawSong.durationMs = durationMs
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment
rawSong.track = track
rawSong.disc = disc
rawSong.subtitle = subtitle
rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
rawSong.releaseTypes = releaseTypes
rawSong.artistMusicBrainzIds = artistMusicBrainzIds
rawSong.artistNames = artistNames
rawSong.artistSortNames = artistSortNames
rawSong.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
rawSong.albumArtistNames = albumArtistNames
rawSong.albumArtistSortNames = albumArtistSortNames
rawSong.genreNames = genreNames
}
object Converters {
@TypeConverter
fun fromMultiValue(values: List<String>) =
values.joinToString(";") { it.replace(";", "\\;") }
@TypeConverter
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
}
companion object {
fun fromRaw(rawSong: RawSong) =
CachedSong(
mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },
dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" },
dateModified =
requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" },
musicBrainzId = rawSong.musicBrainzId,
name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
sortName = rawSong.sortName,
size = rawSong.size,
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,
track = rawSong.track,
disc = rawSong.disc,
subtitle = rawSong.subtitle,
date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,
releaseTypes = rawSong.releaseTypes,
artistMusicBrainzIds = rawSong.artistMusicBrainzIds,
artistNames = rawSong.artistNames,
artistSortNames = rawSong.artistSortNames,
albumArtistMusicBrainzIds = rawSong.albumArtistMusicBrainzIds,
albumArtistNames = rawSong.albumArtistNames,
albumArtistSortNames = rawSong.albumArtistSortNames,
genreNames = rawSong.genreNames)
}
}

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2025 Auxio Project * Copyright (c) 2023 Auxio Project
* MusikrShimModule.kt is part of Auxio. * CacheModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -16,31 +16,34 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.shim package org.oxycblt.auxio.music.cache
import android.content.Context import android.content.Context
import androidx.room.Room
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.cache.db.MutableDBCache
import org.oxycblt.musikr.playlist.db.StoredPlaylists
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class MusikrShimModule { interface CacheModule {
@Singleton @Binds fun cacheRepository(cacheRepository: CacheRepositoryImpl): CacheRepository
@Provides }
fun cache(@ApplicationContext context: Context): MutableCache = MutableDBCache.from(context)
@Module
@Singleton @InstallIn(SingletonComponent::class)
@Provides class CacheRoomModule {
fun storedPlaylists(@ApplicationContext context: Context) = StoredPlaylists.from(context) @Singleton
@Provides
@Provides fun database(@ApplicationContext context: Context) =
fun updateTrackerFactory(@ApplicationContext context: Context): UpdateTrackerFactory = Room.databaseBuilder(
UpdateTrackerFactoryImpl(context) context.applicationContext, CacheDatabase::class.java, "music_cache.db")
.fallbackToDestructiveMigration()
.build()
@Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao()
} }

View file

@ -0,0 +1,121 @@
/*
* Copyright (c) 2022 Auxio Project
* CacheRepository.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.cache
import javax.inject.Inject
import org.oxycblt.auxio.music.device.RawSong
import timber.log.Timber as L
/**
* A repository allowing access to cached metadata obtained in prior music loading operations.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface CacheRepository {
/**
* Read the current [Cache], if it exists.
*
* @return The stored [Cache], or null if it could not be obtained.
*/
suspend fun readCache(): Cache?
/**
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
*
* @param rawSongs The [rawSongs] to write to the cache.
*/
suspend fun writeCache(rawSongs: List<RawSong>)
}
class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: CachedSongsDao) :
CacheRepository {
override suspend fun readCache(): Cache? =
try {
// Faster to load the whole database into memory than do a query on each
// populate call.
val songs = cachedSongsDao.readSongs()
L.d("Successfully read ${songs.size} songs from cache")
CacheImpl(songs)
} catch (e: Exception) {
L.e("Unable to load cache database.")
L.e(e.stackTraceToString())
null
}
override suspend fun writeCache(rawSongs: List<RawSong>) {
try {
// Still write out whatever data was extracted.
cachedSongsDao.nukeSongs()
L.d("Successfully deleted old cache")
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
L.d("Successfully wrote ${rawSongs.size} songs to cache")
} catch (e: Exception) {
L.e("Unable to save cache database.")
L.e(e.stackTraceToString())
}
}
}
/**
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with
* [CacheRepository].
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Cache {
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
val invalidated: Boolean
/**
* Populate a [RawSong] from a cache entry, if it exists.
*
* @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise.
*/
fun populate(rawSong: RawSong): Boolean
}
private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
private val cacheMap = buildMap {
for (cachedSong in cachedSongs) {
put(cachedSong.mediaStoreId, cachedSong)
}
}
override var invalidated = false
override fun populate(rawSong: RawSong): Boolean {
// For a cached raw song to be used, it must exist within the cache and have matching
// addition and modification timestamps. Technically the addition timestamp doesn't
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
// check for it anyway.
val cachedSong = cacheMap[rawSong.mediaStoreId]
if (cachedSong != null &&
cachedSong.dateAdded == rawSong.dateAdded &&
cachedSong.dateModified == rawSong.dateModified) {
cachedSong.copyToRaw(rawSong)
return true
}
// We could not populate this song. This means our cache is stale and should be
// re-written with newly-loaded music.
invalidated = true
return false
}
}

View file

@ -33,10 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -29,11 +29,10 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -53,9 +52,6 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlayl
builder builder
.setTitle(R.string.lbl_confirm_delete_playlist) .setTitle(R.string.lbl_confirm_delete_playlist)
.setPositiveButton(R.string.lbl_delete) { _, _ -> .setPositiveButton(R.string.lbl_delete) { _, _ ->
// Normally the navigateUp will occur after this, which then collides with the
// playlist view's navigation. Forcefully navigate up to stop this.
findNavController().navigateUp()
// Now we can delete the playlist for-real this time. // Now we can delete the playlist for-real this time.
musicModel.deletePlaylist( musicModel.deletePlaylist(
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true) unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)

View file

@ -31,13 +31,12 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.playlist.ExportConfig
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -25,7 +25,6 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater

View file

@ -25,14 +25,14 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.resolve import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Music import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.ExportConfig
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -89,13 +89,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
var refreshChoicesWith: List<Song>? = null var refreshChoicesWith: List<Song>? = null
val library = musicRepository.library val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && library != null) { if (changes.deviceLibrary && deviceLibrary != null) {
_currentPendingNewPlaylist.value = _currentPendingNewPlaylist.value =
_currentPendingNewPlaylist.value?.let { pendingPlaylist -> _currentPendingNewPlaylist.value?.let { pendingPlaylist ->
PendingNewPlaylist( PendingNewPlaylist(
pendingPlaylist.preferredName, pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { library.findSong(it.uid) }, pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) },
pendingPlaylist.template, pendingPlaylist.template,
pendingPlaylist.reason) pendingPlaylist.reason)
} }
@ -104,7 +104,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
_currentSongsToAdd.value = _currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs -> _currentSongsToAdd.value?.let { pendingSongs ->
pendingSongs pendingSongs
.mapNotNull { library.findSong(it.uid) } .mapNotNull { deviceLibrary.findSong(it.uid) }
.ifEmpty { null } .ifEmpty { null }
.also { refreshChoicesWith = it } .also { refreshChoicesWith = it }
} }
@ -127,7 +127,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
_currentPlaylistToExport.value = _currentPlaylistToExport.value =
_currentPlaylistToExport.value?.let { playlist -> _currentPlaylistToExport.value?.let { playlist ->
musicRepository.library?.findPlaylist(playlist.uid) musicRepository.userLibrary?.findPlaylist(playlist.uid)
} }
L.d("Updated playlist to export to ${_currentPlaylistToExport.value}") L.d("Updated playlist to export to ${_currentPlaylistToExport.value}")
} }
@ -153,14 +153,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
reason: PlaylistDecision.New.Reason reason: PlaylistDecision.New.Reason
) { ) {
L.d("Opening ${songUids.size} songs to create a playlist from") L.d("Opening ${songUids.size} songs to create a playlist from")
val library = musicRepository.library ?: return val userLibrary = musicRepository.userLibrary ?: return
val songs = val songs =
musicRepository.library musicRepository.deviceLibrary
?.let { songUids.mapNotNull(it::findSong) } ?.let { songUids.mapNotNull(it::findSong) }
?.also(::refreshPlaylistChoices) ?.also(::refreshPlaylistChoices)
val possibleName = val possibleName =
musicRepository.library?.let { musicRepository.userLibrary?.let {
// Attempt to generate a unique default name for the playlist, like "Playlist 1". // Attempt to generate a unique default name for the playlist, like "Playlist 1".
var i = 1 var i = 1
var possibleName: String var possibleName: String
@ -168,7 +168,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
possibleName = context.getString(R.string.fmt_def_playlist, i) possibleName = context.getString(R.string.fmt_def_playlist, i)
L.d("Trying $possibleName as a playlist name") L.d("Trying $possibleName as a playlist name")
++i ++i
} while (library.playlists.any { it.name.resolve(context) == possibleName }) } while (userLibrary.playlists.any { it.name.resolve(context) == possibleName })
L.d("$possibleName is unique, using it as the playlist name") L.d("$possibleName is unique, using it as the playlist name")
possibleName possibleName
} }
@ -194,8 +194,9 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
reason: PlaylistDecision.Rename.Reason reason: PlaylistDecision.Rename.Reason
) { ) {
L.d("Opening playlist $playlistUid to rename") L.d("Opening playlist $playlistUid to rename")
val playlist = musicRepository.library?.findPlaylist(playlistUid) val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid)
val applySongs = musicRepository.library?.let { applySongUids.mapNotNull(it::findSong) } val applySongs =
musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
_currentPendingRenamePlaylist.value = _currentPendingRenamePlaylist.value =
if (playlist != null && applySongs != null) { if (playlist != null && applySongs != null) {
@ -215,7 +216,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
L.d("Opening playlist $playlistUid to export") L.d("Opening playlist $playlistUid to export")
// TODO: Add this guard to the rest of the methods here // TODO: Add this guard to the rest of the methods here
if (_currentPlaylistToExport.value?.uid == playlistUid) return if (_currentPlaylistToExport.value?.uid == playlistUid) return
_currentPlaylistToExport.value = musicRepository.library?.findPlaylist(playlistUid) _currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToExport.value == null) { if (_currentPlaylistToExport.value == null) {
L.w("Given playlist UID to export was invalid") L.w("Given playlist UID to export was invalid")
} else { } else {
@ -240,7 +241,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
*/ */
fun setPlaylistToDelete(playlistUid: Music.UID) { fun setPlaylistToDelete(playlistUid: Music.UID) {
L.d("Opening playlist $playlistUid to delete") L.d("Opening playlist $playlistUid to delete")
_currentPlaylistToDelete.value = musicRepository.library?.findPlaylist(playlistUid) _currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
if (_currentPlaylistToDelete.value == null) { if (_currentPlaylistToDelete.value == null) {
L.w("Given playlist UID to delete was invalid") L.w("Given playlist UID to delete was invalid")
} }
@ -265,8 +266,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
else -> { else -> {
val trimmed = name.trim() val trimmed = name.trim()
val library = musicRepository.library val userLibrary = musicRepository.userLibrary
if (library != null && library.findPlaylistByName(trimmed) == null) { if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
L.d("Chosen name is valid") L.d("Chosen name is valid")
ChosenName.Valid(trimmed) ChosenName.Valid(trimmed)
} else { } else {
@ -285,7 +286,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
fun setSongsToAdd(songUids: Array<Music.UID>) { fun setSongsToAdd(songUids: Array<Music.UID>) {
L.d("Opening ${songUids.size} songs to add to a playlist") L.d("Opening ${songUids.size} songs to add to a playlist")
_currentSongsToAdd.value = _currentSongsToAdd.value =
musicRepository.library musicRepository.deviceLibrary
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } } ?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
?.also(::refreshPlaylistChoices) ?.also(::refreshPlaylistChoices)
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) { if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
@ -294,10 +295,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
private fun refreshPlaylistChoices(songs: List<Song>) { private fun refreshPlaylistChoices(songs: List<Song>) {
val library = musicRepository.library ?: return val userLibrary = musicRepository.userLibrary ?: return
L.d("Refreshing playlist choices") L.d("Refreshing playlist choices")
_playlistAddChoices.value = _playlistAddChoices.value =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(library.playlists).map { Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map {
val songSet = it.songs.toSet() val songSet = it.songs.toSet()
PlaylistChoice(it, songs.all(songSet::contains)) PlaylistChoice(it, songs.all(songSet::contains))
} }
@ -354,4 +355,4 @@ sealed interface ChosenName {
* [Playlist]. * [Playlist].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item

View file

@ -30,7 +30,6 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -0,0 +1,42 @@
package org.oxycblt.auxio.music.device
interface AlbumTree {
fun register(linkedSong: ArtistTree.LinkedSong): LinkedSong
fun resolve(): Collection<AlbumImpl>
data class LinkedSong(
val linkedArtistSong: ArtistTree.LinkedSong,
val album: Linked<AlbumImpl, SongImpl>
)
}
interface ArtistTree {
fun register(preSong: GenreTree.LinkedSong): LinkedSong
fun resolve(): Collection<ArtistImpl>
data class LinkedSong(
val linkedGenreSong: GenreTree.LinkedSong,
val linkedAlbum: LinkedAlbum,
val artists: Linked<List<ArtistImpl>, SongImpl>
)
data class LinkedAlbum(
val preAlbum: PreAlbum,
val artists: Linked<List<ArtistImpl>, AlbumImpl>
)
}
interface GenreTree {
fun register(preSong: PreSong): LinkedSong
fun resolve(): Collection<GenreImpl>
data class LinkedSong(
val preSong: PreSong,
val genres: Linked<List<GenreImpl>, SongImpl>
)
}
interface Linked<P, C> {
fun resolve(child: C): P
}

View file

@ -0,0 +1,399 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceLibrary.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.device
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
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.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.fs.useQuery
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.sendWithTimeout
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
/**
* Organized music library information obtained from device storage.
*
* This class allows for the creation of a well-formed music library graph from raw song
* information. Instances are immutable. It's generally not expected to create this yourself and
* instead use [MusicRepository].
*
* @author Alexander Capehart
*/
interface DeviceLibrary {
/** All [Song]s in this [DeviceLibrary]. */
val songs: Collection<Song>
/** All [Album]s in this [DeviceLibrary]. */
val albums: Collection<Album>
/** All [Artist]s in this [DeviceLibrary]. */
val artists: Collection<Artist>
/** All [Genre]s in this [DeviceLibrary]. */
val genres: Collection<Genre>
/**
* Find a [Song] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Song], or null if one was not found.
*/
fun findSong(uid: Music.UID): Song?
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
*
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri): Song?
/**
* Find a [Song] instance corresponding to the given [Path].
*
* @param path [Path] to search for.
* @return A [Song] corresponding to the given [Path], or null if one could not be found.
*/
fun findSongByPath(path: Path): Song?
/**
* Find a [Album] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Album], or null if one was not found.
*/
fun findAlbum(uid: Music.UID): Album?
/**
* Find a [Artist] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Artist], or null if one was not found.
*/
fun findArtist(uid: Music.UID): Artist?
/**
* Find a [Genre] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Genre], or null if one was not found.
*/
fun findGenre(uid: Music.UID): Genre?
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
interface Factory {
/**
* Creates a new [DeviceLibrary] instance asynchronously based on the incoming stream of
* [RawSong] instances.
*
* @param rawSongs A stream of [RawSong] instances to process.
* @param processedSongs A stream of [RawSong] instances that will have been processed by
* the instance.
*/
suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibrary
}
}
class DeviceLibraryFactoryImpl2 @Inject constructor(
val interpreterFactory: Interpreter.Factory
) : DeviceLibrary.Factory {
override suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibrary {
val interpreter = interpreterFactory.create(nameFactory, separators)
rawSongs.forEachWithTimeout { rawSong ->
interpreter.consume(rawSong)
processedSongs.sendWithTimeout(rawSong)
}
return interpreter.resolve()
}
}
class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
override suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibrary {
val songGrouping = mutableMapOf<Music.UID, SongImpl>()
val albumGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawAlbum, SongImpl>>>()
val artistGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawArtist, Music>>>()
val genreGrouping = mutableMapOf<String?, Grouping<RawGenre, SongImpl>>()
// All music information is grouped as it is indexed by other components.
rawSongs.forEachWithTimeout { rawSong ->
val song = SongImpl(rawSong, nameFactory, separators)
// At times the indexer produces duplicate songs, try to filter these. Comparing by
// UID is sufficient for something like this, and also prevents collisions from
// causing severe issues elsewhere.
if (songGrouping.containsKey(song.uid)) {
L.w(
"Duplicate song found: ${song.path} " +
"collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}")
// We still want to say that we "processed" the song so that the user doesn't
// get confused at why the bar was only partly filled by the end of the loading
// process.
processedSongs.sendWithTimeout(rawSong)
return@forEachWithTimeout
}
songGrouping[song.uid] = song
// Group the new song into an album.
appendToMusicBrainzIdTree(song, song.rawAlbum, albumGrouping) { old, new ->
compareSongTracks(old, new)
}
// Group the song into each of it's artists.
for (rawArtist in song.rawArtists) {
appendToMusicBrainzIdTree(song, rawArtist, artistGrouping) { old, new ->
// Artist information from earlier dates is prioritized, as it is less likely to
// change with the addition of new tracks. Fall back to the name otherwise.
check(old is SongImpl) // This should always be the case.
compareSongDates(old, new)
}
}
// Group the song into each of it's genres.
for (rawGenre in song.rawGenres) {
appendToNameTree(song, rawGenre, genreGrouping) { old, new -> new.name < old.name }
}
processedSongs.sendWithTimeout(rawSong)
}
// Now that all songs are processed, also process albums and group them into their
// respective artists.
pruneMusicBrainzIdTree(albumGrouping) { old, new -> compareSongTracks(old, new) }
val albums = flattenMusicBrainzIdTree(albumGrouping) { AlbumImpl(it, nameFactory) }
for (album in albums) {
for (rawArtist in album.rawArtists) {
appendToMusicBrainzIdTree(album, rawArtist, artistGrouping) { old, new ->
when (old) {
// Immediately replace any songs that initially held the priority position.
is SongImpl -> true
is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
}
}
// Artists and genres do not need to be grouped and can be processed immediately.
pruneMusicBrainzIdTree(artistGrouping) { old, new ->
when {
// Immediately replace any songs that initially held the priority position.
old is SongImpl && new is AlbumImpl -> true
old is AlbumImpl && new is SongImpl -> false
old is SongImpl && new is SongImpl -> {
compareSongDates(old, new)
}
old is AlbumImpl && new is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
val artists = flattenMusicBrainzIdTree(artistGrouping) { ArtistImpl(it, nameFactory) }
val genres = flattenNameTree(genreGrouping) { GenreImpl(it, nameFactory) }
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
}
private inline fun <R : NameGroupable, O : Music, N : O> appendToNameTree(
music: N,
raw: R,
tree: MutableMap<String?, Grouping<R, O>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val body = tree[nameKey]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
tree[nameKey] = Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
}
private inline fun <R : NameGroupable, O : Music, P : MusicParent> flattenNameTree(
tree: MutableMap<String?, Grouping<R, O>>,
map: (Grouping<R, O>) -> P
): Set<P> = tree.values.mapTo(mutableSetOf()) { map(it) }
private inline fun <R : MusicBrainzGroupable, O : Music, N : O> appendToMusicBrainzIdTree(
music: N,
raw: R,
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, O>>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val musicBrainzIdGroups = tree[nameKey]
if (musicBrainzIdGroups != null) {
val body = musicBrainzIdGroups[raw.musicBrainzId]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
musicBrainzIdGroups[raw.musicBrainzId] =
Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
} else {
// Need to initialize this grouping.
tree[nameKey] =
mutableMapOf(
raw.musicBrainzId to Grouping(PrioritizedRaw(raw, music), mutableSetOf(music)))
}
}
private inline fun <R, M : Music> pruneMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
prioritize: (old: M, new: M) -> Boolean
) {
for ((_, musicBrainzIdGroups) in tree) {
var nullGroup = musicBrainzIdGroups[null]
if (nullGroup == null) {
// Full MusicBrainz ID tagging. Nothing to do.
continue
}
// Only partial MusicBrainz ID tagging. For the sake of basic sanity, just
// collapse all of them into the null group.
// TODO: More advanced heuristics eventually (tm)
musicBrainzIdGroups
.filter { it.key != null }
.forEach {
val (_, group) = it
nullGroup.music.addAll(group.music)
if (prioritize(group.raw.src, nullGroup.raw.src)) {
nullGroup.raw = group.raw
}
musicBrainzIdGroups.remove(it.key)
}
}
}
private inline fun <R, M : Music, T : MusicParent> flattenMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
map: (Grouping<R, M>) -> T
): Set<T> {
val result = mutableSetOf<T>()
for ((_, musicBrainzIdGroups) in tree) {
for (group in musicBrainzIdGroups.values) {
result += map(group)
}
}
return result
}
private fun compareSongTracks(old: SongImpl, new: SongImpl) =
new.track != null &&
(old.track == null ||
new.track < old.track ||
(new.track == old.track && new.name < old.name))
private fun compareAlbumDates(old: AlbumImpl, new: AlbumImpl) =
new.dates != null &&
(old.dates == null ||
new.dates < old.dates ||
(new.dates == old.dates && new.name < old.name))
private fun compareSongDates(old: SongImpl, new: SongImpl) =
new.date != null &&
(old.date == null ||
new.date < old.date ||
(new.date == old.date && new.name < old.name))
}
// TODO: Avoid redundant data creation
class DeviceLibraryImpl(
override val songs: Collection<SongImpl>,
override val albums: Collection<AlbumImpl>,
override val artists: Collection<ArtistImpl>,
override val genres: Collection<GenreImpl>
) : DeviceLibrary {
// Use a mapping to make finding information based on it's UID much faster.
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
private val songPathMap = buildMap { songs.forEach { put(it.path, it) } }
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
// All other music is built from songs, so comparison only needs to check songs.
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
override fun hashCode() = songs.hashCode()
override fun toString() =
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " +
"artists=${artists.size}, genres=${genres.size})"
override fun findSong(uid: Music.UID): Song? = songUidMap[uid]
override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid]
override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid]
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
override fun findSongByPath(path: Path) = songPathMap[path]
override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst()
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
// song. Do what we can to hopefully find the song the user wanted to open.
val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
songs.find { it.path.name == displayName && it.size == size }
}
}

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2025 Auxio Project * Copyright (c) 2023 Auxio Project
* JClassRef.cpp is part of Auxio. * DeviceModule.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@ -16,19 +16,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
#include "JClassRef.h" package org.oxycblt.auxio.music.device
JClassRef::JClassRef(JNIEnv *env, const char *classpath) : env(env) {
clazz = env->FindClass(classpath);
}
JClassRef::~JClassRef() { import dagger.Binds
env->DeleteLocalRef(clazz); import dagger.Module
} import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
jmethodID JClassRef::method(const char *name, const char *signature) { @Module
return env->GetMethodID(clazz, name, signature); @InstallIn(SingletonComponent::class)
} interface DeviceModule {
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl2): DeviceLibrary.Factory
jclass& JClassRef::operator*() { @Binds fun interpreterFactory(factory: InterpreterFactoryImpl): Interpreter.Factory
return clazz;
} }

View file

@ -0,0 +1,335 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceMusicImpl.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.sort.Sort
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.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.auxio.util.update
import kotlin.math.min
/**
* Library-backed implementation of [Song].
*
* @param linkedSong The completed [LinkedSong] all metadata van be inferred from
* @author Alexander Capehart (OxygenCobalt)
*/
class SongImpl(linkedSong: LinkedSong) : Song {
private val preSong = linkedSong.preSong
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
preSong.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
?: Music.UID.auxio(MusicType.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(preSong.rawName)
update(preSong.preAlbum.rawName)
update(preSong.date)
update(preSong.track)
update(preSong.disc?.number)
update(preSong.preArtists.map { it.rawName })
update(preSong.preAlbum.preArtists.map { it.rawName })
}
override val name = preSong.name
override val track = preSong.track
override val disc = preSong.disc
override val date = preSong.date
override val uri = preSong.uri
override val cover = preSong.cover
override val path = preSong.path
override val mimeType = preSong.mimeType
override val size = preSong.size
override val durationMs = preSong.durationMs
override val replayGainAdjustment = preSong.replayGainAdjustment
override val dateAdded = preSong.dateAdded
override val album = linkedSong.album.resolve(this)
override val artists = linkedSong.artists.resolve(this)
override val genres = linkedSong.genres.resolve(this)
private val hashCode = 31 * uid.hashCode() + preSong.hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is SongImpl &&
uid == other.uid &&
preSong == other.preSong
override fun toString() = "Song(uid=$uid, name=$name)"
}
/**
* Library-backed implementation of [Album].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
private val preAlbum = linkedAlbum.preAlbum
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
preAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
?: Music.UID.auxio(MusicType.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(preAlbum.rawName)
update(preAlbum.preArtists.map { it.rawName })
}
override val name = preAlbum.name
override val releaseType = preAlbum.releaseType
override var durationMs = 0L
override var dateAdded = 0L
override lateinit var cover: ParentCover
override var dates: Date.Range? = null
override val artists = linkedAlbum.artists.resolve(this)
override val songs = mutableSetOf<Song>()
private var hashCode = 31 * uid.hashCode() + preAlbum.hashCode()
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is AlbumImpl &&
uid == other.uid &&
preAlbum == other.preAlbum &&
songs == other.songs
override fun toString() = "Album(uid=$uid, name=$name)"
fun link(song: SongImpl) {
songs.add(song)
hashCode = 31 * hashCode + song.hashCode()
durationMs += song.durationMs
dateAdded = min(dateAdded, song.dateAdded)
if (song.date != null) {
dates = dates?.let {
if (song.date < it.min) Date.Range(song.date, it.max)
else if (song.date > it.max) Date.Range(it.min, song.date)
else it
} ?: Date.Range(song.date, song.date)
}
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Album].
*/
fun finalize(): Album {
return this
}
}
/**
* Library-backed implementation of [Artist].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImpl(
grouping: Grouping<RawArtist, Music>,
private val nameFactory: Name.Known.Factory
) : Artist {
private val rawArtist = grouping.raw.inner
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
override val name =
rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
?: Name.Unknown(R.string.def_artist)
override val songs: Set<Song>
override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album>
override val durationMs: Long?
override val cover: ParentCover
override lateinit var genres: List<Genre>
private var hashCode = uid.hashCode()
init {
val distinctSongs = mutableSetOf<Song>()
val albumMap = mutableMapOf<Album, Boolean>()
for (music in grouping.music) {
when (music) {
is SongImpl -> {
music.link(this)
distinctSongs.add(music)
if (albumMap[music.album] == null) {
albumMap[music.album] = false
}
}
is AlbumImpl -> {
music.link(this)
albumMap[music] = true
}
else -> error("Unexpected input music $music in $name ${music::class.simpleName}")
}
}
songs = distinctSongs
val albums = albumMap.keys
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
val singleCover =
when (val src = grouping.raw.src) {
is SongImpl -> src.cover
is AlbumImpl -> src.cover.single
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
}
cover = ParentCover.from(singleCover, songs)
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
// Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal.
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is ArtistImpl &&
uid == other.uid &&
rawArtist == other.rawArtist &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Artist(uid=$uid, name=$name)"
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Artist].
*/
fun finalize(): Artist {
// There are valid artist configurations:
// 1. No songs, no implicit albums, some explicit albums
// 2. Some songs, no implicit albums, some explicit albums
// 3. Some songs, some implicit albums, no implicit albums
// 4. Some songs, some implicit albums, some explicit albums
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
// but I can't be 100% certain.
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
"Malformed artist $name: Empty"
}
genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
return this
}
}
/**
* Library-backed implementation of [Genre].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImpl(
grouping: Grouping<RawGenre, SongImpl>,
private val nameFactory: Name.Known.Factory
) : Genre {
private val rawGenre = grouping.raw.inner
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
override val name =
rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
?: Name.Unknown(R.string.def_genre)
override val songs: Set<Song>
override val artists: Set<Artist>
override val durationMs: Long
override val cover: ParentCover
private var hashCode = uid.hashCode()
init {
val distinctArtists = mutableSetOf<Artist>()
var totalDuration = 0L
for (song in grouping.music) {
song.link(this)
distinctArtists.addAll(song.artists)
totalDuration += song.durationMs
}
songs = grouping.music
artists = distinctArtists
durationMs = totalDuration
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is GenreImpl &&
uid == other.uid &&
rawGenre == other.rawGenre &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Genre].
*/
fun finalize(): Genre {
check(songs.isNotEmpty()) { "Malformed genre $name: Empty" }
return this
}
}

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