diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index 8a347a391..8738c44dd 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -11,22 +11,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - name: Clone repository
+ uses: actions/checkout@v3
+ - name: Clone submodules
+ run: git submodule update --init --recursive
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
cache: gradle
- - name: Set up NDK r21e
- uses: nttld/setup-ndk@v1.2.0
- id: setup-ndk
- with:
- ndk-version: r21e
- add-to-path: false
- - run: python3 prebuild.py
- env:
- ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Test app with Gradle
diff --git a/.gitignore b/.gitignore
index aa3f9683b..c03b3271b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,8 +3,6 @@
local.properties
build/
release/
-srclibs/
-libs/
# Studio
.idea/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..e806f30bf
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "ExoPlayer"]
+ path = ExoPlayer
+ url = https://github.com/OxygenCobalt/ExoPlayer.git
+ branch = auxio
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5088867b..11e2cbbfe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,37 @@
# Changelog
+## 3.0.3
+
+#### What's New
+- Added support for disc subtitles
+- Added support for ALAC files
+- Song properties view now shows tags
+- Added option to control whether articles like "the" are ignored when sorting
+
+#### What's Improved
+- Will now accept zeroed track/disc numbers in the presence of non-zero total
+track/disc fields
+- Music loading has been made slightly faster
+- Improved sort menu usability
+- Fall back to `TXXX:RELEASETYPE` on ID3v2 files
+- Switches and checkboxes have been mildly visually refreshed
+
+#### What's Fixed
+- Fixed non-functioning "repeat all" repeat mode
+- Fixed visual clipping of shuffle button shadow
+- Fixed SeekBar remaining in a "stuck" state if gesture navigation was used
+while selecting it.
+
+#### Dev/Meta
+- Started using dependency injection
+- Started code obsfucation
+- Only bundle audio-related extractors with ExoPlayer
+- Switched to Room for database management
+- Updated to MDC 1.8.0 alpha-01
+- Updated to AGP 7.4.1
+- Updated to Gradle 8.0
+- Updated to ExoPlayer 2.18.3
+
## 3.0.2
#### What's New
diff --git a/ExoPlayer b/ExoPlayer
new file mode 160000
index 000000000..268d683ba
--- /dev/null
+++ b/ExoPlayer
@@ -0,0 +1 @@
+Subproject commit 268d683bab060fff43e75732248416d9bf476ef3
diff --git a/README.md b/README.md
index fb76b3840..1b59eb426 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
Auxio
A simple, rational music player for android.
-
-
+
+
@@ -68,12 +68,12 @@ precise/original dates, sort tags, and more
## Building
-Auxio relies on a custom version of ExoPlayer that enables some extra features. So, the build process is as follows:
-
-1. `cd` into the project directory.
-2. Run `python3 prebuild.py`, which installs ExoPlayer and it's extensions.
- - The pre-build process only works with \*nix systems. On windows, this process must be done manually.
-3. Build the project normally in Android Studio.
+Auxio relies on a custom version of ExoPlayer that enables some extra features. This adds some caveats to
+the build process:
+1. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
+download in the external code.
+2. You are **unable** to build this project on windows, as the custom ExoPlayer build runs shell scripts that
+will only work on unix-based systems.
## Contributing
diff --git a/app/build.gradle b/app/build.gradle
index 45bf5f5ea..15c5a46d7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -4,16 +4,24 @@ plugins {
id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize"
+ id "dagger.hilt.android.plugin"
+ id "kotlin-kapt"
+ id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 33
+ // 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 = "23.2.8568313"
namespace "org.oxycblt.auxio"
defaultConfig {
applicationId namespace
- versionName "3.0.2"
- versionCode 26
+ versionName "3.0.3"
+ versionCode 27
minSdk 21
targetSdk 33
@@ -21,14 +29,13 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
- // ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
- jvmTarget = "1.8"
+ jvmTarget = "11"
freeCompilerArgs += "-Xjvm-default=all"
}
@@ -42,17 +49,17 @@ android {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
+
+ dependenciesInfo {
+ includeInApk = false
+ includeInBundle = false
+ }
}
}
buildFeatures {
viewBinding true
}
-
- dependenciesInfo {
- includeInApk = false
- includeInBundle = false
- }
}
dependencies {
@@ -66,7 +73,7 @@ dependencies {
// General
// 1.4.0 is used in order to avoid a ripple bug in material components
- implementation "androidx.appcompat:appcompat:1.4.0"
+ implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.core:core-ktx:1.9.0"
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5"
@@ -75,6 +82,7 @@ dependencies {
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
+ implementation 'androidx.core:core-ktx:+'
// Lifecycle
def lifecycle_version = "2.5.1"
@@ -93,30 +101,38 @@ dependencies {
// Preferences
implementation "androidx.preference:preference-ktx:1.2.0"
+ // Database
+ def room_version = '2.5.0'
+ implementation "androidx.room:room-runtime:$room_version"
+ kapt "androidx.room:room-compiler:$room_version"
+ implementation "androidx.room:room-ktx:$room_version"
+
// --- THIRD PARTY ---
- // Exoplayer
- // WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE PRE-BUILD SCRIPT.
- // IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
- implementation("com.google.android.exoplayer:exoplayer-core:2.18.2") {
- exclude group: "com.google.android.exoplayer", module: "exoplayer-extractor"
- }
-
- implementation fileTree(dir: "libs", include: ["library-*.aar"])
- implementation fileTree(dir: "libs", include: ["extension-*.aar"])
+ // Exoplayer (Vendored)
+ implementation project(":exoplayer-library-core")
+ implementation project(":exoplayer-extension-ffmpeg")
// Image loading
- implementation "io.coil-kt:coil:2.1.0"
+ implementation 'io.coil-kt:coil-base:2.2.2'
// Material
- // Locked below 1.7.0-alpha03 to avoid the same ripple bug
- implementation "com.google.android.material:material:1.7.0-alpha02"
+ // TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout can be worked around
+ // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
+ // PR a fix.
+ implementation "com.google.android.material:material:1.8.0-alpha01"
- // Development
- debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1"
+ // Dependency Injection
+ def dagger_version = '2.45'
+ implementation "com.google.dagger:dagger:$dagger_version"
+ kapt "com.google.dagger:dagger-compiler:$dagger_version"
+ implementation "com.google.dagger:hilt-android:$hilt_version"
+ kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
+ // Testing
+ debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation "junit:junit:4.13.2"
- androidTestImplementation 'androidx.test.ext:junit:1.1.4'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
spotless {
diff --git a/app/src/main/java/org/oxycblt/auxio/Auxio.kt b/app/src/main/java/org/oxycblt/auxio/Auxio.kt
index bcb8777e8..01f4eecab 100644
--- a/app/src/main/java/org/oxycblt/auxio/Auxio.kt
+++ b/app/src/main/java/org/oxycblt/auxio/Auxio.kt
@@ -22,15 +22,9 @@ import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
-import coil.ImageLoader
-import coil.ImageLoaderFactory
-import coil.request.CachePolicy
+import dagger.hilt.android.HiltAndroidApp
+import javax.inject.Inject
import org.oxycblt.auxio.image.ImageSettings
-import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
-import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
-import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
-import org.oxycblt.auxio.image.extractor.GenreImageFetcher
-import org.oxycblt.auxio.image.extractor.MusicKeyer
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
@@ -38,16 +32,22 @@ import org.oxycblt.auxio.ui.UISettings
* A simple, rational music player for android.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Auxio : Application(), ImageLoaderFactory {
+@HiltAndroidApp
+class Auxio : Application() {
+ @Inject lateinit var imageSettings: ImageSettings
+ @Inject lateinit var playbackSettings: PlaybackSettings
+ @Inject lateinit var uiSettings: UISettings
+
override fun onCreate() {
super.onCreate()
// Migrate any settings that may have changed in an app update.
- ImageSettings.from(this).migrate()
- PlaybackSettings.from(this).migrate()
- UISettings.from(this).migrate()
+ imageSettings.migrate()
+ playbackSettings.migrate()
+ uiSettings.migrate()
// Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release
// Auxio instances.
+ // TODO: Switch to static shortcuts
ShortcutManagerCompat.addDynamicShortcuts(
this,
listOf(
@@ -61,22 +61,6 @@ class Auxio : Application(), ImageLoaderFactory {
.build()))
}
- override fun newImageLoader() =
- ImageLoader.Builder(applicationContext)
- .components {
- // Add fetchers for Music components to make them usable with ImageRequest
- add(MusicKeyer())
- add(AlbumCoverFetcher.SongFactory())
- add(AlbumCoverFetcher.AlbumFactory())
- add(ArtistImageFetcher.Factory())
- add(GenreImageFetcher.Factory())
- }
- // Use our own crossfade with error drawable support
- .transitionFactory(ErrorCrossfadeTransitionFactory())
- // Not downloading anything, so no disk-caching
- .diskCachePolicy(CachePolicy.DISABLED)
- .build()
-
companion object {
/** The [Intent] name for the "Shuffle All" shortcut. */
const val INTENT_KEY_SHORTCUT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE_ALL"
diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
index e2cbcb5ce..3c724786a 100644
--- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
+++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
@@ -31,8 +31,8 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST = 0xA002
/** GenreViewHolder */
const val VIEW_TYPE_GENRE = 0xA003
- /** HeaderViewHolder */
- const val VIEW_TYPE_HEADER = 0xA004
+ /** BasicHeaderViewHolder */
+ const val VIEW_TYPE_BASIC_HEADER = 0xA004
/** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA005
/** AlbumDetailViewHolder */
diff --git a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
index 201abbd18..d956bb4ce 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainActivity.kt
@@ -20,17 +20,19 @@ package org.oxycblt.auxio
import android.content.Intent
import android.os.Bundle
import android.view.View
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.WindowCompat
import androidx.core.view.updatePadding
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings
-import org.oxycblt.auxio.util.androidViewModels
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat
@@ -50,8 +52,10 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
*
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
- private val playbackModel: PlaybackViewModel by androidViewModels()
+ private val playbackModel: PlaybackViewModel by viewModels()
+ @Inject lateinit var uiSettings: UISettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -81,17 +85,16 @@ class MainActivity : AppCompatActivity() {
}
private fun setupTheme() {
- val settings = UISettings.from(this)
// Apply the theme configuration.
- AppCompatDelegate.setDefaultNightMode(settings.theme)
+ AppCompatDelegate.setDefaultNightMode(uiSettings.theme)
// Apply the color scheme. The black theme requires it's own set of themes since
// it's not possible to modify the themes at run-time.
- if (isNight && settings.useBlackTheme) {
- logD("Applying black theme [accent ${settings.accent}]")
- setTheme(settings.accent.blackTheme)
+ if (isNight && uiSettings.useBlackTheme) {
+ logD("Applying black theme [accent ${uiSettings.accent}]")
+ setTheme(uiSettings.accent.blackTheme)
} else {
- logD("Applying normal theme [accent ${settings.accent}]")
- setTheme(settings.accent.theme)
+ logD("Applying normal theme [accent ${uiSettings.accent}]")
+ setTheme(uiSettings.accent.theme)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index 8b93763f5..b036dd393 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -33,6 +33,7 @@ import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.transition.MaterialFadeThrough
+import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
@@ -52,11 +53,12 @@ import org.oxycblt.auxio.util.*
* high-level navigation features.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class MainFragment :
ViewBindingFragment(),
ViewTreeObserver.OnPreDrawListener,
NavController.OnDestinationChangedListener {
- private val playbackModel: PlaybackViewModel by androidActivityViewModels()
+ private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback()
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
index 90689c188..74a7eb6bd 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt
@@ -26,28 +26,36 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis
+import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
+import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.playback.PlaybackViewModel
+import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
/**
* A [ListFragment] that shows information about an [Album].
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class AlbumDetailFragment :
ListFragment(), AlbumDetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
+ override val navModel: NavigationViewModel by activityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs()
@@ -143,14 +151,19 @@ class AlbumDetailFragment :
openMenu(anchor, R.menu.menu_album_sort) {
val sort = detailModel.albumSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
- unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
+ val directionItemId =
+ when (sort.direction) {
+ Sort.Direction.ASCENDING -> R.id.option_sort_asc
+ Sort.Direction.DESCENDING -> R.id.option_sort_dec
+ }
+ unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true
setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked
detailModel.albumSongSort =
- if (item.itemId == R.id.option_sort_asc) {
- sort.withAscending(item.isChecked)
- } else {
- sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
+ when (item.itemId) {
+ R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
+ R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
+ else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
index 5734ee1a8..8bdca12ab 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt
@@ -25,19 +25,23 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.transition.MaterialSharedAxis
+import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
+import org.oxycblt.auxio.list.selection.SelectionViewModel
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.Song
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.playback.PlaybackViewModel
+import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
@@ -48,9 +52,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* A [ListFragment] that shows information about an [Artist].
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class ArtistDetailFragment :
ListFragment(), DetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
+ override val navModel: NavigationViewModel by activityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs()
@@ -159,15 +167,20 @@ class ArtistDetailFragment :
openMenu(anchor, R.menu.menu_artist_sort) {
val sort = detailModel.artistSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
- unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
+ val directionItemId =
+ when (sort.direction) {
+ Sort.Direction.ASCENDING -> R.id.option_sort_asc
+ Sort.Direction.DESCENDING -> R.id.option_sort_dec
+ }
+ unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true
setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked
detailModel.artistSongSort =
- if (item.itemId == R.id.option_sort_asc) {
- sort.withAscending(item.isChecked)
- } else {
- sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
+ when (item.itemId) {
+ R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
+ R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
+ else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
deleted file mode 100644
index 1788b4ddd..000000000
--- a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.oxycblt.auxio.detail
-
-import androidx.annotation.StringRes
-import org.oxycblt.auxio.list.Item
-import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.storage.MimeType
-
-/**
- * A header variation that displays a button to open a sort menu.
- * @param titleRes The string resource to use as the header title
- * @author Alexander Capehart (OxygenCobalt)
- */
-data class SortHeader(@StringRes val titleRes: Int) : Item
-
-/**
- * A header variation that delimits between disc groups.
- * @param disc The disc number to be displayed on the header.
- * @author Alexander Capehart (OxygenCobalt)
- */
-data class DiscHeader(val disc: Int) : Item
-
-/**
- * The properties of a [Song]'s file.
- * @param bitrateKbps The bit rate, in kilobytes-per-second. Null if it could not be parsed.
- * @param sampleRateHz The sample rate, in hertz.
- * @param resolvedMimeType The known mime type of the [Song] after it's file format was determined.
- * @author Alexander Capehart (OxygenCobalt)
- */
-data class SongProperties(
- val bitrateKbps: Int?,
- val sampleRateHz: Int?,
- val resolvedMimeType: MimeType
-)
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
index d51cd3734..7c738a768 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt
@@ -17,12 +17,11 @@
package org.oxycblt.auxio.detail
-import android.app.Application
-import android.media.MediaExtractor
-import android.media.MediaFormat
import androidx.annotation.StringRes
-import androidx.lifecycle.AndroidViewModel
+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
@@ -30,30 +29,32 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.list.Header
+import org.oxycblt.auxio.detail.recycler.SortHeader
+import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.MusicStore
-import org.oxycblt.auxio.music.library.Library
-import org.oxycblt.auxio.music.library.Sort
-import org.oxycblt.auxio.music.storage.MimeType
-import org.oxycblt.auxio.music.tags.ReleaseType
+import org.oxycblt.auxio.music.metadata.AudioInfo
+import org.oxycblt.auxio.music.metadata.Disc
+import org.oxycblt.auxio.music.metadata.ReleaseType
+import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*
/**
- * [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of
- * the current item they are showing, sub-data to display, and configuration. Since this ViewModel
- * requires a context, it must be instantiated [AndroidViewModel]'s Factory.
- * @param application [Application] context required to initialize certain information.
+ * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
+ * current item they are showing, sub-data to display, and configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
-class DetailViewModel(application: Application) :
- AndroidViewModel(application), MusicStore.Listener {
- private val musicStore = MusicStore.getInstance()
- private val musicSettings = MusicSettings.from(application)
- private val playbackSettings = PlaybackSettings.from(application)
-
+@HiltViewModel
+class DetailViewModel
+@Inject
+constructor(
+ private val musicRepository: MusicRepository,
+ private val audioInfoProvider: AudioInfo.Provider,
+ private val musicSettings: MusicSettings,
+ private val playbackSettings: PlaybackSettings
+) : ViewModel(), MusicRepository.Listener {
private var currentSongJob: Job? = null
// --- SONG ---
@@ -63,9 +64,9 @@ class DetailViewModel(application: Application) :
val currentSong: StateFlow
get() = _currentSong
- private val _songProperties = MutableStateFlow(null)
- /** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */
- val songProperties: StateFlow = _songProperties
+ private val _songAudioInfo = MutableStateFlow(null)
+ /** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
+ val songAudioInfo: StateFlow = _songAudioInfo
// --- ALBUM ---
@@ -136,11 +137,11 @@ class DetailViewModel(application: Application) :
get() = playbackSettings.inParentPlaybackMode
init {
- musicStore.addListener(this)
+ musicRepository.addListener(this)
}
override fun onCleared() {
- musicStore.removeListener(this)
+ musicRepository.removeListener(this)
}
override fun onLibraryChanged(library: Library?) {
@@ -155,7 +156,7 @@ class DetailViewModel(application: Application) :
val song = currentSong.value
if (song != null) {
- _currentSong.value = library.sanitize(song)?.also(::loadProperties)
+ _currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo)
logD("Updated song to ${currentSong.value}")
}
@@ -180,7 +181,7 @@ class DetailViewModel(application: Application) :
/**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
- * [songProperties] will be updated to align with the new [Song].
+ * [songAudioInfo] will be updated to align with the new [Song].
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSongUid(uid: Music.UID) {
@@ -189,7 +190,7 @@ class DetailViewModel(application: Application) :
return
}
logD("Opening Song [uid: $uid]")
- _currentSong.value = requireMusic(uid)?.also(::loadProperties)
+ _currentSong.value = requireMusic(uid)?.also(::refreshAudioInfo)
}
/**
@@ -234,86 +235,24 @@ class DetailViewModel(application: Application) :
_currentGenre.value = requireMusic(uid)?.also(::refreshGenreList)
}
- private fun requireMusic(uid: Music.UID) = musicStore.library?.find(uid)
+ private fun requireMusic(uid: Music.UID) = musicRepository.library?.find(uid)
/**
- * Start a new job to load a given [Song]'s [SongProperties]. Result is pushed to
- * [songProperties].
+ * Start a new job to load a given [Song]'s [AudioInfo]. Result is pushed to [songAudioInfo].
* @param song The song to load.
*/
- private fun loadProperties(song: Song) {
+ private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
- _songProperties.value = null
+ _songAudioInfo.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
- val properties = this@DetailViewModel.loadPropertiesImpl(song)
+ val info = audioInfoProvider.extract(song)
yield()
- _songProperties.value = properties
+ _songAudioInfo.value = info
}
}
- private fun loadPropertiesImpl(song: Song): SongProperties {
- // While we would use ExoPlayer to extract this information, it doesn't support
- // common data like bit rate in progressive data sources due to there being no
- // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
- val extractor = MediaExtractor()
-
- try {
- extractor.setDataSource(context, song.uri, emptyMap())
- } catch (e: Exception) {
- // Can feasibly fail with invalid file formats. Note that this isn't considered
- // an error condition in the UI, as there is still plenty of other song information
- // that we can show.
- logW("Unable to extract song attributes.")
- logW(e.stackTraceToString())
- return SongProperties(null, null, song.mimeType)
- }
-
- // Get the first track from the extractor (This is basically always the only
- // track we need to analyze).
- val format = extractor.getTrackFormat(0)
-
- // Accessing fields can throw an exception if the fields are not present, and
- // the new method for using default values is not available on lower API levels.
- // So, we are forced to handle the exception and map it to a saner null value.
- val bitrate =
- try {
- // Convert bytes-per-second to kilobytes-per-second.
- format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000
- } catch (e: NullPointerException) {
- logD("Unable to extract bit rate field")
- null
- }
-
- val sampleRate =
- try {
- format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
- } catch (e: NullPointerException) {
- logE("Unable to extract sample rate field")
- null
- }
-
- val resolvedMimeType =
- if (song.mimeType.fromFormat != null) {
- // ExoPlayer was already able to populate the format.
- song.mimeType
- } else {
- // ExoPlayer couldn't populate the format somehow, populate it here.
- val formatMimeType =
- try {
- format.getString(MediaFormat.KEY_MIME)
- } catch (e: NullPointerException) {
- logE("Unable to extract mime type field")
- null
- }
-
- MimeType(song.mimeType.fromExtension, formatMimeType)
- }
-
- return SongProperties(bitrate, sampleRate, resolvedMimeType)
- }
-
private fun refreshAlbumList(album: Album) {
logD("Refreshing album data")
val data = mutableListOf- (album)
@@ -323,11 +262,11 @@ class DetailViewModel(application: Application) :
// songs up by disc and then delimit the groups by a disc header.
val songs = albumSongSort.songs(album.songs)
// Songs without disc tags become part of Disc 1.
- val byDisc = songs.groupBy { it.disc ?: 1 }
+ val byDisc = songs.groupBy { it.disc ?: Disc(1, null) }
if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) {
- data.add(DiscHeader(entry.key))
+ data.add(entry.key)
data.addAll(entry.value)
}
} else {
@@ -341,7 +280,7 @@ class DetailViewModel(application: Application) :
private fun refreshArtistList(artist: Artist) {
logD("Refreshing artist data")
val data = mutableListOf
- (artist)
- val albums = Sort(Sort.Mode.ByDate, false).albums(artist.albums)
+ val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
val byReleaseGroup =
albums.groupBy {
@@ -367,7 +306,7 @@ class DetailViewModel(application: Application) :
logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
- data.add(Header(entry.key.headerTitleRes))
+ data.add(BasicHeader(entry.key.headerTitleRes))
data.addAll(entry.value)
}
@@ -385,7 +324,7 @@ class DetailViewModel(application: Application) :
logD("Refreshing genre data")
val data = mutableListOf
- (genre)
// Genre is guaranteed to always have artists and songs.
- data.add(Header(R.string.lbl_artists))
+ data.add(BasicHeader(R.string.lbl_artists))
data.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSongSort.songs(genre.songs))
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
index 5d6ac4482..ce9fa505c 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt
@@ -25,20 +25,24 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.transition.MaterialSharedAxis
+import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
+import org.oxycblt.auxio.list.selection.SelectionViewModel
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.Song
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.playback.PlaybackViewModel
+import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
@@ -49,9 +53,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* A [ListFragment] that shows information for a particular [Genre].
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class GenreDetailFragment :
ListFragment(), DetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
+ override val navModel: NavigationViewModel by activityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel by activityViewModels()
// Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs()
@@ -158,14 +166,19 @@ class GenreDetailFragment :
openMenu(anchor, R.menu.menu_genre_sort) {
val sort = detailModel.genreSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true
- unlikelyToBeNull(menu.findItem(R.id.option_sort_asc)).isChecked = sort.isAscending
+ val directionItemId =
+ when (sort.direction) {
+ Sort.Direction.ASCENDING -> R.id.option_sort_asc
+ Sort.Direction.DESCENDING -> R.id.option_sort_dec
+ }
+ unlikelyToBeNull(menu.findItem(directionItemId)).isChecked = true
setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked
detailModel.genreSongSort =
- if (item.itemId == R.id.option_sort_asc) {
- sort.withAscending(item.isChecked)
- } else {
- sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
+ when (item.itemId) {
+ R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
+ R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
+ else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
}
true
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
index cf2d516d7..1ebf9ff46 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt
@@ -17,30 +17,40 @@
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.core.view.isInvisible
+import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
+import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding
+import org.oxycblt.auxio.detail.recycler.SongProperty
+import org.oxycblt.auxio.detail.recycler.SongPropertyAdapter
+import org.oxycblt.auxio.list.adapter.BasicListInstructions
+import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.metadata.AudioInfo
+import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
-import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
+import org.oxycblt.auxio.util.concatLocalized
/**
* A [ViewBindingDialogFragment] that shows information about a Song.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class SongDetailDialog : ViewBindingDialogFragment() {
- private val detailModel: DetailViewModel by androidActivityViewModels()
+ private val detailModel: DetailViewModel by activityViewModels()
// Information about what song to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an song.
private val args: SongDetailDialogArgs by navArgs()
+ private val detailAdapter = SongPropertyAdapter()
override fun onCreateBinding(inflater: LayoutInflater) =
DialogSongDetailBinding.inflate(inflater)
@@ -52,48 +62,72 @@ class SongDetailDialog : ViewBindingDialogFragment() {
override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
+ binding.detailProperties.adapter = detailAdapter
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.itemUid)
- collectImmediately(detailModel.currentSong, detailModel.songProperties, ::updateSong)
+ collectImmediately(detailModel.currentSong, detailModel.songAudioInfo, ::updateSong)
}
- private fun updateSong(song: Song?, properties: SongProperties?) {
+ private fun updateSong(song: Song?, info: AudioInfo?) {
if (song == null) {
// Song we were showing no longer exists.
findNavController().navigateUp()
return
}
- val binding = requireBinding()
- if (properties != null) {
- // Finished loading Song properties, populate and show the list of Song information.
- binding.detailLoading.isInvisible = true
- binding.detailContainer.isInvisible = false
-
+ if (info != null) {
val context = requireContext()
- binding.detailFileName.setText(song.path.name)
- binding.detailRelativeDir.setText(song.path.parent.resolveName(context))
- binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context))
- binding.detailSize.setText(Formatter.formatFileSize(context, song.size))
- binding.detailDuration.setText(song.durationMs.formatDurationMs(true))
-
- if (properties.bitrateKbps != null) {
- binding.detailBitrate.setText(
- getString(R.string.fmt_bitrate, properties.bitrateKbps))
- } else {
- binding.detailBitrate.setText(R.string.def_bitrate)
- }
-
- if (properties.sampleRateHz != null) {
- binding.detailSampleRate.setText(
- getString(R.string.fmt_sample_rate, properties.sampleRateHz))
- } else {
- binding.detailSampleRate.setText(R.string.def_sample_rate)
- }
- } else {
- // Loading is still on-going, don't show anything yet.
- binding.detailLoading.isInvisible = false
- binding.detailContainer.isInvisible = true
+ detailAdapter.submitList(
+ 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.resolveDate(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_file_name, song.path.name))
+ add(
+ SongProperty(
+ R.string.lbl_relative_path, song.path.parent.resolveName(context)))
+ info.resolvedMimeType.resolveName(context)?.let {
+ 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)))
+ }
+ },
+ BasicListInstructions.REPLACE)
}
}
+
+ private fun T.zipName(context: Context) =
+ if (rawSortName != null) {
+ getString(R.string.fmt_zipped_names, resolveName(context), rawSortName)
+ } else {
+ resolveName(context)
+ }
+
+ private fun List.zipNames(context: Context) =
+ concatLocalized(context) { it.zipName(context) }
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
index 6a8611cb7..aa573a4e0 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/AlbumDetailAdapter.kt
@@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
+import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
@@ -26,13 +27,15 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
-import org.oxycblt.auxio.detail.DiscHeader
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.areRawNamesTheSame
+import org.oxycblt.auxio.music.metadata.Disc
+import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
@@ -60,7 +63,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
when (getItem(position)) {
// Support the Album header, sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE
- is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
+ is Disc -> DiscViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
@@ -68,7 +71,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
- DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
+ DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
@@ -77,7 +80,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
super.onBindViewHolder(holder, position)
when (val item = getItem(position)) {
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener)
- is DiscHeader -> (holder as DiscHeaderViewHolder).bind(item)
+ is Disc -> (holder as DiscViewHolder).bind(item)
is Song -> (holder as AlbumSongViewHolder).bind(item, listener)
}
}
@@ -88,7 +91,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
}
// The album and disc headers should be full-width in all configurations.
val item = getItem(position)
- return item is Album || item is DiscHeader
+ return item is Album || item is Disc
}
private companion object {
@@ -99,8 +102,8 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
return when {
oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
- oldItem is DiscHeader && newItem is DiscHeader ->
- DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
+ oldItem is Disc && newItem is Disc ->
+ DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@@ -135,7 +138,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
// Artist name maps to the subhead text
binding.detailSubhead.apply {
- text = album.resolveArtistContents(context)
+ text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
@@ -172,7 +175,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
- oldItem.areArtistContentsTheSame(newItem) &&
+ oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs &&
@@ -182,18 +185,22 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
}
/**
- * A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
- * [from] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from]
+ * to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
-private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
+private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
- * @param discHeader The new [DiscHeader] to bind.
+ * @param disc The new [disc] to bind.
*/
- fun bind(discHeader: DiscHeader) {
- binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, discHeader.disc)
+ fun bind(disc: Disc) {
+ binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
+ binding.discName.apply {
+ text = disc.name
+ isGone = disc.name == null
+ }
}
companion object {
@@ -206,13 +213,13 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
* @return A new instance.
*/
fun from(parent: View) =
- DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
+ DiscViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
- object : SimpleDiffCallback() {
- override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
- oldItem.disc == newItem.disc
+ object : SimpleDiffCallback() {
+ override fun areContentsTheSame(oldItem: Disc, newItem: Disc) =
+ oldItem.number == newItem.number && oldItem.name == newItem.name
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
index e706efc23..655577638 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/ArtistDetailAdapter.kt
@@ -30,10 +30,7 @@ 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.Album
-import org.oxycblt.auxio.music.Artist
-import org.oxycblt.auxio.music.Music
-import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
@@ -122,7 +119,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
- text = artist.resolveGenreContents(binding.context)
+ text = artist.genres.resolveNames(context)
}
// Song and album counts map to the info
@@ -168,7 +165,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName &&
- oldItem.areGenreContentsTheSame(newItem) &&
+ oldItem.genres.areRawNamesTheSame(newItem.genres) &&
oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.size
}
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt
index 2ce12a786..a529aa5ac 100644
--- a/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/DetailAdapter.kt
@@ -19,12 +19,13 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View
import android.view.ViewGroup
+import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
-import org.oxycblt.auxio.detail.SortHeader
+import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
@@ -52,21 +53,21 @@ abstract class DetailAdapter(
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Implement support for headers and sort headers
- is Header -> HeaderViewHolder.VIEW_TYPE
+ is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
- HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent)
+ BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType")
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = getItem(position)) {
- is Header -> (holder as HeaderViewHolder).bind(item)
+ is BasicHeader -> (holder as BasicHeaderViewHolder).bind(item)
is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
}
}
@@ -74,7 +75,7 @@ abstract class DetailAdapter(
override fun isItemFullWidth(position: Int): Boolean {
// Headers should be full-width in all configurations.
val item = getItem(position)
- return item is Header || item is SortHeader
+ return item is BasicHeader || item is SortHeader
}
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */
@@ -105,8 +106,8 @@ abstract class DetailAdapter(
object : SimpleDiffCallback
- () {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
- oldItem is Header && newItem is Header ->
- HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
+ oldItem is BasicHeader && newItem is BasicHeader ->
+ BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> false
@@ -117,8 +118,15 @@ abstract class DetailAdapter(
}
/**
- * A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a
- * button opening a menu for sorting. Use [from] to create an instance.
+ * A header variation that displays a button to open a sort menu.
+ * @param titleRes The string resource to use as the header title
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+data class SortHeader(@StringRes override val titleRes: Int) : Header
+
+/**
+ * A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [BasicHeader] that adds
+ * a button opening a menu for sorting. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :
diff --git a/app/src/main/java/org/oxycblt/auxio/detail/recycler/SongPropertyAdapter.kt b/app/src/main/java/org/oxycblt/auxio/detail/recycler/SongPropertyAdapter.kt
new file mode 100644
index 000000000..863a921e5
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/detail/recycler/SongPropertyAdapter.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.detail.recycler
+
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.StringRes
+import androidx.recyclerview.widget.RecyclerView
+import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
+import org.oxycblt.auxio.list.Item
+import org.oxycblt.auxio.list.adapter.BasicListInstructions
+import org.oxycblt.auxio.list.adapter.DiffAdapter
+import org.oxycblt.auxio.list.adapter.ListDiffer
+import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
+import org.oxycblt.auxio.list.recycler.DialogRecyclerView
+import org.oxycblt.auxio.util.context
+import org.oxycblt.auxio.util.inflater
+
+/**
+ * An adapter for [SongProperty] instances.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class SongPropertyAdapter :
+ DiffAdapter(
+ ListDiffer.Blocking(SongPropertyViewHolder.DIFF_CALLBACK)) {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+ SongPropertyViewHolder.from(parent)
+
+ override fun onBindViewHolder(holder: SongPropertyViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+}
+
+/**
+ * A property entry for use in [SongPropertyAdapter].
+ * @param name The contextual title to use for the property.
+ * @param value The value of the property.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+data class SongProperty(@StringRes val name: Int, val value: String) : Item
+
+/**
+ * A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+class SongPropertyViewHolder private constructor(private val binding: ItemSongPropertyBinding) :
+ DialogRecyclerView.ViewHolder(binding.root) {
+ fun bind(property: SongProperty) {
+ val context = binding.context
+ binding.propertyName.hint = context.getString(property.name)
+ binding.propertyValue.setText(property.value)
+ }
+
+ companion object {
+ /**
+ * Create a new instance.
+ * @param parent The parent to inflate this instance from.
+ * @return A new instance.
+ */
+ fun from(parent: View) =
+ SongPropertyViewHolder(ItemSongPropertyBinding.inflate(parent.context.inflater))
+
+ /** A comparator that can be used with DiffUtil. */
+ val DIFF_CALLBACK =
+ object : SimpleDiffCallback() {
+ override fun areContentsTheSame(oldItem: SongProperty, newItem: SongProperty) =
+ oldItem.name == newItem.name && oldItem.value == newItem.value
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt b/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt
index 87032bfe6..81fe40edd 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/EdgeFrameLayout.kt
@@ -22,6 +22,7 @@ import android.util.AttributeSet
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes
+import androidx.core.view.updatePadding
import org.oxycblt.auxio.util.systemBarInsetsCompat
/**
@@ -38,7 +39,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Prevent excessive layouts by using translation instead of padding.
- translationY = -insets.systemBarInsetsCompat.bottom.toFloat()
+ updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
index 39ae497f0..41a7f2240 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt
@@ -23,6 +23,7 @@ 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.isVisible
import androidx.core.view.iterator
import androidx.core.view.updatePadding
@@ -37,6 +38,7 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout
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 kotlin.math.abs
import org.oxycblt.auxio.BuildConfig
@@ -48,11 +50,13 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.selection.SelectionFragment
+import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.library.Library
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.system.Indexer
+import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.*
@@ -62,9 +66,12 @@ import org.oxycblt.auxio.util.*
* to other views.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class HomeFragment :
SelectionFragment(), AppBarLayout.OnOffsetChangedListener {
- private val homeModel: HomeViewModel by androidActivityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel by activityViewModels()
+ private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher? = null
@@ -98,7 +105,10 @@ class HomeFragment :
// --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this)
- binding.homeToolbar.setOnMenuItemClickListener(this)
+ binding.homeToolbar.apply {
+ setOnMenuItemClickListener(this@HomeFragment)
+ MenuCompat.setGroupDividerEnabled(menu, true)
+ }
// Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources
@@ -207,11 +217,18 @@ class HomeFragment :
// Junk click event when opening the menu
}
R.id.option_sort_asc -> {
- item.isChecked = !item.isChecked
+ item.isChecked = true
homeModel.setSortForCurrentTab(
homeModel
.getSortForTab(homeModel.currentTabMode.value)
- .withAscending(item.isChecked))
+ .withDirection(Sort.Direction.ASCENDING))
+ }
+ R.id.option_sort_dec -> {
+ item.isChecked = true
+ homeModel.setSortForCurrentTab(
+ homeModel
+ .getSortForTab(homeModel.currentTabMode.value)
+ .withDirection(Sort.Direction.DESCENDING))
}
else -> {
// Sorting option was selected, mark it as selected and update the mode
@@ -264,6 +281,7 @@ class HomeFragment :
// Only allow sorting by name, count, and duration for artists
MusicMode.ARTISTS -> { id ->
id == R.id.option_sort_asc ||
+ id == R.id.option_sort_dec ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
@@ -271,6 +289,7 @@ class HomeFragment :
// Only allow sorting by name, count, and duration for genres
MusicMode.GENRES -> { id ->
id == R.id.option_sort_asc ||
+ id == R.id.option_sort_dec ||
id == R.id.option_sort_name ||
id == R.id.option_sort_count ||
id == R.id.option_sort_duration
@@ -286,7 +305,10 @@ class HomeFragment :
// Check the ascending option and corresponding sort option to align with
// the current sort of the tab.
if (option.itemId == toHighlight.mode.itemId ||
- (option.itemId == R.id.option_sort_asc && toHighlight.isAscending)) {
+ (option.itemId == R.id.option_sort_asc &&
+ toHighlight.direction == Sort.Direction.ASCENDING) ||
+ (option.itemId == R.id.option_sort_dec &&
+ toHighlight.direction == Sort.Direction.DESCENDING)) {
option.isChecked = true
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt
similarity index 60%
rename from app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt
rename to app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt
index 72177d409..ddff79aa7 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeModule.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2022 Auxio Project
+ * Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -15,17 +15,15 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.extractor
+package org.oxycblt.auxio.home
-/**
- * Represents the result of an extraction operation.
- * @author Alexander Capehart (OxygenCobalt)
- */
-enum class ExtractionResult {
- /** A raw song was successfully extracted from the cache. */
- CACHED,
- /** A raw song was successfully extracted from parsing it's file. */
- PARSED,
- /** A raw song could not be parsed. */
- NONE
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface HomeModule {
+ @Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
index c36e9d838..2499b5918 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeSettings.kt
@@ -19,6 +19,8 @@ package org.oxycblt.auxio.home
import android.content.Context
import androidx.core.content.edit
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.settings.Settings
@@ -40,39 +42,30 @@ interface HomeSettings : Settings {
/** Called when the [shouldHideCollaborators] configuration changes. */
fun onHideCollaboratorsChanged()
}
+}
- private class Real(context: Context) : Settings.Real(context), HomeSettings {
- override var homeTabs: Array
- get() =
- Tab.fromIntCode(
- sharedPreferences.getInt(
- getString(R.string.set_key_home_tabs), Tab.SEQUENCE_DEFAULT))
- ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(value))
- apply()
- }
- }
-
- override val shouldHideCollaborators: Boolean
- get() =
- sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
-
- override fun onSettingChanged(key: String, listener: Listener) {
- when (key) {
- getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
- getString(R.string.set_key_hide_collaborators) ->
- listener.onHideCollaboratorsChanged()
+class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
+ Settings.Impl(context), HomeSettings {
+ override var homeTabs: Array
+ get() =
+ Tab.fromIntCode(
+ sharedPreferences.getInt(
+ getString(R.string.set_key_home_tabs), Tab.SEQUENCE_DEFAULT))
+ ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(value))
+ apply()
}
}
- }
- companion object {
- /**
- * Get a framework-backed implementation.
- * @param context [Context] required.
- */
- fun from(context: Context): HomeSettings = Real(context)
+ override val shouldHideCollaborators: Boolean
+ get() = sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
+
+ override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
+ when (key) {
+ getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
+ getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
index 63e6058bd..7fe81ed7f 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
@@ -17,15 +17,15 @@
package org.oxycblt.auxio.home
-import android.app.Application
-import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.ViewModel
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.MusicStore
-import org.oxycblt.auxio.music.library.Library
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.logD
@@ -33,12 +33,15 @@ import org.oxycblt.auxio.util.logD
* The ViewModel for managing the tab data and lists of the home view.
* @author Alexander Capehart (OxygenCobalt)
*/
-class HomeViewModel(application: Application) :
- AndroidViewModel(application), MusicStore.Listener, HomeSettings.Listener {
- private val musicStore = MusicStore.getInstance()
- private val homeSettings = HomeSettings.from(application)
- private val musicSettings = MusicSettings.from(application)
- private val playbackSettings = PlaybackSettings.from(application)
+@HiltViewModel
+class HomeViewModel
+@Inject
+constructor(
+ private val homeSettings: HomeSettings,
+ private val playbackSettings: PlaybackSettings,
+ private val musicRepository: MusicRepository,
+ private val musicSettings: MusicSettings
+) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener {
private val _songsList = MutableStateFlow(listOf())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
@@ -92,13 +95,13 @@ class HomeViewModel(application: Application) :
val isFastScrolling: StateFlow = _isFastScrolling
init {
- musicStore.addListener(this)
+ musicRepository.addListener(this)
homeSettings.registerListener(this)
}
override fun onCleared() {
super.onCleared()
- musicStore.removeListener(this)
+ musicRepository.removeListener(this)
homeSettings.unregisterListener(this)
}
@@ -130,7 +133,7 @@ class HomeViewModel(application: Application) :
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
- onLibraryChanged(musicStore.library)
+ onLibraryChanged(musicRepository.library)
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
index e56a9c39e..8820db820 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt
@@ -23,6 +23,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
+import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
@@ -30,25 +31,32 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
+import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
+import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
/**
* A [ListFragment] that shows a list of [Album]s.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class AlbumListFragment :
ListFragment(),
FastScrollRecyclerView.Listener,
FastScrollRecyclerView.PopupProvider {
private val homeModel: HomeViewModel by activityViewModels()
+ override val navModel: NavigationViewModel by activityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel by activityViewModels()
private val albumAdapter = AlbumAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
index a0a4aed08..5d9ec7357 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt
@@ -22,22 +22,26 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
+import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
+import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
+import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.nonZeroOrNull
@@ -45,11 +49,15 @@ import org.oxycblt.auxio.util.nonZeroOrNull
* A [ListFragment] that shows a list of [Artist]s.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class ArtistListFragment :
ListFragment(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
+ override val navModel: NavigationViewModel by activityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel by activityViewModels()
private val artistAdapter = ArtistAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
index 83b49723d..863eb22cb 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt
@@ -22,33 +22,41 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
+import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.GenreViewHolder
+import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
+import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
/**
* A [ListFragment] that shows a list of [Genre]s.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class GenreListFragment :
ListFragment(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
+ override val navModel: NavigationViewModel by activityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel by activityViewModels()
private val genreAdapter = GenreAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) =
diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
index eba715958..1990737df 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt
@@ -23,6 +23,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
+import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
@@ -30,28 +31,35 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder
+import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
+import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately
/**
* A [ListFragment] that shows a list of [Song]s.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class SongListFragment :
ListFragment(),
FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels()
+ override val navModel: NavigationViewModel by activityViewModels()
+ override val playbackModel: PlaybackViewModel by activityViewModels()
+ override val selectionModel: SelectionViewModel 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)
diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
index e514413a4..516a54257 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/TabCustomizeDialog.kt
@@ -22,6 +22,8 @@ import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding
@@ -34,10 +36,12 @@ import org.oxycblt.auxio.util.logD
* A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class TabCustomizeDialog :
ViewBindingDialogFragment(), EditableListListener {
private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null
+ @Inject lateinit var homeSettings: HomeSettings
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
@@ -46,13 +50,13 @@ class TabCustomizeDialog :
.setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
logD("Committing tab changes")
- HomeSettings.from(requireContext()).homeTabs = tabAdapter.tabs
+ homeSettings.homeTabs = tabAdapter.tabs
}
.setNegativeButton(R.string.lbl_cancel, null)
}
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) {
- var tabs = HomeSettings.from(requireContext()).homeTabs
+ var tabs = homeSettings.homeTabs
// Try to restore a pending tab configuration that was saved prior.
if (savedInstanceState != null) {
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))
diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
index 8bb6d83a6..70a4a912b 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt
@@ -20,10 +20,12 @@ package org.oxycblt.auxio.image
import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
-import coil.imageLoader
+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.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Song
@@ -38,7 +40,12 @@ import org.oxycblt.auxio.music.Song
* @param context [Context] required to load images.
* @author Alexander Capehart (OxygenCobalt)
*/
-class BitmapProvider(private val context: Context) {
+class BitmapProvider
+@Inject
+constructor(
+ @ApplicationContext private val context: Context,
+ private val imageLoader: ImageLoader
+) {
/**
* An extension of [Disposable] with an additional [Target] to deliver the final [Bitmap] to.
*/
@@ -94,7 +101,7 @@ class BitmapProvider(private val context: Context) {
onSuccess = {
synchronized(this) {
if (currentHandle == handle) {
- // Has not been superceded by a new request, can deliver
+ // Has not been superseded by a new request, can deliver
// this result.
target.onCompleted(it.toBitmap())
}
@@ -103,13 +110,13 @@ class BitmapProvider(private val context: Context) {
onError = {
synchronized(this) {
if (currentHandle == handle) {
- // Has not been superceded by a new request, can deliver
+ // Has not been superseded by a new request, can deliver
// this result.
target.onCompleted(null)
}
}
})
- currentRequest = Request(context.imageLoader.enqueue(imageRequest.build()), target)
+ currentRequest = Request(imageLoader.enqueue(imageRequest.build()), target)
}
/** Release this instance, cancelling any currently running operations. */
diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt
new file mode 100644
index 000000000..1520abf1e
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.image
+
+import android.content.Context
+import coil.ImageLoader
+import coil.request.CachePolicy
+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.auxio.image.extractor.AlbumCoverFetcher
+import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
+import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
+import org.oxycblt.auxio.image.extractor.GenreImageFetcher
+import org.oxycblt.auxio.image.extractor.MusicKeyer
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface ImageModule {
+ @Binds fun settings(imageSettings: ImageSettingsImpl): ImageSettings
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+class CoilModule {
+ @Singleton
+ @Provides
+ fun imageLoader(
+ @ApplicationContext context: Context,
+ songFactory: AlbumCoverFetcher.SongFactory,
+ albumFactory: AlbumCoverFetcher.AlbumFactory,
+ artistFactory: ArtistImageFetcher.Factory,
+ genreFactory: GenreImageFetcher.Factory
+ ) =
+ ImageLoader.Builder(context)
+ .components {
+ // Add fetchers for Music components to make them usable with ImageRequest
+ add(MusicKeyer())
+ add(songFactory)
+ add(albumFactory)
+ add(artistFactory)
+ add(genreFactory)
+ }
+ // Use our own crossfade with error drawable support
+ .transitionFactory(ErrorCrossfadeTransitionFactory())
+ // Not downloading anything, so no disk-caching
+ .diskCachePolicy(CachePolicy.DISABLED)
+ .build()
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt
index d5dd32dd1..866cdca2f 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/ImageSettings.kt
@@ -19,6 +19,8 @@ package org.oxycblt.auxio.image
import android.content.Context
import androidx.core.content.edit
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
@@ -35,53 +37,46 @@ interface ImageSettings : Settings {
/** Called when [coverMode] changes. */
fun onCoverModeChanged() {}
}
+}
- private class Real(context: Context) : Settings.Real(context), ImageSettings {
- override val coverMode: CoverMode
- get() =
- CoverMode.fromIntCode(
- sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
- ?: CoverMode.MEDIA_STORE
+class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
+ Settings.Impl(context), ImageSettings {
+ override val coverMode: CoverMode
+ get() =
+ CoverMode.fromIntCode(
+ sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
+ ?: CoverMode.MEDIA_STORE
- override fun migrate() {
- // Show album covers and Ignore MediaStore covers were unified in 3.0.0
- if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
- sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
- logD("Migrating cover settings")
+ override fun migrate() {
+ // Show album covers and Ignore MediaStore covers were unified in 3.0.0
+ if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
+ sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
+ logD("Migrating cover settings")
- val mode =
- when {
- !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
- !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
- CoverMode.MEDIA_STORE
- else -> CoverMode.QUALITY
- }
-
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_cover_mode), mode.intCode)
- remove(OLD_KEY_SHOW_COVERS)
- remove(OLD_KEY_QUALITY_COVERS)
+ val mode =
+ when {
+ !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
+ !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
+ CoverMode.MEDIA_STORE
+ else -> CoverMode.QUALITY
}
- }
- }
- override fun onSettingChanged(key: String, listener: Listener) {
- if (key == getString(R.string.set_key_cover_mode)) {
- listOf(key, listener)
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_cover_mode), mode.intCode)
+ remove(OLD_KEY_SHOW_COVERS)
+ remove(OLD_KEY_QUALITY_COVERS)
}
}
-
- private companion object {
- const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
- const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
- }
}
- companion object {
- /**
- * Get a framework-backed implementation.
- * @param context [Context] required.
- */
- fun from(context: Context): ImageSettings = Real(context)
+ override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
+ if (key == getString(R.string.set_key_cover_mode)) {
+ listener.onCoverModeChanged()
+ }
+ }
+
+ private companion object {
+ const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
+ const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt
index 5da781bb3..0c2fe5b98 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/PlaybackIndicatorView.kt
@@ -26,6 +26,8 @@ import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.widget.ImageViewCompat
import com.google.android.material.shape.MaterialShapeDrawable
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.UISettings
@@ -41,6 +43,7 @@ import org.oxycblt.auxio.util.getDrawableCompat
*
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class PlaybackIndicatorView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@@ -52,6 +55,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
+ @Inject lateinit var uiSettings: UISettings
/**
* The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius
@@ -61,7 +65,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
set(value) {
field = value
(background as? MaterialShapeDrawable)?.let { bg ->
- if (UISettings.from(context).roundMode) {
+ if (uiSettings.roundMode) {
bg.setCornerSize(value)
} else {
bg.setCornerSize(0f)
diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt
index d838a7b63..61e197263 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt
@@ -29,9 +29,12 @@ import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.DrawableCompat
-import coil.dispose
-import coil.load
+import coil.ImageLoader
+import coil.request.ImageRequest
+import coil.util.CoilUtils
import com.google.android.material.shape.MaterialShapeDrawable
+import dagger.hilt.android.AndroidEntryPoint
+import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Album
@@ -53,10 +56,14 @@ import org.oxycblt.auxio.util.getDrawableCompat
*
* @author Alexander Capehart (OxygenCobalt)
*/
+@AndroidEntryPoint
class StyledImageView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) {
+ @Inject lateinit var imageLoader: ImageLoader
+ @Inject lateinit var uiSettings: UISettings
+
init {
// Load view attributes
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
@@ -81,7 +88,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background =
MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg)
- if (UISettings.from(context).roundMode) {
+ if (uiSettings.roundMode) {
// Only use the specified corner radius when round mode is enabled.
setCornerSize(cornerRadius)
}
@@ -120,13 +127,16 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* field for the name of the [Music].
*/
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
+ val request =
+ ImageRequest.Builder(context)
+ .data(music)
+ .error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
+ .transformations(SquareFrameTransform.INSTANCE)
+ .target(this)
+ .build()
// Dispose of any previous image request and load a new image.
- dispose()
- load(music) {
- error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
- transformations(SquareFrameTransform.INSTANCE)
- }
-
+ CoilUtils.dispose(this)
+ imageLoader.enqueue(request)
// Update the content description to the specified resource.
contentDescription = context.getString(descRes, music.resolveName(context))
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
index a324690a2..64c9a948a 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt
@@ -27,15 +27,17 @@ import coil.fetch.SourceResult
import coil.key.Keyer
import coil.request.Options
import coil.size.Size
+import javax.inject.Inject
import kotlin.math.min
import okio.buffer
import okio.source
+import org.oxycblt.auxio.image.ImageSettings
+import org.oxycblt.auxio.list.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.Song
-import org.oxycblt.auxio.music.library.Sort
/**
* A [Keyer] implementation for [Music] data.
@@ -57,9 +59,13 @@ class MusicKeyer : Keyer {
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumCoverFetcher
-private constructor(private val context: Context, private val album: Album) : Fetcher {
+private constructor(
+ private val context: Context,
+ private val imageSettings: ImageSettings,
+ private val album: Album
+) : Fetcher {
override suspend fun fetch(): FetchResult? =
- Covers.fetch(context, album)?.run {
+ Covers.fetch(context, imageSettings, album)?.run {
SourceResult(
source = ImageSource(source().buffer(), context),
mimeType = null,
@@ -67,15 +73,17 @@ private constructor(private val context: Context, private val album: Album) : Fe
}
/** A [Fetcher.Factory] implementation that works with [Song]s. */
- class SongFactory : Fetcher.Factory {
+ class SongFactory @Inject constructor(private val imageSettings: ImageSettings) :
+ Fetcher.Factory {
override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
- AlbumCoverFetcher(options.context, data.album)
+ AlbumCoverFetcher(options.context, imageSettings, data.album)
}
/** A [Fetcher.Factory] implementation that works with [Album]s. */
- class AlbumFactory : Fetcher.Factory {
+ class AlbumFactory @Inject constructor(private val imageSettings: ImageSettings) :
+ Fetcher.Factory {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
- AlbumCoverFetcher(options.context, data)
+ AlbumCoverFetcher(options.context, imageSettings, data)
}
}
@@ -86,20 +94,23 @@ private constructor(private val context: Context, private val album: Album) : Fe
class ArtistImageFetcher
private constructor(
private val context: Context,
+ private val imageSettings: ImageSettings,
private val size: Size,
private val artist: Artist
) : Fetcher {
override suspend fun fetch(): FetchResult? {
// Pick the "most prominent" albums (i.e albums with the most songs) to show in the image.
- val albums = Sort(Sort.Mode.ByCount, false).albums(artist.albums)
- val results = albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, album) }
+ val albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums)
+ val results =
+ albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, imageSettings, album) }
return Images.createMosaic(context, results, size)
}
/** [Fetcher.Factory] implementation. */
- class Factory : Fetcher.Factory {
+ class Factory @Inject constructor(private val imageSettings: ImageSettings) :
+ Fetcher.Factory {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
- ArtistImageFetcher(options.context, options.size, data)
+ ArtistImageFetcher(options.context, imageSettings, options.size, data)
}
}
@@ -110,18 +121,20 @@ private constructor(
class GenreImageFetcher
private constructor(
private val context: Context,
+ private val imageSettings: ImageSettings,
private val size: Size,
private val genre: Genre
) : Fetcher {
override suspend fun fetch(): FetchResult? {
- val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, it) }
+ val results = genre.albums.mapAtMostNotNull(4) { Covers.fetch(context, imageSettings, it) }
return Images.createMosaic(context, results, size)
}
/** [Fetcher.Factory] implementation. */
- class Factory : Fetcher.Factory {
+ class Factory @Inject constructor(private val imageSettings: ImageSettings) :
+ Fetcher.Factory {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
- GenreImageFetcher(options.context, options.size, data)
+ GenreImageFetcher(options.context, imageSettings, options.size, data)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt
index b26141f7b..16b14f1a1 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Covers.kt
@@ -24,6 +24,7 @@ import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.flac.PictureFrame
import com.google.android.exoplayer2.metadata.id3.ApicFrame
+import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlinx.coroutines.Dispatchers
@@ -31,6 +32,7 @@ import kotlinx.coroutines.withContext
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album
+import org.oxycblt.auxio.music.AudioOnlyExtractors
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@@ -42,13 +44,14 @@ object Covers {
/**
* Fetch an album cover, respecting the current cover configuration.
* @param context [Context] required to load the image.
+ * @param imageSettings [ImageSettings] required to obtain configuration information.
* @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null if the cover
* loading failed or should not occur.
*/
- suspend fun fetch(context: Context, album: Album): InputStream? {
+ suspend fun fetch(context: Context, imageSettings: ImageSettings, album: Album): InputStream? {
return try {
- when (ImageSettings.from(context).coverMode) {
+ when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
CoverMode.QUALITY -> fetchQualityCovers(context, album)
@@ -102,7 +105,9 @@ object Covers {
*/
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri
- val future = MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(uri))
+ val future =
+ MetadataRetriever.retrieveMetadata(
+ DefaultMediaSourceFactory(context, AudioOnlyExtractors), MediaItem.fromUri(uri))
// future.get is a blocking call that makes us spin until the future is done.
// This is bad for a co-routine, as it prevents cancellation and by extension
diff --git a/app/src/main/java/org/oxycblt/auxio/list/Data.kt b/app/src/main/java/org/oxycblt/auxio/list/Data.kt
index 878a6a9d3..e77d0afb4 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/Data.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/Data.kt
@@ -24,6 +24,16 @@ interface Item
/**
* A "header" used for delimiting groups of data.
- * @param titleRes The string resource used for the header's title.
+ * @author Alexander Capehart (OxygenCobalt)
*/
-data class Header(@StringRes val titleRes: Int) : Item
+interface Header : Item {
+ /** The string resource used for the header's title. */
+ val titleRes: Int
+}
+
+/**
+ * A basic header with no additional actions.
+ * @param titleRes The string resource used for the header's title.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+data class BasicHeader(@StringRes override val titleRes: Int) : Header
diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
index dee519263..2363fc059 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/ListFragment.kt
@@ -21,7 +21,8 @@ import android.view.MenuItem
import android.view.View
import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu
-import androidx.fragment.app.activityViewModels
+import androidx.core.internal.view.SupportMenu
+import androidx.core.view.MenuCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.MainFragmentDirections
@@ -39,7 +40,7 @@ import org.oxycblt.auxio.util.showToast
*/
abstract class ListFragment :
SelectionFragment(), SelectableListListener {
- protected val navModel: NavigationViewModel by activityViewModels()
+ protected abstract val navModel: NavigationViewModel
private var currentMenu: PopupMenu? = null
override fun onDestroyBinding(binding: VB) {
@@ -238,6 +239,8 @@ abstract class ListFragment :
currentMenu =
PopupMenu(requireContext(), anchor).apply {
inflate(menuRes)
+ logD(menu is SupportMenu)
+ MenuCompat.setGroupDividerEnabled(menu, true)
block()
setOnDismissListener { currentMenu = null }
show()
diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt
similarity index 73%
rename from app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt
rename to app/src/main/java/org/oxycblt/auxio/list/Sort.kt
index 2cda0e76f..941d7ffc5 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/library/Sort.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt
@@ -15,15 +15,16 @@
* along with this program. If not, see .
*/
-package org.oxycblt.auxio.music.library
+package org.oxycblt.auxio.list
import androidx.annotation.IdRes
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
+import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.*
-import org.oxycblt.auxio.music.library.Sort.Mode
-import org.oxycblt.auxio.music.tags.Date
+import org.oxycblt.auxio.music.metadata.Date
+import org.oxycblt.auxio.music.metadata.Disc
/**
* A sorting method.
@@ -31,30 +32,30 @@ import org.oxycblt.auxio.music.tags.Date
* This can be used not only to sort items, but also represent a sorting mode within the UI.
*
* @param mode A [Mode] dictating how to sort the list.
- * @param isAscending Whether to sort in ascending or descending order.
+ * @param direction The [Direction] to sort in.
* @author Alexander Capehart (OxygenCobalt)
*/
-data class Sort(val mode: Mode, val isAscending: Boolean) {
+data class Sort(val mode: Mode, val direction: Direction) {
/**
- * Create a new [Sort] with the same [mode], but different [isAscending] value.
- * @param isAscending Whether the new sort should be in ascending order or not.
- * @return A new sort with the same mode, but with the new [isAscending] value applied.
+ * Create a new [Sort] with the same [mode], but a different [Direction].
+ * @param direction The new [Direction] to sort in.
+ * @return A new sort with the same mode, but with the new [Direction] value applied.
*/
- fun withAscending(isAscending: Boolean) = Sort(mode, isAscending)
+ fun withDirection(direction: Direction) = Sort(mode, direction)
/**
- * Create a new [Sort] with the same [isAscending] value, but different [mode] value.
+ * Create a new [Sort] with the same [direction] value, but different [mode] value.
* @param mode Tbe new mode to use for the Sort.
- * @return A new sort with the same [isAscending] value, but with the new [mode] applied.
+ * @return A new sort with the same [direction] value, but with the new [mode] applied.
*/
- fun withMode(mode: Mode) = Sort(mode, isAscending)
+ fun withMode(mode: Mode) = Sort(mode, direction)
/**
* Sort a list of [Song]s.
* @param songs The list of [Song]s.
* @return A new list of [Song]s sorted by this [Sort]'s configuration.
*/
- fun songs(songs: Collection): List {
+ fun songs(songs: Collection): List {
val mutable = songs.toMutableList()
songsInPlace(mutable)
return mutable
@@ -65,7 +66,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param albums The list of [Album]s.
* @return A new list of [Album]s sorted by this [Sort]'s configuration.
*/
- fun albums(albums: Collection): List {
+ fun albums(albums: Collection): List {
val mutable = albums.toMutableList()
albumsInPlace(mutable)
return mutable
@@ -76,7 +77,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param artists The list of [Artist]s.
* @return A new list of [Artist]s sorted by this [Sort]'s configuration.
*/
- fun artists(artists: Collection): List {
+ fun artists(artists: Collection): List {
val mutable = artists.toMutableList()
artistsInPlace(mutable)
return mutable
@@ -87,7 +88,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param genres The list of [Genre]s.
* @return A new list of [Genre]s sorted by this [Sort]'s configuration.
*/
- fun genres(genres: Collection): List {
+ fun genres(genres: Collection): List {
val mutable = genres.toMutableList()
genresInPlace(mutable)
return mutable
@@ -97,32 +98,32 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
* @param songs The [Song]s to sort.
*/
- private fun songsInPlace(songs: MutableList) {
- songs.sortWith(mode.getSongComparator(isAscending))
+ private fun songsInPlace(songs: MutableList) {
+ songs.sortWith(mode.getSongComparator(direction))
}
/**
* Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration.
* @param albums The [Album]s to sort.
*/
- private fun albumsInPlace(albums: MutableList) {
- albums.sortWith(mode.getAlbumComparator(isAscending))
+ private fun albumsInPlace(albums: MutableList) {
+ albums.sortWith(mode.getAlbumComparator(direction))
}
/**
* Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration.
* @param artists The [Album]s to sort.
*/
- private fun artistsInPlace(artists: MutableList) {
- artists.sortWith(mode.getArtistComparator(isAscending))
+ private fun artistsInPlace(artists: MutableList) {
+ artists.sortWith(mode.getArtistComparator(direction))
}
/**
* Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration.
* @param genres The [Genre]s to sort.
*/
- private fun genresInPlace(genres: MutableList) {
- genres.sortWith(mode.getGenreComparator(isAscending))
+ private fun genresInPlace(genres: MutableList) {
+ genres.sortWith(mode.getGenreComparator(direction))
}
/**
@@ -133,8 +134,14 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
// Sort's integer representation is formatted as AMMMM, where A is a bitflag
// representing if the sort is in ascending or descending order, and M is the
// integer representation of the sort mode.
- get() = mode.intCode.shl(1) or if (isAscending) 1 else 0
+ get() =
+ mode.intCode.shl(1) or
+ when (direction) {
+ Direction.ASCENDING -> 1
+ Direction.DESCENDING -> 0
+ }
+ /** Describes the type of data to sort with. */
sealed class Mode {
/** The integer representation of this sort mode. */
abstract val intCode: Int
@@ -143,37 +150,37 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
/**
* Get a [Comparator] that sorts [Song]s according to this [Mode].
- * @param isAscending Whether to sort in ascending or descending order.
+ * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Song] list according to this [Mode].
*/
- open fun getSongComparator(isAscending: Boolean): Comparator {
+ open fun getSongComparator(direction: Direction): Comparator {
throw UnsupportedOperationException()
}
/**
* Get a [Comparator] that sorts [Album]s according to this [Mode].
- * @param isAscending Whether to sort in ascending or descending order.
+ * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Album] list according to this [Mode].
*/
- open fun getAlbumComparator(isAscending: Boolean): Comparator {
+ open fun getAlbumComparator(direction: Direction): Comparator {
throw UnsupportedOperationException()
}
/**
* Return a [Comparator] that sorts [Artist]s according to this [Mode].
- * @param isAscending Whether to sort in ascending or descending order.
+ * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Artist] list according to this [Mode].
*/
- open fun getArtistComparator(isAscending: Boolean): Comparator {
+ open fun getArtistComparator(direction: Direction): Comparator {
throw UnsupportedOperationException()
}
/**
* Return a [Comparator] that sorts [Genre]s according to this [Mode].
- * @param isAscending Whether to sort in ascending or descending order.
+ * @param direction The direction to sort in.
* @return A [Comparator] that can be used to sort a [Genre] list according to this [Mode].
*/
- open fun getGenreComparator(isAscending: Boolean): Comparator {
+ open fun getGenreComparator(direction: Direction): Comparator {
throw UnsupportedOperationException()
}
@@ -188,17 +195,17 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_name
- override fun getSongComparator(isAscending: Boolean) =
- compareByDynamic(isAscending, BasicComparator.SONG)
+ override fun getSongComparator(direction: Direction) =
+ compareByDynamic(direction, BasicComparator.SONG)
- override fun getAlbumComparator(isAscending: Boolean) =
- compareByDynamic(isAscending, BasicComparator.ALBUM)
+ override fun getAlbumComparator(direction: Direction) =
+ compareByDynamic(direction, BasicComparator.ALBUM)
- override fun getArtistComparator(isAscending: Boolean) =
- compareByDynamic(isAscending, BasicComparator.ARTIST)
+ override fun getArtistComparator(direction: Direction) =
+ compareByDynamic(direction, BasicComparator.ARTIST)
- override fun getGenreComparator(isAscending: Boolean) =
- compareByDynamic(isAscending, BasicComparator.GENRE)
+ override fun getGenreComparator(direction: Direction) =
+ compareByDynamic(direction, BasicComparator.GENRE)
}
/**
@@ -212,10 +219,10 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_album
- override fun getSongComparator(isAscending: Boolean): Comparator =
+ override fun getSongComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, BasicComparator.ALBUM) { it.album },
- compareBy(NullableComparator.INT) { it.disc },
+ compareByDynamic(direction, BasicComparator.ALBUM) { it.album },
+ compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
@@ -231,18 +238,18 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_artist
- override fun getSongComparator(isAscending: Boolean): Comparator =
+ override fun getSongComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
+ compareByDynamic(direction, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
- compareBy(NullableComparator.INT) { it.disc },
+ compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
- override fun getAlbumComparator(isAscending: Boolean): Comparator =
+ override fun getAlbumComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists },
+ compareByDynamic(direction, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
@@ -259,17 +266,17 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_year
- override fun getSongComparator(isAscending: Boolean): Comparator =
+ override fun getSongComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates },
+ compareByDynamic(direction, NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album },
- compareBy(NullableComparator.INT) { it.disc },
+ compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
- override fun getAlbumComparator(isAscending: Boolean): Comparator =
+ override fun getAlbumComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates },
+ compareByDynamic(direction, NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM))
}
@@ -281,25 +288,22 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_duration
- override fun getSongComparator(isAscending: Boolean): Comparator =
+ override fun getSongComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending) { it.durationMs },
- compareBy(BasicComparator.SONG))
+ compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.SONG))
- override fun getAlbumComparator(isAscending: Boolean): Comparator =
+ override fun getAlbumComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending) { it.durationMs },
- compareBy(BasicComparator.ALBUM))
+ compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.ALBUM))
- override fun getArtistComparator(isAscending: Boolean): Comparator =
+ override fun getArtistComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, NullableComparator.LONG) { it.durationMs },
+ compareByDynamic(direction, NullableComparator.LONG) { it.durationMs },
compareBy(BasicComparator.ARTIST))
- override fun getGenreComparator(isAscending: Boolean): Comparator =
+ override fun getGenreComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending) { it.durationMs },
- compareBy(BasicComparator.GENRE))
+ compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.GENRE))
}
/**
@@ -313,20 +317,18 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_count
- override fun getAlbumComparator(isAscending: Boolean): Comparator =
+ override fun getAlbumComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending) { it.songs.size },
- compareBy(BasicComparator.ALBUM))
+ compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.ALBUM))
- override fun getArtistComparator(isAscending: Boolean): Comparator =
+ override fun getArtistComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, NullableComparator.INT) { it.songs.size },
+ compareByDynamic(direction, NullableComparator.INT) { it.songs.size },
compareBy(BasicComparator.ARTIST))
- override fun getGenreComparator(isAscending: Boolean): Comparator =
+ override fun getGenreComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending) { it.songs.size },
- compareBy(BasicComparator.GENRE))
+ compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.GENRE))
}
/**
@@ -340,9 +342,9 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_disc
- override fun getSongComparator(isAscending: Boolean): Comparator =
+ override fun getSongComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending, NullableComparator.INT) { it.disc },
+ compareByDynamic(direction, NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
@@ -358,10 +360,10 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_track
- override fun getSongComparator(isAscending: Boolean): Comparator =
+ override fun getSongComparator(direction: Direction): Comparator =
MultiComparator(
- compareBy(NullableComparator.INT) { it.disc },
- compareByDynamic(isAscending, NullableComparator.INT) { it.track },
+ compareBy(NullableComparator.DISC) { it.disc },
+ compareByDynamic(direction, NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG))
}
@@ -377,48 +379,47 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int
get() = R.id.option_sort_date_added
- override fun getSongComparator(isAscending: Boolean): Comparator =
+ override fun getSongComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending) { it.dateAdded }, compareBy(BasicComparator.SONG))
+ compareByDynamic(direction) { it.dateAdded }, compareBy(BasicComparator.SONG))
- override fun getAlbumComparator(isAscending: Boolean): Comparator =
+ override fun getAlbumComparator(direction: Direction): Comparator =
MultiComparator(
- compareByDynamic(isAscending) { album -> album.dateAdded },
+ compareByDynamic(direction) { album -> album.dateAdded },
compareBy(BasicComparator.ALBUM))
}
/**
- * Utility function to create a [Comparator] in a dynamic way determined by [isAscending].
- * @param isAscending Whether to sort in ascending or descending order.
+ * Utility function to create a [Comparator] in a dynamic way determined by [direction].
+ * @param direction The [Direction] to sort in.
* @see compareBy
* @see compareByDescending
*/
protected inline fun > compareByDynamic(
- isAscending: Boolean,
+ direction: Direction,
crossinline selector: (T) -> K
) =
- if (isAscending) {
- compareBy(selector)
- } else {
- compareByDescending(selector)
+ when (direction) {
+ Direction.ASCENDING -> compareBy(selector)
+ Direction.DESCENDING -> compareByDescending(selector)
}
/**
- * Utility function to create a [Comparator] in a dynamic way determined by [isAscending]
- * @param isAscending Whether to sort in ascending or descending order.
+ * Utility function to create a [Comparator] in a dynamic way determined by [direction]
+ * @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration.
* @see compareBy
* @see compareByDescending
*/
protected fun compareByDynamic(
- isAscending: Boolean,
+ direction: Direction,
comparator: Comparator
- ): Comparator = compareByDynamic(isAscending, comparator) { it }
+ ): Comparator = compareByDynamic(direction, comparator) { it }
/**
- * Utility function to create a [Comparator] a dynamic way determined by [isAscending]
- * @param isAscending Whether to sort in ascending or descending order.
+ * Utility function to create a [Comparator] a dynamic way determined by [direction]
+ * @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap.
* @param selector Called to obtain a specific attribute to sort by.
* @return A new [Comparator] with the specified configuration.
@@ -426,14 +427,13 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @see compareByDescending
*/
protected inline fun compareByDynamic(
- isAscending: Boolean,
+ direction: Direction,
comparator: Comparator,
crossinline selector: (T) -> K
) =
- if (isAscending) {
- compareBy(comparator, selector)
- } else {
- compareByDescending(comparator, selector)
+ when (direction) {
+ Direction.ASCENDING -> compareBy(comparator, selector)
+ Direction.DESCENDING -> compareByDescending(comparator, selector)
}
/**
@@ -545,6 +545,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
val INT = NullableComparator()
/** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator()
+ /** A re-usable instance configured for [Disc]s */
+ val DISC = NullableComparator()
/** A re-usable instance configured for [Date.Range]s. */
val DATE_RANGE = NullableComparator()
}
@@ -593,6 +595,12 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
}
}
+ /** The direction to sort items in. */
+ enum class Direction {
+ ASCENDING,
+ DESCENDING
+ }
+
companion object {
/**
* Convert a [Sort] integer representation into an instance.
@@ -604,9 +612,9 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
// Sort's integer representation is formatted as AMMMM, where A is a bitflag
// representing on if the mode is ascending or descending, and M is the integer
// representation of the sort mode.
- val isAscending = (intCode and 1) == 1
+ val direction = if ((intCode and 1) == 1) Direction.ASCENDING else Direction.DESCENDING
val mode = Mode.fromIntCode(intCode.shr(1)) ?: return null
- return Sort(mode, isAscending)
+ return Sort(mode, direction)
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt b/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt
index e2be45833..7c5207e6c 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/adapter/ListDiffer.kt
@@ -63,7 +63,7 @@ interface ListDiffer {
class Async(private val diffCallback: DiffUtil.ItemCallback) :
Factory() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer =
- RealAsyncListDiffer(AdapterListUpdateCallback(adapter), diffCallback)
+ AsyncListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
/**
@@ -75,7 +75,7 @@ interface ListDiffer {
class Blocking(private val diffCallback: DiffUtil.ItemCallback) :
Factory() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer =
- RealBlockingListDiffer(AdapterListUpdateCallback(adapter), diffCallback)
+ BlockingListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
}
@@ -113,7 +113,7 @@ private abstract class BasicListDiffer : ListDiffer
protected abstract fun replaceList(newList: List, onDone: () -> Unit)
}
-private class RealAsyncListDiffer(
+private class AsyncListDifferImpl(
updateCallback: ListUpdateCallback,
diffCallback: DiffUtil.ItemCallback
) : BasicListDiffer() {
@@ -132,7 +132,7 @@ private class RealAsyncListDiffer(
}
}
-private class RealBlockingListDiffer(
+private class BlockingListDifferImpl(
private val updateCallback: ListUpdateCallback,
private val diffCallback: DiffUtil.ItemCallback
) : BasicListDiffer() {
diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
index d72bf6814..ff09af8b1 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/ViewHolders.kt
@@ -24,7 +24,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
-import org.oxycblt.auxio.list.Header
+import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
@@ -49,7 +49,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context)
- binding.songInfo.text = song.resolveArtistContents(binding.context)
+ binding.songInfo.text = song.artists.resolveNames(binding.context)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@@ -76,7 +76,8 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
val DIFF_CALLBACK =
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Song, newItem: Song) =
- oldItem.rawName == newItem.rawName && oldItem.areArtistContentsTheSame(newItem)
+ oldItem.rawName == newItem.rawName &&
+ oldItem.artists.areRawNamesTheSame(newItem.artists)
}
}
}
@@ -96,7 +97,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context)
- binding.parentInfo.text = album.resolveArtistContents(binding.context)
+ binding.parentInfo.text = album.artists.resolveNames(binding.context)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@@ -124,7 +125,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
object : SimpleDiffCallback() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName &&
- oldItem.areArtistContentsTheSame(newItem) &&
+ oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.releaseType == newItem.releaseType
}
}
@@ -241,23 +242,23 @@ class GenreViewHolder private constructor(private val binding: ItemParentBinding
}
/**
- * A [RecyclerView.ViewHolder] that displays a [Header]. Use [from] to create an instance.
+ * A [RecyclerView.ViewHolder] that displays a [BasicHeader]. Use [from] to create an instance.
* @author Alexander Capehart (OxygenCobalt)
*/
-class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
+class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
- * @param header The new [Header] to bind.
+ * @param basicHeader The new [BasicHeader] to bind.
*/
- fun bind(header: Header) {
- logD(binding.context.getString(header.titleRes))
- binding.title.text = binding.context.getString(header.titleRes)
+ fun bind(basicHeader: BasicHeader) {
+ logD(binding.context.getString(basicHeader.titleRes))
+ binding.title.text = binding.context.getString(basicHeader.titleRes)
}
companion object {
/** Unique ID for this ViewHolder type. */
- const val VIEW_TYPE = IntegerTable.VIEW_TYPE_HEADER
+ const val VIEW_TYPE = IntegerTable.VIEW_TYPE_BASIC_HEADER
/**
* Create a new instance.
@@ -265,13 +266,15 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
* @return A new instance.
*/
fun from(parent: View) =
- HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
+ BasicHeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
- object : SimpleDiffCallback() {
- override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
- oldItem.titleRes == newItem.titleRes
+ object : SimpleDiffCallback() {
+ override fun areContentsTheSame(
+ oldItem: BasicHeader,
+ newItem: BasicHeader
+ ): Boolean = oldItem.titleRes == newItem.titleRes
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt
index 32edb8f7a..b9912bc09 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionFragment.kt
@@ -20,12 +20,10 @@ package org.oxycblt.auxio.list.selection
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.widget.Toolbar
-import androidx.fragment.app.activityViewModels
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment
-import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.showToast
/**
@@ -34,8 +32,8 @@ import org.oxycblt.auxio.util.showToast
*/
abstract class SelectionFragment :
ViewBindingFragment(), Toolbar.OnMenuItemClickListener {
- protected val selectionModel: SelectionViewModel by activityViewModels()
- protected val playbackModel: PlaybackViewModel by androidActivityViewModels()
+ protected abstract val selectionModel: SelectionViewModel
+ protected abstract val playbackModel: PlaybackViewModel
/**
* Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by
diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
index a607b9cd6..66424b1d1 100644
--- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt
@@ -18,26 +18,27 @@
package org.oxycblt.auxio.list.selection
import androidx.lifecycle.ViewModel
+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.*
-import org.oxycblt.auxio.music.MusicStore
-import org.oxycblt.auxio.music.library.Library
+import org.oxycblt.auxio.music.model.Library
/**
* A [ViewModel] that manages the current selection.
* @author Alexander Capehart (OxygenCobalt)
*/
-class SelectionViewModel : ViewModel(), MusicStore.Listener {
- private val musicStore = MusicStore.getInstance()
-
+@HiltViewModel
+class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
+ ViewModel(), MusicRepository.Listener {
private val _selected = MutableStateFlow(listOf())
/** the currently selected items. These are ordered in earliest selected and latest selected. */
val selected: StateFlow
>
get() = _selected
init {
- musicStore.addListener(this)
+ musicRepository.addListener(this)
}
override fun onLibraryChanged(library: Library?) {
@@ -60,7 +61,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Listener {
override fun onCleared() {
super.onCleared()
- musicStore.removeListener(this)
+ musicRepository.removeListener(this)
}
/**
diff --git a/app/src/main/java/org/oxycblt/auxio/music/AudioOnlyExtractors.kt b/app/src/main/java/org/oxycblt/auxio/music/AudioOnlyExtractors.kt
new file mode 100644
index 000000000..75eb1bdd1
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/AudioOnlyExtractors.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music
+
+import com.google.android.exoplayer2.extractor.ExtractorsFactory
+import com.google.android.exoplayer2.extractor.flac.FlacExtractor
+import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor
+import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
+import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor
+import com.google.android.exoplayer2.extractor.ogg.OggExtractor
+import com.google.android.exoplayer2.extractor.ts.AdtsExtractor
+import com.google.android.exoplayer2.extractor.wav.WavExtractor
+
+/**
+ * A [ExtractorsFactory] that only provides audio containers to save APK space.
+ * @author Alexander Capehart (OxygenCobalt)
+ */
+object AudioOnlyExtractors : ExtractorsFactory {
+ override fun createExtractors() =
+ arrayOf(
+ FlacExtractor(),
+ WavExtractor(),
+ Mp4Extractor(),
+ OggExtractor(),
+ MatroskaExtractor(),
+ // Enable constant bitrate seeking so that certain MP3s/AACs are seekable
+ AdtsExtractor(AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING),
+ Mp3Extractor(Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING))
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
index 39aeab02d..665010655 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 Auxio Project
+ * Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -15,50 +15,43 @@
* along with this program. If not, see .
*/
-@file:Suppress("PropertyName", "FunctionName")
-
package org.oxycblt.auxio.music
import android.content.Context
+import android.net.Uri
import android.os.Parcelable
-import androidx.annotation.VisibleForTesting
import java.security.MessageDigest
import java.text.CollationKey
-import java.text.Collator
import java.util.UUID
import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
-import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
-import org.oxycblt.auxio.music.library.Sort
-import org.oxycblt.auxio.music.parsing.parseId3GenreNames
-import org.oxycblt.auxio.music.parsing.parseMultiValue
-import org.oxycblt.auxio.music.storage.*
-import org.oxycblt.auxio.music.tags.Date
-import org.oxycblt.auxio.music.tags.ReleaseType
-import org.oxycblt.auxio.util.nonZeroOrNull
-import org.oxycblt.auxio.util.unlikelyToBeNull
-
-// --- MUSIC MODELS ---
+import org.oxycblt.auxio.music.metadata.Date
+import org.oxycblt.auxio.music.metadata.Disc
+import org.oxycblt.auxio.music.metadata.ReleaseType
+import org.oxycblt.auxio.music.storage.MimeType
+import org.oxycblt.auxio.music.storage.Path
+import org.oxycblt.auxio.util.concatLocalized
+import org.oxycblt.auxio.util.toUuidOrNull
/**
* Abstract music data. This contains universal information about all concrete music
* implementations, such as identification information and names.
* @author Alexander Capehart (OxygenCobalt)
*/
-sealed class Music : Item {
+sealed interface Music : Item {
/**
* A unique identifier for this music item.
* @see UID
*/
- abstract val uid: UID
+ val uid: UID
/**
* The raw name of this item as it was extracted from the file-system. Will be null if the
* item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName].
*/
- abstract val rawName: String?
+ val rawName: String?
/**
* Returns a name suitable for use in the app UI. This should be favored over [rawName] in
@@ -67,14 +60,14 @@ sealed class Music : Item {
* @return A human-readable string representing the name of this music. In the case that the
* item does not have a name, an analogous "Unknown X" name is returned.
*/
- abstract fun resolveName(context: Context): String
+ fun resolveName(context: Context): String
/**
* The raw sort name of this item as it was extracted from the file-system. This can be used not
* only when sorting music, but also trying to locate music based on a fuzzy search by the user.
* Will be null if the item has no known sort name.
*/
- abstract val rawSortName: String?
+ val rawSortName: String?
/**
* A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a
@@ -85,62 +78,7 @@ sealed class Music : Item {
* - If the string begins with an article, such as "the", it will be stripped, as is usually
* convention for sorting media. This is not internationalized.
*/
- abstract val collationKey: CollationKey?
-
- /**
- * Finalize this item once the music library has been fully constructed. This is where any final
- * ordering or sanity checking should occur. **This function is internal to the music package.
- * Do not use it elsewhere.**
- */
- abstract fun _finalize()
-
- /**
- * Provided implementation to create a [CollationKey] in the way described by [collationKey].
- * This should be used in all overrides of all [CollationKey].
- * @return A [CollationKey] that follows the specification described by [collationKey].
- */
- protected fun makeCollationKeyImpl(): CollationKey? {
- val sortName =
- (rawSortName ?: rawName)?.run {
- when {
- length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
- length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
- length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
- else -> this
- }
- }
-
- return COLLATOR.getCollationKey(sortName)
- }
-
- /**
- * Join a list of [Music]'s resolved names into a string in a localized manner, using
- * [R.string.fmt_list].
- * @param context [Context] required to obtain localized formatting.
- * @param values The list of [Music] to format.
- * @return A single string consisting of the values delimited by a localized separator.
- */
- protected fun resolveNames(context: Context, values: List): String {
- if (values.isEmpty()) {
- // Nothing to do.
- return ""
- }
-
- var joined = values.first().resolveName(context)
- for (i in 1..values.lastIndex) {
- // Chain all previous values with the next value in the list with another delimiter.
- joined = context.getString(R.string.fmt_list, joined, values[i].resolveName(context))
- }
- return joined
- }
-
- // Note: We solely use the UID in comparisons so that certain items that differ in all
- // but UID are treated differently.
-
- override fun hashCode() = uid.hashCode()
-
- override fun equals(other: Any?) =
- other is Music && javaClass == other.javaClass && uid == other.uid
+ val collationKey: CollationKey?
/**
* A unique identifier for a piece of music.
@@ -192,6 +130,7 @@ sealed class Music : Item {
private enum class Format(val namespace: String) {
/** @see auxio */
AUXIO("org.oxycblt.auxio"),
+
/** @see musicBrainz */
MUSICBRAINZ("org.musicbrainz")
}
@@ -281,799 +220,156 @@ sealed class Music : Item {
}
}
}
-
- private companion object {
- /** Cached collator instance re-used with [makeCollationKeyImpl]. */
- val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
- }
}
/**
* An abstract grouping of [Song]s and other [Music] data.
* @author Alexander Capehart (OxygenCobalt)
*/
-sealed class MusicParent : Music() {
- /** The [Song]s in this this group. */
- abstract val songs: List
-
- // Note: Append song contents to MusicParent equality so that Groups with
- // the same UID but different contents are not equal.
-
- override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
-
- override fun equals(other: Any?) =
- other is MusicParent &&
- javaClass == other.javaClass &&
- uid == other.uid &&
- songs == other.songs
+sealed interface MusicParent : Music {
+ /** The child [Song]s of this [MusicParent]. */
+ val songs: List
}
/**
- * A song. Perhaps the foundation of the entirety of Auxio.
- * @param raw The [Song.Raw] to derive the member data from.
- * @param musicSettings [MusicSettings] to perform further user-configured parsing.
+ * A song.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() {
- override val uid =
- // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
- raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) }
- ?: UID.auxio(MusicMode.SONGS) {
- // Song UIDs are based on the raw data without parsing so that they remain
- // consistent across music setting changes. Parents are not held up to the
- // same standard since grouping is already inherently linked to settings.
- update(raw.name)
- update(raw.albumName)
- update(raw.date)
-
- update(raw.track)
- update(raw.disc)
-
- update(raw.artistNames)
- update(raw.albumArtistNames)
- }
- override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
- override val rawSortName = raw.sortName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName
-
+interface Song : Music {
/** The track number. Will be null if no valid track number was present in the metadata. */
- val track = raw.track
-
- /** The disc number. Will be null if no valid disc number was present in the metadata. */
- val disc = raw.disc
-
+ val track: Int?
+ /** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
+ val disc: Disc?
/** The release [Date]. Will be null if no valid date was present in the metadata. */
- val date = raw.date
-
+ val date: Date?
/**
* The URI to the audio file that this instance was created from. This can be used to access the
* audio file in a way that is scoped-storage-safe.
*/
- val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()
-
+ val uri: Uri
/**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file.
*/
- val path =
- Path(
- name = requireNotNull(raw.fileName) { "Invalid raw: No display name" },
- parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
-
+ val path: Path
/** The [MimeType] of the audio file. Only intended for display. */
- val mimeType =
- MimeType(
- fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" },
- fromFormat = null)
-
+ val mimeType: MimeType
/** The size of the audio file, in bytes. */
- val size = requireNotNull(raw.size) { "Invalid raw: No size" }
-
+ val size: Long
/** The duration of the audio file, in milliseconds. */
- val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" }
-
+ val durationMs: Long
/** The date the audio file was added to the device, as a unix epoch timestamp. */
- val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" }
-
- private var _album: Album? = null
+ val dateAdded: Long
/**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead.
*/
val album: Album
- get() = unlikelyToBeNull(_album)
-
- private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings)
- private val artistNames = raw.artistNames.parseMultiValue(musicSettings)
- private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings)
- private val rawArtists =
- artistNames.mapIndexed { i, name ->
- Artist.Raw(
- artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
- name,
- artistSortNames.getOrNull(i))
- }
-
- private val albumArtistMusicBrainzIds =
- raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
- private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings)
- private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings)
- private val rawAlbumArtists =
- albumArtistNames.mapIndexed { i, name ->
- Artist.Raw(
- albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
- name,
- albumArtistSortNames.getOrNull(i))
- }
-
- private val _artists = mutableListOf()
/**
* The parent [Artist]s of this [Song]. Is often one, but there can be multiple if more than one
* [Artist] name was specified in the metadata. Unliked [Album], artists are prioritized for
* this field.
*/
val artists: List
- get() = _artists
-
- /**
- * Resolves one or more [Artist]s into a single piece of human-readable names.
- * @param context [Context] required for [resolveName]. formatter.
- */
- fun resolveArtistContents(context: Context) = resolveNames(context, artists)
-
- /**
- * Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only
- * compare surface-level names, and not [Music.UID]s.
- * @param other The [Song] to compare to.
- * @return True if the [Artist] displays are equal, false otherwise
- */
- fun areArtistContentsTheSame(other: Song): Boolean {
- for (i in 0 until max(artists.size, other.artists.size)) {
- val a = artists.getOrNull(i) ?: return false
- val b = other.artists.getOrNull(i) ?: return false
- if (a.rawName != b.rawName) {
- return false
- }
- }
-
- return true
- }
-
- private val _genres = mutableListOf()
/**
* The parent [Genre]s of this [Song]. Is often one, but there can be multiple if more than one
* [Genre] name was specified in the metadata.
*/
val genres: List
- get() = _genres
-
- /**
- * Resolves one or more [Genre]s into a single piece human-readable names.
- * @param context [Context] required for [resolveName].
- */
- fun resolveGenreContents(context: Context) = resolveNames(context, genres)
-
- // --- INTERNAL FIELDS ---
-
- /**
- * The [Album.Raw] instances collated by the [Song]. This can be used to group [Song]s into an
- * [Album]. **This is only meant for use within the music package.**
- */
- val _rawAlbum =
- Album.Raw(
- mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" },
- musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
- name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
- sortName = raw.albumSortName,
- releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)),
- rawArtists =
- rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) })
-
- /**
- * The [Artist.Raw] instances collated by the [Song]. The artists of the song take priority,
- * followed by the album artists. If there are no artists, this field will be a single "unknown"
- * [Artist.Raw]. This can be used to group up [Song]s into an [Artist]. **This is only meant for
- * use within the music package.**
- */
- val _rawArtists = rawArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(Artist.Raw()) }
-
- /**
- * The [Genre.Raw] instances collated by the [Song]. This can be used to group up [Song]s into a
- * [Genre]. ID3v2 Genre names are automatically converted to their resolved names. **This is
- * only meant for use within the music package.**
- */
- val _rawGenres =
- raw.genreNames
- .parseId3GenreNames(musicSettings)
- .map { Genre.Raw(it) }
- .ifEmpty { listOf(Genre.Raw()) }
-
- /**
- * Links this [Song] with a parent [Album].
- * @param album The parent [Album] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(album: Album) {
- _album = album
- }
-
- /**
- * Links this [Song] with a parent [Artist].
- * @param artist The parent [Artist] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(artist: Artist) {
- _artists.add(artist)
- }
-
- /**
- * Links this [Song] with a parent [Genre].
- * @param genre The parent [Genre] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(genre: Genre) {
- _genres.add(genre)
- }
-
- override fun _finalize() {
- checkNotNull(_album) { "Malformed song: No album" }
-
- check(_artists.isNotEmpty()) { "Malformed song: No artists" }
- for (i in _artists.indices) {
- // Non-destructively reorder the linked artists so that they align with
- // the artist ordering within the song metadata.
- val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
- val other = _artists[newIdx]
- _artists[newIdx] = _artists[i]
- _artists[i] = other
- }
-
- check(_genres.isNotEmpty()) { "Malformed song: No genres" }
- for (i in _genres.indices) {
- // Non-destructively reorder the linked genres so that they align with
- // the genre ordering within the song metadata.
- val newIdx = _genres[i]._getOriginalPositionIn(_rawGenres)
- val other = _genres[newIdx]
- _genres[newIdx] = _genres[i]
- _genres[i] = other
- }
- }
-
- /**
- * Raw information about a [Song] obtained from the filesystem/Extractor instances. **This is
- * only meant for use within the music package.**
- */
- class Raw
- constructor(
- /**
- * The ID of the [Song]'s audio file, obtained from MediaStore. Note that this ID is highly
- * unstable and should only be used for accessing the audio file.
- */
- var mediaStoreId: Long? = null,
- /** @see Song.dateAdded */
- var dateAdded: Long? = null,
- /** The latest date the [Song]'s audio file was modified, as a unix epoch timestamp. */
- var dateModified: Long? = null,
- /** @see Song.path */
- var fileName: String? = null,
- /** @see Song.path */
- var directory: Directory? = null,
- /** @see Song.size */
- var size: Long? = null,
- /** @see Song.durationMs */
- var durationMs: Long? = null,
- /** @see Song.mimeType */
- var extensionMimeType: String? = null,
- /** @see Music.UID */
- var musicBrainzId: String? = null,
- /** @see Music.rawName */
- var name: String? = null,
- /** @see Music.rawSortName */
- var sortName: String? = null,
- /** @see Song.track */
- var track: Int? = null,
- /** @see Song.disc */
- var disc: Int? = null,
- /** @see Song.date */
- var date: Date? = null,
- /** @see Album.Raw.mediaStoreId */
- var albumMediaStoreId: Long? = null,
- /** @see Album.Raw.musicBrainzId */
- var albumMusicBrainzId: String? = null,
- /** @see Album.Raw.name */
- var albumName: String? = null,
- /** @see Album.Raw.sortName */
- var albumSortName: String? = null,
- /** @see Album.Raw.releaseType */
- var releaseTypes: List = listOf(),
- /** @see Artist.Raw.musicBrainzId */
- var artistMusicBrainzIds: List = listOf(),
- /** @see Artist.Raw.name */
- var artistNames: List = listOf(),
- /** @see Artist.Raw.sortName */
- var artistSortNames: List = listOf(),
- /** @see Artist.Raw.musicBrainzId */
- var albumArtistMusicBrainzIds: List = listOf(),
- /** @see Artist.Raw.name */
- var albumArtistNames: List = listOf(),
- /** @see Artist.Raw.sortName */
- var albumArtistSortNames: List = listOf(),
- /** @see Genre.Raw.name */
- var genreNames: List = listOf()
- )
}
/**
* An abstract release group. While it may be called an album, it encompasses other types of
* releases like singles, EPs, and compilations.
- * @param raw The [Album.Raw] to derive the member data from.
- * @param songs The [Song]s that are a part of this [Album]. These items will be linked to this
- * [Album].
* @author Alexander Capehart (OxygenCobalt)
*/
-class Album constructor(raw: Raw, override val songs: List) : MusicParent() {
- override val uid =
- // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
- raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ALBUMS, it) }
- ?: UID.auxio(MusicMode.ALBUMS) {
- // Hash based on only names despite the presence of a date to increase stability.
- // I don't know if there is any situation where an artist will have two albums with
- // the exact same name, but if there is, I would love to know.
- update(raw.name)
- update(raw.rawArtists.map { it.name })
- }
- override val rawName = raw.name
- override val rawSortName = raw.sortName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName
-
+interface Album : MusicParent {
/** The [Date.Range] that [Song]s in the [Album] were released. */
- val dates = Date.Range.from(songs.mapNotNull { it.date })
-
+ val dates: Date.Range?
/**
* The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to
* [ReleaseType.Album].
*/
- val releaseType = raw.releaseType ?: ReleaseType.Album(null)
+ val releaseType: ReleaseType
/**
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
* cost of image quality.
*/
- val coverUri = raw.mediaStoreId.toCoverUri()
-
+ val coverUri: Uri
/** The duration of all songs in the album, in milliseconds. */
val durationMs: Long
-
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
val dateAdded: Long
-
- init {
- var totalDuration: Long = 0
- var earliestDateAdded: Long = Long.MAX_VALUE
-
- // Do linking and value generation in the same loop for efficiency.
- for (song in songs) {
- song._link(this)
- if (song.dateAdded < earliestDateAdded) {
- earliestDateAdded = song.dateAdded
- }
- totalDuration += song.durationMs
- }
-
- durationMs = totalDuration
- dateAdded = earliestDateAdded
- }
-
- private val _artists = mutableListOf()
/**
* The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than
* one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
* are prioritized for this field.
*/
val artists: List
- get() = _artists
-
- /**
- * Resolves one or more [Artist]s into a single piece of human-readable names.
- * @param context [Context] required for [resolveName].
- */
- fun resolveArtistContents(context: Context) = resolveNames(context, artists)
-
- /**
- * Checks if the [Artist] *display* of this [Album] and another [Album] are equal. This will
- * only compare surface-level names, and not [Music.UID]s.
- * @param other The [Album] to compare to.
- * @return True if the [Artist] displays are equal, false otherwise
- */
- fun areArtistContentsTheSame(other: Album): Boolean {
- for (i in 0 until max(artists.size, other.artists.size)) {
- val a = artists.getOrNull(i) ?: return false
- val b = other.artists.getOrNull(i) ?: return false
- if (a.rawName != b.rawName) {
- return false
- }
- }
-
- return true
- }
-
- // --- INTERNAL FIELDS ---
-
- /**
- * The [Artist.Raw] instances collated by the [Album]. The album artists of the song take
- * priority, followed by the artists. If there are no artists, this field will be a single
- * "unknown" [Artist.Raw]. This can be used to group up [Album]s into an [Artist]. **This is
- * only meant for use within the music package.**
- */
- val _rawArtists = raw.rawArtists
-
- /**
- * Links this [Album] with a parent [Artist].
- * @param artist The parent [Artist] to link to. **This is only meant for use within the music
- * package.**
- */
- fun _link(artist: Artist) {
- _artists.add(artist)
- }
-
- override fun _finalize() {
- check(songs.isNotEmpty()) { "Malformed album: Empty" }
- check(_artists.isNotEmpty()) { "Malformed album: No artists" }
- for (i in _artists.indices) {
- // Non-destructively reorder the linked artists so that they align with
- // the artist ordering within the song metadata.
- val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
- val other = _artists[newIdx]
- _artists[newIdx] = _artists[i]
- _artists[i] = other
- }
- }
-
- /**
- * Raw information about an [Album] obtained from the component [Song] instances. **This is only
- * meant for use within the music package.**
- */
- class Raw(
- /**
- * The ID of the [Album]'s grouping, obtained from MediaStore. Note that this ID is highly
- * unstable and should only be used for accessing the system-provided cover art.
- */
- val mediaStoreId: Long,
- /** @see Music.uid */
- val musicBrainzId: UUID?,
- /** @see Music.rawName */
- val name: String,
- /** @see Music.rawSortName */
- val sortName: String?,
- /** @see Album.releaseType */
- val releaseType: ReleaseType?,
- /** @see Artist.Raw.name */
- val rawArtists: List
- ) {
- // Albums are grouped as follows:
- // - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
- // same name to be differentiated, which is common in large libraries.
- // - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
- // artist name. This allows for case-insensitive artist/album grouping, which can be common
- // for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
-
- // Cache the hash-code for HashMap efficiency.
- private val hashCode =
- musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
-
- override fun hashCode() = hashCode
-
- override fun equals(other: Any?) =
- other is Raw &&
- when {
- musicBrainzId != null && other.musicBrainzId != null ->
- musicBrainzId == other.musicBrainzId
- musicBrainzId == null && other.musicBrainzId == null ->
- name.equals(other.name, true) && rawArtists == other.rawArtists
- else -> false
- }
- }
}
/**
* An abstract artist. These are actually a combination of the artist and album artist tags from
* within the library, derived from [Song]s and [Album]s respectively.
- * @param raw The [Artist.Raw] to derive the member data from.
- * @param songAlbums A list of the [Song]s and [Album]s that are a part of this [Artist], either
- * through artist or album artist tags. Providing [Song]s to the artist is optional. These instances
- * will be linked to this [Artist].
* @author Alexander Capehart (OxygenCobalt)
*/
-class Artist constructor(private val raw: Raw, songAlbums: List) : MusicParent() {
- override val uid =
- // Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
- raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ARTISTS, it) }
- ?: UID.auxio(MusicMode.ARTISTS) { update(raw.name) }
- override val rawName = raw.name
- override val rawSortName = raw.sortName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
- override val songs: List
-
+interface Artist : MusicParent {
/**
* All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist
* will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus
* included in this list.
*/
val albums: List
-
/**
* The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no
* songs.
*/
val durationMs: Long?
-
/**
* Whether this artist is considered a "collaborator", i.e it is not directly credited on any
* [Album].
*/
val isCollaborator: Boolean
-
- init {
- val distinctSongs = mutableSetOf()
- val distinctAlbums = mutableSetOf()
-
- var noAlbums = true
-
- for (music in songAlbums) {
- when (music) {
- is Song -> {
- music._link(this)
- distinctSongs.add(music)
- distinctAlbums.add(music.album)
- }
- is Album -> {
- music._link(this)
- distinctAlbums.add(music)
- noAlbums = false
- }
- else -> error("Unexpected input music ${music::class.simpleName}")
- }
- }
-
- songs = distinctSongs.toList()
- albums = distinctAlbums.toList()
- durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
- isCollaborator = noAlbums
- }
-
- private lateinit var genres: List
-
- /**
- * Resolves one or more [Genre]s into a single piece of human-readable names.
- * @param context [Context] required for [resolveName].
- */
- fun resolveGenreContents(context: Context) = resolveNames(context, genres)
-
- /**
- * Checks if the [Genre] *display* of this [Artist] and another [Artist] are equal. This will
- * only compare surface-level names, and not [Music.UID]s.
- * @param other The [Artist] to compare to.
- * @return True if the [Genre] displays are equal, false otherwise
- */
- fun areGenreContentsTheSame(other: Artist): Boolean {
- for (i in 0 until max(genres.size, other.genres.size)) {
- val a = genres.getOrNull(i) ?: return false
- val b = other.genres.getOrNull(i) ?: return false
- if (a.rawName != b.rawName) {
- return false
- }
- }
-
- return true
- }
-
- // --- INTERNAL METHODS ---
-
- /**
- * Returns the original position of this [Artist]'s [Artist.Raw] within the given [Artist.Raw]
- * list. This can be used to create a consistent ordering within child [Artist] lists based on
- * the original tag order.
- * @param rawArtists The [Artist.Raw] instances to check. It is assumed that this [Artist]'s
- * [Artist.Raw] will be within the list.
- * @return The index of the [Artist]'s [Artist.Raw] within the list. **This is only meant for
- * use within the music package.**
- */
- fun _getOriginalPositionIn(rawArtists: List) = rawArtists.indexOf(raw)
-
- override fun _finalize() {
- check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" }
- genres =
- Sort(Sort.Mode.ByName, true)
- .genres(songs.flatMapTo(mutableSetOf()) { it.genres })
- .sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
- }
-
- /**
- * Raw information about an [Artist] obtained from the component [Song] and [Album] instances.
- * **This is only meant for use within the music package.**
- */
- class Raw(
- /** @see Music.UID */
- val musicBrainzId: UUID? = null,
- /** @see Music.rawName */
- val name: String? = null,
- /** @see Music.rawSortName */
- val sortName: String? = null
- ) {
- // Artists are grouped as follows:
- // - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
- // same name to be differentiated, which is common in large libraries.
- // - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
- // grouping to be case-insensitive.
-
- // Cache the hashCode for HashMap efficiency.
- private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()
-
- // Compare names and MusicBrainz IDs in order to differentiate artists with the
- // same name in large libraries.
-
- override fun hashCode() = hashCode
-
- override fun equals(other: Any?) =
- other is Raw &&
- when {
- musicBrainzId != null && other.musicBrainzId != null ->
- musicBrainzId == other.musicBrainzId
- musicBrainzId == null && other.musicBrainzId == null ->
- when {
- name != null && other.name != null -> name.equals(other.name, true)
- name == null && other.name == null -> true
- else -> false
- }
- else -> false
- }
- }
+ /** The [Genre]s of this artist. */
+ val genres: List
}
/**
- * A genre of [Song]s.
+ * A genre.
* @author Alexander Capehart (OxygenCobalt)
*/
-class Genre constructor(private val raw: Raw, override val songs: List) : MusicParent() {
- override val uid = UID.auxio(MusicMode.GENRES) { update(raw.name) }
- override val rawName = raw.name
- override val rawSortName = rawName
- override val collationKey = makeCollationKeyImpl()
- override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
-
+interface Genre : MusicParent {
/** The albums indirectly linked to by the [Song]s of this [Genre]. */
val albums: List
-
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */
val artists: List
-
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
+}
- init {
- val distinctAlbums = mutableSetOf()
- val distinctArtists = mutableSetOf()
- var totalDuration = 0L
+/**
+ * Run [Music.resolveName] 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 List.resolveNames(context: Context) =
+ concatLocalized(context) { it.resolveName(context) }
- for (song in songs) {
- song._link(this)
- distinctAlbums.add(song.album)
- distinctArtists.addAll(song.artists)
- totalDuration += song.durationMs
+/**
+ * Returns if [Music.rawName] 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.rawName]), false otherwise.
+ */
+fun List.areRawNamesTheSame(other: List): 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.rawName != b.rawName) {
+ return false
}
-
- albums =
- Sort(Sort.Mode.ByName, true).albums(distinctAlbums).sortedByDescending { album ->
- album.songs.count { it.genres.contains(this) }
- }
- artists = Sort(Sort.Mode.ByName, true).artists(distinctArtists)
- durationMs = totalDuration
}
- // --- INTERNAL METHODS ---
-
- /**
- * Returns the original position of this [Genre]'s [Genre.Raw] within the given [Genre.Raw]
- * list. This can be used to create a consistent ordering within child [Genre] lists based on
- * the original tag order.
- * @param rawGenres The [Genre.Raw] instances to check. It is assumed that this [Genre]'s
- * [Genre.Raw] will be within the list.
- * @return The index of the [Genre]'s [Genre.Raw] within the list. **This is only meant for use
- * within the music package.**
- */
- fun _getOriginalPositionIn(rawGenres: List) = rawGenres.indexOf(raw)
-
- override fun _finalize() {
- check(songs.isNotEmpty()) { "Malformed genre: Empty" }
- }
-
- /**
- * Raw information about a [Genre] obtained from the component [Song] instances. **This is only
- * meant for use within the music package.**
- */
- class Raw(
- /** @see Music.rawName */
- val name: String? = null
- ) {
- // Only group by the lowercase genre name. This allows Genre grouping to be
- // case-insensitive, which may be helpful in some libraries with different ways of
- // formatting genres.
-
- // Cache the hashCode for HashMap efficiency.
- private val hashCode = name?.lowercase().hashCode()
-
- override fun hashCode() = hashCode
-
- override fun equals(other: Any?) =
- other is Raw &&
- when {
- name != null && other.name != null -> name.equals(other.name, true)
- name == null && other.name == null -> true
- else -> false
- }
- }
-}
-
-// --- MUSIC UID CREATION UTILITIES ---
-
-/**
- * Convert a [String] to a [UUID].
- * @return A [UUID] converted from the [String] value, or null if the value was not valid.
- * @see UUID.fromString
- */
-private fun String.toUuidOrNull(): UUID? =
- try {
- UUID.fromString(this)
- } catch (e: IllegalArgumentException) {
- null
- }
-
-/**
- * Update a [MessageDigest] with a lowercase [String].
- * @param string The [String] to hash. If null, it will not be hashed.
- */
-@VisibleForTesting
-fun MessageDigest.update(string: String?) {
- if (string != null) {
- update(string.lowercase().toByteArray())
- } else {
- update(0)
- }
-}
-
-/**
- * Update a [MessageDigest] with the string representation of a [Date].
- * @param date The [Date] to hash. If null, nothing will be done.
- */
-@VisibleForTesting
-fun MessageDigest.update(date: Date?) {
- if (date != null) {
- update(date.toString().toByteArray())
- } else {
- update(0)
- }
-}
-
-/**
- * Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
- * @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
- */
-@VisibleForTesting
-fun MessageDigest.update(strings: List) {
- strings.forEach(::update)
-}
-
-/**
- * Update a [MessageDigest] with the little-endian bytes of a [Int].
- * @param n The [Int] to write. If null, nothing will be done.
- */
-@VisibleForTesting
-fun MessageDigest.update(n: Int?) {
- if (n != null) {
- update(byteArrayOf(n.toByte(), n.shr(8).toByte(), n.shr(16).toByte(), n.shr(24).toByte()))
- } else {
- update(0)
- }
+ return true
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt
new file mode 100644
index 000000000..2f91cfdba
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import org.oxycblt.auxio.music.system.Indexer
+import org.oxycblt.auxio.music.system.IndexerImpl
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface MusicModule {
+ @Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
+ @Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer
+ @Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
similarity index 72%
rename from app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
rename to app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
index 2e9bbab2d..9b4f73884 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 Auxio Project
+ * Copyright (c) 2023 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,7 +17,8 @@
package org.oxycblt.auxio.music
-import org.oxycblt.auxio.music.library.Library
+import javax.inject.Inject
+import org.oxycblt.auxio.music.model.Library
/**
* A repository granting access to the music library.
@@ -28,22 +29,13 @@ import org.oxycblt.auxio.music.library.Library
*
* @author Alexander Capehart (OxygenCobalt)
*/
-class MusicStore private constructor() {
- private val listeners = mutableListOf()
-
+interface MusicRepository {
/**
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
* can change, so it's highly recommended to not access this directly and instead rely on
* [Listener].
*/
- @Volatile
- var library: Library? = null
- set(value) {
- field = value
- for (callback in listeners) {
- callback.onLibraryChanged(library)
- }
- }
+ var library: Library?
/**
* Add a [Listener] to this instance. This can be used to receive changes in the music library.
@@ -51,11 +43,7 @@ class MusicStore private constructor() {
* @param listener The [Listener] to add.
* @see Listener
*/
- @Synchronized
- fun addListener(listener: Listener) {
- listener.onLibraryChanged(library)
- listeners.add(listener)
- }
+ fun addListener(listener: Listener)
/**
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
@@ -63,12 +51,9 @@ class MusicStore private constructor() {
* the first place.
* @see Listener
*/
- @Synchronized
- fun removeListener(listener: Listener) {
- listeners.remove(listener)
- }
+ fun removeListener(listener: Listener)
- /** A listener for changes in the music library. */
+ /** A listener for changes in [MusicRepository] */
interface Listener {
/**
* Called when the current [Library] has changed.
@@ -76,25 +61,28 @@ class MusicStore private constructor() {
*/
fun onLibraryChanged(library: Library?)
}
+}
- companion object {
- @Volatile private var INSTANCE: MusicStore? = null
+class MusicRepositoryImpl @Inject constructor() : MusicRepository {
+ private val listeners = mutableListOf()
- /**
- * Get a singleton instance.
- * @return The (possibly newly-created) singleton instance.
- */
- fun getInstance(): MusicStore {
- val currentInstance = INSTANCE
- if (currentInstance != null) {
- return currentInstance
- }
-
- synchronized(this) {
- val newInstance = MusicStore()
- INSTANCE = newInstance
- return newInstance
+ @Volatile
+ override var library: Library? = null
+ set(value) {
+ field = value
+ for (callback in listeners) {
+ callback.onLibraryChanged(library)
}
}
+
+ @Synchronized
+ override fun addListener(listener: MusicRepository.Listener) {
+ listener.onLibraryChanged(library)
+ listeners.add(listener)
+ }
+
+ @Synchronized
+ override fun removeListener(listener: MusicRepository.Listener) {
+ listeners.remove(listener)
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
index b96a97fbd..252693747 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt
@@ -20,8 +20,10 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.os.storage.StorageManager
import androidx.core.content.edit
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
import org.oxycblt.auxio.R
-import org.oxycblt.auxio.music.library.Sort
+import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.MusicDirectories
import org.oxycblt.auxio.settings.Settings
@@ -40,6 +42,8 @@ interface MusicSettings : Settings {
val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */
var multiValueSeparators: String
+ /** Whether to trim english articles with song sort names. */
+ val automaticSortNames: Boolean
/** The [Sort] mode used in [Song] lists. */
var songSort: Sort
/** The [Sort] mode used in [Album] lists. */
@@ -61,164 +65,158 @@ interface MusicSettings : Settings {
/** Called when the [shouldBeObserving] configuration has changed. */
fun onObservingChanged() {}
}
+}
- private class Real(context: Context) : Settings.Real(context), MusicSettings {
- private val storageManager = context.getSystemServiceCompat(StorageManager::class)
+class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
+ Settings.Impl(context), MusicSettings {
+ private val storageManager = context.getSystemServiceCompat(StorageManager::class)
- override var musicDirs: MusicDirectories
- get() {
- val dirs =
- (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
- ?: emptySet())
- .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) }
- return MusicDirectories(
- dirs,
- sharedPreferences.getBoolean(
- getString(R.string.set_key_music_dirs_include), false))
- }
- set(value) {
- sharedPreferences.edit {
- putStringSet(
- getString(R.string.set_key_music_dirs),
- value.dirs.map(Directory::toDocumentTreeUri).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 shouldBeObserving: Boolean
- get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
-
- override var multiValueSeparators: String
- // Differ from convention and store a string of separator characters instead of an int
- // code. This makes it easier to use and more extendable.
- get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: ""
- set(value) {
- sharedPreferences.edit {
- putString(getString(R.string.set_key_separators), value)
- apply()
- }
- }
-
- override var songSort: Sort
- get() =
- Sort.fromIntCode(
- sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE))
- ?: Sort(Sort.Mode.ByName, true)
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_songs_sort), value.intCode)
- apply()
- }
- }
-
- override var albumSort: Sort
- get() =
- Sort.fromIntCode(
- sharedPreferences.getInt(
- getString(R.string.set_key_albums_sort), Int.MIN_VALUE))
- ?: Sort(Sort.Mode.ByName, true)
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_albums_sort), value.intCode)
- apply()
- }
- }
-
- override var artistSort: Sort
- get() =
- Sort.fromIntCode(
- sharedPreferences.getInt(
- getString(R.string.set_key_artists_sort), Int.MIN_VALUE))
- ?: Sort(Sort.Mode.ByName, true)
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_artists_sort), value.intCode)
- apply()
- }
- }
-
- override var genreSort: Sort
- get() =
- Sort.fromIntCode(
- sharedPreferences.getInt(
- getString(R.string.set_key_genres_sort), Int.MIN_VALUE))
- ?: Sort(Sort.Mode.ByName, true)
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_genres_sort), value.intCode)
- apply()
- }
- }
-
- override var albumSongSort: Sort
- get() {
- var sort =
- Sort.fromIntCode(
- sharedPreferences.getInt(
- getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE))
- ?: Sort(Sort.Mode.ByDisc, true)
-
- // Correct legacy album sort modes to Disc
- if (sort.mode is Sort.Mode.ByName) {
- sort = sort.withMode(Sort.Mode.ByDisc)
- }
-
- return sort
- }
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_album_songs_sort), value.intCode)
- apply()
- }
- }
-
- override var artistSongSort: Sort
- get() =
- Sort.fromIntCode(
- sharedPreferences.getInt(
- getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE))
- ?: Sort(Sort.Mode.ByDate, false)
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_artist_songs_sort), value.intCode)
- apply()
- }
- }
-
- override var genreSongSort: Sort
- get() =
- Sort.fromIntCode(
- sharedPreferences.getInt(
- getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE))
- ?: Sort(Sort.Mode.ByName, true)
- set(value) {
- sharedPreferences.edit {
- putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
- apply()
- }
- }
-
- override fun onSettingChanged(key: String, listener: Listener) {
- when (key) {
- 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) -> listener.onIndexingSettingChanged()
- getString(R.string.set_key_observing) -> listener.onObservingChanged()
+ override var musicDirs: MusicDirectories
+ get() {
+ val dirs =
+ (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
+ ?: emptySet())
+ .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) }
+ return MusicDirectories(
+ dirs,
+ sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
+ }
+ set(value) {
+ sharedPreferences.edit {
+ putStringSet(
+ getString(R.string.set_key_music_dirs),
+ value.dirs.map(Directory::toDocumentTreeUri).toSet())
+ putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
+ apply()
}
}
- }
- companion object {
- /**
- * Get a framework-backed implementation.
- * @param context [Context] required.
- */
- fun from(context: Context): MusicSettings = Real(context)
+ override val excludeNonMusic: Boolean
+ get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
+
+ override val shouldBeObserving: Boolean
+ get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
+
+ override var multiValueSeparators: String
+ // Differ from convention and store a string of separator characters instead of an int
+ // code. This makes it easier to use and more extendable.
+ get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: ""
+ set(value) {
+ sharedPreferences.edit {
+ putString(getString(R.string.set_key_separators), value)
+ apply()
+ }
+ }
+
+ override val automaticSortNames: Boolean
+ get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true)
+
+ override var songSort: Sort
+ get() =
+ Sort.fromIntCode(
+ sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE))
+ ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_songs_sort), value.intCode)
+ apply()
+ }
+ }
+
+ override var albumSort: Sort
+ get() =
+ Sort.fromIntCode(
+ sharedPreferences.getInt(getString(R.string.set_key_albums_sort), Int.MIN_VALUE))
+ ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_albums_sort), value.intCode)
+ apply()
+ }
+ }
+
+ override var artistSort: Sort
+ get() =
+ Sort.fromIntCode(
+ sharedPreferences.getInt(getString(R.string.set_key_artists_sort), Int.MIN_VALUE))
+ ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_artists_sort), value.intCode)
+ apply()
+ }
+ }
+
+ override var genreSort: Sort
+ get() =
+ Sort.fromIntCode(
+ sharedPreferences.getInt(getString(R.string.set_key_genres_sort), Int.MIN_VALUE))
+ ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_genres_sort), value.intCode)
+ apply()
+ }
+ }
+
+ override var albumSongSort: Sort
+ get() {
+ var sort =
+ Sort.fromIntCode(
+ sharedPreferences.getInt(
+ getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE))
+ ?: Sort(Sort.Mode.ByDisc, Sort.Direction.ASCENDING)
+
+ // Correct legacy album sort modes to Disc
+ if (sort.mode is Sort.Mode.ByName) {
+ sort = sort.withMode(Sort.Mode.ByDisc)
+ }
+
+ return sort
+ }
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_album_songs_sort), value.intCode)
+ apply()
+ }
+ }
+
+ override var artistSongSort: Sort
+ get() =
+ Sort.fromIntCode(
+ sharedPreferences.getInt(
+ getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE))
+ ?: Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_artist_songs_sort), value.intCode)
+ apply()
+ }
+ }
+
+ override var genreSongSort: Sort
+ get() =
+ Sort.fromIntCode(
+ sharedPreferences.getInt(
+ getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE))
+ ?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
+ set(value) {
+ sharedPreferences.edit {
+ putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
+ apply()
+ }
+ }
+
+ override fun onSettingChanged(key: String, listener: MusicSettings.Listener) {
+ // TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
+ // (just need to manipulate data)
+ when (key) {
+ 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) -> listener.onIndexingSettingChanged()
+ getString(R.string.set_key_observing) -> listener.onObservingChanged()
+ }
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
index 8df230e71..a8cae7af8 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt
@@ -18,6 +18,8 @@
package org.oxycblt.auxio.music
import androidx.lifecycle.ViewModel
+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.system.Indexer
@@ -26,8 +28,9 @@ import org.oxycblt.auxio.music.system.Indexer
* A [ViewModel] providing data specific to the music loading process.
* @author Alexander Capehart (OxygenCobalt)
*/
-class MusicViewModel : ViewModel(), Indexer.Listener {
- private val indexer = Indexer.getInstance()
+@HiltViewModel
+class MusicViewModel @Inject constructor(private val indexer: Indexer) :
+ ViewModel(), Indexer.Listener {
private val _indexerState = MutableStateFlow(null)
/** The current music loading state, or null if no loading is going on. */
diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
new file mode 100644
index 000000000..a82179730
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music.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.metadata.Date
+import org.oxycblt.auxio.music.metadata.correctWhitespace
+import org.oxycblt.auxio.music.metadata.splitEscaped
+import org.oxycblt.auxio.music.model.RawSong
+
+@Database(entities = [CachedSong::class], version = 27, exportSchema = false)
+abstract class CacheDatabase : RoomDatabase() {
+ abstract fun cachedSongsDao(): CachedSongsDao
+}
+
+@Dao
+interface CachedSongsDao {
+ @Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List
+ @Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs()
+ @Insert suspend fun insertSongs(songs: List)
+}
+
+@Entity(tableName = CachedSong.TABLE_NAME)
+@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.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.albumMusicBrainzId */
+ var albumMusicBrainzId: String? = null,
+ /** @see RawSong.albumName */
+ var albumName: String,
+ /** @see RawSong.albumSortName */
+ var albumSortName: String? = null,
+ /** @see RawSong.releaseTypes */
+ var releaseTypes: List = listOf(),
+ /** @see RawSong.artistMusicBrainzIds */
+ var artistMusicBrainzIds: List = listOf(),
+ /** @see RawSong.artistNames */
+ var artistNames: List = listOf(),
+ /** @see RawSong.artistSortNames */
+ var artistSortNames: List = listOf(),
+ /** @see RawSong.albumArtistMusicBrainzIds */
+ var albumArtistMusicBrainzIds: List = listOf(),
+ /** @see RawSong.albumArtistNames */
+ var albumArtistNames: List = listOf(),
+ /** @see RawSong.albumArtistSortNames */
+ var albumArtistSortNames: List = listOf(),
+ /** @see RawSong.genreNames */
+ var genreNames: List = listOf()
+) {
+ fun copyToRaw(rawSong: RawSong): CachedSong {
+ rawSong.musicBrainzId = musicBrainzId
+ rawSong.name = name
+ rawSong.sortName = sortName
+
+ rawSong.size = size
+ rawSong.durationMs = durationMs
+
+ rawSong.track = track
+ rawSong.disc = disc
+ rawSong.subtitle = subtitle
+ rawSong.date = date
+
+ 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
+ return this
+ }
+
+ object Converters {
+ @TypeConverter
+ fun fromMultiValue(values: List) =
+ 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 {
+ const val TABLE_NAME = "cached_songs"
+
+ 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" },
+ track = rawSong.track,
+ disc = rawSong.disc,
+ subtitle = rawSong.subtitle,
+ date = rawSong.date,
+ 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)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt
new file mode 100644
index 000000000..4dac9555d
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2023 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music.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
+
+@Module
+@InstallIn(SingletonComponent::class)
+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()
+ .fallbackToDestructiveMigrationFrom(0)
+ .fallbackToDestructiveMigrationOnDowngrade()
+ .build()
+
+ @Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao()
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
new file mode 100644
index 000000000..34b19a617
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.music.cache
+
+import javax.inject.Inject
+import org.oxycblt.auxio.music.model.RawSong
+import org.oxycblt.auxio.util.*
+
+/**
+ * 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)
+}
+
+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.
+ CacheImpl(cachedSongsDao.readSongs())
+ } catch (e: Exception) {
+ logE("Unable to load cache database.")
+ logE(e.stackTraceToString())
+ null
+ }
+
+ override suspend fun writeCache(rawSongs: List) {
+ try {
+ // Still write out whatever data was extracted.
+ cachedSongsDao.nukeSongs()
+ cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
+ } catch (e: Exception) {
+ logE("Unable to save cache database.")
+ logE(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) : 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
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
deleted file mode 100644
index 94531c376..000000000
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/CacheExtractor.kt
+++ /dev/null
@@ -1,468 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.oxycblt.auxio.music.extractor
-
-import android.content.ContentValues
-import android.content.Context
-import android.database.sqlite.SQLiteDatabase
-import android.database.sqlite.SQLiteOpenHelper
-import androidx.core.database.getIntOrNull
-import androidx.core.database.getStringOrNull
-import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.parsing.correctWhitespace
-import org.oxycblt.auxio.music.parsing.splitEscaped
-import org.oxycblt.auxio.music.tags.Date
-import org.oxycblt.auxio.util.*
-
-/**
- * Defines an Extractor that can load cached music. This is the first step in the music extraction
- * process and is an optimization to avoid the slow [MediaStoreExtractor] and [MetadataExtractor]
- * extraction process.
- * @author Alexander Capehart (OxygenCobalt)
- */
-interface CacheExtractor {
- /** Initialize the Extractor by reading the cache data into memory. */
- fun init()
-
- /**
- * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
- * freeing up memory.
- * @param rawSongs The songs to write into the cache.
- */
- fun finalize(rawSongs: List)
-
- /**
- * Use the cache to populate the given [Song.Raw].
- * @param rawSong The [Song.Raw] to attempt to populate. Note that this [Song.Raw] will only
- * contain the bare minimum information required to load a cache entry.
- * @return An [ExtractionResult] representing the result of the operation.
- * [ExtractionResult.PARSED] is not returned.
- */
- fun populate(rawSong: Song.Raw): ExtractionResult
-}
-
-/**
- * A [CacheExtractor] only capable of writing to the cache. This can be used to load music with
- * without the cache if the user desires.
- * @param context [Context] required to read the cache database.
- * @see CacheExtractor
- * @author Alexander Capehart (OxygenCobalt)
- */
-open class WriteOnlyCacheExtractor(private val context: Context) : CacheExtractor {
- override fun init() {
- // Nothing to do.
- }
-
- override fun finalize(rawSongs: List) {
- try {
- // Still write out whatever data was extracted.
- CacheDatabase.getInstance(context).write(rawSongs)
- } catch (e: Exception) {
- logE("Unable to save cache database.")
- logE(e.stackTraceToString())
- }
- }
-
- override fun populate(rawSong: Song.Raw) =
- // Nothing to do.
- ExtractionResult.NONE
-}
-
-/**
- * A [CacheExtractor] that supports reading from and writing to the cache.
- * @param context [Context] required to load
- * @see CacheExtractor
- * @author Alexander Capehart
- */
-class ReadWriteCacheExtractor(private val context: Context) : WriteOnlyCacheExtractor(context) {
- private var cacheMap: Map? = null
- private var invalidate = false
-
- override fun init() {
- try {
- // Faster to load the whole database into memory than do a query on each
- // populate call.
- cacheMap = CacheDatabase.getInstance(context).read()
- } catch (e: Exception) {
- logE("Unable to load cache database.")
- logE(e.stackTraceToString())
- }
- }
-
- override fun finalize(rawSongs: List) {
- cacheMap = null
- // Same some time by not re-writing the cache if we were able to create the entire
- // library from it. If there is even just one song we could not populate from the
- // cache, then we will re-write it.
- if (invalidate) {
- logD("Cache was invalidated during loading, rewriting")
- super.finalize(rawSongs)
- }
- }
-
- override fun populate(rawSong: Song.Raw): ExtractionResult {
- val map = cacheMap ?: return ExtractionResult.NONE
-
- // For a cached raw song to be used, it must exist within the cache and have matching
- // addition and modification timestamps. Technically the addition timestamp doesn't
- // exist, but to safeguard against possible OEM-specific timestamp incoherence, we
- // check for it anyway.
- val cachedRawSong = map[rawSong.mediaStoreId]
- if (cachedRawSong != null &&
- cachedRawSong.dateAdded == rawSong.dateAdded &&
- cachedRawSong.dateModified == rawSong.dateModified) {
- // No built-in "copy from" method for data classes, just have to assign
- // the data ourselves.
- rawSong.musicBrainzId = cachedRawSong.musicBrainzId
- rawSong.name = cachedRawSong.name
- rawSong.sortName = cachedRawSong.sortName
-
- rawSong.size = cachedRawSong.size
- rawSong.durationMs = cachedRawSong.durationMs
-
- rawSong.track = cachedRawSong.track
- rawSong.disc = cachedRawSong.disc
- rawSong.date = cachedRawSong.date
-
- rawSong.albumMusicBrainzId = cachedRawSong.albumMusicBrainzId
- rawSong.albumName = cachedRawSong.albumName
- rawSong.albumSortName = cachedRawSong.albumSortName
- rawSong.releaseTypes = cachedRawSong.releaseTypes
-
- rawSong.artistMusicBrainzIds = cachedRawSong.artistMusicBrainzIds
- rawSong.artistNames = cachedRawSong.artistNames
- rawSong.artistSortNames = cachedRawSong.artistSortNames
-
- rawSong.albumArtistMusicBrainzIds = cachedRawSong.albumArtistMusicBrainzIds
- rawSong.albumArtistNames = cachedRawSong.albumArtistNames
- rawSong.albumArtistSortNames = cachedRawSong.albumArtistSortNames
-
- rawSong.genreNames = cachedRawSong.genreNames
-
- return ExtractionResult.CACHED
- }
-
- // We could not populate this song. This means our cache is stale and should be
- // re-written with newly-loaded music.
- invalidate = true
- return ExtractionResult.NONE
- }
-}
-
-/**
- * Internal [Song.Raw] cache database.
- * @author Alexander Capehart (OxygenCobalt)
- * @see [CacheExtractor]
- */
-private class CacheDatabase(context: Context) :
- SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
- override fun onCreate(db: SQLiteDatabase) {
- // Map the cacheable raw song fields to database fields. Cache-able in this context
- // means information independent of the file-system, excluding IDs and timestamps required
- // to retrieve items from the cache.
- db.createTable(TABLE_RAW_SONGS) {
- append("${Columns.MEDIA_STORE_ID} LONG PRIMARY KEY,")
- append("${Columns.DATE_ADDED} LONG NOT NULL,")
- append("${Columns.DATE_MODIFIED} LONG NOT NULL,")
- append("${Columns.SIZE} LONG NOT NULL,")
- append("${Columns.DURATION} LONG NOT NULL,")
- append("${Columns.MUSIC_BRAINZ_ID} STRING,")
- append("${Columns.NAME} STRING NOT NULL,")
- append("${Columns.SORT_NAME} STRING,")
- append("${Columns.TRACK} INT,")
- append("${Columns.DISC} INT,")
- append("${Columns.DATE} STRING,")
- append("${Columns.ALBUM_MUSIC_BRAINZ_ID} STRING,")
- append("${Columns.ALBUM_NAME} STRING NOT NULL,")
- append("${Columns.ALBUM_SORT_NAME} STRING,")
- append("${Columns.RELEASE_TYPES} STRING,")
- append("${Columns.ARTIST_MUSIC_BRAINZ_IDS} STRING,")
- append("${Columns.ARTIST_NAMES} STRING,")
- append("${Columns.ARTIST_SORT_NAMES} STRING,")
- append("${Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS} STRING,")
- append("${Columns.ALBUM_ARTIST_NAMES} STRING,")
- append("${Columns.ALBUM_ARTIST_SORT_NAMES} STRING,")
- append("${Columns.GENRE_NAMES} STRING")
- }
- }
-
- override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
-
- override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = nuke(db)
-
- private fun nuke(db: SQLiteDatabase) {
- // No cost to nuking this database, only causes higher loading times.
- logD("Nuking database")
- db.apply {
- execSQL("DROP TABLE IF EXISTS $TABLE_RAW_SONGS")
- onCreate(this)
- }
- }
-
- /**
- * Read out this database into memory.
- * @return A mapping between the MediaStore IDs of the cache entries and a [Song.Raw] containing
- * the cacheable data for the entry. Note that any filesystem-dependent information (excluding
- * IDs and timestamps) is not cached.
- */
- fun read(): Map {
- requireBackgroundThread()
- val start = System.currentTimeMillis()
- val map = mutableMapOf()
- readableDatabase.queryAll(TABLE_RAW_SONGS) { cursor ->
- if (cursor.count == 0) {
- // Nothing to do.
- return@queryAll
- }
-
- val idIndex = cursor.getColumnIndexOrThrow(Columns.MEDIA_STORE_ID)
- val dateAddedIndex = cursor.getColumnIndexOrThrow(Columns.DATE_ADDED)
- val dateModifiedIndex = cursor.getColumnIndexOrThrow(Columns.DATE_MODIFIED)
-
- val sizeIndex = cursor.getColumnIndexOrThrow(Columns.SIZE)
- val durationIndex = cursor.getColumnIndexOrThrow(Columns.DURATION)
-
- val musicBrainzIdIndex = cursor.getColumnIndexOrThrow(Columns.MUSIC_BRAINZ_ID)
- val nameIndex = cursor.getColumnIndexOrThrow(Columns.NAME)
- val sortNameIndex = cursor.getColumnIndexOrThrow(Columns.SORT_NAME)
-
- val trackIndex = cursor.getColumnIndexOrThrow(Columns.TRACK)
- val discIndex = cursor.getColumnIndexOrThrow(Columns.DISC)
- val dateIndex = cursor.getColumnIndexOrThrow(Columns.DATE)
-
- val albumMusicBrainzIdIndex =
- cursor.getColumnIndexOrThrow(Columns.ALBUM_MUSIC_BRAINZ_ID)
- val albumNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_NAME)
- val albumSortNameIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_SORT_NAME)
- val releaseTypesIndex = cursor.getColumnIndexOrThrow(Columns.RELEASE_TYPES)
-
- val artistMusicBrainzIdsIndex =
- cursor.getColumnIndexOrThrow(Columns.ARTIST_MUSIC_BRAINZ_IDS)
- val artistNamesIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_NAMES)
- val artistSortNamesIndex = cursor.getColumnIndexOrThrow(Columns.ARTIST_SORT_NAMES)
-
- val albumArtistMusicBrainzIdsIndex =
- cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS)
- val albumArtistNamesIndex = cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_NAMES)
- val albumArtistSortNamesIndex =
- cursor.getColumnIndexOrThrow(Columns.ALBUM_ARTIST_SORT_NAMES)
-
- val genresIndex = cursor.getColumnIndexOrThrow(Columns.GENRE_NAMES)
-
- while (cursor.moveToNext()) {
- val raw = Song.Raw()
- val id = cursor.getLong(idIndex)
-
- raw.mediaStoreId = id
- raw.dateAdded = cursor.getLong(dateAddedIndex)
- raw.dateModified = cursor.getLong(dateModifiedIndex)
-
- raw.size = cursor.getLong(sizeIndex)
- raw.durationMs = cursor.getLong(durationIndex)
-
- raw.musicBrainzId = cursor.getStringOrNull(musicBrainzIdIndex)
- raw.name = cursor.getString(nameIndex)
- raw.sortName = cursor.getStringOrNull(sortNameIndex)
-
- raw.track = cursor.getIntOrNull(trackIndex)
- raw.disc = cursor.getIntOrNull(discIndex)
- raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from)
-
- raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
- raw.albumName = cursor.getString(albumNameIndex)
- raw.albumSortName = cursor.getStringOrNull(albumSortNameIndex)
- cursor.getStringOrNull(releaseTypesIndex)?.let {
- raw.releaseTypes = it.parseSQLMultiValue()
- }
-
- cursor.getStringOrNull(artistMusicBrainzIdsIndex)?.let {
- raw.artistMusicBrainzIds = it.parseSQLMultiValue()
- }
- cursor.getStringOrNull(artistNamesIndex)?.let {
- raw.artistNames = it.parseSQLMultiValue()
- }
- cursor.getStringOrNull(artistSortNamesIndex)?.let {
- raw.artistSortNames = it.parseSQLMultiValue()
- }
-
- cursor.getStringOrNull(albumArtistMusicBrainzIdsIndex)?.let {
- raw.albumArtistMusicBrainzIds = it.parseSQLMultiValue()
- }
- cursor.getStringOrNull(albumArtistNamesIndex)?.let {
- raw.albumArtistNames = it.parseSQLMultiValue()
- }
- cursor.getStringOrNull(albumArtistSortNamesIndex)?.let {
- raw.albumArtistSortNames = it.parseSQLMultiValue()
- }
-
- cursor.getStringOrNull(genresIndex)?.let {
- raw.genreNames = it.parseSQLMultiValue()
- }
-
- map[id] = raw
- }
- }
-
- logD("Read cache in ${System.currentTimeMillis() - start}ms")
-
- return map
- }
-
- /**
- * Write a new list of [Song.Raw] to this database.
- * @param rawSongs The new [Song.Raw] instances to cache. Note that any filesystem-dependent
- * information (excluding IDs and timestamps) is not cached.
- */
- fun write(rawSongs: List) {
- val start = System.currentTimeMillis()
-
- writableDatabase.writeList(rawSongs, TABLE_RAW_SONGS) { _, rawSong ->
- ContentValues(22).apply {
- put(Columns.MEDIA_STORE_ID, rawSong.mediaStoreId)
- put(Columns.DATE_ADDED, rawSong.dateAdded)
- put(Columns.DATE_MODIFIED, rawSong.dateModified)
-
- put(Columns.SIZE, rawSong.size)
- put(Columns.DURATION, rawSong.durationMs)
-
- put(Columns.MUSIC_BRAINZ_ID, rawSong.musicBrainzId)
- put(Columns.NAME, rawSong.name)
- put(Columns.SORT_NAME, rawSong.sortName)
-
- put(Columns.TRACK, rawSong.track)
- put(Columns.DISC, rawSong.disc)
- put(Columns.DATE, rawSong.date?.toString())
-
- put(Columns.ALBUM_MUSIC_BRAINZ_ID, rawSong.albumMusicBrainzId)
- put(Columns.ALBUM_NAME, rawSong.albumName)
- put(Columns.ALBUM_SORT_NAME, rawSong.albumSortName)
- put(Columns.RELEASE_TYPES, rawSong.releaseTypes.toSQLMultiValue())
-
- put(Columns.ARTIST_MUSIC_BRAINZ_IDS, rawSong.artistMusicBrainzIds.toSQLMultiValue())
- put(Columns.ARTIST_NAMES, rawSong.artistNames.toSQLMultiValue())
- put(Columns.ARTIST_SORT_NAMES, rawSong.artistSortNames.toSQLMultiValue())
-
- put(
- Columns.ALBUM_ARTIST_MUSIC_BRAINZ_IDS,
- rawSong.albumArtistMusicBrainzIds.toSQLMultiValue())
- put(Columns.ALBUM_ARTIST_NAMES, rawSong.albumArtistNames.toSQLMultiValue())
- put(Columns.ALBUM_ARTIST_SORT_NAMES, rawSong.albumArtistSortNames.toSQLMultiValue())
-
- put(Columns.GENRE_NAMES, rawSong.genreNames.toSQLMultiValue())
- }
- }
-
- logD("Wrote cache in ${System.currentTimeMillis() - start}ms")
- }
-
- // SQLite does not natively support multiple values, so we have to serialize multi-value
- // tags with separators. Not ideal, but nothing we can do.
-
- /**
- * Transforms the multi-string list into a SQL-safe multi-string value.
- * @return A single string containing all values within the multi-string list, delimited by a
- * ";". Pre-existing ";" characters will be escaped.
- */
- private fun List.toSQLMultiValue() =
- if (isNotEmpty()) {
- joinToString(";") { it.replace(";", "\\;") }
- } else {
- null
- }
-
- /**
- * Transforms the SQL-safe multi-string value into a multi-string list.
- * @return A list of strings corresponding to the delimited values present within the original
- * string. Escaped delimiters are converted back into their normal forms.
- */
- private fun String.parseSQLMultiValue() = splitEscaped { it == ';' }.correctWhitespace()
-
- /** Defines the columns used in this database. */
- private object Columns {
- /** @see Song.Raw.mediaStoreId */
- const val MEDIA_STORE_ID = "msid"
- /** @see Song.Raw.dateAdded */
- const val DATE_ADDED = "date_added"
- /** @see Song.Raw.dateModified */
- const val DATE_MODIFIED = "date_modified"
- /** @see Song.Raw.size */
- const val SIZE = "size"
- /** @see Song.Raw.durationMs */
- const val DURATION = "duration"
- /** @see Song.Raw.musicBrainzId */
- const val MUSIC_BRAINZ_ID = "mbid"
- /** @see Song.Raw.name */
- const val NAME = "name"
- /** @see Song.Raw.sortName */
- const val SORT_NAME = "sort_name"
- /** @see Song.Raw.track */
- const val TRACK = "track"
- /** @see Song.Raw.disc */
- const val DISC = "disc"
- /** @see Song.Raw.date */
- const val DATE = "date"
- /** @see Song.Raw.albumMusicBrainzId */
- const val ALBUM_MUSIC_BRAINZ_ID = "album_mbid"
- /** @see Song.Raw.albumName */
- const val ALBUM_NAME = "album"
- /** @see Song.Raw.albumSortName */
- const val ALBUM_SORT_NAME = "album_sort"
- /** @see Song.Raw.releaseTypes */
- const val RELEASE_TYPES = "album_types"
- /** @see Song.Raw.artistMusicBrainzIds */
- const val ARTIST_MUSIC_BRAINZ_IDS = "artists_mbid"
- /** @see Song.Raw.artistNames */
- const val ARTIST_NAMES = "artists"
- /** @see Song.Raw.artistSortNames */
- const val ARTIST_SORT_NAMES = "artists_sort"
- /** @see Song.Raw.albumArtistMusicBrainzIds */
- const val ALBUM_ARTIST_MUSIC_BRAINZ_IDS = "album_artists_mbid"
- /** @see Song.Raw.albumArtistNames */
- const val ALBUM_ARTIST_NAMES = "album_artists"
- /** @see Song.Raw.albumArtistSortNames */
- const val ALBUM_ARTIST_SORT_NAMES = "album_artists_sort"
- /** @see Song.Raw.genreNames */
- const val GENRE_NAMES = "genres"
- }
-
- companion object {
- private const val DB_NAME = "auxio_music_cache.db"
- private const val DB_VERSION = 2
- private const val TABLE_RAW_SONGS = "raw_songs"
-
- @Volatile private var INSTANCE: CacheDatabase? = null
-
- /**
- * Get a singleton instance.
- * @return The (possibly newly-created) singleton instance.
- */
- fun getInstance(context: Context): CacheDatabase {
- val currentInstance = INSTANCE
-
- if (currentInstance != null) {
- return currentInstance
- }
-
- synchronized(this) {
- val newInstance = CacheDatabase(context.applicationContext)
- INSTANCE = newInstance
- return newInstance
- }
- }
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
deleted file mode 100644
index 0145222aa..000000000
--- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt
+++ /dev/null
@@ -1,587 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.oxycblt.auxio.music.extractor
-
-import android.content.Context
-import android.database.Cursor
-import android.os.Build
-import android.os.storage.StorageManager
-import android.os.storage.StorageVolume
-import android.provider.MediaStore
-import androidx.annotation.RequiresApi
-import androidx.core.database.getIntOrNull
-import androidx.core.database.getStringOrNull
-import java.io.File
-import org.oxycblt.auxio.music.MusicSettings
-import org.oxycblt.auxio.music.Song
-import org.oxycblt.auxio.music.parsing.parseId3v2Position
-import org.oxycblt.auxio.music.storage.Directory
-import org.oxycblt.auxio.music.storage.contentResolverSafe
-import org.oxycblt.auxio.music.storage.directoryCompat
-import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
-import org.oxycblt.auxio.music.storage.safeQuery
-import org.oxycblt.auxio.music.storage.storageVolumesCompat
-import org.oxycblt.auxio.music.storage.useQuery
-import org.oxycblt.auxio.music.tags.Date
-import org.oxycblt.auxio.util.getSystemServiceCompat
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.nonZeroOrNull
-
-/**
- * The layer that loads music from the [MediaStore] database. This is an intermediate step in the
- * music extraction process and primarily intended for redundancy for files not natively supported
- * by [MetadataExtractor]. Solely relying on this is not recommended, as it often produces bad
- * metadata.
- * @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
- * @author Alexander Capehart (OxygenCobalt)
- */
-abstract class MediaStoreExtractor(
- private val context: Context,
- private val cacheExtractor: CacheExtractor
-) {
- private var cursor: Cursor? = null
- private var idIndex = -1
- private var titleIndex = -1
- private var displayNameIndex = -1
- private var mimeTypeIndex = -1
- private var sizeIndex = -1
- private var dateAddedIndex = -1
- private var dateModifiedIndex = -1
- private var durationIndex = -1
- private var yearIndex = -1
- private var albumIndex = -1
- private var albumIdIndex = -1
- private var artistIndex = -1
- private var albumArtistIndex = -1
- private val genreNamesMap = mutableMapOf()
-
- /**
- * The [StorageVolume]s currently scanned by [MediaStore]. This should be used to transform path
- * information from the database into volume-aware paths.
- */
- protected var volumes = listOf()
- private set
-
- /**
- * Initialize this instance. This involves setting up the required sub-extractors and querying
- * the media database for music files.
- * @return A [Cursor] of the music data returned from the database.
- */
- open fun init(): Cursor {
- val start = System.currentTimeMillis()
- cacheExtractor.init()
- val musicSettings = MusicSettings.from(context)
- val storageManager = context.getSystemServiceCompat(StorageManager::class)
-
- val args = mutableListOf()
- var selector = BASE_SELECTOR
-
- // Filter out audio that is not music, if enabled.
- if (musicSettings.excludeNonMusic) {
- logD("Excluding non-music")
- selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
- }
-
- // Set up the projection to follow the music directory configuration.
- val dirs = musicSettings.musicDirs
- if (dirs.dirs.isNotEmpty()) {
- selector += " AND "
- if (!dirs.shouldInclude) {
- // Without a NOT, the query will be restricted to the specified paths, resulting
- // in the "Include" mode. With a NOT, the specified paths will not be included,
- // resulting in the "Exclude" mode.
- selector += "NOT "
- }
- selector += " ("
-
- // Specifying the paths to filter is version-specific, delegate to the concrete
- // implementations.
- for (i in dirs.dirs.indices) {
- if (addDirToSelector(dirs.dirs[i], args)) {
- selector +=
- if (i < dirs.dirs.lastIndex) {
- "$dirSelectorTemplate OR "
- } else {
- dirSelectorTemplate
- }
- }
- }
-
- selector += ')'
- }
-
- // Now we can actually query MediaStore.
- logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]")
- val cursor =
- context.contentResolverSafe
- .safeQuery(
- MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
- projection,
- selector,
- args.toTypedArray())
- .also { cursor = it }
- logD("Song query succeeded [Projected total: ${cursor.count}]")
-
- // Set up cursor indices for later use.
- idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
- titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
- displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
- mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
- sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
- dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
- dateModifiedIndex =
- cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED)
- durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
- yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
- albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
- albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
- artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
- albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
-
- // Since we can't obtain the genre tag from a song query, we must construct our own
- // equivalent from genre database queries. Theoretically, this isn't needed since
- // MetadataLayer will fill this in for us, but I'd imagine there are some obscure
- // formats where genre support is only really covered by this, so we are forced to
- // bite the O(n^2) complexity here.
- context.contentResolverSafe.useQuery(
- MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
- arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
- val idIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
- val nameIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
-
- while (genreCursor.moveToNext()) {
- val id = genreCursor.getLong(idIndex)
- val name = genreCursor.getStringOrNull(nameIndex) ?: continue
-
- context.contentResolverSafe.useQuery(
- MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
- arrayOf(MediaStore.Audio.Genres.Members._ID)) { cursor ->
- val songIdIndex =
- cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
-
- while (cursor.moveToNext()) {
- // Assume that a song can't inhabit multiple genre entries, as I doubt
- // MediaStore is actually aware that songs can have multiple genres.
- genreNamesMap[cursor.getLong(songIdIndex)] = name
- }
- }
- }
- }
-
- volumes = storageManager.storageVolumesCompat
- logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
-
- return cursor
- }
-
- /**
- * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside
- * freeing up memory.
- * @param rawSongs The songs to write into the cache.
- */
- fun finalize(rawSongs: List) {
- // Free the cursor (and it's resources)
- cursor?.close()
- cursor = null
- cacheExtractor.finalize(rawSongs)
- }
-
- /**
- * Populate a [Song.Raw] with the next [Cursor] value provided by [MediaStore].
- * @param raw The [Song.Raw] to populate.
- * @return An [ExtractionResult] signifying the result of the operation. Will return
- * [ExtractionResult.CACHED] if [CacheExtractor] returned it.
- */
- fun populate(raw: Song.Raw): ExtractionResult {
- val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
- // Move to the next cursor, stopping if we have exhausted it.
- if (!cursor.moveToNext()) {
- logD("Cursor is exhausted")
- return ExtractionResult.NONE
- }
-
- // Populate the minimum required columns to maybe obtain a cache entry.
- populateFileData(cursor, raw)
- if (cacheExtractor.populate(raw) == ExtractionResult.CACHED) {
- // We found a valid cache entry, no need to fully read the entry.
- return ExtractionResult.CACHED
- }
-
- // Could not load entry from cache, we have to read the rest of the metadata.
- populateMetadata(cursor, raw)
- return ExtractionResult.PARSED
- }
-
- /**
- * The database columns available to all android versions supported by Auxio. Concrete
- * implementations can extend this projection to add version-specific columns.
- */
- protected open val projection: Array
- get() =
- arrayOf(
- // These columns are guaranteed to work on all versions of android
- MediaStore.Audio.AudioColumns._ID,
- MediaStore.Audio.AudioColumns.DATE_ADDED,
- MediaStore.Audio.AudioColumns.DATE_MODIFIED,
- MediaStore.Audio.AudioColumns.DISPLAY_NAME,
- MediaStore.Audio.AudioColumns.SIZE,
- MediaStore.Audio.AudioColumns.DURATION,
- MediaStore.Audio.AudioColumns.MIME_TYPE,
- MediaStore.Audio.AudioColumns.TITLE,
- MediaStore.Audio.AudioColumns.YEAR,
- MediaStore.Audio.AudioColumns.ALBUM,
- MediaStore.Audio.AudioColumns.ALBUM_ID,
- MediaStore.Audio.AudioColumns.ARTIST,
- AUDIO_COLUMN_ALBUM_ARTIST)
-
- /**
- * The companion template to add to the projection's selector whenever arguments are added by
- * [addDirToSelector].
- * @see addDirToSelector
- */
- protected abstract val dirSelectorTemplate: String
-
- /**
- * Add a [Directory] to the given list of projection selector arguments.
- * @param dir The [Directory] to add.
- * @param args The destination list to append selector arguments to that are analogous to the
- * given [Directory].
- * @return true if the [Directory] was added, false otherwise.
- * @see dirSelectorTemplate
- */
- protected abstract fun addDirToSelector(dir: Directory, args: MutableList): Boolean
-
- /**
- * Populate a [Song.Raw] with the "File Data" of the given [MediaStore] [Cursor], which is the
- * data that cannot be cached. This includes any information not intrinsic to the file and
- * instead dependent on the file-system, which could change without invalidating the cache due
- * to volume additions or removals.
- * @param cursor The [Cursor] to read from.
- * @param raw The [Song.Raw] to populate.
- * @see populateMetadata
- */
- protected open fun populateFileData(cursor: Cursor, raw: Song.Raw) {
- raw.mediaStoreId = cursor.getLong(idIndex)
- raw.dateAdded = cursor.getLong(dateAddedIndex)
- raw.dateModified = cursor.getLong(dateAddedIndex)
- // Try to use the DISPLAY_NAME column to obtain a (probably sane) file name
- // from the android system.
- raw.fileName = cursor.getStringOrNull(displayNameIndex)
- raw.extensionMimeType = cursor.getString(mimeTypeIndex)
- raw.albumMediaStoreId = cursor.getLong(albumIdIndex)
- }
-
- /**
- * Populate a [Song.Raw] with the Metadata of the given [MediaStore] [Cursor], which is the data
- * about a [Song.Raw] that can be cached. This includes any information intrinsic to the file or
- * it's file format, such as music tags.
- * @param cursor The [Cursor] to read from.
- * @param raw The [Song.Raw] to populate.
- * @see populateFileData
- */
- protected open fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
- // Song title
- raw.name = cursor.getString(titleIndex)
- // Size (in bytes)
- raw.size = cursor.getLong(sizeIndex)
- // Duration (in milliseconds)
- raw.durationMs = cursor.getLong(durationIndex)
- // MediaStore only exposes the year value of a file. This is actually worse than it
- // seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
- // This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
- raw.date = cursor.getStringOrNull(yearIndex)?.let(Date::from)
- // A non-existent album name should theoretically be the name of the folder it contained
- // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
- // file is not actually in the root internal storage directory. We can't do anything to
- // fix this, really.
- raw.albumName = cursor.getString(albumIndex)
- // Android does not make a non-existent artist tag null, it instead fills it in
- // as , which makes absolutely no sense given how other columns default
- // to null if they are not present. If this column is such, null it so that
- // it's easier to handle later.
- val artist = cursor.getString(artistIndex)
- if (artist != MediaStore.UNKNOWN_STRING) {
- raw.artistNames = listOf(artist)
- }
- // The album artist column is nullable and never has placeholder values.
- cursor.getStringOrNull(albumArtistIndex)?.let { raw.albumArtistNames = listOf(it) }
- // Get the genre value we had to query for in initialization
- genreNamesMap[raw.mediaStoreId]?.let { raw.genreNames = listOf(it) }
- }
-
- private companion object {
- /**
- * The base selector that works across all versions of android. Does not exclude
- * directories.
- */
- const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
-
- /**
- * The album artist of a song. This column has existed since at least API 21, but until API
- * 30 it was an undocumented extension for Google Play Music. This column will work on all
- * versions that Auxio supports.
- */
- @Suppress("InlinedApi")
- const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
-
- /**
- * The external volume. This naming has existed since API 21, but no constant existed for it
- * until API 29. This will work on all versions that Auxio supports.
- */
- @Suppress("InlinedApi") const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
- }
-}
-
-// Note: The separation between version-specific backends may not be the cleanest. To preserve
-// speed, we only want to add redundancy on known issues, not with possible issues.
-
-/**
- * A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 21
- * onwards to API 28.
- * @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
- * @author Alexander Capehart (OxygenCobalt)
- */
-class Api21MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
- MediaStoreExtractor(context, cacheExtractor) {
- private var trackIndex = -1
- private var dataIndex = -1
-
- override fun init(): Cursor {
- val cursor = super.init()
- // Set up cursor indices for later use.
- trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
- dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
- return cursor
- }
-
- override val projection: Array
- get() =
- super.projection +
- arrayOf(
- MediaStore.Audio.AudioColumns.TRACK,
- // Below API 29, we are restricted to the absolute path (Called DATA by
- // MedaStore) when working with audio files.
- MediaStore.Audio.AudioColumns.DATA)
-
- // The selector should be configured to convert the given directories instances to their
- // absolute paths and then compare them to DATA.
-
- override val dirSelectorTemplate: String
- get() = "${MediaStore.Audio.Media.DATA} LIKE ?"
-
- override fun addDirToSelector(dir: Directory, args: MutableList): Boolean {
- // "%" signifies to accept any DATA value that begins with the Directory's path,
- // thus recursively filtering all files in the directory.
- args.add("${dir.volume.directoryCompat ?: return false}/${dir.relativePath}%")
- return true
- }
-
- override fun populateFileData(cursor: Cursor, raw: Song.Raw) {
- super.populateFileData(cursor, raw)
-
- val data = cursor.getString(dataIndex)
-
- // On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
- // that this only applies to below API 29, as beyond API 29, this column not being
- // present would completely break the scoped storage system. Fill it in with DATA
- // if it's not available.
- if (raw.fileName == null) {
- raw.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
- }
-
- // Find the volume that transforms the DATA column into a relative path. This is
- // the Directory we will use.
- val rawPath = data.substringBeforeLast(File.separatorChar)
- for (volume in volumes) {
- val volumePath = volume.directoryCompat ?: continue
- val strippedPath = rawPath.removePrefix(volumePath)
- if (strippedPath != rawPath) {
- raw.directory = Directory.from(volume, strippedPath)
- break
- }
- }
- }
-
- override fun populateMetadata(cursor: Cursor, raw: Song.Raw) {
- super.populateMetadata(cursor, raw)
- // See unpackTrackNo/unpackDiscNo for an explanation
- // of how this column is set up.
- val rawTrack = cursor.getIntOrNull(trackIndex)
- if (rawTrack != null) {
- rawTrack.unpackTrackNo()?.let { raw.track = it }
- rawTrack.unpackDiscNo()?.let { raw.disc = it }
- }
- }
-}
-
-/**
- * A [MediaStoreExtractor] that implements common behavior supported from API 29 onwards.
- * @param context [Context] required to query the media database.
- * @param cacheExtractor [CacheExtractor] implementation for cache optimizations.
- * @author Alexander Capehart (OxygenCobalt)
- */
-@RequiresApi(Build.VERSION_CODES.Q)
-open class BaseApi29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) :
- MediaStoreExtractor(context, cacheExtractor) {
- private var volumeIndex = -1
- private var relativePathIndex = -1
-
- override fun init(): Cursor {
- val cursor = super.init()
- // Set up cursor indices for later use.
- volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
- relativePathIndex =
- cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
- return cursor
- }
-
- override val projection: Array
- get() =
- super.projection +
- arrayOf(
- // After API 29, we now have access to the volume name and relative
- // path, which simplifies working with Paths significantly.
- MediaStore.Audio.AudioColumns.VOLUME_NAME,
- MediaStore.Audio.AudioColumns.RELATIVE_PATH)
-
- // The selector should be configured to compare both the volume name and relative path
- // of the given directories, albeit with some conversion to the analogous MediaStore
- // column values.
-
- override val dirSelectorTemplate: String
- get() =
- "(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
- "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
-
- override fun addDirToSelector(dir: Directory, args: MutableList