Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a784f73c5e |
430 changed files with 12130 additions and 14783 deletions
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-crash-report.yml
vendored
|
@ -34,7 +34,6 @@ body:
|
|||
attributes:
|
||||
label: What android version do you use?
|
||||
options:
|
||||
- Android 15
|
||||
- Android 14
|
||||
- Android 13
|
||||
- Android 12L
|
||||
|
|
6
.github/workflows/android.yml
vendored
6
.github/workflows/android.yml
vendored
|
@ -25,10 +25,8 @@ jobs:
|
|||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Check formatting with spotless
|
||||
run: ./gradlew spotlessCheck
|
||||
- name: Test musikr with Gradle
|
||||
run: ./gradlew musikr:testDebug
|
||||
- name: Test app with Gradle
|
||||
run: ./gradlew app:testDebug
|
||||
- name: Build debug APK with Gradle
|
||||
run: ./gradlew app:packageDebug
|
||||
- name: Upload debug APK artifact
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -14,5 +14,3 @@ captures/
|
|||
*.iml
|
||||
.cxx
|
||||
.kotlin
|
||||
.aider*
|
||||
.env
|
||||
|
|
5
.gitmodules
vendored
5
.gitmodules
vendored
|
@ -1,8 +1,3 @@
|
|||
[submodule "media"]
|
||||
path = media
|
||||
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
|
||||
|
|
84
CHANGELOG.md
84
CHANGELOG.md
|
@ -1,100 +1,30 @@
|
|||
# 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
|
||||
|
||||
#### 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
|
||||
- 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
|
||||
- Initial music loading is signifigantly faster and less resource intensive
|
||||
- Album grouping no longer done with artist
|
||||
- Album grouping no longer done with artist in mind by default
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- No longer possible to save a sort with no direction specified
|
||||
- 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
|
||||
- No longer using custom logging setup
|
||||
- Music loading split off into separate musikr module
|
||||
|
||||
## 3.6.3
|
||||
|
||||
|
|
43
README.md
43
README.md
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.4">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
|
||||
<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=v3.6.3&color=64B5F6&style=flat">
|
||||
</a>
|
||||
<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">
|
||||
|
@ -15,12 +15,7 @@
|
|||
</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>
|
||||
<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://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://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
|
||||
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
|
||||
</p>
|
||||
|
||||
|
@ -33,12 +28,14 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
|
|||
## Screenshots
|
||||
|
||||
<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/shot1.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=250>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.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=200>
|
||||
<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=200>
|
||||
<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=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>
|
||||
|
||||
|
||||
|
@ -64,39 +61,29 @@ precise/original dates, sort tags, and more
|
|||
- Headset autoplay
|
||||
- Stylish widgets that automatically adapt to their size
|
||||
- Completely private and offline
|
||||
- No rounded album covers (if you want them)
|
||||
- No rounded album covers (by default)
|
||||
|
||||
## Permissions
|
||||
|
||||
- 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
|
||||
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
||||
- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
|
||||
|
||||
## 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!
|
||||
|
||||
<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">
|
||||
<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/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a>
|
||||
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
|
||||
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=50 /></a>
|
||||
</p>
|
||||
|
||||
## Building
|
||||
|
||||
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata
|
||||
parsing. This adds some caveats to the build process:
|
||||
Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
|
||||
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
|
||||
download the external code.
|
||||
|
|
|
@ -2,6 +2,7 @@ plugins {
|
|||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "androidx.navigation.safeargs.kotlin"
|
||||
id "com.diffplug.spotless"
|
||||
id "kotlin-parcelize"
|
||||
id "dagger.hilt.android.plugin"
|
||||
id "kotlin-kapt"
|
||||
|
@ -11,18 +12,20 @@ plugins {
|
|||
|
||||
android {
|
||||
compileSdk 35
|
||||
// Auxio implicitly depends on the native modules, explicitly specify it
|
||||
// here so the libraries are still stripped.
|
||||
ndkVersion ndk_version
|
||||
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
|
||||
// it here so that binary stripping will work.
|
||||
// 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"
|
||||
|
||||
defaultConfig {
|
||||
applicationId namespace
|
||||
versionName "4.0.4"
|
||||
versionCode 63
|
||||
versionName "3.6.3"
|
||||
versionCode 53
|
||||
|
||||
minSdk min_sdk
|
||||
targetSdk target_sdk
|
||||
minSdk 24
|
||||
targetSdk 35
|
||||
|
||||
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-jdk7:$kotlin_version"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
|
||||
def coroutines_version = '1.7.2'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
|
||||
|
||||
// --- SUPPORT ---
|
||||
|
||||
// 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.activity:activity-ktx:1.9.3"
|
||||
// noinspection GradleDependency
|
||||
|
@ -121,26 +125,20 @@ dependencies {
|
|||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
|
||||
// Database
|
||||
def room_version = '2.6.1'
|
||||
implementation "androidx.room:room-runtime:$room_version"
|
||||
ksp "androidx.room:room-compiler:$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 ---
|
||||
|
||||
// Exoplayer (Vendored)
|
||||
implementation project(":media-lib-exoplayer")
|
||||
implementation project(":media-lib-decoder-ffmpeg")
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.3"
|
||||
|
||||
// Image loading
|
||||
implementation 'io.coil-kt.coil3:coil-core:3.0.2'
|
||||
implementation 'io.coil-kt:coil-base:2.4.0'
|
||||
|
||||
// Material
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="info_app_name" translatable="false">Auxio Debug</string>
|
||||
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
|
||||
</resources>
|
|
@ -2,6 +2,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
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_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
@ -97,15 +100,6 @@
|
|||
</intent-filter>
|
||||
</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.
|
||||
See the class for more info.
|
||||
|
|
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -1309,6 +1309,7 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
+ " should not be set externally.");
|
||||
}
|
||||
if (!hideable && state == STATE_HIDDEN) {
|
||||
Log.w(TAG, "Cannot set state: " + state);
|
||||
return;
|
||||
}
|
||||
final int finalState;
|
||||
|
@ -1632,13 +1633,12 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
|
|||
return;
|
||||
}
|
||||
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
|
||||
boolean canActuallyHide = hideable && isHideableWhenDragging();
|
||||
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.
|
||||
setState(canActuallyHide ? STATE_HIDDEN : STATE_COLLAPSED);
|
||||
setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED);
|
||||
return;
|
||||
}
|
||||
if (canActuallyHide) {
|
||||
if (hideable && isHideableWhenDragging()) {
|
||||
bottomContainerBackHelper.finishBackProgressNotPersistent(
|
||||
backEvent,
|
||||
new AnimatorListenerAdapter() {
|
||||
|
|
|
@ -36,7 +36,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.music.service.MusicServiceFragment
|
||||
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
|
||||
import timber.log.Timber
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuxioService :
|
||||
|
@ -54,30 +53,24 @@ class AuxioService :
|
|||
musicFragment = musicFragmentFactory.create(this, this, this)
|
||||
sessionToken = playbackFragment.attach()
|
||||
musicFragment.attach()
|
||||
Timber.d("Service Created")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// TODO: Start command occurring from a foreign service basically implies a detached
|
||||
// service, we might need more handling here.
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
onHandleForeground(intent)
|
||||
// If we die we want to not restart, we will immediately try to foreground in and just
|
||||
// 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
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
val binder = super.onBind(intent)
|
||||
onHandleForeground(intent)
|
||||
return binder
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
private fun onHandleForeground(intent: Intent?) {
|
||||
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
|
||||
musicFragment.start()
|
||||
playbackFragment.start(intent)
|
||||
playbackFragment.start(startId)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
|
@ -141,7 +134,6 @@ class AuxioService :
|
|||
}
|
||||
// Nothing changed, but don't show anything music related since we can always
|
||||
// index during playback.
|
||||
isForeground = true
|
||||
} else {
|
||||
musicFragment.createNotification {
|
||||
if (it != null) {
|
||||
|
|
|
@ -65,8 +65,6 @@ object IntegerTable {
|
|||
const val START_ID_ACTIVITY = 0xA050
|
||||
/** Tasker AuxioService Start ID */
|
||||
const val START_ID_TASKER = 0xA051
|
||||
/** MediaButtonReceiver AuxioService Start ID */
|
||||
const val START_ID_MEDIA_BUTTON = 0xA052
|
||||
/** RepeatMode.NONE */
|
||||
const val REPEAT_MODE_NONE = 0xA100
|
||||
/** RepeatMode.ALL */
|
||||
|
@ -125,10 +123,10 @@ object IntegerTable {
|
|||
const val ACTION_MODE_SHUFFLE = 0xA11B
|
||||
/** CoverMode.Off */
|
||||
const val COVER_MODE_OFF = 0xA11C
|
||||
/** CoverMode.Balanced */
|
||||
const val COVER_MODE_BALANCED = 0xA11D
|
||||
/** CoverMode.MediaStore */
|
||||
const val COVER_MODE_MEDIA_STORE = 0xA11D
|
||||
/** CoverMode.Quality */
|
||||
const val COVER_MODE_HIGH_QUALITY = 0xA11E
|
||||
const val COVER_MODE_QUALITY = 0xA11E
|
||||
/** PlaySong.FromAll */
|
||||
const val PLAY_SONG_FROM_ALL = 0xA11F
|
||||
/** PlaySong.FromAlbum */
|
||||
|
@ -141,8 +139,4 @@ object IntegerTable {
|
|||
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
|
||||
/** PlaySong.ByItself */
|
||||
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
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
package org.oxycblt.auxio
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewTreeObserver
|
||||
|
@ -26,7 +27,6 @@ import androidx.activity.BackEventCompat
|
|||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.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.list.ListViewModel
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.OpenPanel
|
||||
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
|
||||
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.navigateSafe
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
@ -257,9 +257,9 @@ class MainFragment :
|
|||
}
|
||||
|
||||
override fun onPreDraw(): Boolean {
|
||||
// This is where I shove literally all the UI logic that won't behave any callback
|
||||
// or "normal" method I've tried. Surely running this on every frame will actually cause
|
||||
// it to work properly!
|
||||
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
|
||||
// sheets continually getting stuck. I need something with even more frequent updates,
|
||||
// or otherwise bottom sheets get stuck.
|
||||
|
||||
// We overload CoordinatorLayout far too much to rely on any of it's typical
|
||||
// listener functionality. Just update all transitions before every draw. Should
|
||||
|
@ -367,10 +367,6 @@ class MainFragment :
|
|||
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
|
||||
.invalidateEnabled()
|
||||
|
||||
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
|
||||
binding.mainFabContainer.isVisible =
|
||||
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
@ -408,6 +404,9 @@ class MainFragment :
|
|||
}
|
||||
|
||||
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) {
|
||||
L.d("Received ok response")
|
||||
val binding = requireBinding()
|
||||
|
@ -513,6 +512,8 @@ class MainFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private var scrimAnimator: ValueAnimator? = null
|
||||
|
||||
private fun updateSpeedDial(open: Boolean) {
|
||||
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
|
||||
.invalidateEnabled(open)
|
||||
|
|
|
@ -22,7 +22,6 @@ import android.os.Bundle
|
|||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.oxycblt.auxio.R
|
||||
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.ListFragment
|
||||
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.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
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.showToast
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -117,7 +115,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
binding.detailToolbarTitle.text = name
|
||||
binding.detailCover.bind(album)
|
||||
// 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
|
||||
// Artist name maps to the subhead text
|
||||
binding.detailSubhead.apply {
|
||||
|
@ -133,7 +131,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
// Date, song count, and duration map to the info text
|
||||
binding.detailInfo.apply {
|
||||
// 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 duration = album.durationMs.formatDurationMs(true)
|
||||
text = context.getString(R.string.fmt_three, date, songCount, duration)
|
||||
|
@ -142,15 +140,9 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
|
||||
}
|
||||
updatePlayback(
|
||||
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
|
||||
// collapsed appbar), so we need to collapse the appbar if that's the case.
|
||||
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 {
|
||||
// Use a custom smooth scroller that will settle the item in the middle of
|
||||
// the screen rather than the end.
|
||||
|
@ -329,6 +316,4 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
|
||||
}
|
||||
|
|
|
@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
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.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
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.showToast
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -164,15 +163,9 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
|
|||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
}
|
||||
|
|
|
@ -35,13 +35,13 @@ import org.oxycblt.auxio.list.ListFragment
|
|||
import org.oxycblt.auxio.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.PlainDivider
|
||||
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.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
|
||||
import org.oxycblt.auxio.util.setFullWidthLookup
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
|
||||
abstract class DetailFragment<P : MusicParent, C : Music> :
|
||||
ListFragment<C, FragmentDetailBinding>(),
|
||||
|
@ -123,9 +123,6 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
|
|||
val detailContent = binding.detailToolbarContent
|
||||
detailContent.alpha = inRatio
|
||||
detailContent.translationY = spacingSmall * (1 - inRatio)
|
||||
|
||||
// Enable fast scrolling once fully collapsed
|
||||
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
|
||||
}
|
||||
|
||||
abstract fun onOpenParentMenu()
|
||||
|
|
|
@ -23,17 +23,17 @@ import javax.inject.Inject
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
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.MusicType
|
||||
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 org.oxycblt.musikr.tag.Disc
|
||||
import org.oxycblt.musikr.tag.ReleaseType
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.info.ReleaseType
|
||||
import timber.log.Timber as L
|
||||
|
||||
interface DetailGenerator {
|
||||
|
@ -121,7 +121,7 @@ private class DetailGeneratorImpl(
|
|||
}
|
||||
|
||||
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 discs = songs.groupBy { it.disc }
|
||||
val section =
|
||||
|
@ -134,7 +134,7 @@ private class DetailGeneratorImpl(
|
|||
}
|
||||
|
||||
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 =
|
||||
artist.explicitAlbums.groupByTo(sortedMapOf()) {
|
||||
// Remap the complicated ReleaseType data structure into detail sections
|
||||
|
@ -173,14 +173,14 @@ private class DetailGeneratorImpl(
|
|||
}
|
||||
|
||||
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 songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
|
||||
return Detail(genre, listOf(artists, songs))
|
||||
}
|
||||
|
||||
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()) {
|
||||
val songs = DetailSection.Songs(playlist.songs)
|
||||
return Detail(playlist, listOf(songs))
|
||||
|
|
|
@ -22,14 +22,16 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.detail.list.DiscDivider
|
||||
import org.oxycblt.auxio.detail.list.DiscHeader
|
||||
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.list.BasicHeader
|
||||
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.adapter.UpdateInstructions
|
||||
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.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.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -66,11 +69,11 @@ class DetailViewModel
|
|||
constructor(
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val audioPropertiesFactory: AudioProperties.Factory,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
detailGeneratorFactory: DetailGenerator.Factory
|
||||
) : ViewModel(), DetailGenerator.Invalidator {
|
||||
private val _toShow = MutableEvent<Show>()
|
||||
|
||||
/**
|
||||
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
|
||||
*/
|
||||
|
@ -79,34 +82,30 @@ constructor(
|
|||
|
||||
// --- 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. */
|
||||
val currentSong: StateFlow<Song?>
|
||||
get() = _currentSong
|
||||
|
||||
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
|
||||
|
||||
/** The current properties of [currentSong]. Empty if nothing to show. */
|
||||
val currentSongProperties: StateFlow<List<SongProperty>>
|
||||
get() = _currentSongProperties
|
||||
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
|
||||
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
|
||||
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
|
||||
|
||||
// --- ALBUM ---
|
||||
|
||||
private val _currentAlbum = MutableStateFlow<Album?>(null)
|
||||
|
||||
/** The current [Album] to display. Null if there is nothing to show. */
|
||||
val currentAlbum: StateFlow<Album?>
|
||||
get() = _currentAlbum
|
||||
|
||||
private val _albumSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentAlbum]. */
|
||||
val albumSongList: StateFlow<List<Item>>
|
||||
get() = _albumSongList
|
||||
|
||||
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [albumSongList] in the UI. */
|
||||
val albumSongInstructions: Event<UpdateInstructions>
|
||||
get() = _albumSongInstructions
|
||||
|
@ -122,18 +121,15 @@ constructor(
|
|||
// --- ARTIST ---
|
||||
|
||||
private val _currentArtist = MutableStateFlow<Artist?>(null)
|
||||
|
||||
/** The current [Artist] to display. Null if there is nothing to show. */
|
||||
val currentArtist: StateFlow<Artist?>
|
||||
get() = _currentArtist
|
||||
|
||||
private val _artistSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list derived from [currentArtist]. */
|
||||
val artistSongList: StateFlow<List<Item>> = _artistSongList
|
||||
|
||||
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val artistSongInstructions: Event<UpdateInstructions>
|
||||
get() = _artistSongInstructions
|
||||
|
@ -149,18 +145,15 @@ constructor(
|
|||
// --- GENRE ---
|
||||
|
||||
private val _currentGenre = MutableStateFlow<Genre?>(null)
|
||||
|
||||
/** The current [Genre] to display. Null if there is nothing to show. */
|
||||
val currentGenre: StateFlow<Genre?>
|
||||
get() = _currentGenre
|
||||
|
||||
private val _genreSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentGenre]. */
|
||||
val genreSongList: StateFlow<List<Item>> = _genreSongList
|
||||
|
||||
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [artistSongList] in the UI. */
|
||||
val genreSongInstructions: Event<UpdateInstructions>
|
||||
get() = _genreSongInstructions
|
||||
|
@ -176,24 +169,20 @@ constructor(
|
|||
// --- PLAYLIST ---
|
||||
|
||||
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
|
||||
|
||||
/** The current [Playlist] to display. Null if there is nothing to do. */
|
||||
val currentPlaylist: StateFlow<Playlist?>
|
||||
get() = _currentPlaylist
|
||||
|
||||
private val _playlistSongList = MutableStateFlow(listOf<Item>())
|
||||
|
||||
/** The current list data derived from [currentPlaylist] */
|
||||
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
|
||||
|
||||
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
|
||||
|
||||
/** Instructions for updating [playlistSongList] in the UI. */
|
||||
val playlistSongInstructions: Event<UpdateInstructions>
|
||||
get() = _playlistSongInstructions
|
||||
|
||||
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
|
||||
|
||||
/**
|
||||
* The new playlist songs created during the current editing session. Null if no editing session
|
||||
* is occurring.
|
||||
|
@ -319,14 +308,14 @@ constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
|
||||
* the new [Song].
|
||||
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
|
||||
* be updated to align with the new [Song].
|
||||
*
|
||||
* @param uid The UID of the [Song] to load. Must be valid.
|
||||
*/
|
||||
fun setSong(uid: Music.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) {
|
||||
L.w("Given song UID was invalid")
|
||||
}
|
||||
|
@ -522,32 +511,16 @@ constructor(
|
|||
}
|
||||
|
||||
private fun refreshAudioInfo(song: Song) {
|
||||
_currentSongProperties.value = buildList {
|
||||
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
|
||||
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
|
||||
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
|
||||
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
|
||||
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
|
||||
song.track?.let {
|
||||
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
|
||||
}
|
||||
song.disc?.let {
|
||||
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)))
|
||||
}
|
||||
L.d("Refreshing audio info")
|
||||
// Clear any previous job in order to avoid stale data from appearing in the UI.
|
||||
currentSongJob?.cancel()
|
||||
_songAudioProperties.value = null
|
||||
currentSongJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val info = audioPropertiesFactory.extract(song)
|
||||
yield()
|
||||
L.d("Updating audio info to $info")
|
||||
_songAudioProperties.value = info
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,9 +29,13 @@ import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
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.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.util.collect
|
||||
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.showToast
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -133,15 +132,9 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
|
|||
binding.detailPlayButton?.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailToolbarPlay.setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailShuffleButton?.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
binding.detailToolbarShuffle.setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
}
|
||||
|
|
|
@ -35,9 +35,13 @@ import org.oxycblt.auxio.detail.list.PlaylistDragCallback
|
|||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.ListFragment
|
||||
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.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.formatDurationMs
|
||||
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.showToast
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -232,24 +231,12 @@ class PlaylistDetailFragment :
|
|||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailToolbarPlay.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailShuffleButton?.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
binding.detailToolbarShuffle.apply {
|
||||
isEnabled = playable
|
||||
setOnClickListener {
|
||||
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
|
||||
}
|
||||
}
|
||||
updatePlayback(
|
||||
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
|
||||
}
|
||||
|
|
|
@ -18,7 +18,9 @@
|
|||
|
||||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
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.SongPropertyAdapter
|
||||
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.util.collectImmediately
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.auxio.util.concatLocalized
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
@ -62,19 +71,74 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
|
|||
// DetailViewModel handles most initialization from the navigation argument.
|
||||
detailModel.setSong(args.songUid)
|
||||
detailModel.toShow.consume()
|
||||
collectImmediately(detailModel.currentSong, ::updateSong)
|
||||
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
|
||||
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
|
||||
}
|
||||
|
||||
private fun updateSong(song: Song?) {
|
||||
L.d("No song to show, navigating away")
|
||||
private fun updateSong(song: Song?, info: AudioProperties?) {
|
||||
if (song == null) {
|
||||
L.d("No song to show, navigating away")
|
||||
findNavController().navigateUp()
|
||||
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>) {
|
||||
detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
|
||||
private fun <T : Music> T.zipName(context: Context): String {
|
||||
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) }
|
||||
}
|
||||
|
|
|
@ -25,10 +25,9 @@ import org.oxycblt.auxio.list.ClickableListListener
|
|||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
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.inflater
|
||||
import org.oxycblt.musikr.Artist
|
||||
|
||||
/**
|
||||
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with
|
||||
|
|
|
@ -23,12 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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.musikr.Album
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Library
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
@ -56,9 +56,9 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
|
|||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
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.
|
||||
_artistChoices.value = _artistChoices.value?.sanitize(library)
|
||||
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
|
||||
L.d("Updated artist choices: ${_artistChoices.value}")
|
||||
}
|
||||
|
||||
|
@ -98,15 +98,16 @@ sealed interface ArtistShowChoices {
|
|||
val uid: Music.UID
|
||||
/** The current [Artist] choices. */
|
||||
val choices: List<Artist>
|
||||
/** Sanitize this instance with a [Library]. */
|
||||
fun sanitize(newLibrary: Library): ArtistShowChoices?
|
||||
/** Sanitize this instance with a [DeviceLibrary]. */
|
||||
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
|
||||
|
||||
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
|
||||
class FromSong(val song: Song) : ArtistShowChoices {
|
||||
override val uid = song.uid
|
||||
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]. */
|
||||
|
@ -114,7 +115,7 @@ sealed interface ArtistShowChoices {
|
|||
override val uid = album.uid
|
||||
override val choices = album.artists
|
||||
|
||||
override fun sanitize(newLibrary: Library) =
|
||||
override fun sanitize(newLibrary: DeviceLibrary) =
|
||||
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,9 +32,9 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Artist
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,14 +35,14 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
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.util.context
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
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.
|
||||
|
@ -121,7 +121,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
|
|||
*/
|
||||
fun bind(discHeader: DiscHeader) {
|
||||
val disc = discHeader.inner
|
||||
binding.discNumber.text = disc.resolve(binding.context)
|
||||
binding.discNumber.text = disc.resolveNumber(binding.context)
|
||||
binding.discName.apply {
|
||||
text = disc?.name
|
||||
isGone = disc?.name == null
|
||||
|
|
|
@ -29,13 +29,12 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
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.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.
|
||||
|
@ -105,7 +104,8 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
|
|||
binding.parentName.text = album.name.resolve(binding.context)
|
||||
binding.parentInfo.text =
|
||||
// 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) {
|
||||
|
|
|
@ -35,9 +35,9 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
|||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.DividerViewHolder
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.util.context
|
||||
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
|
||||
|
|
|
@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
|
||||
import org.oxycblt.auxio.list.recycler.SongViewHolder
|
||||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
/**
|
||||
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.
|
||||
|
|
|
@ -40,13 +40,12 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
|||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
|
||||
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.util.context
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,26 +18,17 @@
|
|||
|
||||
package org.oxycblt.auxio.detail.list
|
||||
|
||||
import android.text.format.Formatter
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.auxio.R
|
||||
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.SimpleDiffCallback
|
||||
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.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.
|
||||
|
@ -62,31 +53,7 @@ class SongPropertyAdapter :
|
|||
* @param value The value of the property.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class SongProperty(@StringRes val name: Int, val value: Value) {
|
||||
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
|
||||
}
|
||||
}
|
||||
data class SongProperty(@StringRes val name: Int, val value: String) : Item
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
val context = binding.context
|
||||
binding.propertyName.hint = context.getString(property.name)
|
||||
when (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))
|
||||
}
|
||||
}
|
||||
binding.propertyValue.setText(property.value)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Album
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Artist
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Genre
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Genre
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
|
|||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.list.sort.SortDialog
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* 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.util.AttributeSet
|
||||
|
@ -40,6 +40,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
// Prevent excessive layouts by using translation instead of padding.
|
||||
updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
|
||||
return insets
|
||||
}
|
|
@ -24,11 +24,9 @@ import android.os.Build
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.openInBrowser
|
||||
|
@ -44,12 +42,10 @@ import org.oxycblt.auxio.util.showToast
|
|||
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
|
||||
private val args: ErrorDetailsDialogArgs by navArgs()
|
||||
private var clipboardManager: ClipboardManager? = null
|
||||
private val musicModel: MusicViewModel by viewModels()
|
||||
|
||||
override fun onConfigDialog(builder: AlertDialog.Builder) {
|
||||
builder
|
||||
.setTitle(R.string.lbl_error_info)
|
||||
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
|
||||
.setPositiveButton(R.string.lbl_report) { _, _ ->
|
||||
requireContext().openInBrowser(LINK_ISSUES)
|
||||
}
|
||||
|
|
|
@ -22,10 +22,10 @@ import android.annotation.SuppressLint
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.view.MenuCompat
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
|
@ -37,10 +37,12 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
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.transition.MaterialSharedAxis
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.Method
|
||||
import kotlin.math.abs
|
||||
import org.oxycblt.auxio.R
|
||||
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.PlaylistListFragment
|
||||
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.list.ListViewModel
|
||||
import org.oxycblt.auxio.list.SelectionFragment
|
||||
import org.oxycblt.auxio.list.menu.Menu
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.music.IndexingState
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
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.PlaylistMessage
|
||||
import org.oxycblt.auxio.music.external.M3U
|
||||
import org.oxycblt.auxio.playback.PlaybackDecision
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.util.collect
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -172,7 +178,6 @@ class HomeFragment :
|
|||
|
||||
// --- VIEWMODEL SETUP ---
|
||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
|
||||
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
|
||||
collect(detailModel.toShow.flow, ::handleShow)
|
||||
collect(listModel.menu.flow, ::handleMenu)
|
||||
|
@ -266,7 +271,9 @@ class HomeFragment :
|
|||
|
||||
// Set up the mapping between the ViewPager and TabLayout.
|
||||
TabLayoutMediator(
|
||||
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
|
||||
binding.homeTabs,
|
||||
binding.homePager,
|
||||
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
|
||||
.attach()
|
||||
}
|
||||
|
||||
|
@ -297,49 +304,98 @@ class HomeFragment :
|
|||
homeModel.recreateTabs.consume()
|
||||
}
|
||||
|
||||
private fun handleChooseFolders(unit: Unit?) {
|
||||
if (unit == null) {
|
||||
return
|
||||
}
|
||||
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
|
||||
homeModel.chooseMusicLocations.consume()
|
||||
}
|
||||
|
||||
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()
|
||||
when (state) {
|
||||
is IndexingState.Completed -> {
|
||||
binding.homeIndexingContainer.isInvisible = state.error == null
|
||||
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
|
||||
}
|
||||
is IndexingState.Completed -> setupCompleteState(binding, state.error)
|
||||
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
|
||||
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 {
|
||||
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,13 +22,13 @@ import javax.inject.Inject
|
|||
import org.oxycblt.auxio.home.tabs.Tab
|
||||
import org.oxycblt.auxio.list.ListSettings
|
||||
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.MusicType
|
||||
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.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
interface HomeGenerator {
|
||||
|
@ -36,8 +36,6 @@ interface HomeGenerator {
|
|||
|
||||
fun release()
|
||||
|
||||
fun empty(): Boolean
|
||||
|
||||
fun songs(): List<Song>
|
||||
|
||||
fun albums(): List<Album>
|
||||
|
@ -51,8 +49,6 @@ interface HomeGenerator {
|
|||
fun tabs(): List<MusicType>
|
||||
|
||||
interface Invalidator {
|
||||
fun invalidateEmpty() {}
|
||||
|
||||
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
|
||||
|
||||
fun invalidateTabs()
|
||||
|
@ -123,10 +119,8 @@ private class HomeGeneratorImpl(
|
|||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
invalidator.invalidateEmpty()
|
||||
|
||||
val library = musicRepository.library
|
||||
if (changes.deviceLibrary && library != null) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
L.d("Refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
|
@ -136,7 +130,8 @@ private class HomeGeneratorImpl(
|
|||
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
|
||||
}
|
||||
|
||||
if (changes.userLibrary && library != null) {
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (changes.userLibrary && userLibrary != null) {
|
||||
L.d("Refreshing playlists")
|
||||
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
|
||||
}
|
||||
|
@ -148,16 +143,15 @@ private class HomeGeneratorImpl(
|
|||
homeSettings.unregisterListener(this)
|
||||
}
|
||||
|
||||
override fun empty() = musicRepository.library?.empty() ?: true
|
||||
|
||||
override fun songs() =
|
||||
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
|
||||
musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
|
||||
|
||||
override fun albums() =
|
||||
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
|
||||
musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) }
|
||||
?: emptyList()
|
||||
|
||||
override fun artists() =
|
||||
musicRepository.library?.let { deviceLibrary ->
|
||||
musicRepository.deviceLibrary?.let { deviceLibrary ->
|
||||
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
|
||||
if (homeSettings.shouldHideCollaborators) {
|
||||
sorted.filter { it.explicitAlbums.isNotEmpty() }
|
||||
|
@ -167,10 +161,11 @@ private class HomeGeneratorImpl(
|
|||
} ?: emptyList()
|
||||
|
||||
override fun genres() =
|
||||
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
|
||||
musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) }
|
||||
?: emptyList()
|
||||
|
||||
override fun playlists() =
|
||||
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
|
||||
musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) }
|
||||
?: emptyList()
|
||||
|
||||
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
|
||||
|
|
|
@ -27,16 +27,16 @@ import org.oxycblt.auxio.home.tabs.Tab
|
|||
import org.oxycblt.auxio.list.ListSettings
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -120,10 +120,6 @@ constructor(
|
|||
val playlistList: StateFlow<List<Playlist>>
|
||||
get() = _playlistList
|
||||
|
||||
private val _empty = MutableStateFlow(false)
|
||||
val empty: StateFlow<Boolean>
|
||||
get() = _empty
|
||||
|
||||
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
|
||||
/** Instructions for how to update [genreList] in the UI. */
|
||||
val playlistInstructions: Event<UpdateInstructions>
|
||||
|
@ -163,10 +159,6 @@ constructor(
|
|||
val showOuter: Event<Outer>
|
||||
get() = _showOuter
|
||||
|
||||
private val _chooseMusicLocations = MutableEvent<Unit>()
|
||||
val chooseMusicLocations: Event<Unit>
|
||||
get() = _chooseMusicLocations
|
||||
|
||||
init {
|
||||
homeGenerator.attach()
|
||||
}
|
||||
|
@ -176,10 +168,6 @@ constructor(
|
|||
homeGenerator.release()
|
||||
}
|
||||
|
||||
override fun invalidateEmpty() {
|
||||
_empty.value = homeGenerator.empty()
|
||||
}
|
||||
|
||||
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
|
||||
when (type) {
|
||||
MusicType.SONGS -> {
|
||||
|
@ -275,10 +263,6 @@ constructor(
|
|||
_isFastScrolling.value = isFastScrolling
|
||||
}
|
||||
|
||||
fun startChooseMusicLocations() {
|
||||
_chooseMusicLocations.put(Unit)
|
||||
}
|
||||
|
||||
fun showSettings() {
|
||||
_showOuter.put(Outer.Settings)
|
||||
}
|
||||
|
|
|
@ -190,8 +190,6 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
|
||||
overlayLayout.setBackgroundColor(overlayColor)
|
||||
}
|
||||
// Fix default margins added by library
|
||||
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
private fun Int.withModulatedAlpha(
|
||||
|
@ -232,24 +230,13 @@ class ThemedSpeedDialView : SpeedDialView {
|
|||
return super.addActionItem(actionItem, position, animate)?.apply {
|
||||
fab.apply {
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
|
||||
if (position == actionItems.lastIndex) {
|
||||
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
|
||||
setMargins(0, 0, rightMargin, bottomMargin)
|
||||
} else {
|
||||
setMargins(0, 0, rightMargin, 0)
|
||||
}
|
||||
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
|
||||
setMargins(horizontalMargin, 0, horizontalMargin, 0)
|
||||
}
|
||||
useCompatPadding = false
|
||||
}
|
||||
|
||||
labelBackground.apply {
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
if (position == actionItems.lastIndex) {
|
||||
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
|
||||
setMargins(0, 0, rightMargin, bottomMargin)
|
||||
}
|
||||
}
|
||||
useCompatPadding = false
|
||||
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
|
||||
background =
|
||||
|
|
|
@ -22,8 +22,6 @@ import android.os.Bundle
|
|||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.FastScrollRecyclerView
|
||||
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.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
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.
|
||||
|
@ -82,16 +79,7 @@ class 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.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
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.
|
||||
return when (homeModel.albumSort.mode) {
|
||||
// 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
|
||||
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)
|
||||
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
|
||||
|
@ -127,7 +115,7 @@ class AlbumListFragment :
|
|||
|
||||
// Last added -> Format as date
|
||||
is Sort.Mode.ByDateAdded -> {
|
||||
val dateAddedMillis = album.addedMs
|
||||
val dateAddedMillis = album.dateAdded.secsToMs()
|
||||
formatterSb.setLength(0)
|
||||
DateUtils.formatDateRange(
|
||||
context,
|
||||
|
@ -159,14 +147,6 @@ class AlbumListFragment :
|
|||
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>) {
|
||||
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.FastScrollRecyclerView
|
||||
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.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
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.
|
||||
|
@ -77,16 +74,7 @@ class 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.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
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.
|
||||
return when (homeModel.artistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> artist.name.thumb()
|
||||
is Sort.Mode.ByName -> artist.name.thumb
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
|
||||
|
@ -135,14 +123,6 @@ class ArtistListFragment :
|
|||
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>) {
|
||||
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.GenreViewHolder
|
||||
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.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
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.
|
||||
|
@ -76,16 +73,7 @@ class 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.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
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.
|
||||
return when (homeModel.genreSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> genre.name.thumb()
|
||||
is Sort.Mode.ByName -> genre.name.thumb
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
|
||||
|
@ -134,14 +122,6 @@ class GenreListFragment :
|
|||
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>) {
|
||||
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -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 -> "?"
|
||||
}
|
|
@ -21,8 +21,6 @@ package org.oxycblt.auxio.home.list
|
|||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import org.oxycblt.auxio.R
|
||||
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.PlaylistViewHolder
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
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.
|
||||
|
@ -74,18 +71,7 @@ class 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.empty,
|
||||
homeModel.playlistList,
|
||||
musicModel.indexingState,
|
||||
::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
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.
|
||||
return when (homeModel.playlistSort.mode) {
|
||||
// By Name -> Use Name
|
||||
is Sort.Mode.ByName -> playlist.name.thumb()
|
||||
is Sort.Mode.ByName -> playlist.name.thumb
|
||||
|
||||
// Duration -> Use formatted duration
|
||||
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
|
||||
|
@ -134,26 +120,6 @@ class PlaylistListFragment :
|
|||
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>) {
|
||||
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -22,8 +22,6 @@ import android.os.Bundle
|
|||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
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.SongViewHolder
|
||||
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.resolve
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.playback.secsToMs
|
||||
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.
|
||||
|
@ -62,7 +59,6 @@ class SongListFragment :
|
|||
override val musicModel: MusicViewModel by activityViewModels()
|
||||
override val playbackModel: PlaybackViewModel by activityViewModels()
|
||||
private val songAdapter = SongAdapter(this)
|
||||
|
||||
// Save memory by re-using the same formatter and string builder when creating popup text
|
||||
private val formatterSb = StringBuilder(64)
|
||||
private val formatter = Formatter(formatterSb)
|
||||
|
@ -80,16 +76,7 @@ class 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.empty, musicModel.indexingState, ::updateNoMusicIndicator)
|
||||
collectImmediately(listModel.selected, ::updateSelection)
|
||||
collectImmediately(
|
||||
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.
|
||||
return when (homeModel.songSort.mode) {
|
||||
// Name -> Use name
|
||||
is Sort.Mode.ByName -> song.name.thumb()
|
||||
is Sort.Mode.ByName -> song.name.thumb
|
||||
|
||||
// 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
|
||||
is Sort.Mode.ByAlbum -> song.album.name.thumb()
|
||||
is Sort.Mode.ByAlbum -> song.album.name.thumb
|
||||
|
||||
// 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
|
||||
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
|
||||
|
||||
// Last added -> Format as date
|
||||
is Sort.Mode.ByDateAdded -> {
|
||||
val dateAddedMillis = song.addedMs
|
||||
val dateAddedMillis = song.dateAdded.secsToMs()
|
||||
formatterSb.setLength(0)
|
||||
DateUtils.formatDateRange(
|
||||
context,
|
||||
|
@ -159,14 +146,6 @@ class SongListFragment :
|
|||
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>) {
|
||||
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import coil3.toBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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.
|
||||
|
@ -94,7 +94,7 @@ constructor(
|
|||
target
|
||||
.onConfigRequest(
|
||||
ImageRequest.Builder(context)
|
||||
.data(song.cover)
|
||||
.data(listOf(song.cover))
|
||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||
.size(Size.ORIGINAL))
|
||||
.target(
|
||||
|
|
|
@ -26,11 +26,12 @@ import org.oxycblt.auxio.IntegerTable
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class CoverMode {
|
||||
/** Do not load album covers ("Off"). */
|
||||
OFF,
|
||||
SAVE_SPACE,
|
||||
BALANCED,
|
||||
HIGH_QUALITY,
|
||||
AS_IS;
|
||||
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
|
||||
MEDIA_STORE,
|
||||
/** Load high-quality covers directly from music files ("Quality"). */
|
||||
QUALITY;
|
||||
|
||||
/**
|
||||
* The integer representation of this instance.
|
||||
|
@ -41,10 +42,8 @@ enum class CoverMode {
|
|||
get() =
|
||||
when (this) {
|
||||
OFF -> IntegerTable.COVER_MODE_OFF
|
||||
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE
|
||||
BALANCED -> IntegerTable.COVER_MODE_BALANCED
|
||||
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
|
||||
AS_IS -> IntegerTable.COVER_MODE_AS_IS
|
||||
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
|
||||
QUALITY -> IntegerTable.COVER_MODE_QUALITY
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -58,10 +57,8 @@ enum class CoverMode {
|
|||
fun fromIntCode(intCode: Int) =
|
||||
when (intCode) {
|
||||
IntegerTable.COVER_MODE_OFF -> OFF
|
||||
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE
|
||||
IntegerTable.COVER_MODE_BALANCED -> BALANCED
|
||||
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
|
||||
IntegerTable.COVER_MODE_AS_IS -> AS_IS
|
||||
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
|
||||
IntegerTable.COVER_MODE_QUALITY -> QUALITY
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -37,35 +37,31 @@ import androidx.annotation.DrawableRes
|
|||
import androidx.annotation.Px
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isEmpty
|
||||
import androidx.core.view.updateMarginsRelative
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.target
|
||||
import coil3.request.transformations
|
||||
import coil3.util.CoilUtils
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.R as MR
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
|
||||
import org.oxycblt.auxio.image.coil.SquareCropTransformation
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
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.UISettings
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
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
|
||||
|
@ -173,7 +169,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
super.onFinishInflate()
|
||||
|
||||
// The image isn't added if other children have populated the body. This is by design.
|
||||
if (isEmpty()) {
|
||||
if (childCount == 0) {
|
||||
addView(image)
|
||||
}
|
||||
|
||||
|
@ -317,7 +313,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(song: Song) =
|
||||
bindImpl(
|
||||
song.cover,
|
||||
listOf(song.cover),
|
||||
context.getString(R.string.desc_album_cover, song.album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -328,7 +324,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(album: Album) =
|
||||
bindImpl(
|
||||
album.covers,
|
||||
album.cover.all,
|
||||
context.getString(R.string.desc_album_cover, album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -339,7 +335,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(artist: Artist) =
|
||||
bindImpl(
|
||||
artist.covers,
|
||||
artist.cover.all,
|
||||
context.getString(R.string.desc_artist_image, artist.name),
|
||||
R.drawable.ic_artist_24)
|
||||
|
||||
|
@ -350,7 +346,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(genre: Genre) =
|
||||
bindImpl(
|
||||
genre.covers,
|
||||
genre.cover.all,
|
||||
context.getString(R.string.desc_genre_image, genre.name),
|
||||
R.drawable.ic_genre_24)
|
||||
|
||||
|
@ -361,7 +357,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(playlist: Playlist) =
|
||||
bindImpl(
|
||||
playlist.covers,
|
||||
playlist.cover?.all ?: emptyList(),
|
||||
context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
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.
|
||||
*/
|
||||
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 =
|
||||
ImageRequest.Builder(context)
|
||||
.data(cover)
|
||||
.error(
|
||||
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
||||
.asImage())
|
||||
.data(covers)
|
||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize))
|
||||
.target(image)
|
||||
|
||||
val cornersTransformation =
|
||||
|
@ -410,7 +404,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
@Px val iconSize: Int?
|
||||
) : Drawable() {
|
||||
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.
|
||||
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
get() =
|
||||
CoverMode.fromIntCode(
|
||||
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
|
||||
?: CoverMode.BALANCED
|
||||
?: CoverMode.MEDIA_STORE
|
||||
|
||||
override val forceSquareCovers: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
|
||||
|
@ -64,8 +64,8 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
when {
|
||||
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
|
||||
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
|
||||
CoverMode.BALANCED
|
||||
else -> CoverMode.BALANCED
|
||||
CoverMode.MEDIA_STORE
|
||||
else -> CoverMode.QUALITY
|
||||
}
|
||||
|
||||
sharedPreferences.edit {
|
||||
|
@ -74,24 +74,6 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
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) {
|
||||
|
@ -105,6 +87,5 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
|
|||
private companion object {
|
||||
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
|
||||
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
|
||||
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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}"
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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}"
|
||||
}
|
|
@ -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() }
|
|
@ -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)
|
||||
}
|
||||
}
|
66
app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
Normal file
66
app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
60
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal file
60
app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt
Normal 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)
|
||||
}
|
|
@ -16,15 +16,15 @@
|
|||
* 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 coil3.request.ImageResult
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.transition.CrossfadeDrawable
|
||||
import coil3.transition.CrossfadeTransition
|
||||
import coil3.transition.Transition
|
||||
import coil3.transition.TransitionTarget
|
||||
import coil.decode.DataSource
|
||||
import coil.drawable.CrossfadeDrawable
|
||||
import coil.request.ImageResult
|
||||
import coil.request.SuccessResult
|
||||
import coil.transition.CrossfadeTransition
|
||||
import coil.transition.Transition
|
||||
import coil.transition.TransitionTarget
|
||||
|
||||
/**
|
||||
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* 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
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.image.coil
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.content.Context
|
||||
import coil3.ImageLoader
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.transitionFactory
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
@ -31,22 +30,19 @@ import javax.inject.Singleton
|
|||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CoilModule {
|
||||
class ExtractorModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun imageLoader(
|
||||
@ApplicationContext context: Context,
|
||||
coverKeyer: CoverKeyer,
|
||||
coverFactory: CoverFetcher.Factory,
|
||||
coverCollectionKeyer: CoverCollectionKeyer,
|
||||
coverCollectionFactory: CoverCollectionFetcher.Factory
|
||||
keyer: CoverKeyer,
|
||||
factory: CoverFetcher.Factory
|
||||
) =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(coverKeyer)
|
||||
add(coverFactory)
|
||||
add(coverCollectionKeyer)
|
||||
add(coverCollectionFactory)
|
||||
// Add fetchers for Music components to make them usable with ImageRequest
|
||||
add(keyer)
|
||||
add(factory)
|
||||
}
|
||||
// Use our own crossfade with error drawable support
|
||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
|
@ -16,7 +16,7 @@
|
|||
* 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.createBitmap
|
||||
|
@ -30,16 +30,16 @@ import android.graphics.RectF
|
|||
import android.graphics.Shader
|
||||
import androidx.annotation.Px
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import coil3.decode.DecodeUtils
|
||||
import coil3.size.Scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.transform.Transformation
|
||||
import coil.decode.DecodeUtils
|
||||
import coil.size.Scale
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import coil.transform.Transformation
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
|
||||
* without cropping them.
|
||||
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
|
||||
* images without cropping them.
|
||||
*
|
||||
* @author Coil Team, Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
|
@ -48,7 +48,7 @@ class RoundedRectTransformation(
|
|||
@Px private val topRight: Float = 0f,
|
||||
@Px private val bottomLeft: Float = 0f,
|
||||
@Px private val bottomRight: Float = 0f
|
||||
) : Transformation() {
|
||||
) : Transformation {
|
||||
|
||||
constructor(@Px radius: Float) : this(radius, radius, radius, radius)
|
||||
|
|
@ -16,13 +16,12 @@
|
|||
* 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 androidx.core.graphics.scale
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.transform.Transformation
|
||||
import coil.size.Size
|
||||
import coil.size.pxOrElse
|
||||
import coil.transform.Transformation
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
|
@ -31,7 +30,7 @@ import kotlin.math.min
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class SquareCropTransformation : Transformation() {
|
||||
class SquareCropTransformation : Transformation {
|
||||
override val cacheKey: String
|
||||
get() = "SquareCropTransformation"
|
||||
|
||||
|
@ -47,7 +46,7 @@ class SquareCropTransformation : Transformation() {
|
|||
val desiredHeight = size.height.pxOrElse { dstSize }
|
||||
if (dstSize != desiredWidth || dstSize != desiredHeight) {
|
||||
// Image is not the desired size, upscale it.
|
||||
return dst.scale(desiredWidth, desiredHeight)
|
||||
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
|
||||
}
|
||||
return dst
|
||||
}
|
|
@ -22,9 +22,9 @@ import androidx.annotation.StringRes
|
|||
|
||||
// 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. */
|
||||
typealias Item = Any
|
||||
interface Item
|
||||
|
||||
interface Header
|
||||
interface Header : Item
|
||||
|
||||
/**
|
||||
* A "header" used for delimiting groups of data.
|
||||
|
@ -44,7 +44,7 @@ interface PlainHeader : Header {
|
|||
*/
|
||||
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
|
||||
|
||||
interface Divider<T> {
|
||||
interface Divider<T> : Item {
|
||||
val anchor: T?
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
|
|||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.auxio.music.Music
|
||||
|
||||
/**
|
||||
* A Fragment containing a selectable list.
|
||||
|
|
|
@ -25,17 +25,17 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.auxio.util.Event
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -64,17 +64,18 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
|
|||
}
|
||||
|
||||
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
|
||||
// won't appear in any list.
|
||||
_selected.value =
|
||||
_selected.value.mapNotNull {
|
||||
when (it) {
|
||||
is Song -> library.findSong(it.uid)
|
||||
is Album -> library.findAlbum(it.uid)
|
||||
is Artist -> library.findArtist(it.uid)
|
||||
is Genre -> library.findGenre(it.uid)
|
||||
is Playlist -> library.findPlaylist(it.uid)
|
||||
is Song -> deviceLibrary.findSong(it.uid)
|
||||
is Album -> deviceLibrary.findAlbum(it.uid)
|
||||
is Artist -> deviceLibrary.findArtist(it.uid)
|
||||
is Genre -> deviceLibrary.findGenre(it.uid)
|
||||
is Playlist -> userLibrary.findPlaylist(it.uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ package org.oxycblt.auxio.list.adapter
|
|||
import android.view.View
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
|
|||
import android.os.Parcelable
|
||||
import androidx.annotation.MenuRes
|
||||
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.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.
|
||||
|
|
|
@ -27,18 +27,17 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogMenuBinding
|
||||
import org.oxycblt.auxio.detail.DetailViewModel
|
||||
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.resolve
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.playback.formatDurationMs
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
import org.oxycblt.auxio.util.share
|
||||
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].
|
||||
|
@ -113,7 +112,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
|
|||
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
|
||||
val context = requireContext()
|
||||
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.menuInfo.text = menu.album.artists.resolveNames(context)
|
||||
}
|
||||
|
|
|
@ -23,9 +23,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.MusicParent
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.playback.PlaySong
|
||||
import org.oxycblt.musikr.MusicParent
|
||||
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? {
|
||||
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 playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
|
||||
return Menu.ForSong(parcel.res, song, playWith)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
|
||||
val library = musicRepository.library ?: return null
|
||||
val songs = parcel.songUids.mapNotNull(library::findSong)
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return null
|
||||
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
|
||||
return Menu.ForSelection(parcel.res, songs)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,37 +19,21 @@
|
|||
package org.oxycblt.auxio.list.recycler
|
||||
|
||||
import android.animation.Animator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
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 com.google.android.material.textview.MaterialTextView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.ui.MaterialFadingSlider
|
||||
import org.oxycblt.auxio.ui.MaterialSlider
|
||||
import org.oxycblt.auxio.util.getAttrColorCompat
|
||||
import org.oxycblt.auxio.util.getDimenPixels
|
||||
import org.oxycblt.auxio.util.getDrawableCompat
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.isRtl
|
||||
import org.oxycblt.auxio.util.isUnder
|
||||
|
@ -78,70 +62,33 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
|
|||
* - Added drag listener
|
||||
* - Added documentation
|
||||
* - Completely new design
|
||||
* - New scroll position backend
|
||||
*
|
||||
* @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
|
||||
@JvmOverloads
|
||||
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
|
||||
AuxioRecyclerView(context, attrs, defStyleAttr) {
|
||||
// Thumb
|
||||
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
|
||||
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
|
||||
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
|
||||
private val thumbSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||
private val slider = MaterialSlider(context, thumbSize)
|
||||
private var thumbAnimator: Animator? = null
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private val thumbView =
|
||||
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
|
||||
thumbSlider.jumpOut(this)
|
||||
}
|
||||
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) }
|
||||
private val thumbPadding = Rect(0, 0, 0, 0)
|
||||
private var thumbOffset = 0
|
||||
|
||||
private var showingThumb = false
|
||||
private val hideThumbRunnable = Runnable {
|
||||
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
|
||||
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
|
||||
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 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
|
||||
set(value) {
|
||||
if (field == value) {
|
||||
|
@ -187,9 +116,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
if (field) {
|
||||
removeCallbacks(hideThumbRunnable)
|
||||
showScrollbar()
|
||||
showPopup()
|
||||
} else {
|
||||
hidePopup()
|
||||
postAutoHideScrollbar()
|
||||
}
|
||||
|
||||
|
@ -201,7 +128,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
init {
|
||||
overlay.add(thumbView)
|
||||
overlay.add(popupView)
|
||||
|
||||
addItemDecoration(
|
||||
object : ItemDecoration() {
|
||||
|
@ -230,96 +156,26 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
// --- RECYCLERVIEW EVENT MANAGEMENT ---
|
||||
|
||||
private fun onPreDraw() {
|
||||
updateThumbState()
|
||||
updateScrollbarState()
|
||||
|
||||
thumbView.layoutDirection = layoutDirection
|
||||
thumbView.measure(
|
||||
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
|
||||
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY))
|
||||
val thumbTop = thumbPadding.top + thumbOffset
|
||||
val thumbLeft =
|
||||
if (isRtl) {
|
||||
thumbPadding.left
|
||||
} else {
|
||||
width - thumbPadding.right - thumbWidth
|
||||
width - thumbPadding.right - thumbSize
|
||||
}
|
||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
|
||||
|
||||
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)
|
||||
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize)
|
||||
}
|
||||
|
||||
override fun onScrolled(dx: Int, dy: Int) {
|
||||
super.onScrolled(dx, dy)
|
||||
|
||||
updateThumbState()
|
||||
updateScrollbarState()
|
||||
|
||||
// Measure or layout events result in a fake onScrolled call. Ignore those.
|
||||
if (dx == 0 && dy == 0) {
|
||||
|
@ -337,15 +193,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
return insets
|
||||
}
|
||||
|
||||
private fun updateThumbState() {
|
||||
private fun updateScrollbarState() {
|
||||
// Then calculate the thumb position, which is just:
|
||||
// [proportion of scroll position to scroll range] * [total thumb range]
|
||||
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
|
||||
val offsetY = computeVerticalScrollOffset()
|
||||
if (computeVerticalScrollRange() < height || isEmpty()) {
|
||||
fastScrollingPossible = false
|
||||
hideThumb()
|
||||
hidePopup()
|
||||
if (computeVerticalScrollRange() < height || childCount == 0) {
|
||||
return
|
||||
}
|
||||
val extentY = computeVerticalScrollExtent()
|
||||
|
@ -354,10 +206,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun onItemTouch(event: MotionEvent): Boolean {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
dragging = false
|
||||
return false
|
||||
}
|
||||
val eventX = event.x
|
||||
val eventY = event.y
|
||||
|
||||
|
@ -371,9 +219,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
|
||||
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
|
||||
dragStartThumbOffset = thumbOffset
|
||||
} else if (eventX > thumbView.right - thumbWidth / 4) {
|
||||
dragStartThumbOffset =
|
||||
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
||||
} else if (eventX > thumbView.right - thumbSize / 4) {
|
||||
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
|
||||
scrollToThumbOffset(dragStartThumbOffset)
|
||||
} else {
|
||||
return false
|
||||
|
@ -391,8 +238,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
dragStartThumbOffset = thumbOffset
|
||||
} else {
|
||||
dragStartY = eventY
|
||||
dragStartThumbOffset =
|
||||
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
|
||||
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt()
|
||||
scrollToThumbOffset(dragStartThumbOffset)
|
||||
}
|
||||
|
||||
|
@ -436,65 +282,30 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
}
|
||||
|
||||
private fun showScrollbar() {
|
||||
if (!fastScrollingEnabled || !fastScrollingPossible) {
|
||||
return
|
||||
}
|
||||
if (showingThumb) {
|
||||
return
|
||||
}
|
||||
|
||||
showingThumb = true
|
||||
thumbAnimator?.cancel()
|
||||
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
|
||||
thumbAnimator = slider.slideIn(thumbView).also { it.start() }
|
||||
}
|
||||
|
||||
private fun hideThumb() {
|
||||
private fun hideScrollbar() {
|
||||
if (!showingThumb) {
|
||||
return
|
||||
}
|
||||
|
||||
showingThumb = false
|
||||
thumbAnimator?.cancel()
|
||||
thumbAnimator = thumbSlider.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
|
||||
})
|
||||
thumbAnimator = slider.slideOut(thumbView).also { it.start() }
|
||||
}
|
||||
|
||||
// --- LAYOUT STATE ---
|
||||
|
||||
private val thumbOffsetRange: Int
|
||||
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. */
|
||||
|
|
|
@ -92,6 +92,7 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
|
|||
|
||||
// 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.
|
||||
// TODO: I think this is possible to improve with a raw ValueAnimator.
|
||||
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
|
||||
L.d("Lifting ViewHolder")
|
||||
|
||||
|
|
|
@ -32,17 +32,16 @@ import org.oxycblt.auxio.list.PlainDivider
|
|||
import org.oxycblt.auxio.list.SelectableListListener
|
||||
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
|
||||
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.resolve
|
||||
import org.oxycblt.auxio.music.resolveNames
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getPlural
|
||||
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.
|
||||
|
|
|
@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
|
|||
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
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.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
|
||||
|
||||
/**
|
||||
* A sorting method.
|
||||
|
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
|
|||
override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
|
||||
songs.sortBy { it.name }
|
||||
when (direction) {
|
||||
Direction.ASCENDING -> songs.sortBy { it.addedMs }
|
||||
Direction.DESCENDING -> songs.sortByDescending { it.addedMs }
|
||||
Direction.ASCENDING -> songs.sortBy { it.dateAdded }
|
||||
Direction.DESCENDING -> songs.sortByDescending { it.dateAdded }
|
||||
}
|
||||
}
|
||||
|
||||
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
|
||||
albums.sortBy { it.name }
|
||||
when (direction) {
|
||||
Direction.ASCENDING -> albums.sortBy { it.addedMs }
|
||||
Direction.DESCENDING -> albums.sortByDescending { it.addedMs }
|
||||
Direction.ASCENDING -> albums.sortBy { it.dateAdded }
|
||||
Direction.DESCENDING -> albums.sortByDescending { it.dateAdded }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
87
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal 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"
|
||||
}
|
|
@ -16,25 +16,29 @@
|
|||
* 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.os.Parcelable
|
||||
import androidx.room.TypeConverter
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import kotlin.math.max
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
import org.oxycblt.musikr.fs.Format
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
import org.oxycblt.musikr.tag.Disc
|
||||
import org.oxycblt.musikr.tag.Name
|
||||
import org.oxycblt.musikr.tag.ReleaseType
|
||||
import org.oxycblt.musikr.tag.ReplayGainAdjustment
|
||||
import org.oxycblt.musikr.util.toUuidOrNull
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
import org.oxycblt.auxio.image.extractor.ParentCover
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.fs.MimeType
|
||||
import org.oxycblt.auxio.music.fs.Path
|
||||
import org.oxycblt.auxio.music.info.Date
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.music.info.ReleaseType
|
||||
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
|
||||
|
@ -42,7 +46,7 @@ import org.oxycblt.musikr.util.toUuidOrNull
|
|||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface Music {
|
||||
sealed interface Music : Item {
|
||||
/**
|
||||
* A unique identifier for this music item.
|
||||
*
|
||||
|
@ -77,34 +81,23 @@ sealed interface Music {
|
|||
class UID
|
||||
private constructor(
|
||||
private val format: Format,
|
||||
private val item: Item,
|
||||
private val type: MusicType,
|
||||
private val uuid: UUID
|
||||
) : Parcelable {
|
||||
// Cache the hashCode for HashMap efficiency.
|
||||
@IgnoredOnParcel private var hashCode = format.hashCode()
|
||||
|
||||
init {
|
||||
hashCode = 31 * hashCode + item.hashCode()
|
||||
hashCode = 31 * hashCode + type.hashCode()
|
||||
hashCode = 31 * hashCode + uuid.hashCode()
|
||||
}
|
||||
|
||||
override fun hashCode() = hashCode
|
||||
|
||||
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"
|
||||
|
||||
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)
|
||||
}
|
||||
override fun toString() = "${format.namespace}:${type.intCode.toString(16)}-$uuid"
|
||||
|
||||
/**
|
||||
* Internal marker of [Music.UID] format type.
|
||||
|
@ -124,7 +117,7 @@ sealed interface Music {
|
|||
@TypeConverter fun fromMusicUID(uid: UID?) = uid?.toString()
|
||||
|
||||
/** @see [Music.UID.fromString] */
|
||||
@TypeConverter fun toMusicUid(string: String?) = string?.let(Companion::fromString)
|
||||
@TypeConverter fun toMusicUid(string: String?) = string?.let(UID::fromString)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -132,23 +125,23 @@ sealed interface Music {
|
|||
* Creates an Auxio-style [UID] of random composition. Used if there is no
|
||||
* 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 {
|
||||
return UID(Format.AUXIO, item, UUID.randomUUID())
|
||||
fun auxio(type: MusicType): UID {
|
||||
return UID(Format.AUXIO, type, UUID.randomUUID())
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Auxio-style [UID] with a [UUID] composed of a hash of the 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].
|
||||
* @param updates Block to update the [MessageDigest] hash with the metadata of the
|
||||
* item. Make sure the metadata hashed semantically aligns with the format
|
||||
* specification.
|
||||
* @return A new auxio-style [UID].
|
||||
*/
|
||||
internal fun auxio(item: Item, updates: MessageDigest.() -> Unit): UID {
|
||||
fun auxio(type: MusicType, updates: MessageDigest.() -> Unit): UID {
|
||||
val digest =
|
||||
MessageDigest.getInstance("SHA-256").run {
|
||||
updates()
|
||||
|
@ -178,19 +171,19 @@ sealed interface Music {
|
|||
.or(digest[13].toLong().and(0xFF).shl(16))
|
||||
.or(digest[14].toLong().and(0xFF).shl(8))
|
||||
.or(digest[15].toLong().and(0xFF)))
|
||||
return UID(Format.AUXIO, item, uuid)
|
||||
return UID(Format.AUXIO, type, uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MusicBrainz-style [UID] with a [UUID] derived from the MusicBrainz ID
|
||||
* 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
|
||||
* file.
|
||||
* @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.
|
||||
|
@ -218,8 +211,8 @@ sealed interface Music {
|
|||
return null
|
||||
}
|
||||
|
||||
val intCode = ids[0].toIntOrNull(16) ?: return null
|
||||
val type = Item.entries.firstOrNull { it.intCode == intCode } ?: return null
|
||||
val type =
|
||||
MusicType.fromIntCode(ids[0].toIntOrNull(16) ?: return null) ?: return null
|
||||
val uuid = ids[1].toUuidOrNull() ?: return null
|
||||
return UID(format, type, uuid)
|
||||
}
|
||||
|
@ -243,7 +236,6 @@ sealed interface MusicParent : Music {
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface Song : Music {
|
||||
override val name: Name.Known
|
||||
/** The track number. Will be null if no valid track number was present in the metadata. */
|
||||
val track: Int?
|
||||
/** 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.
|
||||
*/
|
||||
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
|
||||
* instead for accessing the audio file.
|
||||
*/
|
||||
val path: Path
|
||||
/** The [Format] of the audio file. Only intended for display. */
|
||||
val format: Format
|
||||
/** The [MimeType] of the audio file. Only intended for display. */
|
||||
val mimeType: MimeType
|
||||
/** The size of the audio file, in bytes. */
|
||||
val size: Long
|
||||
/** The duration of the audio file, in milliseconds. */
|
||||
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. */
|
||||
val replayGainAdjustment: ReplayGainAdjustment
|
||||
/**
|
||||
* The date last modified the audio file was last modified, in milliseconds since the unix
|
||||
* 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 date the audio file was added to the device, as a unix epoch timestamp. */
|
||||
val dateAdded: Long
|
||||
/**
|
||||
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
|
||||
* instead.
|
||||
|
@ -313,12 +296,12 @@ interface Album : MusicParent {
|
|||
* [ReleaseType.Album].
|
||||
*/
|
||||
val releaseType: ReleaseType
|
||||
/** Cover information from album's songs. */
|
||||
val covers: CoverCollection
|
||||
/** Cover information from the template song used for the album. */
|
||||
val cover: ParentCover
|
||||
/** The duration of all songs in the album, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** The earliest date a song in this album was added, in milliseconds since the unix epoch. */
|
||||
val addedMs: Long
|
||||
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
|
||||
val dateAdded: Long
|
||||
/**
|
||||
* 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
|
||||
|
@ -344,7 +327,7 @@ interface Artist : MusicParent {
|
|||
*/
|
||||
val durationMs: Long?
|
||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||
val covers: CoverCollection
|
||||
val cover: ParentCover
|
||||
/** The [Genre]s of this artist. */
|
||||
val genres: List<Genre>
|
||||
}
|
||||
|
@ -360,7 +343,7 @@ interface Genre : MusicParent {
|
|||
/** The total duration of the songs in this genre, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** 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. */
|
||||
val durationMs: Long
|
||||
/** 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
|
||||
}
|
|
@ -19,30 +19,31 @@
|
|||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.UUID
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
||||
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
|
||||
import org.oxycblt.musikr.IndexingProgress
|
||||
import org.oxycblt.musikr.Interpretation
|
||||
import org.oxycblt.musikr.Library
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Musikr
|
||||
import org.oxycblt.musikr.MutableLibrary
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
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 org.oxycblt.auxio.music.cache.CacheRepository
|
||||
import org.oxycblt.auxio.music.device.DeviceLibrary
|
||||
import org.oxycblt.auxio.music.device.RawSong
|
||||
import org.oxycblt.auxio.music.fs.MediaStoreExtractor
|
||||
import org.oxycblt.auxio.music.info.Name
|
||||
import org.oxycblt.auxio.music.metadata.Separators
|
||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
||||
import org.oxycblt.auxio.music.user.MutableUserLibrary
|
||||
import org.oxycblt.auxio.music.user.UserLibrary
|
||||
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT
|
||||
import org.oxycblt.auxio.util.forEachWithTimeout
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
@ -57,9 +58,10 @@ import timber.log.Timber as L
|
|||
* configurations
|
||||
*/
|
||||
interface MusicRepository {
|
||||
/** The current library */
|
||||
val library: Library?
|
||||
|
||||
/** The current music information found on the device. */
|
||||
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. */
|
||||
val indexingState: IndexingState?
|
||||
|
||||
|
@ -173,7 +175,7 @@ interface MusicRepository {
|
|||
* @param withCache Whether to load with the music cache or not.
|
||||
* @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. */
|
||||
interface UpdateListener {
|
||||
|
@ -188,8 +190,8 @@ interface MusicRepository {
|
|||
/**
|
||||
* Flags indicating which kinds of music information changed.
|
||||
*
|
||||
* @param deviceLibrary Whether the current songs/albums/artists/genres has changed.
|
||||
* @param userLibrary Whether the current playlists have changed.
|
||||
* @param deviceLibrary Whether the current [DeviceLibrary] has changed.
|
||||
* @param userLibrary Whether the current [Playlist]s have changed.
|
||||
*/
|
||||
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. */
|
||||
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
|
||||
* 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
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val cache: MutableCache,
|
||||
private val storedPlaylists: StoredPlaylists,
|
||||
private val settingCovers: SettingCovers,
|
||||
private val cacheRepository: CacheRepository,
|
||||
private val mediaStoreExtractor: MediaStoreExtractor,
|
||||
private val tagExtractor: TagExtractor,
|
||||
private val deviceLibraryFactory: DeviceLibrary.Factory,
|
||||
private val userLibraryFactory: UserLibrary.Factory,
|
||||
private val musicSettings: MusicSettings
|
||||
) : MusicRepository {
|
||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||
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 currentIndexingState: IndexingState? = null
|
||||
override val indexingState: IndexingState?
|
||||
|
@ -283,7 +271,7 @@ constructor(
|
|||
}
|
||||
|
||||
@Synchronized
|
||||
override fun registerWorker(worker: IndexingWorker) {
|
||||
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
|
||||
if (indexingWorker != null) {
|
||||
L.w("Worker is already registered")
|
||||
return
|
||||
|
@ -293,7 +281,7 @@ constructor(
|
|||
}
|
||||
|
||||
@Synchronized
|
||||
override fun unregisterWorker(worker: IndexingWorker) {
|
||||
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
|
||||
if (indexingWorker !== worker) {
|
||||
L.w("Given worker did not match current worker")
|
||||
return
|
||||
|
@ -305,51 +293,41 @@ constructor(
|
|||
|
||||
@Synchronized
|
||||
override fun find(uid: Music.UID) =
|
||||
(library?.run {
|
||||
findSong(uid)
|
||||
?: findAlbum(uid)
|
||||
?: findArtist(uid)
|
||||
?: findGenre(uid)
|
||||
?: findPlaylist(uid)
|
||||
})
|
||||
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) }
|
||||
?: userLibrary?.findPlaylist(uid))
|
||||
|
||||
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")
|
||||
val newLibrary = library.createPlaylist(name, songs)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
userLibrary.createPlaylist(name, songs)
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
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")
|
||||
val newLibrary = library.renamePlaylist(playlist, name)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
userLibrary.renamePlaylist(playlist, name)
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
override suspend fun deletePlaylist(playlist: Playlist) {
|
||||
val library = synchronized(this) { library ?: return }
|
||||
val userLibrary = synchronized(this) { userLibrary ?: return }
|
||||
L.d("Deleting $playlist")
|
||||
val newLibrary = library.deletePlaylist(playlist)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
userLibrary.deletePlaylist(playlist)
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
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")
|
||||
val newLibrary = library.addToPlaylist(playlist, songs)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
userLibrary.addToPlaylist(playlist, songs)
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
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")
|
||||
val newLibrary = library.rewritePlaylist(playlist, songs)
|
||||
synchronized(this) { this.library = newLibrary }
|
||||
userLibrary.rewritePlaylist(playlist, songs)
|
||||
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
|
||||
}
|
||||
|
||||
|
@ -359,53 +337,241 @@ constructor(
|
|||
indexingWorker?.requestIndex(withCache)
|
||||
}
|
||||
|
||||
override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
|
||||
L.d("Begin index [cache=$withCache]")
|
||||
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
|
||||
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) }
|
||||
|
||||
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
|
||||
try {
|
||||
indexImpl(withCache)
|
||||
indexImpl(context, scope, withCache)
|
||||
} catch (e: CancellationException) {
|
||||
// Got cancelled, propagate upwards to top-level co-routine.
|
||||
L.d("Loading routine was cancelled")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// 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(e.stackTraceToString())
|
||||
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
|
||||
val constraints =
|
||||
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
|
||||
val separators = Separators.from(musicSettings.separators)
|
||||
val nameFactory =
|
||||
if (musicSettings.intelligentSorting) {
|
||||
Naming.intelligent()
|
||||
Name.Known.IntelligentFactory
|
||||
} else {
|
||||
Naming.simple()
|
||||
Name.Known.SimpleFactory
|
||||
}
|
||||
val locations = musicSettings.musicLocations
|
||||
val withHidden = musicSettings.withHidden
|
||||
|
||||
val currentRevision = musicSettings.revision
|
||||
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
|
||||
val cache = if (withCache) cache else WriteOnlyMutableCache(cache)
|
||||
val covers = settingCovers.mutate(context, newRevision)
|
||||
val storage = Storage(cache, covers, storedPlaylists)
|
||||
val interpretation = Interpretation(nameFactory, separators, withHidden)
|
||||
val result =
|
||||
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
|
||||
// Music loading completed, update the revision right now so we re-use this work
|
||||
// later.
|
||||
musicSettings.revision = newRevision
|
||||
// Deliver the library to the rest of the app
|
||||
// This will more or less block until all required item translation and
|
||||
// cleanup finishes.
|
||||
emitLibrary(result.library)
|
||||
// Clean up old data that is now impossible for the app to be using.
|
||||
result.cleanup()
|
||||
// Finish up loading.
|
||||
// Begin with querying MediaStore and the music cache. The former is needed for Auxio
|
||||
// to figure out what songs are (probably) on the device, and the latter will be needed
|
||||
// for discovery (described later). These have no shared state, so they are done in
|
||||
// parallel.
|
||||
L.d("Starting MediaStore query")
|
||||
emitIndexingProgress(IndexingProgress.Indeterminate)
|
||||
|
||||
val mediaStoreQueryJob =
|
||||
scope.async {
|
||||
val query =
|
||||
try {
|
||||
mediaStoreExtractor.query(constraints)
|
||||
} catch (e: Exception) {
|
||||
// Normally, errors in an async call immediately bubble up to the Looper
|
||||
// and crash the app. Thus, we have to wrap any error into a Result
|
||||
// and then manually forward it to the try block that indexImpl is
|
||||
// called from.
|
||||
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)
|
||||
|
||||
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) {
|
||||
|
@ -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?) {
|
||||
yield()
|
||||
synchronized(this) {
|
||||
|
|
|
@ -21,11 +21,11 @@ package org.oxycblt.auxio.music
|
|||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
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.musikr.fs.MusicLocation
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
@ -34,14 +34,10 @@ import timber.log.Timber as L
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface MusicSettings : Settings<MusicSettings.Listener> {
|
||||
/** The current library revision. */
|
||||
var revision: UUID?
|
||||
/** The locations of music to load. */
|
||||
var musicLocations: List<MusicLocation>
|
||||
/** The configuration on how to handle particular directories in the music library. */
|
||||
var musicDirs: MusicDirectories
|
||||
/** Whether to exclude non-music audio files from the music library. */
|
||||
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. */
|
||||
val shouldBeObserving: Boolean
|
||||
/** 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
|
||||
|
||||
interface Listener {
|
||||
/** Called when the current music locations changed. */
|
||||
fun onMusicLocationsChanged() {}
|
||||
/** Called when a setting controlling how music is loaded has changed. */
|
||||
fun onIndexingSettingChanged() {}
|
||||
/** 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) :
|
||||
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||
|
||||
override var revision: UUID?
|
||||
get() =
|
||||
sharedPreferences
|
||||
.getString(getString(R.string.set_key_library_revision), null)
|
||||
?.let(UUID::fromString)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putString(getString(R.string.set_key_library_revision), value.toString())
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override var musicLocations: List<MusicLocation>
|
||||
class MusicSettingsImpl
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val documentPathFactory: DocumentPathFactory
|
||||
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||
override var musicDirs: MusicDirectories
|
||||
get() {
|
||||
val locations =
|
||||
sharedPreferences.getString(getString(R.string.set_key_music_locations), null)
|
||||
?: return emptyList()
|
||||
return MusicLocation.existing(context, locations)
|
||||
val dirs =
|
||||
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
|
||||
?: emptySet())
|
||||
.mapNotNull(documentPathFactory::fromDocumentId)
|
||||
return MusicDirectories(
|
||||
dirs,
|
||||
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
|
||||
}
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putString(
|
||||
getString(R.string.set_key_music_locations), MusicLocation.toString(value))
|
||||
commit()
|
||||
// Sometimes changing this setting just won't actually trigger the listener.
|
||||
// Only this one. No idea why.
|
||||
listener?.onMusicLocationsChanged()
|
||||
putStringSet(
|
||||
getString(R.string.set_key_music_dirs),
|
||||
value.dirs.map(documentPathFactory::toDocumentId).toSet())
|
||||
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
override val excludeNonMusic: Boolean
|
||||
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
|
||||
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"
|
||||
// (just need to manipulate data)
|
||||
when (key) {
|
||||
getString(R.string.set_key_music_locations) -> {
|
||||
L.d("Dispatching music locations change")
|
||||
listener.onMusicLocationsChanged()
|
||||
}
|
||||
getString(R.string.set_key_exclude_non_music),
|
||||
getString(R.string.set_key_music_dirs),
|
||||
getString(R.string.set_key_music_dirs_include),
|
||||
getString(R.string.set_key_separators),
|
||||
getString(R.string.set_key_auto_sort_names),
|
||||
getString(R.string.set_key_with_hidden),
|
||||
getString(R.string.set_key_exclude_non_music) -> {
|
||||
getString(R.string.set_key_auto_sort_names) -> {
|
||||
L.d("Dispatching indexing setting change for $key")
|
||||
listener.onIndexingSettingChanged()
|
||||
}
|
||||
|
|
|
@ -27,10 +27,15 @@ import org.oxycblt.auxio.R
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
enum class MusicType {
|
||||
/** @see Song */
|
||||
SONGS,
|
||||
/** @see Album */
|
||||
ALBUMS,
|
||||
/** @see Artist */
|
||||
ARTISTS,
|
||||
/** @see Genre */
|
||||
GENRES,
|
||||
/** @see Playlist */
|
||||
PLAYLISTS;
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -18,12 +18,10 @@
|
|||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -31,15 +29,10 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.R
|
||||
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.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
|
||||
|
||||
/**
|
||||
|
@ -51,11 +44,10 @@ import timber.log.Timber as L
|
|||
class MusicViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val listSettings: ListSettings,
|
||||
private val musicRepository: MusicRepository
|
||||
private val musicRepository: MusicRepository,
|
||||
private val externalPlaylistManager: ExternalPlaylistManager
|
||||
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
|
||||
private val externalPlaylistManager = ExternalPlaylistManager.from(context)
|
||||
|
||||
private val _indexingState = MutableStateFlow<IndexingState?>(null)
|
||||
|
||||
|
@ -93,14 +85,14 @@ constructor(
|
|||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.deviceLibrary) return
|
||||
val library = musicRepository.library ?: return
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return
|
||||
_statistics.value =
|
||||
Statistics(
|
||||
library.songs.size,
|
||||
library.albums.size,
|
||||
library.artists.size,
|
||||
library.genres.size,
|
||||
library.songs.sumOf { it.durationMs })
|
||||
deviceLibrary.songs.size,
|
||||
deviceLibrary.albums.size,
|
||||
deviceLibrary.artists.size,
|
||||
deviceLibrary.genres.size,
|
||||
deviceLibrary.songs.sumOf { it.durationMs })
|
||||
L.d("Updated statistics: ${_statistics.value}")
|
||||
}
|
||||
|
||||
|
@ -170,10 +162,10 @@ constructor(
|
|||
return@launch
|
||||
}
|
||||
|
||||
val library = musicRepository.library ?: return@launch
|
||||
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch
|
||||
val songs =
|
||||
importedPlaylist.paths.mapNotNull {
|
||||
it.firstNotNullOfOrNull(library::findSongByPath)
|
||||
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath)
|
||||
}
|
||||
|
||||
if (songs.isEmpty()) {
|
||||
|
|
187
app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
vendored
Normal file
187
app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
vendored
Normal 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)
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* MusikrShimModule.kt is part of Auxio.
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CacheModule.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
|
||||
|
@ -16,31 +16,34 @@
|
|||
* 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 androidx.room.Room
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
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
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class MusikrShimModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun cache(@ApplicationContext context: Context): MutableCache = MutableDBCache.from(context)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun storedPlaylists(@ApplicationContext context: Context) = StoredPlaylists.from(context)
|
||||
|
||||
@Provides
|
||||
fun updateTrackerFactory(@ApplicationContext context: Context): UpdateTrackerFactory =
|
||||
UpdateTrackerFactoryImpl(context)
|
||||
interface CacheModule {
|
||||
@Binds fun cacheRepository(cacheRepository: CacheRepositoryImpl): CacheRepository
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class CacheRoomModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun database(@ApplicationContext context: Context) =
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
@Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao()
|
||||
}
|
121
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal file
121
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal 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
|
||||
}
|
||||
}
|
|
@ -33,10 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
|
|||
import org.oxycblt.auxio.list.ClickableListListener
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.navigateSafe
|
||||
import org.oxycblt.musikr.Song
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
|
|
@ -29,11 +29,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
|
||||
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.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
@ -53,9 +52,6 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlayl
|
|||
builder
|
||||
.setTitle(R.string.lbl_confirm_delete_playlist)
|
||||
.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.
|
||||
musicModel.deletePlaylist(
|
||||
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)
|
||||
|
|
|
@ -31,13 +31,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
|
||||
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.util.collectImmediately
|
||||
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
|
||||
|
||||
/**
|
||||
|
|
|
@ -25,7 +25,6 @@ import org.oxycblt.auxio.list.ClickableListListener
|
|||
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
|
||||
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
|
||||
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
|
||||
|
|
|
@ -25,14 +25,14 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.Playlist
|
||||
import org.oxycblt.auxio.music.PlaylistDecision
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.playlist.ExportConfig
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.external.ExportConfig
|
||||
import timber.log.Timber as L
|
||||
|
||||
/**
|
||||
|
@ -89,13 +89,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
var refreshChoicesWith: List<Song>? = null
|
||||
val library = musicRepository.library
|
||||
if (changes.deviceLibrary && library != null) {
|
||||
val deviceLibrary = musicRepository.deviceLibrary
|
||||
if (changes.deviceLibrary && deviceLibrary != null) {
|
||||
_currentPendingNewPlaylist.value =
|
||||
_currentPendingNewPlaylist.value?.let { pendingPlaylist ->
|
||||
PendingNewPlaylist(
|
||||
pendingPlaylist.preferredName,
|
||||
pendingPlaylist.songs.mapNotNull { library.findSong(it.uid) },
|
||||
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) },
|
||||
pendingPlaylist.template,
|
||||
pendingPlaylist.reason)
|
||||
}
|
||||
|
@ -104,7 +104,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
_currentSongsToAdd.value =
|
||||
_currentSongsToAdd.value?.let { pendingSongs ->
|
||||
pendingSongs
|
||||
.mapNotNull { library.findSong(it.uid) }
|
||||
.mapNotNull { deviceLibrary.findSong(it.uid) }
|
||||
.ifEmpty { null }
|
||||
.also { refreshChoicesWith = it }
|
||||
}
|
||||
|
@ -127,7 +127,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
|
||||
_currentPlaylistToExport.value =
|
||||
_currentPlaylistToExport.value?.let { playlist ->
|
||||
musicRepository.library?.findPlaylist(playlist.uid)
|
||||
musicRepository.userLibrary?.findPlaylist(playlist.uid)
|
||||
}
|
||||
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
|
||||
) {
|
||||
L.d("Opening ${songUids.size} songs to create a playlist from")
|
||||
val library = musicRepository.library ?: return
|
||||
val userLibrary = musicRepository.userLibrary ?: return
|
||||
val songs =
|
||||
musicRepository.library
|
||||
musicRepository.deviceLibrary
|
||||
?.let { songUids.mapNotNull(it::findSong) }
|
||||
?.also(::refreshPlaylistChoices)
|
||||
|
||||
val possibleName =
|
||||
musicRepository.library?.let {
|
||||
musicRepository.userLibrary?.let {
|
||||
// Attempt to generate a unique default name for the playlist, like "Playlist 1".
|
||||
var i = 1
|
||||
var possibleName: String
|
||||
|
@ -168,7 +168,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
possibleName = context.getString(R.string.fmt_def_playlist, i)
|
||||
L.d("Trying $possibleName as a playlist name")
|
||||
++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")
|
||||
possibleName
|
||||
}
|
||||
|
@ -194,8 +194,9 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
reason: PlaylistDecision.Rename.Reason
|
||||
) {
|
||||
L.d("Opening playlist $playlistUid to rename")
|
||||
val playlist = musicRepository.library?.findPlaylist(playlistUid)
|
||||
val applySongs = musicRepository.library?.let { applySongUids.mapNotNull(it::findSong) }
|
||||
val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||
val applySongs =
|
||||
musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
|
||||
|
||||
_currentPendingRenamePlaylist.value =
|
||||
if (playlist != null && applySongs != null) {
|
||||
|
@ -215,7 +216,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
L.d("Opening playlist $playlistUid to export")
|
||||
// TODO: Add this guard to the rest of the methods here
|
||||
if (_currentPlaylistToExport.value?.uid == playlistUid) return
|
||||
_currentPlaylistToExport.value = musicRepository.library?.findPlaylist(playlistUid)
|
||||
_currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||
if (_currentPlaylistToExport.value == null) {
|
||||
L.w("Given playlist UID to export was invalid")
|
||||
} else {
|
||||
|
@ -240,7 +241,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
*/
|
||||
fun setPlaylistToDelete(playlistUid: Music.UID) {
|
||||
L.d("Opening playlist $playlistUid to delete")
|
||||
_currentPlaylistToDelete.value = musicRepository.library?.findPlaylist(playlistUid)
|
||||
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid)
|
||||
if (_currentPlaylistToDelete.value == null) {
|
||||
L.w("Given playlist UID to delete was invalid")
|
||||
}
|
||||
|
@ -265,8 +266,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
}
|
||||
else -> {
|
||||
val trimmed = name.trim()
|
||||
val library = musicRepository.library
|
||||
if (library != null && library.findPlaylistByName(trimmed) == null) {
|
||||
val userLibrary = musicRepository.userLibrary
|
||||
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) {
|
||||
L.d("Chosen name is valid")
|
||||
ChosenName.Valid(trimmed)
|
||||
} else {
|
||||
|
@ -285,7 +286,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
|
|||
fun setSongsToAdd(songUids: Array<Music.UID>) {
|
||||
L.d("Opening ${songUids.size} songs to add to a playlist")
|
||||
_currentSongsToAdd.value =
|
||||
musicRepository.library
|
||||
musicRepository.deviceLibrary
|
||||
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
|
||||
?.also(::refreshPlaylistChoices)
|
||||
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>) {
|
||||
val library = musicRepository.library ?: return
|
||||
val userLibrary = musicRepository.userLibrary ?: return
|
||||
L.d("Refreshing playlist choices")
|
||||
_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()
|
||||
PlaylistChoice(it, songs.all(songSet::contains))
|
||||
}
|
||||
|
@ -354,4 +355,4 @@ sealed interface ChosenName {
|
|||
* [Playlist].
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean)
|
||||
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item
|
||||
|
|
|
@ -30,7 +30,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
|
||||
import org.oxycblt.auxio.music.MusicViewModel
|
||||
import org.oxycblt.auxio.music.resolve
|
||||
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
|
||||
import org.oxycblt.auxio.util.collectImmediately
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* JClassRef.cpp is part of Auxio.
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* DeviceModule.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
|
||||
|
@ -16,19 +16,16 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "JClassRef.h"
|
||||
JClassRef::JClassRef(JNIEnv *env, const char *classpath) : env(env) {
|
||||
clazz = env->FindClass(classpath);
|
||||
}
|
||||
package org.oxycblt.auxio.music.device
|
||||
|
||||
JClassRef::~JClassRef() {
|
||||
env->DeleteLocalRef(clazz);
|
||||
}
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
|
||||
jmethodID JClassRef::method(const char *name, const char *signature) {
|
||||
return env->GetMethodID(clazz, name, signature);
|
||||
}
|
||||
|
||||
jclass& JClassRef::operator*() {
|
||||
return clazz;
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface DeviceModule {
|
||||
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl2): DeviceLibrary.Factory
|
||||
@Binds fun interpreterFactory(factory: InterpreterFactoryImpl): Interpreter.Factory
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue