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.

- - Latest Version + + Latest Version Releases @@ -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): Boolean { - // MediaStore uses a different naming scheme for it's volume column convert this - // directory's volume to it. - args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false) - // "%" signifies to accept any DATA value that begins with the Directory's path, - // thus recursively filtering all files in the directory. - args.add("${dir.relativePath}%") - return true - } - - override fun populateFileData(cursor: Cursor, raw: Song.Raw) { - super.populateFileData(cursor, raw) - // Find the StorageVolume whose MediaStore name corresponds to this song. - // This is combined with the plain relative path column to create the directory. - val volumeName = cursor.getString(volumeIndex) - val relativePath = cursor.getString(relativePathIndex) - val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } - if (volume != null) { - raw.directory = Directory.from(volume, relativePath) - } - } -} - -/** - * A [MediaStoreExtractor] that completes the music loading process in a way compatible with at API - * 29. - * @param context [Context] required to query the media database. - * @param cacheExtractor [CacheExtractor] implementation for cache functionality. - * @author Alexander Capehart (OxygenCobalt) - */ -@RequiresApi(Build.VERSION_CODES.Q) -open class Api29MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) : - BaseApi29MediaStoreExtractor(context, cacheExtractor) { - private var trackIndex = -1 - - override fun init(): Cursor { - val cursor = super.init() - // Set up cursor indices for later use. - trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) - return cursor - } - - override val projection: Array - get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) - - override fun populateMetadata(cursor: Cursor, raw: Song.Raw) { - super.populateMetadata(cursor, raw) - // This extractor is volume-aware, but does not support the modern track columns. - // Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation - // of how this column is set up. - val rawTrack = cursor.getIntOrNull(trackIndex) - if (rawTrack != null) { - rawTrack.unpackTrackNo()?.let { raw.track = it } - rawTrack.unpackDiscNo()?.let { raw.disc = it } - } - } -} - -/** - * A [MediaStoreExtractor] that completes the music loading process in a way compatible from API 30 - * onwards. - * @param context [Context] required to query the media database. - * @param cacheExtractor [CacheExtractor] implementation for cache optimizations. - * @author Alexander Capehart (OxygenCobalt) - */ -@RequiresApi(Build.VERSION_CODES.R) -class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor) : - BaseApi29MediaStoreExtractor(context, cacheExtractor) { - private var trackIndex: Int = -1 - private var discIndex: Int = -1 - - override fun init(): Cursor { - val cursor = super.init() - // Set up cursor indices for later use. - trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) - discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER) - return cursor - } - - override val projection: Array - get() = - super.projection + - arrayOf( - // API 30 grant us access to the superior CD_TRACK_NUMBER and DISC_NUMBER - // fields, which take the place of TRACK. - MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, - MediaStore.Audio.AudioColumns.DISC_NUMBER) - - override fun populateMetadata(cursor: Cursor, raw: Song.Raw) { - super.populateMetadata(cursor, raw) - // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in - // the tag itself, which is to say that it is formatted as NN/TT tracks, where - // N is the number and T is the total. Parse the number while ignoring the - // total, as we have no use for it. - cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it } - cursor.getStringOrNull(discIndex)?.parseId3v2Position()?.let { raw.disc = it } - } -} - -/** - * Unpack the track number from a combined track + disc [Int] field. These fields appear within - * MediaStore's TRACK column, and combine the track and disc value into a single field where the - * disc number is the 4th+ digit. - * @return The track number extracted from the combined integer value, or null if the value was - * zero. - */ -private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull() - -/** - * Unpack the disc number from a combined track + disc [Int] field. These fields appear within - * MediaStore's TRACK column, and combine the track and disc value into a single field where the - * disc number is the 4th+ digit. - * @return The disc number extracted from the combined integer field, or null if the value was zero. - */ -private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull() diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt new file mode 100644 index 000000000..9411c5cfe --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt @@ -0,0 +1,123 @@ +/* + * 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.metadata + +import android.content.Context +import android.media.MediaExtractor +import android.media.MediaFormat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.logW + +/** + * 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 AudioInfo( + val bitrateKbps: Int?, + val sampleRateHz: Int?, + val resolvedMimeType: MimeType +) { + /** Implements the process of extracting [AudioInfo] from a given [Song]. */ + interface Provider { + /** + * Extract the [AudioInfo] of a given [Song]. + * @param song The [Song] to read. + * @return The [AudioInfo] of the [Song], if possible to obtain. + */ + suspend fun extract(song: Song): AudioInfo + } +} + +/** + * A framework-backed implementation of [AudioInfo.Provider]. + * @param context [Context] required to read audio files. + */ +class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) : + AudioInfo.Provider { + + override suspend fun extract(song: Song): AudioInfo { + // 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 AudioInfo(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) + } + + extractor.release() + + return AudioInfo(bitrate, sampleRate, resolvedMimeType) + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Date.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/Date.kt index d3658ce6f..bd4764390 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/tags/Date.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Date.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.tags +package org.oxycblt.auxio.music.metadata import android.content.Context import java.text.ParseException diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt new file mode 100644 index 000000000..b38db06fb --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt @@ -0,0 +1,31 @@ +/* + * 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.metadata + +import org.oxycblt.auxio.list.Item + +/** + * A disc identifier for a song. + * @param number The disc number. + * @param name The name of the disc group, if any. Null if not present. + */ +class Disc(val number: Int, val name: String?) : Item, Comparable { + override fun hashCode() = number.hashCode() + override fun equals(other: Any?) = other is Disc && number == other.number + override fun compareTo(other: Disc) = number.compareTo(other.number) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt new file mode 100644 index 000000000..469d21a7b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt @@ -0,0 +1,30 @@ +/* + * 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.metadata + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface MetadataModule { + @Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor + @Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/ReleaseType.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/ReleaseType.kt index 3331fda7a..2479444a9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/tags/ReleaseType.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/ReleaseType.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.tags +package org.oxycblt.auxio.music.metadata import org.oxycblt.auxio.R @@ -125,7 +125,7 @@ sealed class ReleaseType { } /** - * A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or a + * A Mix-tape. These are usually [EP]-sized releases of music made to promote an Artist or a * future release. */ object Mixtape : ReleaseType() { @@ -141,7 +141,7 @@ sealed class ReleaseType { /** A release consisting of a live performance */ LIVE, - /** A release consisting of another [Artist]s remix of a prior performance. */ + /** A release consisting of another Artists remix of a prior performance. */ REMIX } diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt index c270f6d1d..dbc63447c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.parsing +package org.oxycblt.auxio.music.metadata /** * Defines the allowed separator characters that can be used to delimit multi-value tags. diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt similarity index 93% rename from app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index 6289ddc43..05ac292e2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -15,13 +15,15 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.parsing +package org.oxycblt.auxio.music.metadata import android.os.Bundle import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import androidx.core.view.children import com.google.android.material.checkbox.MaterialCheckBox +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogSeparatorsBinding @@ -33,7 +35,10 @@ import org.oxycblt.auxio.ui.ViewBindingDialogFragment * split tags with multiple values. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class SeparatorsDialog : ViewBindingDialogFragment() { + @Inject lateinit var musicSettings: MusicSettings + override fun onCreateBinding(inflater: LayoutInflater) = DialogSeparatorsBinding.inflate(inflater) @@ -42,7 +47,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() { .setTitle(R.string.set_separators) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> - MusicSettings.from(requireContext()).multiValueSeparators = getCurrentSeparators() + musicSettings.multiValueSeparators = getCurrentSeparators() } } @@ -59,7 +64,7 @@ class SeparatorsDialog : ViewBindingDialogFragment() { // the corresponding CheckBox for each character instead of doing an iteration // through the separator list for each CheckBox. (savedInstanceState?.getString(KEY_PENDING_SEPARATORS) - ?: MusicSettings.from(requireContext()).multiValueSeparators) + ?: musicSettings.multiValueSeparators) .forEach { when (it) { Separators.COMMA -> binding.separatorComma.isChecked = true diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt similarity index 52% rename from app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt index b80b45882..581ff63c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.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,103 +15,87 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.extractor +package org.oxycblt.auxio.music.metadata import android.content.Context import androidx.core.text.isDigitsOnly import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever -import kotlinx.coroutines.flow.flow -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.parsing.parseId3v2Position +import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.yield +import org.oxycblt.auxio.music.AudioOnlyExtractors +import org.oxycblt.auxio.music.model.RawSong import org.oxycblt.auxio.music.storage.toAudioUri -import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW /** * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the * last step in the music extraction process and is mostly responsible for papering over the bad - * metadata that [MediaStoreExtractor] produces. + * metadata that other extractors produce. * - * @param context [Context] required for reading audio files. - * @param mediaStoreExtractor [MediaStoreExtractor] implementation for cache optimizations and - * redundancy. * @author Alexander Capehart (OxygenCobalt) */ -class MetadataExtractor( - private val context: Context, - private val mediaStoreExtractor: MediaStoreExtractor -) { - // We can parallelize MetadataRetriever Futures to work around it's speed issues, - // producing similar throughput's to other kinds of manual metadata extraction. - private val taskPool: Array = arrayOfNulls(TASK_CAPACITY) - +interface TagExtractor { /** - * Initialize this extractor. This actually initializes the sub-extractors that this instance - * relies on. - * @return The amount of music that is expected to be loaded. + * Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will + * terminate as soon as [incompleteSongs] is closed. + * @param incompleteSongs A [Channel] of incomplete songs to process. + * @param completeSongs A [Channel] to send completed songs to. */ - fun init() = mediaStoreExtractor.init().count + suspend fun consume(incompleteSongs: Channel, completeSongs: Channel) +} - /** - * Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside - * freeing up memory. - * @param rawSongs The songs to write into the cache. - */ - fun finalize(rawSongs: List) = mediaStoreExtractor.finalize(rawSongs) +class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) : + TagExtractor { + override suspend fun consume( + incompleteSongs: Channel, + completeSongs: Channel + ) { + // We can parallelize MetadataRetriever Futures to work around it's speed issues, + // producing similar throughput's to other kinds of manual metadata extraction. + val taskPool: Array = arrayOfNulls(TASK_CAPACITY) - /** - * Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will - * first delegate to the sub-extractors before parsing the metadata itself. - * @return A flow of [Song.Raw] instances. - */ - fun extract() = flow { - while (true) { - val raw = Song.Raw() - when (mediaStoreExtractor.populate(raw)) { - ExtractionResult.NONE -> break - ExtractionResult.PARSED -> {} - ExtractionResult.CACHED -> { - // Avoid running the expensive parsing process on songs we can already - // restore from the cache. - emit(raw) - continue - } - } - - // Spin until there is an open slot we can insert a task in. + for (song in incompleteSongs) { spin@ while (true) { for (i in taskPool.indices) { val task = taskPool[i] if (task != null) { - val finishedRaw = task.get() - if (finishedRaw != null) { - emit(finishedRaw) - taskPool[i] = Task(context, raw) - break@spin + val finishedRawSong = task.get() + if (finishedRawSong != null) { + completeSongs.send(finishedRawSong) + yield() + } else { + continue } - } else { - taskPool[i] = Task(context, raw) - break@spin } + taskPool[i] = Task(context, song) + break@spin } } } - spin@ while (true) { - // Spin until all of the remaining tasks are complete. + do { + var ongoingTasks = false for (i in taskPool.indices) { val task = taskPool[i] if (task != null) { - val finishedRaw = task.get() ?: continue@spin - emit(finishedRaw) - taskPool[i] = null + val finishedRawSong = task.get() + if (finishedRawSong != null) { + completeSongs.send(finishedRawSong) + taskPool[i] = null + yield() + } else { + ongoingTasks = true + } } } + } while (ongoingTasks) - break - } + completeSongs.close() } private companion object { @@ -120,26 +104,26 @@ class MetadataExtractor( } /** - * Wraps a [MetadataExtractor] future and processes it into a [Song.Raw] when completed. + * Wraps a [TagExtractor] future and processes it into a [RawSong] when completed. * @param context [Context] required to open the audio file. - * @param raw [Song.Raw] to process. + * @param rawSong [RawSong] to process. * @author Alexander Capehart (OxygenCobalt) */ -class Task(context: Context, private val raw: Song.Raw) { +private class Task(context: Context, private val rawSong: RawSong) { // Note that we do not leverage future callbacks. This is because errors in the // (highly fallible) extraction process will not bubble up to Indexer when a // listener is used, instead crashing the app entirely. private val future = MetadataRetriever.retrieveMetadata( - context, + DefaultMediaSourceFactory(context, AudioOnlyExtractors), MediaItem.fromUri( - requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())) + requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())) /** * Try to get a completed song from this [Task], if it has finished processing. - * @return A [Song.Raw] instance if processing has completed, null otherwise. + * @return A [RawSong] instance if processing has completed, null otherwise. */ - fun get(): Song.Raw? { + fun get(): RawSong? { if (!future.isDone) { // Not done yet, nothing to do. return null @@ -149,13 +133,13 @@ class Task(context: Context, private val raw: Song.Raw) { try { future.get()[0].getFormat(0) } catch (e: Exception) { - logW("Unable to extract metadata for ${raw.name}") + logW("Unable to extract metadata for ${rawSong.name}") logW(e.stackTraceToString()) null } if (format == null) { - logD("Nothing could be extracted for ${raw.name}") - return raw + logD("Nothing could be extracted for ${rawSong.name}") + return rawSong } val metadata = format.metadata @@ -164,28 +148,29 @@ class Task(context: Context, private val raw: Song.Raw) { populateWithId3v2(textTags.id3v2) populateWithVorbis(textTags.vorbis) } else { - logD("No metadata could be extracted for ${raw.name}") + logD("No metadata could be extracted for ${rawSong.name}") } - return raw + return rawSong } /** - * Complete this instance's [Song.Raw] with ID3v2 Text Identification Frames. + * Complete this instance's [RawSong] with ID3v2 Text Identification Frames. * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more * values. */ private fun populateWithId3v2(textFrames: Map>) { // Song - textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] } - textFrames["TIT2"]?.let { raw.name = it[0] } - textFrames["TSOT"]?.let { raw.sortName = it[0] } + textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() } + textFrames["TIT2"]?.let { rawSong.name = it.first() } + textFrames["TSOT"]?.let { rawSong.sortName = it.first() } - // Track. Only parse out the track number and ignore the total tracks value. - textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it } + // Track. + textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it } - // Disc. Only parse out the disc number and ignore the total discs value. - textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it } + // Disc and it's subtitle name. + textFrames["TPOS"]?.run { first().parseId3v2PositionField() }?.let { rawSong.disc = it } + textFrames["TSST"]?.let { rawSong.subtitle = it.first() } // Dates are somewhat complicated, as not only did their semantics change from a flat year // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of @@ -200,30 +185,36 @@ class Task(context: Context, private val raw: Song.Raw) { ?: textFrames["TDRC"]?.run { Date.from(first()) } ?: textFrames["TDRL"]?.run { Date.from(first()) } ?: parseId3v23Date(textFrames)) - ?.let { raw.date = it } + ?.let { rawSong.date = it } // Album - textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] } - textFrames["TALB"]?.let { raw.albumName = it[0] } - textFrames["TSOA"]?.let { raw.albumSortName = it[0] } - (textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let { - raw.releaseTypes = it - } + textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() } + textFrames["TALB"]?.let { rawSong.albumName = it.first() } + textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } + (textFrames["TXXX:musicbrainz album type"] + ?: textFrames["TXXX:releasetype"] ?: textFrames["GRP1"]) + ?.let { rawSong.releaseTypes = it } // Artist - textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it } - (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it } - (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it } + textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } + (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } + (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { + rawSong.artistSortNames = it + } // Album artist - textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it } - (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it } + textFrames["TXXX:musicbrainz album artist id"]?.let { + rawSong.albumArtistMusicBrainzIds = it + } + (textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { + rawSong.albumArtistNames = it + } (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { - raw.albumArtistSortNames = it + rawSong.albumArtistSortNames = it } // Genre - textFrames["TCON"]?.let { raw.genreNames = it } + textFrames["TCON"]?.let { rawSong.genreNames = it } } /** @@ -243,19 +234,19 @@ class Task(context: Context, private val raw: Song.Raw) { ?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null val tdat = textFrames["TDAT"] - return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) { + return if (tdat != null && tdat.first().length == 4 && tdat.first().isDigitsOnly()) { // TDAT frames consist of a 4-digit string where the first two digits are // the month and the last two digits are the day. - val mm = tdat[0].substring(0..1).toInt() - val dd = tdat[0].substring(2..3).toInt() + val mm = tdat.first().substring(0..1).toInt() + val dd = tdat.first().substring(2..3).toInt() val time = textFrames["TIME"] - if (time != null && time[0].length == 4 && time[0].isDigitsOnly()) { + if (time != null && time.first().length == 4 && time.first().isDigitsOnly()) { // TIME frames consist of a 4-digit string where the first two digits are // the hour and the last two digits are the minutes. No second value is // possible. - val hh = time[0].substring(0..1).toInt() - val mi = time[0].substring(2..3).toInt() + val hh = time.first().substring(0..1).toInt() + val mi = time.first().substring(2..3).toInt() // Able to return a full date. Date.from(year, mm, dd, hh, mi) } else { @@ -269,22 +260,27 @@ class Task(context: Context, private val raw: Song.Raw) { } /** - * Complete this instance's [Song.Raw] with Vorbis comments. + * Complete this instance's [RawSong] with Vorbis comments. * @param comments A mapping between vorbis comment names and one or more vorbis comment values. */ private fun populateWithVorbis(comments: Map>) { // Song - comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] } - comments["title"]?.let { raw.name = it[0] } - comments["titlesort"]?.let { raw.sortName = it[0] } + comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() } + comments["title"]?.let { rawSong.name = it.first() } + comments["titlesort"]?.let { rawSong.sortName = it.first() } - // Track. The total tracks value is in a different comment, so we can just - // convert the entirety of this comment into a number. - comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it } + // Track. + parseVorbisPositionField( + comments["tracknumber"]?.first(), + (comments["totaltracks"] ?: comments["tracktotal"] ?: comments["trackc"])?.first()) + ?.let { rawSong.track = it } - // Disc. The total discs value is in a different comment, so we can just - // convert the entirety of this comment into a number. - comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it } + // Disc and it's subtitle name. + parseVorbisPositionField( + comments["discnumber"]?.first(), + (comments["totaldiscs"] ?: comments["disctotal"] ?: comments["discc"])?.first()) + ?.let { rawSong.disc = it } + comments["discsubtitle"]?.let { rawSong.subtitle = it.first() } // Vorbis dates are less complicated, but there are still several types // Our hierarchy for dates is as such: @@ -295,27 +291,27 @@ class Task(context: Context, private val raw: Song.Raw) { (comments["originaldate"]?.run { Date.from(first()) } ?: comments["date"]?.run { Date.from(first()) } ?: comments["year"]?.run { Date.from(first()) }) - ?.let { raw.date = it } + ?.let { rawSong.date = it } // Album - comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] } - comments["album"]?.let { raw.albumName = it[0] } - comments["albumsort"]?.let { raw.albumSortName = it[0] } - comments["releasetype"]?.let { raw.releaseTypes = it } + comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() } + comments["album"]?.let { rawSong.albumName = it.first() } + comments["albumsort"]?.let { rawSong.albumSortName = it.first() } + comments["releasetype"]?.let { rawSong.releaseTypes = it } // Artist - comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it } - (comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it } - (comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it } + comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } + (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } + (comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it } // Album artist - comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it } - (comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it } + comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } + (comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it } (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { - raw.albumArtistSortNames = it + rawSong.albumArtistSortNames = it } // Genre - comments["genre"]?.let { raw.genreNames = it } + comments["genre"]?.let { rawSong.genreNames = it } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt index 658b1cbea..91d59e139 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.parsing +package org.oxycblt.auxio.music.metadata import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.util.nonZeroOrNull @@ -96,7 +96,7 @@ fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() } /** * Attempt to parse a string by the user's separator preferences. - * @param settings [Settings] required to obtain user separator configuration. + * @param settings [MusicSettings] required to obtain user separator configuration. * @return A list of one or more [String]s that were split up by the user-defined separators. */ private fun String.maybeParseBySeparators(settings: MusicSettings): List { @@ -107,12 +107,45 @@ private fun String.maybeParseBySeparators(settings: MusicSettings): List /// --- ID3v2 PARSING --- /** - * Parse the number out of a ID3v2-style number + total position [String] field. These fields - * consist of a number and an (optional) total value delimited by a /. - * @return The number value extracted from the string field, or null if the value could not be - * parsed or if the value was zero. + * Parse an ID3v2-style position + total [String] field. These fields consist of a number and an + * (optional) total value delimited by a /. + * @return The position value extracted from the string field, or null if: + * - The position could not be parsed + * - The position was zeroed AND the total value was not present/zeroed + * @see transformPositionField */ -fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull() +fun String.parseId3v2PositionField() = + split('/', limit = 2).let { + transformPositionField(it[0].toIntOrNull(), it.getOrNull(1)?.toIntOrNull()) + } + +/** + * Parse a vorbis-style position + total field. These fields consist of two fields for the position + * and total numbers. + * @param pos The position value, or null if not present. + * @param total The total value, if not present. + * @return The position value extracted from the field, or null if: + * - The position could not be parsed + * - The position was zeroed AND the total value was not present/zeroed + * @see transformPositionField + */ +fun parseVorbisPositionField(pos: String?, total: String?) = + transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull()) + +/** + * Transform a raw position + total field into a position a way that tolerates placeholder values. + * @param pos The position value, or null if not present. + * @param total The total value, if not present. + * @return The position value extracted from the field, or null if: + * - The position could not be parsed + * - The position was zeroed AND the total value was not present/zeroed + */ +fun transformPositionField(pos: Int?, total: Int?) = + if (pos != null && (pos > 0 || (total?.nonZeroOrNull() != null))) { + pos + } else { + null + } /** * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt index 493a3421e..de3c28c75 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/TextTags.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TextTags.kt @@ -15,13 +15,12 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.extractor +package org.oxycblt.auxio.music.metadata import com.google.android.exoplayer2.metadata.Metadata import com.google.android.exoplayer2.metadata.id3.InternalFrame import com.google.android.exoplayer2.metadata.id3.TextInformationFrame import com.google.android.exoplayer2.metadata.vorbis.VorbisComment -import org.oxycblt.auxio.music.parsing.correctWhitespace /** * Processing wrapper for [Metadata] that allows organized access to text-based audio tags. diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/model/Library.kt similarity index 56% rename from app/src/main/java/org/oxycblt/auxio/music/library/Library.kt rename to app/src/main/java/org/oxycblt/auxio/music/model/Library.kt index d8a42b40a..54c99a084 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/model/Library.kt @@ -15,11 +15,12 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.model import android.content.Context import android.net.Uri import android.provider.OpenableColumns +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.storage.useQuery @@ -29,27 +30,89 @@ import org.oxycblt.auxio.util.logD * Organized music library information. * * This class allows for the creation of a well-formed music library graph from raw song - * information. It's generally not expected to create this yourself and instead use [MusicStore]. + * information. It's generally not expected to create this yourself and instead use + * [MusicRepository]. * * @author Alexander Capehart */ -class Library(rawSongs: List, settings: MusicSettings) { - /** All [Song]s that were detected on the device. */ - val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }.distinct()) - /** All [Album]s found on the device. */ - val albums = buildAlbums(songs) - /** All [Artist]s found on the device. */ - val artists = buildArtists(songs, albums) - /** All [Genre]s found on the device. */ - val genres = buildGenres(songs) +interface Library { + /** All [Song]s in this [Library]. */ + val songs: List + /** All [Album]s in this [Library]. */ + val albums: List + /** All [Artist]s in this [Library]. */ + val artists: List + /** All [Genre]s in this [Library]. */ + val genres: List + + /** + * Finds a [Music] item [T] in the library by it's [Music.UID]. + * @param uid The [Music.UID] to search for. + * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or + * the [Music.UID] did not correspond to a [T]. + */ + fun find(uid: Music.UID): T? + + /** + * Convert a [Song] from an another library into a [Song] in this [Library]. + * @param song The [Song] to convert. + * @return The analogous [Song] in this [Library], or null if it does not exist. + */ + fun sanitize(song: Song): Song? + + /** + * Convert a [MusicParent] from an another library into a [MusicParent] in this [Library]. + * @param parent The [MusicParent] to convert. + * @return The analogous [Album] in this [Library], or null if it does not exist. + */ + fun sanitize(parent: T): T? + + /** + * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. + * @param context [Context] required to analyze the [Uri]. + * @param uri [Uri] to search for. + * @return A [Song] corresponding to the given [Uri], or null if one could not be found. + */ + fun findSongForUri(context: Context, uri: Uri): Song? + + companion object { + /** + * Create an instance of [Library]. + * @param rawSongs [RawSong]s to create the library out of. + * @param settings [MusicSettings] required. + */ + fun from(rawSongs: List, settings: MusicSettings): Library = + LibraryImpl(rawSongs, settings) + } +} + +private class LibraryImpl(rawSongs: List, settings: MusicSettings) : Library { + override val songs = buildSongs(rawSongs, settings) + override val albums = buildAlbums(songs, settings) + override val artists = buildArtists(songs, albums, settings) + override val genres = buildGenres(songs, settings) // Use a mapping to make finding information based on it's UID much faster. private val uidMap = buildMap { - for (music in (songs + albums + artists + genres)) { - // Finalize all music in the same mapping creation loop for efficiency. - music._finalize() - this[music.uid] = music - } + songs.forEach { put(it.uid, it.finalize()) } + albums.forEach { put(it.uid, it.finalize()) } + artists.forEach { put(it.uid, it.finalize()) } + genres.forEach { put(it.uid, it.finalize()) } + } + + override fun equals(other: Any?) = + other is Library && + other.songs == songs && + other.albums == albums && + other.artists == artists && + other.genres == genres + + override fun hashCode(): Int { + var hashCode = songs.hashCode() + hashCode = hashCode * 31 + albums.hashCode() + hashCode = hashCode * 31 + artists.hashCode() + hashCode = hashCode * 31 + genres.hashCode() + return hashCode } /** @@ -58,43 +121,13 @@ class Library(rawSongs: List, settings: MusicSettings) { * @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or * the [Music.UID] did not correspond to a [T]. */ - @Suppress("UNCHECKED_CAST") fun find(uid: Music.UID) = uidMap[uid] as? T + @Suppress("UNCHECKED_CAST") override fun find(uid: Music.UID) = uidMap[uid] as? T - /** - * Convert a [Song] from an another library into a [Song] in this [Library]. - * @param song The [Song] to convert. - * @return The analogous [Song] in this [Library], or null if it does not exist. - */ - fun sanitize(song: Song) = find(song.uid) + override fun sanitize(song: Song) = find(song.uid) - /** - * Convert a [Album] from an another library into a [Album] in this [Library]. - * @param album The [Album] to convert. - * @return The analogous [Album] in this [Library], or null if it does not exist. - */ - fun sanitize(album: Album) = find(album.uid) + override fun sanitize(parent: T) = find(parent.uid) - /** - * Convert a [Artist] from an another library into a [Artist] in this [Library]. - * @param artist The [Artist] to convert. - * @return The analogous [Artist] in this [Library], or null if it does not exist. - */ - fun sanitize(artist: Artist) = find(artist.uid) - - /** - * Convert a [Genre] from an another library into a [Genre] in this [Library]. - * @param genre The [Genre] to convert. - * @return The analogous [Genre] in this [Library], or null if it does not exist. - */ - fun sanitize(genre: Genre) = find(genre.uid) - - /** - * Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri]. - * @param context [Context] required to analyze the [Uri]. - * @param uri [Uri] to search for. - * @return A [Song] corresponding to the given [Uri], or null if one could not be found. - */ - fun findSongForUri(context: Context, uri: Uri) = + override fun findSongForUri(context: Context, uri: Uri) = context.contentResolverSafe.useQuery( uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> cursor.moveToFirst() @@ -106,18 +139,30 @@ class Library(rawSongs: List, settings: MusicSettings) { songs.find { it.path.name == displayName && it.size == size } } + /** + * Build a list [SongImpl]s from the given [RawSong]. + * @param rawSongs The [RawSong]s to build the [SongImpl]s from. + * @param settings [MusicSettings] to obtain user parsing configuration. + * @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for + * grouping. + */ + private fun buildSongs(rawSongs: List, settings: MusicSettings) = + Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + .songs(rawSongs.map { SongImpl(it, settings) }.distinct()) + /** * Build a list of [Album]s from the given [Song]s. * @param songs The [Song]s to build [Album]s from. These will be linked with their respective * [Album]s when created. + * @param settings [MusicSettings] to obtain user parsing configuration. * @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked * with parent [Artist] instances in order to be usable. */ - private fun buildAlbums(songs: List): List { + private fun buildAlbums(songs: List, settings: MusicSettings): List { // Group songs by their singular raw album, then map the raw instances and their // grouped songs to Album values. Album.Raw will handle the actual grouping rules. - val songsByAlbum = songs.groupBy { it._rawAlbum } - val albums = songsByAlbum.map { Album(it.key, it.value) } + val songsByAlbum = songs.groupBy { it.rawAlbum } + val albums = songsByAlbum.map { AlbumImpl(it.key, settings, it.value) } logD("Successfully built ${albums.size} albums") return albums } @@ -132,28 +177,33 @@ class Library(rawSongs: List, settings: MusicSettings) { * @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of * one or more [Artist] instances. These will be linked with their respective [Artist]s when * created. + * @param settings [MusicSettings] to obtain user parsing configuration. * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings * of [Song]s and [Album]s. */ - private fun buildArtists(songs: List, albums: List): List { + private fun buildArtists( + songs: List, + albums: List, + settings: MusicSettings + ): List { // Add every raw artist credited to each Song/Album to the grouping. This way, // different multi-artist combinations are not treated as different artists. - val musicByArtist = mutableMapOf>() + val musicByArtist = mutableMapOf>() for (song in songs) { - for (rawArtist in song._rawArtists) { + for (rawArtist in song.rawArtists) { musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) } } for (album in albums) { - for (rawArtist in album._rawArtists) { + for (rawArtist in album.rawArtists) { musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album) } } // Convert the combined mapping into artist instances. - val artists = musicByArtist.map { Artist(it.key, it.value) } + val artists = musicByArtist.map { ArtistImpl(it.key, settings, it.value) } logD("Successfully built ${artists.size} artists") return artists } @@ -163,20 +213,21 @@ class Library(rawSongs: List, settings: MusicSettings) { * @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of * one or more [Genre] instances. These will be linked with their respective [Genre]s when * created. + * @param settings [MusicSettings] to obtain user parsing configuration. * @return A non-empty list of [Genre]s. */ - private fun buildGenres(songs: List): List { + private fun buildGenres(songs: List, settings: MusicSettings): List { // Add every raw genre credited to each Song to the grouping. This way, // different multi-genre combinations are not treated as different genres. - val songsByGenre = mutableMapOf>() + val songsByGenre = mutableMapOf>() for (song in songs) { - for (rawGenre in song._rawGenres) { + for (rawGenre in song.rawGenres) { songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) } } // Convert the mapping into genre instances. - val genres = songsByGenre.map { Genre(it.key, it.value) } + val genres = songsByGenre.map { GenreImpl(it.key, settings, it.value) } logD("Successfully built ${genres.size} genres") return genres } diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt new file mode 100644 index 000000000..1bf87f5bf --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt @@ -0,0 +1,542 @@ +/* + * 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.model + +import android.content.Context +import androidx.annotation.VisibleForTesting +import java.security.MessageDigest +import java.text.CollationKey +import java.text.Collator +import org.oxycblt.auxio.R +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.MusicMode +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.Song +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.metadata.parseId3GenreNames +import org.oxycblt.auxio.music.metadata.parseMultiValue +import org.oxycblt.auxio.music.storage.MimeType +import org.oxycblt.auxio.music.storage.Path +import org.oxycblt.auxio.music.storage.toAudioUri +import org.oxycblt.auxio.music.storage.toCoverUri +import org.oxycblt.auxio.util.nonZeroOrNull +import org.oxycblt.auxio.util.toUuidOrNull +import org.oxycblt.auxio.util.unlikelyToBeNull + +/** + * Library-backed implementation of [Song]. + * @param rawSong The [RawSong] to derive the member data from. + * @param musicSettings [MusicSettings] to for user parsing configuration. + * @author Alexander Capehart (OxygenCobalt) + */ +class SongImpl(rawSong: RawSong, musicSettings: MusicSettings) : Song { + override val uid = + // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. + rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicMode.SONGS, it) } + ?: Music.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(rawSong.name) + update(rawSong.albumName) + update(rawSong.date) + + update(rawSong.track) + update(rawSong.disc) + + update(rawSong.artistNames) + update(rawSong.albumArtistNames) + } + override val rawName = requireNotNull(rawSong.name) { "Invalid raw: No title" } + override val rawSortName = rawSong.sortName + override val collationKey = makeCollationKey(musicSettings) + override fun resolveName(context: Context) = rawName + + override val track = rawSong.track + override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } + override val date = rawSong.date + override val uri = requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri() + override val path = + Path( + name = requireNotNull(rawSong.fileName) { "Invalid raw: No display name" }, + parent = requireNotNull(rawSong.directory) { "Invalid raw: No parent directory" }) + override val mimeType = + MimeType( + fromExtension = + requireNotNull(rawSong.extensionMimeType) { "Invalid raw: No mime type" }, + fromFormat = null) + override val size = requireNotNull(rawSong.size) { "Invalid raw: No size" } + override val durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" } + override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" } + private var _album: AlbumImpl? = null + override val album: Album + get() = unlikelyToBeNull(_album) + + // Note: Only compare by UID so songs that differ only in MBID are treated differently. + override fun hashCode() = uid.hashCode() + override fun equals(other: Any?) = other is Song && uid == other.uid + + private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings) + private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings) + private val artistSortNames = rawSong.artistSortNames.parseMultiValue(musicSettings) + private val rawIndividualArtists = + artistNames.mapIndexed { i, name -> + RawArtist( + artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + artistSortNames.getOrNull(i)) + } + + private val albumArtistMusicBrainzIds = + rawSong.albumArtistMusicBrainzIds.parseMultiValue(musicSettings) + private val albumArtistNames = rawSong.albumArtistNames.parseMultiValue(musicSettings) + private val albumArtistSortNames = rawSong.albumArtistSortNames.parseMultiValue(musicSettings) + private val rawAlbumArtists = + albumArtistNames.mapIndexed { i, name -> + RawArtist( + albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + albumArtistSortNames.getOrNull(i)) + } + + private val _artists = mutableListOf() + override val artists: List + get() = _artists + + private val _genres = mutableListOf() + override val genres: List + get() = _genres + + /** + * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an + * [Album]. + */ + val rawAlbum = + RawAlbum( + mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" }, + musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), + name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, + sortName = rawSong.albumSortName, + releaseType = ReleaseType.parse(rawSong.releaseTypes.parseMultiValue(musicSettings)), + rawArtists = + rawAlbumArtists + .ifEmpty { rawIndividualArtists } + .ifEmpty { listOf(RawArtist(null, null)) }) + + /** + * The [RawArtist] 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" + * [RawArtist]. This can be used to group up [Song]s into an [Artist]. + */ + val rawArtists = + rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) } + + /** + * The [RawGenre] 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. + */ + val rawGenres = + rawSong.genreNames + .parseId3GenreNames(musicSettings) + .map { RawGenre(it) } + .ifEmpty { listOf(RawGenre()) } + + /** + * Links this [Song] with a parent [Album]. + * @param album The parent [Album] to link to. + */ + fun link(album: AlbumImpl) { + _album = album + } + + /** + * Links this [Song] with a parent [Artist]. + * @param artist The parent [Artist] to link to. + */ + fun link(artist: ArtistImpl) { + _artists.add(artist) + } + + /** + * Links this [Song] with a parent [Genre]. + * @param genre The parent [Genre] to link to. + */ + fun link(genre: GenreImpl) { + _genres.add(genre) + } + + /** + * Perform final validation and organization on this instance. + * @return This instance upcasted to [Song]. + */ + fun finalize(): Song { + 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 + } + return this + } +} + +/** + * Library-backed implementation of [Album]. + * @param rawAlbum The [RawAlbum] to derive the member data from. + * @param musicSettings [MusicSettings] to for user parsing configuration. + * @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 AlbumImpl( + private val rawAlbum: RawAlbum, + musicSettings: MusicSettings, + override val songs: List +) : Album { + override val uid = + // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. + rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ALBUMS, it) } + ?: Music.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(rawAlbum.name) + update(rawAlbum.rawArtists.map { it.name }) + } + override val rawName = rawAlbum.name + override val rawSortName = rawAlbum.sortName + override val collationKey = makeCollationKey(musicSettings) + override fun resolveName(context: Context) = rawName + + override val dates = Date.Range.from(songs.mapNotNull { it.date }) + override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) + override val coverUri = rawAlbum.mediaStoreId.toCoverUri() + override val durationMs: Long + override val dateAdded: Long + + // 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 AlbumImpl && uid == other.uid && songs == other.songs + + private val _artists = mutableListOf() + override val artists: List + get() = _artists + + 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 + } + + /** + * The [RawArtist] 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" [RawArtist]. This can be used to group up [Album]s into an [Artist]. + */ + val rawArtists = rawAlbum.rawArtists + + /** + * Links this [Album] with a parent [Artist]. + * @param artist The parent [Artist] to link to. + */ + fun link(artist: ArtistImpl) { + _artists.add(artist) + } + + /** + * Perform final validation and organization on this instance. + * @return This instance upcasted to [Album]. + */ + fun finalize(): Album { + 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 + } + return this + } +} + +/** + * Library-backed implementation of [Artist]. + * @param rawArtist The [RawArtist] to derive the member data from. + * @param musicSettings [MusicSettings] to for user parsing configuration. + * @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 ArtistImpl( + private val rawArtist: RawArtist, + musicSettings: MusicSettings, + songAlbums: List +) : Artist { + override val uid = + // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. + rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicMode.ARTISTS, it) } + ?: Music.UID.auxio(MusicMode.ARTISTS) { update(rawArtist.name) } + override val rawName = rawArtist.name + override val rawSortName = rawArtist.sortName + override val collationKey = makeCollationKey(musicSettings) + override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist) + override val songs: List + + override val albums: List + override val durationMs: Long? + override val isCollaborator: Boolean + + // 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 ArtistImpl && uid == other.uid && songs == other.songs + + override lateinit var genres: List + + init { + val distinctSongs = mutableSetOf() + val distinctAlbums = mutableSetOf() + + var noAlbums = true + + for (music in songAlbums) { + when (music) { + is SongImpl -> { + music.link(this) + distinctSongs.add(music) + distinctAlbums.add(music.album) + } + is AlbumImpl -> { + 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 + } + + /** + * Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist] + * list. This can be used to create a consistent ordering within child [Artist] lists based on + * the original tag order. + * @param rawArtists The [RawArtist] instances to check. It is assumed that this [Artist]'s + * [RawArtist] will be within the list. + * @return The index of the [Artist]'s [RawArtist] within the list. + */ + fun getOriginalPositionIn(rawArtists: List) = rawArtists.indexOf(rawArtist) + + /** + * Perform final validation and organization on this instance. + * @return This instance upcasted to [Artist]. + */ + fun finalize(): Artist { + check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" } + genres = + Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + .genres(songs.flatMapTo(mutableSetOf()) { it.genres }) + .sortedByDescending { genre -> songs.count { it.genres.contains(genre) } } + return this + } +} +/** + * Library-backed implementation of [Genre]. + * @param rawGenre [RawGenre] to derive the member data from. + * @param musicSettings [MusicSettings] to for user parsing configuration. + * @param songs Child [SongImpl]s of this instance. + * @author Alexander Capehart (OxygenCobalt) + */ +class GenreImpl( + private val rawGenre: RawGenre, + musicSettings: MusicSettings, + override val songs: List +) : Genre { + override val uid = Music.UID.auxio(MusicMode.GENRES) { update(rawGenre.name) } + override val rawName = rawGenre.name + override val rawSortName = rawName + override val collationKey = makeCollationKey(musicSettings) + override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre) + + override val albums: List + override val artists: List + override val durationMs: Long + + // 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 GenreImpl && uid == other.uid && songs == other.songs + + init { + val distinctAlbums = mutableSetOf() + val distinctArtists = mutableSetOf() + var totalDuration = 0L + + for (song in songs) { + song.link(this) + distinctAlbums.add(song.album) + distinctArtists.addAll(song.artists) + totalDuration += song.durationMs + } + + albums = + Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) + .albums(distinctAlbums) + .sortedByDescending { album -> album.songs.count { it.genres.contains(this) } } + artists = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).artists(distinctArtists) + durationMs = totalDuration + } + + /** + * Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list. + * This can be used to create a consistent ordering within child [Genre] lists based on the + * original tag order. + * @param rawGenres The [RawGenre] instances to check. It is assumed that this [Genre] 's + * [RawGenre] will be within the list. + * @return The index of the [Genre]'s [RawGenre] within the list. + */ + fun getOriginalPositionIn(rawGenres: List) = rawGenres.indexOf(rawGenre) + + /** + * Perform final validation and organization on this instance. + * @return This instance upcasted to [Genre]. + */ + fun finalize(): Music { + check(songs.isNotEmpty()) { "Malformed genre: Empty" } + return this + } +} + +/** + * 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) + } +} + +/** Cached collator instance re-used with [makeCollationKey]. */ +private val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } + +/** + * Provided implementation to create a [CollationKey] in the way described by [Music.collationKey]. + * This should be used in all overrides of all [CollationKey]. + * @param musicSettings [MusicSettings] required for user parsing configuration. + * @return A [CollationKey] that follows the specification described by [Music.collationKey]. + */ +private fun Music.makeCollationKey(musicSettings: MusicSettings): CollationKey? { + var sortName = (rawSortName ?: rawName) ?: return null + + if (musicSettings.automaticSortNames) { + sortName = + sortName.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) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt new file mode 100644 index 000000000..f532266c7 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt @@ -0,0 +1,200 @@ +/* + * 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.model + +import java.util.UUID +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.metadata.* +import org.oxycblt.auxio.music.storage.Directory + +/** + * Raw information about a [SongImpl] obtained from the filesystem/Extractor instances. + * @author Alexander Capehart (OxygenCobalt) + */ +class RawSong( + /** + * The ID of the [SongImpl]'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 [SongImpl]'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 Disc.number */ + var disc: Int? = null, + /** @See Disc.name */ + var subtitle: String? = null, + /** @see Song.date */ + var date: Date? = null, + /** @see RawAlbum.mediaStoreId */ + var albumMediaStoreId: Long? = null, + /** @see RawAlbum.musicBrainzId */ + var albumMusicBrainzId: String? = null, + /** @see RawAlbum.name */ + var albumName: String? = null, + /** @see RawAlbum.sortName */ + var albumSortName: String? = null, + /** @see RawAlbum.releaseType */ + var releaseTypes: List = listOf(), + /** @see RawArtist.musicBrainzId */ + var artistMusicBrainzIds: List = listOf(), + /** @see RawArtist.name */ + var artistNames: List = listOf(), + /** @see RawArtist.sortName */ + var artistSortNames: List = listOf(), + /** @see RawArtist.musicBrainzId */ + var albumArtistMusicBrainzIds: List = listOf(), + /** @see RawArtist.name */ + var albumArtistNames: List = listOf(), + /** @see RawArtist.sortName */ + var albumArtistSortNames: List = listOf(), + /** @see RawGenre.name */ + var genreNames: List = listOf() +) + +/** + * Raw information about an [AlbumImpl] obtained from the component [SongImpl] instances. + * @author Alexander Capehart (OxygenCobalt) + */ +class RawAlbum( + /** + * The ID of the [AlbumImpl]'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 RawArtist.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 RawAlbum && + when { + musicBrainzId != null && other.musicBrainzId != null -> + musicBrainzId == other.musicBrainzId + musicBrainzId == null && other.musicBrainzId == null -> + name.equals(other.name, true) && rawArtists == other.rawArtists + else -> false + } +} + +/** + * Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl] + * instances. + * @author Alexander Capehart (OxygenCobalt) + */ +class RawArtist( + /** @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 RawArtist && + 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 + } +} + +/** + * Raw information about a [GenreImpl] obtained from the component [SongImpl] instances. + * @author Alexander Capehart (OxygenCobalt) + */ +class RawGenre( + /** @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 RawGenre && + when { + name != null && other.name != null -> name.equals(other.name, true) + name == null && other.name == null -> true + else -> false + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt index 5536e46df..ca0cdee6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/Filesystem.kt @@ -53,7 +53,7 @@ class Directory private constructor(val volume: StorageVolume, val relativePath: /** * Converts this [Directory] instance into an opaque document tree path. This is a huge * violation of the document tree URI contract, but it's also the only one can sensibly work - * with these uris in the UI, and it doesn't exactly matter since we never write or read + * with these uris in the UI, and it doesn't exactly matter since we never write or read to * directory. * @return A URI [String] abiding by the document tree specification, or null if the [Directory] * is not valid. @@ -142,10 +142,9 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) { * Resolve the mime type into a human-readable format name, such as "Ogg Vorbis". * @param context [Context] required to obtain human-readable strings. * @return A human-readable name for this mime type. Will first try [fromFormat], then falling - * back to [fromExtension], then falling back to the extension name, and then finally a - * placeholder "No Format" string. + * back to [fromExtension], and then null if that fails. */ - fun resolveName(context: Context): String { + fun resolveName(context: Context): String? { // We try our best to produce a more readable name for the common audio formats. val formatName = when (fromFormat) { @@ -157,6 +156,8 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) { MediaFormat.MIMETYPE_AUDIO_VORBIS -> R.string.cdc_vorbis MediaFormat.MIMETYPE_AUDIO_OPUS -> R.string.cdc_opus MediaFormat.MIMETYPE_AUDIO_FLAC -> R.string.cdc_flac + // TODO: Add ALAC to this as soon as I can stop using MediaFormat for + // extracting metadata and just use ExoPlayer. // We don't give a name to more unpopular formats. else -> -1 } @@ -199,8 +200,6 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) { } else { // Fall back to the extension if we can't find a special name for this format. MimeTypeMap.getSingleton().getExtensionFromMimeType(fromExtension)?.uppercase() - // Fall back to a placeholder if even that fails. - ?: context.getString(R.string.def_codec) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt new file mode 100644 index 000000000..32912061d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt @@ -0,0 +1,598 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.storage + +import android.content.Context +import android.database.Cursor +import android.os.Build +import android.os.storage.StorageManager +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import androidx.core.database.getIntOrNull +import androidx.core.database.getStringOrNull +import java.io.File +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.yield +import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.cache.Cache +import org.oxycblt.auxio.music.metadata.Date +import org.oxycblt.auxio.music.metadata.parseId3v2PositionField +import org.oxycblt.auxio.music.metadata.transformPositionField +import org.oxycblt.auxio.music.model.RawSong +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.logD + +/** + * The layer that loads music from the [MediaStore] database. This is an intermediate step in the + * music extraction process and primarily intended for redundancy for files not natively supported + * by other extractors. Solely relying on this is not recommended, as it often produces bad + * metadata. + * @author Alexander Capehart (OxygenCobalt) + */ +interface MediaStoreExtractor { + /** + * Query the media database. + * @return A new [Query] returned from the media database. + */ + suspend fun query(): Query + + /** + * Consume the [Cursor] loaded after [query]. + * @param query The [Query] to consume. + * @param cache A [Cache] used to avoid extracting metadata for cached songs, or null if no + * [Cache] was available. + * @param incompleteSongs A channel where songs that could not be retrieved from the [Cache] + * should be sent to. + * @param completeSongs A channel where completed songs should be sent to. + */ + suspend fun consume( + query: Query, + cache: Cache?, + incompleteSongs: Channel, + completeSongs: Channel + ) + + /** A black-box interface representing a query from the media database. */ + interface Query { + val projectedTotal: Int + fun moveToNext(): Boolean + fun close() + fun populateFileInfo(rawSong: RawSong) + fun populateTags(rawSong: RawSong) + } + + companion object { + /** + * Create a framework-backed instance. + * @param context [Context] required. + * @param musicSettings [MusicSettings] required. + * @return A new [MediaStoreExtractor] that will work best on the device's API level. + */ + fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor = + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + Api30MediaStoreExtractor(context, musicSettings) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + Api29MediaStoreExtractor(context, musicSettings) + else -> Api21MediaStoreExtractor(context, musicSettings) + } + } +} + +private abstract class BaseMediaStoreExtractor( + protected val context: Context, + private val musicSettings: MusicSettings +) : MediaStoreExtractor { + final override suspend fun query(): MediaStoreExtractor.Query { + val start = System.currentTimeMillis() + + 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()) + logD("Song query succeeded [Projected total: ${cursor.count}]") + + val genreNamesMap = mutableMapOf() + + // 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 + } + } + } + } + + logD("Finished initialization in ${System.currentTimeMillis() - start}ms") + return wrapQuery(cursor, genreNamesMap) + } + + final override suspend fun consume( + query: MediaStoreExtractor.Query, + cache: Cache?, + incompleteSongs: Channel, + completeSongs: Channel + ) { + while (query.moveToNext()) { + val rawSong = RawSong() + query.populateFileInfo(rawSong) + if (cache?.populate(rawSong) == true) { + completeSongs.send(rawSong) + } else { + query.populateTags(rawSong) + incompleteSongs.send(rawSong) + } + yield() + } + // Free the cursor and signal that no more incomplete songs will be produced by + // this extractor. + query.close() + incompleteSongs.close() + } + + /** + * 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 + + protected abstract fun wrapQuery( + cursor: Cursor, + genreNamesMap: Map + ): MediaStoreExtractor.Query + + abstract class Query( + protected val cursor: Cursor, + private val genreNamesMap: Map + ) : MediaStoreExtractor.Query { + private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) + private val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) + private val displayNameIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) + private val mimeTypeIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE) + private val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE) + private val dateAddedIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED) + private val dateModifiedIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED) + private val durationIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION) + private val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR) + private val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) + private val albumIdIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID) + private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST) + private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST) + + final override val projectedTotal = cursor.count + final override fun moveToNext() = cursor.moveToNext() + final override fun close() = cursor.close() + + override fun populateFileInfo(rawSong: RawSong) { + rawSong.mediaStoreId = cursor.getLong(idIndex) + rawSong.dateAdded = cursor.getLong(dateAddedIndex) + rawSong.dateModified = cursor.getLong(dateModifiedIndex) + // Try to use the DISPLAY_NAME column to obtain a (probably sane) file name + // from the android system. + rawSong.fileName = cursor.getStringOrNull(displayNameIndex) + rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) + rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) + } + + override fun populateTags(rawSong: RawSong) { + // Song title + rawSong.name = cursor.getString(titleIndex) + // Size (in bytes) + rawSong.size = cursor.getLong(sizeIndex) + // Duration (in milliseconds) + rawSong.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. + rawSong.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. + rawSong.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) { + rawSong.artistNames = listOf(artist) + } + // The album artist column is nullable and never has placeholder values. + cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) } + // Get the genre value we had to query for in initialization + genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) } + } + } + + companion object { + /** + * The base selector that works across all versions of android. Does not exclude + * directories. + */ + private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0" + + /** + * The album artist of a song. This column has existed since at least API 21, but until API + * 30 it was an undocumented extension for Google Play Music. This column will work on all + * versions that Auxio supports. + */ + @Suppress("InlinedApi") + private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST + + /** + * The external volume. This naming has existed since API 21, but no constant existed for it + * until API 29. This will work on all versions that Auxio supports. + */ + @Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL + } +} + +// Note: The separation between version-specific backends may not be the cleanest. To preserve +// speed, we only want to add redundancy on known issues, not with possible issues. + +// 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. + +private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : + BaseMediaStoreExtractor(context, musicSettings) { + 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 wrapQuery( + cursor: Cursor, + genreNamesMap: Map, + ): MediaStoreExtractor.Query = + Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + + private class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : BaseMediaStoreExtractor.Query(cursor, genreNamesMap) { + // Set up cursor indices for later use. + private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) + private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) + private val volumes = storageManager.storageVolumesCompat + + override fun populateFileInfo(rawSong: RawSong) { + super.populateFileInfo(rawSong) + + 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 (rawSong.fileName == null) { + rawSong.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) { + rawSong.directory = Directory.from(volume, strippedPath) + break + } + } + } + + override fun populateTags(rawSong: RawSong) { + super.populateTags(rawSong) + // See unpackTrackNo/unpackDiscNo for an explanation + // of how this column is set up. + val rawTrack = cursor.getIntOrNull(trackIndex) + if (rawTrack != null) { + rawTrack.unpackTrackNo()?.let { rawSong.track = it } + rawTrack.unpackDiscNo()?.let { rawSong.disc = it } + } + } + } +} + +/** + * A [BaseMediaStoreExtractor] that implements common behavior supported from API 29 onwards. + * @param context [Context] required to query the media database. + * @author Alexander Capehart (OxygenCobalt) + */ +@RequiresApi(Build.VERSION_CODES.Q) +private abstract class BaseApi29MediaStoreExtractor( + context: Context, + musicSettings: MusicSettings +) : BaseMediaStoreExtractor(context, musicSettings) { + override val projection: Array + get() = + super.projection + + arrayOf( + // After API 29, we now have access to the volume name and relative + // path, which simplifies working with Paths significantly. + MediaStore.Audio.AudioColumns.VOLUME_NAME, + MediaStore.Audio.AudioColumns.RELATIVE_PATH) + + // The selector should be configured to compare both the volume name and relative path + // of the given directories, albeit with some conversion to the analogous MediaStore + // column values. + + override val dirSelectorTemplate: String + get() = + "(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " + + "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)" + + override fun addDirToSelector(dir: Directory, args: MutableList): Boolean { + // MediaStore uses a different naming scheme for it's volume column convert this + // directory's volume to it. + args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false) + // "%" signifies to accept any DATA value that begins with the Directory's path, + // thus recursively filtering all files in the directory. + args.add("${dir.relativePath}%") + return true + } + + abstract class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : BaseMediaStoreExtractor.Query(cursor, genreNamesMap) { + private val volumeIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME) + private val relativePathIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH) + private val volumes = storageManager.storageVolumesCompat + + final override fun populateFileInfo(rawSong: RawSong) { + super.populateFileInfo(rawSong) + // Find the StorageVolume whose MediaStore name corresponds to this song. + // This is combined with the plain relative path column to create the directory. + val volumeName = cursor.getString(volumeIndex) + val relativePath = cursor.getString(relativePathIndex) + val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } + if (volume != null) { + rawSong.directory = Directory.from(volume, relativePath) + } + } + } +} + +/** + * A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible with at + * API + * 29. + * @param context [Context] required to query the media database. + * @author Alexander Capehart (OxygenCobalt) + */ +@RequiresApi(Build.VERSION_CODES.Q) +private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : + BaseApi29MediaStoreExtractor(context, musicSettings) { + + override val projection: Array + get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) + + override fun wrapQuery( + cursor: Cursor, + genreNamesMap: Map + ): MediaStoreExtractor.Query = + Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + + private class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) { + private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) + override fun populateTags(rawSong: RawSong) { + super.populateTags(rawSong) + // This extractor is volume-aware, but does not support the modern track columns. + // Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation + // of how this column is set up. + val rawTrack = cursor.getIntOrNull(trackIndex) + if (rawTrack != null) { + rawTrack.unpackTrackNo()?.let { rawSong.track = it } + rawTrack.unpackDiscNo()?.let { rawSong.disc = it } + } + } + } +} + +/** + * A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible from API + * 30 onwards. + * @param context [Context] required to query the media database. + * @author Alexander Capehart (OxygenCobalt) + */ +@RequiresApi(Build.VERSION_CODES.R) +private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : + BaseApi29MediaStoreExtractor(context, musicSettings) { + override val projection: Array + get() = + super.projection + + arrayOf( + // API 30 grant us access to the superior CD_TRACK_NUMBER and DISC_NUMBER + // fields, which take the place of TRACK. + MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, + MediaStore.Audio.AudioColumns.DISC_NUMBER) + + override fun wrapQuery( + cursor: Cursor, + genreNamesMap: Map + ): MediaStoreExtractor.Query = + Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + + private class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) { + private val trackIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) + private val discIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER) + + override fun populateTags(rawSong: RawSong) { + super.populateTags(rawSong) + // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in + // the tag itself, which is to say that it is formatted as NN/TT tracks, where + // N is the number and T is the total. Parse the number while ignoring the + // total, as we have no use for it. + cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { + rawSong.track = it + } + cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it } + } + } +} + +/** + * Unpack the track number from a combined track + disc [Int] field. These fields appear within + * MediaStore's TRACK column, and combine the track and disc value into a single field where the + * disc number is the 4th+ digit. + * @return The track number extracted from the combined integer value, or null if the value was + * zero. + */ +private fun Int.unpackTrackNo() = transformPositionField(mod(1000), null) + +/** + * Unpack the disc number from a combined track + disc [Int] field. These fields appear within + * MediaStore's TRACK column, and combine the track and disc value into a single field where the + * disc number is the 4th+ digit. + * @return The disc number extracted from the combined integer field, or null if the value was zero. + */ +private fun Int.unpackDiscNo() = transformPositionField(div(1000), null) diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt index 3db2c0dff..f6ea77464 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt @@ -28,6 +28,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.view.ViewCompat import androidx.core.view.isVisible +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding @@ -41,11 +43,13 @@ import org.oxycblt.auxio.util.showToast * Dialog that manages the music dirs setting. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class MusicDirsDialog : ViewBindingDialogFragment(), DirectoryAdapter.Listener { private val dirAdapter = DirectoryAdapter(this) private var openDocumentTreeLauncher: ActivityResultLauncher? = null private var storageManager: StorageManager? = null + @Inject lateinit var musicSettings: MusicSettings override fun onCreateBinding(inflater: LayoutInflater) = DialogMusicDirsBinding.inflate(inflater) @@ -55,11 +59,10 @@ class MusicDirsDialog : .setTitle(R.string.set_dirs) .setNegativeButton(R.string.lbl_cancel, null) .setPositiveButton(R.string.lbl_save) { _, _ -> - val settings = MusicSettings.from(requireContext()) val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding())) - if (settings.musicDirs != newDirs) { + if (musicSettings.musicDirs != newDirs) { logD("Committing changes") - settings.musicDirs = newDirs + musicSettings.musicDirs = newDirs } } } @@ -96,7 +99,7 @@ class MusicDirsDialog : itemAnimator = null } - var dirs = MusicSettings.from(context).musicDirs + var dirs = musicSettings.musicDirs if (savedInstanceState != null) { val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) if (pendingDirs != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt new file mode 100644 index 000000000..6869788d1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.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.storage + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.oxycblt.auxio.music.MusicSettings + +@Module +@InstallIn(SingletonComponent::class) +class StorageModule { + @Provides + fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) = + MediaStoreExtractor.from(context, musicSettings) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt index 45215a8d3..5820a096d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -22,14 +22,24 @@ import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.core.content.ContextCompat +import java.util.LinkedList +import javax.inject.Inject import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.extractor.* -import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.cache.CacheRepository +import org.oxycblt.auxio.music.metadata.TagExtractor +import org.oxycblt.auxio.music.model.Library +import org.oxycblt.auxio.music.model.RawSong +import org.oxycblt.auxio.music.storage.MediaStoreExtractor import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW @@ -39,28 +49,20 @@ import org.oxycblt.auxio.util.logW * * This class provides low-level access into the exact state of the music loading process. **This * class should not be used in most cases.** It is highly volatile and provides far more information - * than is usually needed. Use [MusicStore] instead if you do not need to work with the exact music - * loading state. + * than is usually needed. Use [MusicRepository] instead if you do not need to work with the exact + * music loading state. * * @author Alexander Capehart (OxygenCobalt) */ -class Indexer private constructor() { - @Volatile private var lastResponse: Result? = null - @Volatile private var indexingState: Indexing? = null - @Volatile private var controller: Controller? = null - @Volatile private var listener: Listener? = null - +interface Indexer { /** Whether music loading is occurring or not. */ val isIndexing: Boolean - get() = indexingState != null - /** * Whether this instance has not completed a loading process and is not currently loading music. * This often occurs early in an app's lifecycle, and consumers should try to avoid showing any * state when this flag is true. */ val isIndeterminate: Boolean - get() = lastResponse == null && indexingState == null /** * Register a [Controller] for this instance. This instance will handle any commands to start @@ -68,19 +70,7 @@ class Indexer private constructor() { * [Listener] methods to initialize the instance with the current state. * @param controller The [Controller] to register. Will do nothing if already registered. */ - @Synchronized - fun registerController(controller: Controller) { - if (BuildConfig.DEBUG && this.controller != null) { - logW("Controller is already registered") - return - } - - // Initialize the controller with the current state. - val currentState = - indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } - controller.onIndexerStateChanged(currentState) - this.controller = controller - } + fun registerController(controller: Controller) /** * Unregister the [Controller] from this instance, prevent it from recieving any further @@ -88,15 +78,7 @@ class Indexer private constructor() { * @param controller The [Controller] to unregister. Must be the current [Controller]. Does * nothing if invoked by another [Controller] implementation. */ - @Synchronized - fun unregisterController(controller: Controller) { - if (BuildConfig.DEBUG && this.controller !== controller) { - logW("Given controller did not match current controller") - return - } - - this.controller = null - } + fun unregisterController(controller: Controller) /** * Register the [Listener] for this instance. This can be used to receive rapid-fire updates to @@ -104,19 +86,7 @@ class Indexer private constructor() { * [Listener] methods to initialize the instance with the current state. * @param listener The [Listener] to add. */ - @Synchronized - fun registerListener(listener: Listener) { - if (BuildConfig.DEBUG && this.listener != null) { - logW("Listener is already registered") - return - } - - // Initialize the listener with the current state. - val currentState = - indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } - listener.onIndexerStateChanged(currentState) - this.listener = listener - } + fun registerListener(listener: Listener) /** * Unregister a [Listener] from this instance, preventing it from recieving any further updates. @@ -124,15 +94,7 @@ class Indexer private constructor() { * invoked by another [Listener] implementation. * @see Listener */ - @Synchronized - fun unregisterListener(listener: Listener) { - if (BuildConfig.DEBUG && this.listener !== listener) { - logW("Given controller did not match current controller") - return - } - - this.listener = null - } + fun unregisterListener(listener: Listener) /** * Start the indexing process. This should be done from in the background from [Controller]'s @@ -140,172 +102,25 @@ class Indexer private constructor() { * @param context [Context] required to load music. * @param withCache Whether to use the cache or not when loading. If false, the cache will still * be written, but no cache entries will be loaded into the new library. + * @param scope The [CoroutineScope] to run the indexing job in. + * @return The [Job] stacking the indexing status. */ - suspend fun index(context: Context, withCache: Boolean) { - val result = - try { - val start = System.currentTimeMillis() - val library = indexImpl(context, withCache) - logD( - "Music indexing completed successfully in " + - "${System.currentTimeMillis() - start}ms") - Result.success(library) - } catch (e: CancellationException) { - // Got cancelled, propagate upwards to top-level co-routine. - logD("Loading routine was cancelled") - throw e - } catch (e: Exception) { - // Music loading process failed due to something we have not handled. - logE("Music indexing failed") - logE(e.stackTraceToString()) - Result.failure(e) - } - emitCompletion(result) - } + fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job /** * Request that the music library should be reloaded. This should be used by components that do - * not manage the indexing process in order to signal that the [Controller] should call [index] - * eventually. + * not manage the indexing process in order to signal that the [Indexer.Controller] should call + * [index] eventually. * @param withCache Whether to use the cache when loading music. Does nothing if there is no - * [Controller]. + * [Indexer.Controller]. */ - @Synchronized - fun requestReindex(withCache: Boolean) { - logD("Requesting reindex") - controller?.onStartIndexing(withCache) - } + fun requestReindex(withCache: Boolean) /** * Reset the current loading state to signal that the instance is not loading. This should be * called by [Controller] after it's indexing co-routine was cancelled. */ - @Synchronized - fun reset() { - logD("Cancelling last job") - emitIndexing(null) - } - - /** - * Internal implementation of the music loading process. - * @param context [Context] required to load music. - * @param withCache Whether to use the cache or not when loading. If false, the cache will still - * be written, but no cache entries will be loaded into the new library. - * @return A newly-loaded [Library]. - * @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted. - * @throws NoMusicException If no music was found on the device. - */ - private suspend fun indexImpl(context: Context, withCache: Boolean): Library { - if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) == - PackageManager.PERMISSION_DENIED) { - // No permissions, signal that we can't do anything. - throw NoPermissionException() - } - - // Create the chain of extractors. Each extractor builds on the previous and - // enables version-specific features in order to create the best possible music - // experience. - val cacheDatabase = - if (withCache) { - ReadWriteCacheExtractor(context) - } else { - WriteOnlyCacheExtractor(context) - } - val mediaStoreExtractor = - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> - Api30MediaStoreExtractor(context, cacheDatabase) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - Api29MediaStoreExtractor(context, cacheDatabase) - else -> Api21MediaStoreExtractor(context, cacheDatabase) - } - val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor) - val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() } - // Build the rest of the music library from the song list. This is much more powerful - // and reliable compared to using MediaStore to obtain grouping information. - val buildStart = System.currentTimeMillis() - val library = Library(rawSongs, MusicSettings.from(context)) - logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms") - return library - } - - /** - * Load a list of [Song]s from the device. - * @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw] - * instances. - * @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked - * with parent [Album], [Artist], and [Genre] items in order to be usable. - */ - private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List { - logD("Starting indexing process") - val start = System.currentTimeMillis() - // Start initializing the extractors. Use an indeterminate state, as there is no ETA on - // how long a media database query will take. - emitIndexing(Indexing.Indeterminate) - val total = metadataExtractor.init() - yield() - - // Note: We use a set here so we can eliminate song duplicates. - val rawSongs = mutableListOf() - metadataExtractor.extract().collect { rawSong -> - rawSongs.add(rawSong) - // Now we can signal a defined progress by showing how many songs we have - // loaded, and the projected amount of songs we found in the library - // (obtained by the extractors) - yield() - emitIndexing(Indexing.Songs(rawSongs.size, total)) - } - - // Finalize the extractors with the songs we have now loaded. There is no ETA - // on this process, so go back to an indeterminate state. - emitIndexing(Indexing.Indeterminate) - metadataExtractor.finalize(rawSongs) - logD( - "Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms") - return rawSongs - } - - /** - * Emit a new [State.Indexing] state. This can be used to signal the current state of the music - * loading process to external code. Assumes that the callee has already checked if they have - * not been canceled and thus have the ability to emit a new state. - * @param indexing The new [Indexing] state to emit, or null if no loading process is occurring. - */ - @Synchronized - private fun emitIndexing(indexing: Indexing?) { - indexingState = indexing - // If we have canceled the loading process, we want to revert to a previous completion - // whenever possible to prevent state inconsistency. - val state = - indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) } - controller?.onIndexerStateChanged(state) - listener?.onIndexerStateChanged(state) - } - - /** - * Emit a new [State.Complete] state. This can be used to signal the completion of the music - * loading process to external code. Will check if the callee has not been canceled and thus has - * the ability to emit a new state - * @param result The new [Result] to emit, representing the outcome of the music loading - * process. - */ - private suspend fun emitCompletion(result: Result) { - yield() - // Swap to the Main thread so that downstream callbacks don't crash from being on - // a background thread. Does not occur in emitIndexing due to efficiency reasons. - withContext(Dispatchers.Main) { - synchronized(this) { - // Do not check for redundancy here, as we actually need to notify a switch - // from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete. - lastResponse = result - indexingState = null - // Signal that the music loading process has been completed. - val state = State.Complete(result) - controller?.onIndexerStateChanged(state) - listener?.onIndexerStateChanged(state) - } - } - } + fun reset() /** Represents the current state of [Indexer]. */ sealed class State { @@ -357,8 +172,8 @@ class Indexer private constructor() { * A listener for rapid-fire changes in the music loading state. * * This is only useful for code that absolutely must show the current loading process. - * Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of - * the [Library]. + * Otherwise, [MusicRepository.Listener] is highly recommended due to it's updates only + * consisting of the [Library]. */ interface Listener { /** @@ -388,8 +203,6 @@ class Indexer private constructor() { } companion object { - @Volatile private var INSTANCE: Indexer? = null - /** * A version-compatible identifier for the read external storage permission required by the * system to load audio. @@ -401,21 +214,218 @@ class Indexer private constructor() { } else { Manifest.permission.READ_EXTERNAL_STORAGE } + } +} - /** - * Get a singleton instance. - * @return The (possibly newly-created) singleton instance. - */ - fun getInstance(): Indexer { - val currentInstance = INSTANCE - if (currentInstance != null) { - return currentInstance +class IndexerImpl +@Inject +constructor( + private val musicSettings: MusicSettings, + private val cacheRepository: CacheRepository, + private val mediaStoreExtractor: MediaStoreExtractor, + private val tagExtractor: TagExtractor +) : Indexer { + @Volatile private var lastResponse: Result? = null + @Volatile private var indexingState: Indexer.Indexing? = null + @Volatile private var controller: Indexer.Controller? = null + @Volatile private var listener: Indexer.Listener? = null + + override val isIndexing: Boolean + get() = indexingState != null + + override val isIndeterminate: Boolean + get() = lastResponse == null && indexingState == null + + @Synchronized + override fun registerController(controller: Indexer.Controller) { + if (BuildConfig.DEBUG && this.controller != null) { + logW("Controller is already registered") + return + } + + // Initialize the controller with the current state. + val currentState = + indexingState?.let { Indexer.State.Indexing(it) } + ?: lastResponse?.let { Indexer.State.Complete(it) } + controller.onIndexerStateChanged(currentState) + this.controller = controller + } + + @Synchronized + override fun unregisterController(controller: Indexer.Controller) { + if (BuildConfig.DEBUG && this.controller !== controller) { + logW("Given controller did not match current controller") + return + } + + this.controller = null + } + + @Synchronized + override fun registerListener(listener: Indexer.Listener) { + if (BuildConfig.DEBUG && this.listener != null) { + logW("Listener is already registered") + return + } + + // Initialize the listener with the current state. + val currentState = + indexingState?.let { Indexer.State.Indexing(it) } + ?: lastResponse?.let { Indexer.State.Complete(it) } + listener.onIndexerStateChanged(currentState) + this.listener = listener + } + + @Synchronized + override fun unregisterListener(listener: Indexer.Listener) { + if (BuildConfig.DEBUG && this.listener !== listener) { + logW("Given controller did not match current controller") + return + } + + this.listener = null + } + + override fun index(context: Context, withCache: Boolean, scope: CoroutineScope) = + scope.launch { + val result = + try { + val start = System.currentTimeMillis() + val library = indexImpl(context, withCache, this) + logD( + "Music indexing completed successfully in " + + "${System.currentTimeMillis() - start}ms") + Result.success(library) + } catch (e: CancellationException) { + // Got cancelled, propagate upwards to top-level co-routine. + logD("Loading routine was cancelled") + throw e + } catch (e: Exception) { + // Music loading process failed due to something we have not handled. + logE("Music indexing failed") + logE(e.stackTraceToString()) + Result.failure(e) + } + emitCompletion(result) + } + + @Synchronized + override fun requestReindex(withCache: Boolean) { + logD("Requesting reindex") + controller?.onStartIndexing(withCache) + } + + @Synchronized + override fun reset() { + logD("Cancelling last job") + emitIndexing(null) + } + + private suspend fun indexImpl( + context: Context, + withCache: Boolean, + scope: CoroutineScope + ): Library { + if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) == + PackageManager.PERMISSION_DENIED) { + logE("Permission check failed") + // No permissions, signal that we can't do anything. + throw Indexer.NoPermissionException() + } + + // Start initializing the extractors. Use an indeterminate state, as there is no ETA on + // how long a media database query will take. + emitIndexing(Indexer.Indexing.Indeterminate) + + // Do the initial query of the cache and media databases in parallel. + logD("Starting queries") + val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() } + val cache = + if (withCache) { + cacheRepository.readCache() + } else { + null } + val query = mediaStoreQueryJob.await() + // Now start processing the queried song information in parallel. Songs that can't be + // received from the cache are consisted incomplete and pushed to a separate channel + // that will eventually be processed into completed raw songs. + logD("Starting song discovery") + val completeSongs = Channel(Channel.UNLIMITED) + val incompleteSongs = Channel(Channel.UNLIMITED) + val mediaStoreJob = + scope.async { + mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) + } + val metadataJob = scope.async { tagExtractor.consume(incompleteSongs, completeSongs) } + + // Await completed raw songs as they are processed. + val rawSongs = LinkedList() + for (rawSong in completeSongs) { + rawSongs.add(rawSong) + emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal)) + } + // These should be no-ops + mediaStoreJob.await() + metadataJob.await() + + if (rawSongs.isEmpty()) { + logE("Music library was empty") + throw Indexer.NoMusicException() + } + + // Successfully loaded the library, now save the cache and create the library in + // parallel. + logD("Discovered ${rawSongs.size} songs, starting finalization") + emitIndexing(Indexer.Indexing.Indeterminate) + val libraryJob = scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } + if (cache == null || cache.invalidated) { + cacheRepository.writeCache(rawSongs) + } + return libraryJob.await() + } + + /** + * Emit a new [Indexer.State.Indexing] state. This can be used to signal the current state of + * the music loading process to external code. Assumes that the callee has already checked if + * they have not been canceled and thus have the ability to emit a new state. + * @param indexing The new [Indexer.Indexing] state to emit, or null if no loading process is + * occurring. + */ + @Synchronized + private fun emitIndexing(indexing: Indexer.Indexing?) { + indexingState = indexing + // If we have canceled the loading process, we want to revert to a previous completion + // whenever possible to prevent state inconsistency. + val state = + indexingState?.let { Indexer.State.Indexing(it) } + ?: lastResponse?.let { Indexer.State.Complete(it) } + controller?.onIndexerStateChanged(state) + listener?.onIndexerStateChanged(state) + } + + /** + * Emit a new [Indexer.State.Complete] state. This can be used to signal the completion of the + * music loading process to external code. Will check if the callee has not been canceled and + * thus has the ability to emit a new state + * @param result The new [Result] to emit, representing the outcome of the music loading + * process. + */ + private suspend fun emitCompletion(result: Result) { + yield() + // Swap to the Main thread so that downstream callbacks don't crash from being on + // a background thread. Does not occur in emitIndexing due to efficiency reasons. + withContext(Dispatchers.Main) { synchronized(this) { - val newInstance = Indexer() - INSTANCE = newInstance - return newInstance + // Do not check for redundancy here, as we actually need to notify a switch + // from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete. + lastResponse = result + indexingState = null + // Signal that the music loading process has been completed. + val state = Indexer.State.Complete(result) + controller?.onIndexerStateChanged(state) + listener?.onIndexerStateChanged(state) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 6358cb9ce..81e22ce51 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -25,14 +25,15 @@ import android.os.IBinder import android.os.Looper import android.os.PowerManager import android.provider.MediaStore -import coil.imageLoader +import coil.ImageLoader +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.service.ForegroundManager @@ -53,10 +54,13 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { - private val indexer = Indexer.getInstance() - private val musicStore = MusicStore.getInstance() - private val playbackManager = PlaybackStateManager.getInstance() + @Inject lateinit var imageLoader: ImageLoader + @Inject lateinit var musicRepository: MusicRepository + @Inject lateinit var indexer: Indexer + @Inject lateinit var musicSettings: MusicSettings + @Inject lateinit var playbackManager: PlaybackStateManager private val serviceJob = Job() private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private var currentIndexJob: Job? = null @@ -65,7 +69,6 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { private lateinit var observingNotification: ObservingNotification private lateinit var wakeLock: PowerManager.WakeLock private lateinit var indexerContentObserver: SystemContentObserver - private lateinit var settings: MusicSettings override fun onCreate() { super.onCreate() @@ -80,12 +83,11 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. indexerContentObserver = SystemContentObserver() - settings = MusicSettings.from(this) - settings.registerListener(this) + musicSettings.registerListener(this) indexer.registerController(this) // An indeterminate indexer and a missing library implies we are extremely early // in app initialization so start loading music. - if (musicStore.library == null && indexer.isIndeterminate) { + if (musicRepository.library == null && indexer.isIndeterminate) { logD("No library present and no previous response, indexing music now") onStartIndexing(true) } @@ -105,7 +107,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // Then cancel the listener-dependent components to ensure that stray reloading // events will not occur. indexerContentObserver.release() - settings.unregisterListener(this) + musicSettings.unregisterListener(this) indexer.unregisterController(this) // Then cancel any remaining music loading jobs. serviceJob.cancel() @@ -121,7 +123,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { indexer.reset() } // Start a new music loading job on a co-routine. - currentIndexJob = indexScope.launch { indexer.index(this@IndexerService, withCache) } + currentIndexJob = indexer.index(this@IndexerService, withCache, indexScope) } override fun onIndexerStateChanged(state: Indexer.State?) { @@ -129,20 +131,31 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { is Indexer.State.Indexing -> updateActiveSession(state.indexing) is Indexer.State.Complete -> { val newLibrary = state.result.getOrNull() - if (newLibrary != null && newLibrary != musicStore.library) { + if (newLibrary != null && newLibrary != musicRepository.library) { logD("Applying new library") // We only care if the newly-loaded library is going to replace a previously // loaded library. - if (musicStore.library != null) { + if (musicRepository.library != null) { // Wipe possibly-invalidated outdated covers imageLoader.memoryCache?.clear() // Clear invalid models from PlaybackStateManager. This is not connected // to a listener as it is bad practice for a shared object to attach to // the listener system of another. - playbackManager.sanitize(newLibrary) + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + PlaybackStateManager.SavedState( + parent = savedState.parent?.let(newLibrary::sanitize), + queueState = + savedState.queueState.remap { song -> + newLibrary.sanitize(requireNotNull(song)) + }, + positionMs = savedState.positionMs, + repeatMode = savedState.repeatMode), + true) + } } // Forward the new library to MusicStore to continue the update process. - musicStore.library = newLibrary + musicRepository.library = newLibrary } // On errors, while we would want to show a notification that displays the // error, that requires the Android 13 notification permission, which is not @@ -184,7 +197,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { * currently monitoring the music library for changes. */ private fun updateIdleSession() { - if (settings.shouldBeObserving) { + if (musicSettings.shouldBeObserving) { // There are a few reasons why we stay in the foreground with automatic rescanning: // 1. Newer versions of Android have become more and more restrictive regarding // how a foreground service starts. Thus, it's best to go foreground now so that @@ -274,7 +287,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { override fun run() { // Check here if we should even start a reindex. This is much less bug-prone than // registering and de-registering this component as this setting changes. - if (settings.shouldBeObserving) { + if (musicSettings.shouldBeObserving) { onStartIndexing(true) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt index 857a55a1e..48cc378ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/ArtistChoiceAdapter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.picker import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt similarity index 95% rename from app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt index 99be53312..e998e9976 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistNavigationPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/ArtistNavigationPickerDialog.kt @@ -15,12 +15,13 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.picker import android.os.Bundle import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.ui.NavigationViewModel @@ -29,6 +30,7 @@ import org.oxycblt.auxio.ui.NavigationViewModel * An [ArtistPickerDialog] intended for when [Artist] navigation is ambiguous. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class ArtistNavigationPickerDialog : ArtistPickerDialog() { private val navModel: NavigationViewModel by activityViewModels() // Information about what Song to show choices for is initially within the navigation arguments diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt similarity index 96% rename from app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt index 0e537e3ea..a4a57eda2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/ArtistPickerDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.picker import android.os.Bundle import android.view.LayoutInflater @@ -23,6 +23,7 @@ import androidx.appcompat.app.AlertDialog import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.list.ClickableListListener @@ -36,6 +37,7 @@ import org.oxycblt.auxio.util.collectImmediately * to choose from. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint abstract class ArtistPickerDialog : ViewBindingDialogFragment(), ClickableListListener { protected val pickerModel: PickerViewModel by viewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt similarity index 90% rename from app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt index 186404a9d..ea1916fda 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/ArtistPlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/ArtistPlaybackPickerDialog.kt @@ -15,16 +15,17 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.picker import android.os.Bundle +import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel -import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.requireIs import org.oxycblt.auxio.util.unlikelyToBeNull @@ -32,8 +33,9 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class ArtistPlaybackPickerDialog : ArtistPickerDialog() { - private val playbackModel: PlaybackViewModel by androidActivityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() // Information about what Song to show choices for is initially within the navigation arguments // as UIDs, as that is the only safe way to parcel a Song. private val args: ArtistPlaybackPickerDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt b/app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt index b2ddef425..2cea397b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenreChoiceAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/GenreChoiceAdapter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.picker import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt b/app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt similarity index 94% rename from app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt rename to app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt index dc7b3d6af..81e5607b7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/GenrePlaybackPickerDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/GenrePlaybackPickerDialog.kt @@ -15,15 +15,17 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.picker import android.os.Bundle import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.list.ClickableListListener @@ -31,7 +33,6 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingDialogFragment -import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.requireIs import org.oxycblt.auxio.util.unlikelyToBeNull @@ -40,10 +41,11 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class GenrePlaybackPickerDialog : ViewBindingDialogFragment(), ClickableListListener { private val pickerModel: PickerViewModel by viewModels() - private val playbackModel: PlaybackViewModel by androidActivityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() // Information about what Song to show choices for is initially within the navigation arguments // as UIDs, as that is the only safe way to parcel a Song. private val args: GenrePlaybackPickerDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt rename to app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt index c92334228..800f069ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt @@ -15,14 +15,15 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.picker +package org.oxycblt.auxio.picker 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 import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -30,8 +31,9 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * contain the music themselves and then exit if the library changes. * @author Alexander Capehart (OxygenCobalt) */ -class PickerViewModel : ViewModel(), MusicStore.Listener { - private val musicStore = MusicStore.getInstance() +@HiltViewModel +class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.Listener { private val _currentItem = MutableStateFlow(null) /** The current item whose artists should be shown in the picker. Null if there is no item. */ @@ -49,7 +51,7 @@ class PickerViewModel : ViewModel(), MusicStore.Listener { get() = _genreChoices override fun onCleared() { - musicStore.removeListener(this) + musicRepository.removeListener(this) } override fun onLibraryChanged(library: Library?) { @@ -63,7 +65,7 @@ class PickerViewModel : ViewModel(), MusicStore.Listener { * @param uid The [Music.UID] of the [Song] to update to. */ fun setItemUid(uid: Music.UID) { - val library = unlikelyToBeNull(musicStore.library) + val library = unlikelyToBeNull(musicRepository.library) _currentItem.value = library.find(uid) refreshChoices() } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt index 5addedb70..5ecf71d21 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt @@ -20,14 +20,15 @@ package org.oxycblt.auxio.playback import android.os.Bundle import android.view.LayoutInflater import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getColorCompat @@ -36,8 +37,9 @@ import org.oxycblt.auxio.util.getColorCompat * A [ViewBindingFragment] that shows the current playback state in a compact manner. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class PlaybackBarFragment : ViewBindingFragment() { - private val playbackModel: PlaybackViewModel by androidActivityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels() override fun onCreateBinding(inflater: LayoutInflater) = @@ -121,7 +123,7 @@ class PlaybackBarFragment : ViewBindingFragment() { val binding = requireBinding() binding.playbackCover.bind(song) binding.playbackSong.text = song.resolveName(context) - binding.playbackInfo.text = song.resolveArtistContents(context) + binding.playbackInfo.text = song.artists.resolveNames(context) binding.playbackProgressBar.max = song.durationMs.msToDs().toInt() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt new file mode 100644 index 000000000..dc05fc8c1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackModule.kt @@ -0,0 +1,35 @@ +/* + * 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.playback + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.PlaybackStateManagerImpl + +@Module +@InstallIn(SingletonComponent::class) +interface PlaybackModule { + @Singleton + @Binds + fun stateManager(playbackManager: PlaybackStateManagerImpl): PlaybackStateManager + @Binds fun settings(playbackSettings: PlaybackSettingsImpl): PlaybackSettings +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index d5450722d..49f018f85 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -28,17 +28,18 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.Toolbar import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.systemBarInsetsCompat @@ -48,11 +49,12 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * available controls. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class PlaybackPanelFragment : ViewBindingFragment(), Toolbar.OnMenuItemClickListener, StyledSeekBar.Listener { - private val playbackModel: PlaybackViewModel by androidActivityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels() private var equalizerLauncher: ActivityResultLauncher? = null @@ -183,7 +185,7 @@ class PlaybackPanelFragment : val context = requireContext() binding.playbackCover.bind(song) binding.playbackSong.text = song.resolveName(context) - binding.playbackArtist.text = song.resolveArtistContents(context) + binding.playbackArtist.text = song.artists.resolveNames(context) binding.playbackAlbum.text = song.album.resolveName(context) binding.playbackSeekBar.durationDs = song.durationMs.msToDs() } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt index 8c3d36b7a..218c83b89 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSettings.kt @@ -19,6 +19,8 @@ package org.oxycblt.auxio.playback import android.content.Context import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicMode @@ -65,154 +67,143 @@ interface PlaybackSettings : Settings { /** Called when [notificationAction] has changed. */ fun onNotificationActionChanged() {} } +} - private class Real(context: Context) : Settings.Real(context), PlaybackSettings { - override val inListPlaybackMode: MusicMode - get() = - MusicMode.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE)) +class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Context) : + Settings.Impl(context), PlaybackSettings { + override val inListPlaybackMode: MusicMode + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE)) + ?: MusicMode.SONGS + + override val inParentPlaybackMode: MusicMode? + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt( + getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE)) + + override val barAction: ActionMode + get() = + ActionMode.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE)) + ?: ActionMode.NEXT + + override val notificationAction: ActionMode + get() = + ActionMode.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_notif_action), Int.MIN_VALUE)) + ?: ActionMode.REPEAT + + override val headsetAutoplay: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false) + + override val replayGainMode: ReplayGainMode + get() = + ReplayGainMode.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) + ?: ReplayGainMode.DYNAMIC + + override var replayGainPreAmp: ReplayGainPreAmp + get() = + ReplayGainPreAmp( + sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f), + sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f)) + set(value) { + sharedPreferences.edit { + putFloat(getString(R.string.set_key_pre_amp_with), value.with) + putFloat(getString(R.string.set_key_pre_amp_without), value.without) + apply() + } + } + + override val keepShuffle: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true) + + override val rewindWithPrev: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true) + + override val pauseOnRepeat: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false) + + override fun migrate() { + // "Use alternate notification action" was converted to an ActionMode setting in 3.0.0. + if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) { + logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION") + + val mode = + if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) { + ActionMode.SHUFFLE + } else { + ActionMode.REPEAT + } + + sharedPreferences.edit { + putInt(getString(R.string.set_key_notif_action), mode.intCode) + remove(OLD_KEY_ALT_NOTIF_ACTION) + apply() + } + } + + // PlaybackMode was converted to MusicMode in 3.0.0 + + fun Int.migratePlaybackMode() = + when (this) { + // Convert PlaybackMode into MusicMode + IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS + IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS + IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS + IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES + else -> null + } + + if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE") + + val mode = + sharedPreferences + .getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS) + .migratePlaybackMode() ?: MusicMode.SONGS - override val inParentPlaybackMode: MusicMode? - get() = - MusicMode.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE)) - - override val barAction: ActionMode - get() = - ActionMode.fromIntCode( - sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE)) - ?: ActionMode.NEXT - - override val notificationAction: ActionMode - get() = - ActionMode.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_notif_action), Int.MIN_VALUE)) - ?: ActionMode.REPEAT - - override val headsetAutoplay: Boolean - get() = - sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false) - - override val replayGainMode: ReplayGainMode - get() = - ReplayGainMode.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_replay_gain), Int.MIN_VALUE)) - ?: ReplayGainMode.DYNAMIC - - override var replayGainPreAmp: ReplayGainPreAmp - get() = - ReplayGainPreAmp( - sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f), - sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f)) - set(value) { - sharedPreferences.edit { - putFloat(getString(R.string.set_key_pre_amp_with), value.with) - putFloat(getString(R.string.set_key_pre_amp_without), value.without) - apply() - } - } - - override val keepShuffle: Boolean - get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true) - - override val rewindWithPrev: Boolean - get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true) - - override val pauseOnRepeat: Boolean - get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false) - - override fun migrate() { - // "Use alternate notification action" was converted to an ActionMode setting in 3.0.0. - if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) { - logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION") - - val mode = - if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) { - ActionMode.SHUFFLE - } else { - ActionMode.REPEAT - } - - sharedPreferences.edit { - putInt(getString(R.string.set_key_notif_action), mode.intCode) - remove(OLD_KEY_ALT_NOTIF_ACTION) - apply() - } - } - - // PlaybackMode was converted to MusicMode in 3.0.0 - - fun Int.migratePlaybackMode() = - when (this) { - // Convert PlaybackMode into MusicMode - IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS - IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS - IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS - IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES - else -> null - } - - if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) { - logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE") - - val mode = - sharedPreferences - .getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS) - .migratePlaybackMode() - ?: MusicMode.SONGS - - sharedPreferences.edit { - putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode) - remove(OLD_KEY_LIB_PLAYBACK_MODE) - apply() - } - } - - if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) { - logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE") - - val mode = - sharedPreferences - .getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE) - .migratePlaybackMode() - - sharedPreferences.edit { - putInt( - getString(R.string.set_key_in_parent_playback_mode), - mode?.intCode ?: Int.MIN_VALUE) - remove(OLD_KEY_DETAIL_PLAYBACK_MODE) - apply() - } + sharedPreferences.edit { + putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode) + remove(OLD_KEY_LIB_PLAYBACK_MODE) + apply() } } - override fun onSettingChanged(key: String, listener: Listener) { - when (key) { - getString(R.string.set_key_replay_gain), - getString(R.string.set_key_pre_amp_with), - getString(R.string.set_key_pre_amp_without) -> - listener.onReplayGainSettingsChanged() - getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged() - } - } + if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) { + logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE") - private companion object { - const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" - const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" - const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" + val mode = + sharedPreferences + .getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE) + .migratePlaybackMode() + + sharedPreferences.edit { + putInt( + getString(R.string.set_key_in_parent_playback_mode), + mode?.intCode ?: Int.MIN_VALUE) + remove(OLD_KEY_DETAIL_PLAYBACK_MODE) + apply() + } } } - companion object { - /** - * Get a framework-backed implementation. - * @param context [Context] required. - */ - fun from(context: Context): PlaybackSettings = Real(context) + override fun onSettingChanged(key: String, listener: PlaybackSettings.Listener) { + when (key) { + getString(R.string.set_key_replay_gain), + getString(R.string.set_key_pre_amp_with), + getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged() + getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged() + } + } + + private companion object { + const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" + const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" + const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt index 097a9fab9..82b6a8fdd 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackViewModel.kt @@ -17,28 +17,34 @@ package org.oxycblt.auxio.playback -import android.app.Application -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.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.playback.persist.PersistenceRepository +import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.* -import org.oxycblt.auxio.util.context /** - * An [AndroidViewModel] that provides a safe UI frontend for the current playback state. + * An [ViewModel] that provides a safe UI frontend for the current playback state. * @author Alexander Capehart (OxygenCobalt) */ -class PlaybackViewModel(application: Application) : - AndroidViewModel(application), PlaybackStateManager.Listener { - private val musicSettings = MusicSettings.from(application) - private val playbackSettings = PlaybackSettings.from(application) - private val playbackManager = PlaybackStateManager.getInstance() - private val musicStore = MusicStore.getInstance() +@HiltViewModel +class PlaybackViewModel +@Inject +constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val persistenceRepository: PersistenceRepository, + private val musicRepository: MusicRepository, + private val musicSettings: MusicSettings +) : ViewModel(), PlaybackStateManager.Listener { private var lastPositionJob: Job? = null private val _song = MutableStateFlow(null) @@ -277,7 +283,7 @@ class PlaybackViewModel(application: Application) : check(song == null || parent == null || parent.songs.contains(song)) { "Song to play not in parent" } - val library = musicStore.library ?: return + val library = musicRepository.library ?: return val sort = when (parent) { is Genre -> musicSettings.genreSongSort @@ -428,8 +434,7 @@ class PlaybackViewModel(application: Application) : */ fun savePlaybackState(onDone: (Boolean) -> Unit) { viewModelScope.launch { - val saved = playbackManager.saveState(PlaybackStateDatabase.getInstance(context)) - onDone(saved) + onDone(persistenceRepository.saveState(playbackManager.toSavedState())) } } @@ -438,10 +443,7 @@ class PlaybackViewModel(application: Application) : * @param onDone Called when the wipe is completed with true if successful, and false otherwise. */ fun wipePlaybackState(onDone: (Boolean) -> Unit) { - viewModelScope.launch { - val wiped = playbackManager.wipeState(PlaybackStateDatabase.getInstance(context)) - onDone(wiped) - } + viewModelScope.launch { onDone(persistenceRepository.saveState(null)) } } /** @@ -451,9 +453,16 @@ class PlaybackViewModel(application: Application) : */ fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) { viewModelScope.launch { - val restored = - playbackManager.restoreState(PlaybackStateDatabase.getInstance(context), true) - onDone(restored) + val library = musicRepository.library + if (library != null) { + val savedState = persistenceRepository.readState(library) + if (savedState != null) { + playbackManager.applySavedState(savedState, true) + onDone(true) + return@launch + } + } + onDone(false) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt new file mode 100644 index 000000000..be41d0413 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -0,0 +1,156 @@ +/* + * 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.playback.persist + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +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.Music +import org.oxycblt.auxio.playback.state.RepeatMode + +/** + * Provides raw access to the database storing the persisted playback state. + * @author Alexander Capehart + */ +@Database( + entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class], + version = 27, + exportSchema = false) +@TypeConverters(PersistenceDatabase.Converters::class) +abstract class PersistenceDatabase : RoomDatabase() { + /** + * Get the current [PlaybackStateDao]. + * @return A [PlaybackStateDao] providing control of the database's playback state tables. + */ + abstract fun playbackStateDao(): PlaybackStateDao + + /** + * Get the current [QueueDao]. + * @return A [QueueDao] providing control of the database's queue tables. + */ + abstract fun queueDao(): QueueDao + + object Converters { + /** @see [Music.UID.toString] */ + @TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString() + + /** @see [Music.UID.fromString] */ + @TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString) + } +} + +/** + * Provides control of the persisted playback state table. + * @author Alexander Capehart (OxygenCobalt) + */ +@Dao +interface PlaybackStateDao { + /** + * Get the previously persisted [PlaybackState]. + * @return The previously persisted [PlaybackState], or null if one was not present. + */ + @Query("SELECT * FROM ${PlaybackState.TABLE_NAME} WHERE id = 0") + suspend fun getState(): PlaybackState? + + /** Delete any previously persisted [PlaybackState]s. */ + @Query("DELETE FROM ${PlaybackState.TABLE_NAME}") suspend fun nukeState() + + /** + * Insert a new [PlaybackState] into the database. + * @param state The [PlaybackState] to insert. + */ + @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertState(state: PlaybackState) +} + +/** + * Provides control of the persisted queue state tables. + * @author Alexander Capehart (OxygenCobalt) + */ +@Dao +interface QueueDao { + /** + * Get the previously persisted queue heap. + * @return A list of persisted [QueueHeapItem]s wrapping each heap item. + */ + @Query("SELECT * FROM ${QueueHeapItem.TABLE_NAME}") suspend fun getHeap(): List + + /** + * Get the previously persisted queue mapping. + * @return A list of persisted [QueueMappingItem]s wrapping each heap item. + */ + @Query("SELECT * FROM ${QueueMappingItem.TABLE_NAME}") + suspend fun getMapping(): List + + /** Delete any previously persisted queue heap entries. */ + @Query("DELETE FROM ${QueueHeapItem.TABLE_NAME}") suspend fun nukeHeap() + + /** Delete any previously persisted queue mapping entries. */ + @Query("DELETE FROM ${QueueMappingItem.TABLE_NAME}") suspend fun nukeMapping() + + /** + * Insert new heap entries into the database. + * @param heap The list of wrapped [QueueHeapItem]s to insert. + */ + @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertHeap(heap: List) + + /** + * Insert new mapping entries into the database. + * @param mapping The list of wrapped [QueueMappingItem] to insert. + */ + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertMapping(mapping: List) +} + +@Entity(tableName = PlaybackState.TABLE_NAME) +data class PlaybackState( + @PrimaryKey val id: Int, + val index: Int, + val positionMs: Long, + val repeatMode: RepeatMode, + val songUid: Music.UID, + val parentUid: Music.UID? +) { + companion object { + const val TABLE_NAME = "playback_state" + } +} + +@Entity(tableName = QueueHeapItem.TABLE_NAME) +data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) { + companion object { + const val TABLE_NAME = "queue_heap" + } +} + +@Entity(tableName = QueueMappingItem.TABLE_NAME) +data class QueueMappingItem( + @PrimaryKey val id: Int, + val orderedIndex: Int, + val shuffledIndex: Int +) { + companion object { + const val TABLE_NAME = "queue_mapping" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceModule.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceModule.kt new file mode 100644 index 000000000..b43fb17b0 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceModule.kt @@ -0,0 +1,54 @@ +/* + * 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.playback.persist + +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 PersistenceModule { + @Binds fun repository(persistenceRepository: PersistenceRepositoryImpl): PersistenceRepository +} + +@Module +@InstallIn(SingletonComponent::class) +class PersistenceRoomModule { + @Singleton + @Provides + fun database(@ApplicationContext context: Context) = + Room.databaseBuilder( + context.applicationContext, + PersistenceDatabase::class.java, + "playback_persistence.db") + .fallbackToDestructiveMigration() + .fallbackToDestructiveMigrationFrom(1) + .fallbackToDestructiveMigrationOnDowngrade() + .build() + + @Provides fun playbackStateDao(database: PersistenceDatabase) = database.playbackStateDao() + + @Provides fun queueDao(database: PersistenceDatabase) = database.queueDao() +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt new file mode 100644 index 000000000..854906036 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt @@ -0,0 +1,136 @@ +/* + * 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.playback.persist + +import javax.inject.Inject +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.model.Library +import org.oxycblt.auxio.playback.queue.Queue +import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE + +/** + * Manages the persisted playback state in a structured manner. + * @author Alexander Capehart (OxygenCobalt) + */ +interface PersistenceRepository { + /** + * Read the previously persisted [PlaybackStateManager.SavedState]. + * @param library The [Library] required to de-serialize the [PlaybackStateManager.SavedState]. + */ + suspend fun readState(library: Library): PlaybackStateManager.SavedState? + + /** + * Persist a new [PlaybackStateManager.SavedState]. + * @param state The [PlaybackStateManager.SavedState] to persist. + */ + suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean +} + +class PersistenceRepositoryImpl +@Inject +constructor(private val playbackStateDao: PlaybackStateDao, private val queueDao: QueueDao) : + PersistenceRepository { + + override suspend fun readState(library: Library): PlaybackStateManager.SavedState? { + val playbackState: PlaybackState + val heap: List + val mapping: List + try { + playbackState = playbackStateDao.getState() ?: return null + heap = queueDao.getHeap() + mapping = queueDao.getMapping() + } catch (e: Exception) { + logE("Unable to load playback state data") + logE(e.stackTraceToString()) + return null + } + + val orderedMapping = mutableListOf() + val shuffledMapping = mutableListOf() + for (entry in mapping) { + orderedMapping.add(entry.orderedIndex) + shuffledMapping.add(entry.shuffledIndex) + } + + val parent = playbackState.parentUid?.let { library.find(it) } + logD("Read playback state") + + return PlaybackStateManager.SavedState( + parent = parent, + queueState = + Queue.SavedState( + heap.map { library.find(it.uid) }, + orderedMapping, + shuffledMapping, + playbackState.index, + playbackState.songUid), + positionMs = playbackState.positionMs, + repeatMode = playbackState.repeatMode) + } + + override suspend fun saveState(state: PlaybackStateManager.SavedState?): Boolean { + // Only bother saving a state if a song is actively playing from one. + // This is not the case with a null state. + try { + playbackStateDao.nukeState() + queueDao.nukeHeap() + queueDao.nukeMapping() + } catch (e: Exception) { + logE("Unable to clear previous state") + logE(e.stackTraceToString()) + return false + } + logD("Cleared state") + if (state != null) { + // Transform saved state into raw state, which can then be written to the database. + val playbackState = + PlaybackState( + id = 0, + index = state.queueState.index, + positionMs = state.positionMs, + repeatMode = state.repeatMode, + songUid = state.queueState.songUid, + parentUid = state.parent?.uid) + + // Convert the remaining queue information do their database-specific counterparts. + val heap = + state.queueState.heap.mapIndexed { i, song -> + QueueHeapItem(i, requireNotNull(song).uid) + } + val mapping = + state.queueState.orderedMapping.zip(state.queueState.shuffledMapping).mapIndexed { + i, + pair -> + QueueMappingItem(i, pair.first, pair.second) + } + try { + playbackStateDao.insertState(playbackState) + queueDao.insertHeap(heap) + queueDao.insertMapping(mapping) + } catch (e: Exception) { + logE("Unable to write new state") + logE(e.stackTraceToString()) + return false + } + logD("Wrote state") + } + return true + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt similarity index 86% rename from app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt rename to app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt index 6638901fd..9a2b44eb9 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/Queue.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/Queue.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.state +package org.oxycblt.auxio.playback.queue import kotlin.random.Random import kotlin.random.nextInt @@ -36,30 +36,82 @@ import org.oxycblt.auxio.music.Song * * @author OxygenCobalt */ -class Queue { +interface Queue { + /** The index of the currently playing [Song] in the current mapping. */ + val index: Int + /** The currently playing [Song]. */ + val currentSong: Song? + /** Whether this queue is shuffled. */ + val isShuffled: Boolean + /** + * Resolve this queue into a more conventional list of [Song]s. + * @return A list of [Song] corresponding to the current queue mapping. + */ + fun resolve(): List + + /** + * Represents the possible changes that can occur during certain queue mutation events. The + * precise meanings of these differ somewhat depending on the type of mutation done. + */ + enum class ChangeResult { + /** Only the mapping has changed. */ + MAPPING, + /** The mapping has changed, and the index also changed to align with it. */ + INDEX, + /** + * The current song has changed, possibly alongside the mapping and index depending on the + * context. + */ + SONG + } + + /** + * An immutable representation of the queue state. + * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with + * null values to represent [Song]s that were "lost" from the heap without having to change + * other values. + * @param orderedMapping The mapping of the [heap] to an ordered queue. + * @param shuffledMapping The mapping of the [heap] to a shuffled queue. + * @param index The index of the currently playing [Song] at the time of serialization. + * @param songUid The [Music.UID] of the [Song] that was originally at [index]. + */ + class SavedState( + val heap: List, + val orderedMapping: List, + val shuffledMapping: List, + val index: Int, + val songUid: Music.UID, + ) { + /** + * Remaps the [heap] of this instance based on the given mapping function and copies it into + * a new [SavedState]. + * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This + * **MUST** be the same size as the original heap. [Song] instances that could not be + * converted should be replaced with null in the new heap. + * @throws IllegalStateException If the invariant specified by [transform] is violated. + */ + inline fun remap(transform: (Song?) -> Song?) = + SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid) + } +} + +class EditableQueue : Queue { @Volatile private var heap = mutableListOf() @Volatile private var orderedMapping = mutableListOf() @Volatile private var shuffledMapping = mutableListOf() - /** The index of the currently playing [Song] in the current mapping. */ @Volatile - var index = -1 + override var index = -1 private set - /** The currently playing [Song]. */ - val currentSong: Song? + override val currentSong: Song? get() = shuffledMapping .ifEmpty { orderedMapping.ifEmpty { null } } ?.getOrNull(index) ?.let(heap::get) - /** Whether this queue is shuffled. */ - val isShuffled: Boolean + override val isShuffled: Boolean get() = shuffledMapping.isNotEmpty() - /** - * Resolve this queue into a more conventional list of [Song]s. - * @return A list of [Song] corresponding to the current queue mapping. - */ - fun resolve() = + override fun resolve() = if (currentSong != null) { shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } } else { @@ -134,14 +186,15 @@ class Queue { /** * Add [Song]s to the top of the queue. Will start playback if nothing is playing. * @param songs The [Song]s to add. - * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there - * was no prior playback and these enqueued [Song]s start new playback. + * @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or + * [Queue.ChangeResult.SONG] if there was no prior playback and these enqueued [Song]s start new + * playback. */ - fun playNext(songs: List): ChangeResult { + fun playNext(songs: List): Queue.ChangeResult { if (orderedMapping.isEmpty()) { // No playback, start playing these songs. start(songs[0], songs, false) - return ChangeResult.SONG + return Queue.ChangeResult.SONG } val heapIndices = songs.map(::addSongToHeap) @@ -156,20 +209,21 @@ class Queue { orderedMapping.addAll(index + 1, heapIndices) } check() - return ChangeResult.MAPPING + return Queue.ChangeResult.MAPPING } /** * Add [Song]s to the end of the queue. Will start playback if nothing is playing. * @param songs The [Song]s to add. - * @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there - * was no prior playback and these enqueued [Song]s start new playback. + * @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or + * [Queue.ChangeResult.SONG] if there was no prior playback and these enqueued [Song]s start new + * playback. */ - fun addToQueue(songs: List): ChangeResult { + fun addToQueue(songs: List): Queue.ChangeResult { if (orderedMapping.isEmpty()) { // No playback, start playing these songs. start(songs[0], songs, false) - return ChangeResult.SONG + return Queue.ChangeResult.SONG } val heapIndices = songs.map(::addSongToHeap) @@ -179,18 +233,18 @@ class Queue { shuffledMapping.addAll(heapIndices) } check() - return ChangeResult.MAPPING + return Queue.ChangeResult.MAPPING } /** * Move a [Song] at the given position to a new position. * @param src The position of the [Song] to move. * @param dst The destination position of the [Song]. - * @return [ChangeResult.MAPPING] if the move occurred after the current index, - * [ChangeResult.INDEX] if the move occurred before or at the current index, requiring it to be - * mutated. + * @return [Queue.ChangeResult.MAPPING] if the move occurred after the current index, + * [Queue.ChangeResult.INDEX] if the move occurred before or at the current index, requiring it + * to be mutated. */ - fun move(src: Int, dst: Int): ChangeResult { + fun move(src: Int, dst: Int): Queue.ChangeResult { if (shuffledMapping.isNotEmpty()) { // Move songs only in the shuffled mapping. There is no sane analogous form of // this for the ordered mapping. @@ -210,21 +264,21 @@ class Queue { else -> { // Nothing to do. check() - return ChangeResult.MAPPING + return Queue.ChangeResult.MAPPING } } check() - return ChangeResult.INDEX + return Queue.ChangeResult.INDEX } /** * Remove a [Song] at the given position. * @param at The position of the [Song] to remove. - * @return [ChangeResult.MAPPING] if the removed [Song] was after the current index, - * [ChangeResult.INDEX] if the removed [Song] was before the current index, and - * [ChangeResult.SONG] if the currently playing [Song] was removed. + * @return [Queue.ChangeResult.MAPPING] if the removed [Song] was after the current index, + * [Queue.ChangeResult.INDEX] if the removed [Song] was before the current index, and + * [Queue.ChangeResult.SONG] if the currently playing [Song] was removed. */ - fun remove(at: Int): ChangeResult { + fun remove(at: Int): Queue.ChangeResult { if (shuffledMapping.isNotEmpty()) { // Remove the specified index in the shuffled mapping and the analogous song in the // ordered mapping. @@ -242,34 +296,34 @@ class Queue { val result = when { // We just removed the currently playing song. - index == at -> ChangeResult.SONG + index == at -> Queue.ChangeResult.SONG // Index was ahead of removed song, shift back to preserve consistency. index > at -> { index -= 1 - ChangeResult.INDEX + Queue.ChangeResult.INDEX } // Nothing to do - else -> ChangeResult.MAPPING + else -> Queue.ChangeResult.MAPPING } check() return result } /** - * Convert the current state of this instance into a [SavedState]. - * @return A new [SavedState] reflecting the exact state of the queue when called. + * Convert the current state of this instance into a [Queue.SavedState]. + * @return A new [Queue.SavedState] reflecting the exact state of the queue when called. */ fun toSavedState() = currentSong?.let { song -> - SavedState( + Queue.SavedState( heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid) } /** - * Update this instance from the given [SavedState]. - * @param savedState A [SavedState] with a valid queue representation. + * Update this instance from the given [Queue.SavedState]. + * @param savedState A [Queue.SavedState] with a valid queue representation. */ - fun applySavedState(savedState: SavedState) { + fun applySavedState(savedState: Queue.SavedState) { val adjustments = mutableListOf() var currentShift = 0 for (song in savedState.heap) { @@ -345,49 +399,4 @@ class Queue { "Queue inconsistency detected: Shuffled mapping indices out of heap bounds" } } - - /** - * An immutable representation of the queue state. - * @param heap The heap of [Song]s that are/were used in the queue. This can be modified with - * null values to represent [Song]s that were "lost" from the heap without having to change - * other values. - * @param orderedMapping The mapping of the [heap] to an ordered queue. - * @param shuffledMapping The mapping of the [heap] to a shuffled queue. - * @param index The index of the currently playing [Song] at the time of serialization. - * @param songUid The [Music.UID] of the [Song] that was originally at [index]. - */ - class SavedState( - val heap: List, - val orderedMapping: List, - val shuffledMapping: List, - val index: Int, - val songUid: Music.UID, - ) { - /** - * Remaps the [heap] of this instance based on the given mapping function and copies it into - * a new [SavedState]. - * @param transform Code to remap the existing [Song] heap into a new [Song] heap. This - * **MUST** be the same size as the original heap. [Song] instances that could not be - * converted should be replaced with null in the new heap. - * @throws IllegalStateException If the invariant specified by [transform] is violated. - */ - inline fun remap(transform: (Song?) -> Song?) = - SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid) - } - - /** - * Represents the possible changes that can occur during certain queue mutation events. The - * precise meanings of these differ somewhat depending on the type of mutation done. - */ - enum class ChangeResult { - /** Only the mapping has changed. */ - MAPPING, - /** The mapping has changed, and the index also changed to align with it. */ - INDEX, - /** - * The current song has changed, possibly alongside the mapping and index depending on the - * context. - */ - SONG - } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt index 1898af497..2b67c5b3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueAdapter.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.* /** @@ -149,7 +150,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong listener.bind(song, this, bodyView, binding.songDragHandle) 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) // Not swiping this ViewHolder if it's being re-bound, ensure that the background is // not visible. See QueueDragCallback for why this is done. binding.background.isInvisible = true diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt index 50c363721..0e71bb55f 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueFragment.kt @@ -24,6 +24,7 @@ import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import dagger.hilt.android.AndroidEntryPoint import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.list.EditableListListener @@ -31,16 +32,16 @@ import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.collectImmediately /** * A [ViewBindingFragment] that displays an editable queue. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class QueueFragment : ViewBindingFragment(), EditableListListener { private val queueModel: QueueViewModel by activityViewModels() - private val playbackModel: PlaybackViewModel by androidActivityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() private val queueAdapter = QueueAdapter(this) private var touchHelper: ItemTouchHelper? = null diff --git a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt index 71bbe02e9..1e09a1bbf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/queue/QueueViewModel.kt @@ -18,21 +18,23 @@ package org.oxycblt.auxio.playback.queue 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.list.adapter.BasicListInstructions import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Queue /** * A [ViewModel] that manages the current queue state and allows navigation through the queue. * * @author Alexander Capehart (OxygenCobalt) */ -class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { - private val playbackManager = PlaybackStateManager.getInstance() +@HiltViewModel +class QueueViewModel @Inject constructor(private val playbackManager: PlaybackStateManager) : + ViewModel(), PlaybackStateManager.Listener { private val _queue = MutableStateFlow(listOf()) /** The current queue. */ diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt index fa589f015..81db4bad3 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/PreAmpCustomizeDialog.kt @@ -21,6 +21,8 @@ import android.os.Bundle import android.view.LayoutInflater import android.widget.TextView import androidx.appcompat.app.AlertDialog +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlin.math.abs import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogPreAmpBinding @@ -31,7 +33,10 @@ import org.oxycblt.auxio.ui.ViewBindingDialogFragment * aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp]. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class PreAmpCustomizeDialog : ViewBindingDialogFragment() { + @Inject lateinit var playbackSettings: PlaybackSettings + override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater) override fun onConfigDialog(builder: AlertDialog.Builder) { @@ -39,11 +44,11 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { .setTitle(R.string.set_pre_amp) .setPositiveButton(R.string.lbl_ok) { _, _ -> val binding = requireBinding() - PlaybackSettings.from(requireContext()).replayGainPreAmp = + playbackSettings.replayGainPreAmp = ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value) } .setNeutralButton(R.string.lbl_reset) { _, _ -> - PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f) + playbackSettings.replayGainPreAmp = ReplayGainPreAmp(0f, 0f) } .setNegativeButton(R.string.lbl_cancel, null) } @@ -53,7 +58,7 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment() { // First initialization, we need to supply the sliders with the values from // settings. After this, the sliders save their own state, so we do not need to // do any restore behavior. - val preAmp = PlaybackSettings.from(requireContext()).replayGainPreAmp + val preAmp = playbackSettings.replayGainPreAmp binding.withTagsSlider.value = preAmp.with binding.withoutTagsSlider.value = preAmp.without } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 3a2a39893..29f19fde4 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -17,7 +17,6 @@ package org.oxycblt.auxio.playback.replaygain -import android.content.Context import com.google.android.exoplayer2.C import com.google.android.exoplayer2.Format import com.google.android.exoplayer2.Player @@ -26,9 +25,10 @@ import com.google.android.exoplayer2.audio.AudioProcessor import com.google.android.exoplayer2.audio.BaseAudioProcessor import com.google.android.exoplayer2.util.MimeTypes import java.nio.ByteBuffer +import javax.inject.Inject import kotlin.math.pow import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.extractor.TextTags +import org.oxycblt.auxio.music.metadata.TextTags import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD @@ -43,10 +43,12 @@ import org.oxycblt.auxio.util.logD * * @author Alexander Capehart (OxygenCobalt) */ -class ReplayGainAudioProcessor(context: Context) : - BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { - private val playbackManager = PlaybackStateManager.getInstance() - private val playbackSettings = PlaybackSettings.from(context) +class ReplayGainAudioProcessor +@Inject +constructor( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings +) : BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { private var lastFormat: Format? = null private var volume = 1f diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt deleted file mode 100644 index 717750dbf..000000000 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateDatabase.kt +++ /dev/null @@ -1,335 +0,0 @@ -/* - * Copyright (c) 2021 Auxio Project - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.playback.state - -import android.content.ContentValues -import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteOpenHelper -import android.provider.BaseColumns -import androidx.core.database.getIntOrNull -import androidx.core.database.sqlite.transaction -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.util.* - -/** - * A [SQLiteDatabase] that persists the current playback state for future app lifecycles. - * @author Alexander Capehart (OxygenCobalt) - */ -class PlaybackStateDatabase private constructor(context: Context) : - SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { - - override fun onCreate(db: SQLiteDatabase) { - // Here, we have to split the database into two tables. One contains the queue with - // an indefinite amount of items, and the other contains only one entry consisting - // of the non-queue parts of the state, such as the playback position. - db.createTable(TABLE_STATE) { - append("${BaseColumns._ID} INTEGER PRIMARY KEY,") - append("${PlaybackStateColumns.INDEX} INTEGER NOT NULL,") - append("${PlaybackStateColumns.POSITION} LONG NOT NULL,") - append("${PlaybackStateColumns.REPEAT_MODE} INTEGER NOT NULL,") - append("${PlaybackStateColumns.SONG_UID} STRING,") - append("${PlaybackStateColumns.PARENT_UID} STRING") - } - - db.createTable(TABLE_QUEUE_HEAP) { - append("${BaseColumns._ID} INTEGER PRIMARY KEY,") - append("${QueueHeapColumns.SONG_UID} STRING NOT NULL") - } - - db.createTable(TABLE_QUEUE_MAPPINGS) { - append("${BaseColumns._ID} INTEGER PRIMARY KEY,") - append("${QueueMappingColumns.ORDERED_INDEX} INT NOT NULL,") - append("${QueueMappingColumns.SHUFFLED_INDEX} INT") - } - } - - 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) { - logD("Nuking database") - db.apply { - execSQL("DROP TABLE IF EXISTS $TABLE_STATE") - execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_HEAP") - execSQL("DROP TABLE IF EXISTS $TABLE_QUEUE_MAPPINGS") - onCreate(this) - } - } - - // --- INTERFACE FUNCTIONS --- - - /** - * Read a persisted [SavedState] from the database. - * @param library [Library] required to restore [SavedState]. - * @return A persisted [SavedState], or null if one could not be found. - */ - fun read(library: Library): SavedState? { - requireBackgroundThread() - // Read the saved state and queue. If the state is non-null, that must imply an - // existent, albeit possibly empty, queue. - val rawState = readRawPlaybackState() ?: return null - val rawQueueState = readRawQueueState(library) - // Restore parent item from the music library. If this fails, then the playback mode - // reverts to "All Songs", which is considered okay. - val parent = rawState.parentUid?.let { library.find(it) } - return SavedState( - parent = parent, - queueState = - Queue.SavedState( - heap = rawQueueState.heap, - orderedMapping = rawQueueState.orderedMapping, - shuffledMapping = rawQueueState.shuffledMapping, - index = rawState.index, - songUid = rawState.songUid), - positionMs = rawState.positionMs, - repeatMode = rawState.repeatMode) - } - - private fun readRawPlaybackState() = - readableDatabase.queryAll(TABLE_STATE) { cursor -> - if (!cursor.moveToFirst()) { - // Empty, nothing to do. - return@queryAll null - } - - val indexIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.INDEX) - val posIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.POSITION) - val repeatModeIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.REPEAT_MODE) - val songUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.SONG_UID) - val parentUidIndex = cursor.getColumnIndexOrThrow(PlaybackStateColumns.PARENT_UID) - RawPlaybackState( - index = cursor.getInt(indexIndex), - positionMs = cursor.getLong(posIndex), - repeatMode = RepeatMode.fromIntCode(cursor.getInt(repeatModeIndex)) - ?: RepeatMode.NONE, - songUid = Music.UID.fromString(cursor.getString(songUidIndex)) - ?: return@queryAll null, - parentUid = cursor.getString(parentUidIndex)?.let(Music.UID::fromString)) - } - - private fun readRawQueueState(library: Library): RawQueueState { - val heap = mutableListOf() - readableDatabase.queryAll(TABLE_QUEUE_HEAP) { cursor -> - if (cursor.count == 0) { - // Empty, nothing to do. - return@queryAll - } - - val songIndex = cursor.getColumnIndexOrThrow(QueueHeapColumns.SONG_UID) - while (cursor.moveToNext()) { - heap.add(Music.UID.fromString(cursor.getString(songIndex))?.let(library::find)) - } - } - logD("Successfully read queue of ${heap.size} songs") - - val orderedMapping = mutableListOf() - val shuffledMapping = mutableListOf() - readableDatabase.queryAll(TABLE_QUEUE_MAPPINGS) { cursor -> - if (cursor.count == 0) { - // Empty, nothing to do. - return@queryAll - } - - val orderedIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.ORDERED_INDEX) - val shuffledIndex = cursor.getColumnIndexOrThrow(QueueMappingColumns.SHUFFLED_INDEX) - while (cursor.moveToNext()) { - orderedMapping.add(cursor.getInt(orderedIndex)) - cursor.getIntOrNull(shuffledIndex)?.let(shuffledMapping::add) - } - } - - return RawQueueState(heap, orderedMapping.filterNotNull(), shuffledMapping.filterNotNull()) - } - - /** - * Clear the previous [SavedState] and write a new one. - * @param state The new [SavedState] to write, or null to clear the database entirely. - */ - fun write(state: SavedState?) { - requireBackgroundThread() - // Only bother saving a state if a song is actively playing from one. - // This is not the case with a null state. - if (state != null) { - // Transform saved state into raw state, which can then be written to the database. - val rawPlaybackState = - RawPlaybackState( - index = state.queueState.index, - positionMs = state.positionMs, - repeatMode = state.repeatMode, - songUid = state.queueState.songUid, - parentUid = state.parent?.uid) - writeRawPlaybackState(rawPlaybackState) - val rawQueueState = - RawQueueState( - heap = state.queueState.heap, - orderedMapping = state.queueState.orderedMapping, - shuffledMapping = state.queueState.shuffledMapping) - writeRawQueueState(rawQueueState) - logD("Wrote state") - } else { - writeRawPlaybackState(null) - writeRawQueueState(null) - logD("Cleared state") - } - } - - private fun writeRawPlaybackState(rawPlaybackState: RawPlaybackState?) { - writableDatabase.transaction { - delete(TABLE_STATE, null, null) - - if (rawPlaybackState != null) { - val stateData = - ContentValues(7).apply { - put(BaseColumns._ID, 0) - put(PlaybackStateColumns.SONG_UID, rawPlaybackState.songUid.toString()) - put(PlaybackStateColumns.POSITION, rawPlaybackState.positionMs) - put(PlaybackStateColumns.PARENT_UID, rawPlaybackState.parentUid?.toString()) - put(PlaybackStateColumns.INDEX, rawPlaybackState.index) - put(PlaybackStateColumns.REPEAT_MODE, rawPlaybackState.repeatMode.intCode) - } - - insert(TABLE_STATE, null, stateData) - } - } - } - - private fun writeRawQueueState(rawQueueState: RawQueueState?) { - writableDatabase.writeList(rawQueueState?.heap ?: listOf(), TABLE_QUEUE_HEAP) { i, song -> - ContentValues(2).apply { - put(BaseColumns._ID, i) - put(QueueHeapColumns.SONG_UID, unlikelyToBeNull(song).uid.toString()) - } - } - - val combinedMapping = - rawQueueState?.run { - if (shuffledMapping.isNotEmpty()) { - orderedMapping.zip(shuffledMapping) - } else { - orderedMapping.map { Pair(it, null) } - } - } - - writableDatabase.writeList(combinedMapping ?: listOf(), TABLE_QUEUE_MAPPINGS) { i, pair -> - ContentValues(3).apply { - put(BaseColumns._ID, i) - put(QueueMappingColumns.ORDERED_INDEX, pair.first) - put(QueueMappingColumns.SHUFFLED_INDEX, pair.second) - } - } - } - - /** - * A condensed representation of the playback state that can be persisted. - * @param parent The [MusicParent] item currently being played from. - * @param queueState The [Queue.SavedState] - * @param positionMs The current position in the currently played song, in ms - * @param repeatMode The current [RepeatMode]. - */ - data class SavedState( - val parent: MusicParent?, - val queueState: Queue.SavedState, - val positionMs: Long, - val repeatMode: RepeatMode, - ) - - /** A lower-level form of [SavedState] that contains individual field-based information. */ - private data class RawPlaybackState( - /** @see Queue.SavedState.index */ - val index: Int, - /** @see SavedState.positionMs */ - val positionMs: Long, - /** @see SavedState.repeatMode */ - val repeatMode: RepeatMode, - /** - * The [Music.UID] of the [Song] that was originally in the queue at [index]. This can be - * used to restore the currently playing item in the queue if the index mapping changed. - */ - val songUid: Music.UID, - /** @see SavedState.parent */ - val parentUid: Music.UID? - ) - - /** A lower-level form of [Queue.SavedState] that contains heap and mapping information. */ - private data class RawQueueState( - /** @see Queue.SavedState.heap */ - val heap: List, - /** @see Queue.SavedState.orderedMapping */ - val orderedMapping: List, - /** @see Queue.SavedState.shuffledMapping */ - val shuffledMapping: List - ) - - /** Defines the columns used in the playback state table. */ - private object PlaybackStateColumns { - /** @see RawPlaybackState.index */ - const val INDEX = "queue_index" - /** @see RawPlaybackState.positionMs */ - const val POSITION = "position" - /** @see RawPlaybackState.repeatMode */ - const val REPEAT_MODE = "repeat_mode" - /** @see RawPlaybackState.songUid */ - const val SONG_UID = "song_uid" - /** @see RawPlaybackState.parentUid */ - const val PARENT_UID = "parent" - } - - /** Defines the columns used in the queue heap table. */ - private object QueueHeapColumns { - /** @see Music.UID */ - const val SONG_UID = "song_uid" - } - - /** Defines the columns used in the queue mapping table. */ - private object QueueMappingColumns { - /** @see Queue.SavedState.orderedMapping */ - const val ORDERED_INDEX = "ordered_index" - /** @see Queue.SavedState.shuffledMapping */ - const val SHUFFLED_INDEX = "shuffled_index" - } - - companion object { - private const val DB_NAME = "auxio_playback_state.db" - private const val DB_VERSION = 9 - private const val TABLE_STATE = "playback_state" - private const val TABLE_QUEUE_HEAP = "queue_heap" - private const val TABLE_QUEUE_MAPPINGS = "queue_mapping" - - @Volatile private var INSTANCE: PlaybackStateDatabase? = null - - /** - * Get a singleton instance. - * @return The (possibly newly-created) singleton instance. - */ - fun getInstance(context: Context): PlaybackStateDatabase { - val currentInstance = INSTANCE - - if (currentInstance != null) { - return currentInstance - } - - synchronized(this) { - val newInstance = PlaybackStateDatabase(context.applicationContext) - INSTANCE = newInstance - return newInstance - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 734966f89..f00182b53 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.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,17 +17,14 @@ package org.oxycblt.auxio.playback.state -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import javax.inject.Inject import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.MusicStore -import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener +import org.oxycblt.auxio.music.MusicParent +import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.queue.EditableQueue +import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logW -import org.oxycblt.auxio.util.unlikelyToBeNull /** * Core playback state controller class. @@ -36,48 +33,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * MediaSession is poorly designed. This class instead ful-fills this role. * * This should ***NOT*** be used outside of the playback module. - * - If you want to use the playback state in the UI, use - * [org.oxycblt.auxio.playback.PlaybackViewModel] as it can withstand volatile UIs. + * - If you want to use the playback state in the UI, use PlaybackViewModel as it can withstand + * volatile UIs. * - If you want to use the playback state with the ExoPlayer instance or system-side things, use - * [org.oxycblt.auxio.playback.system.PlaybackService]. + * PlaybackService. * * Internal consumers should usually use [Listener], however the component that manages the player * itself should instead use [InternalPlayer]. * - * All access should be done with [PlaybackStateManager.getInstance]. - * * @author Alexander Capehart (OxygenCobalt) */ -class PlaybackStateManager private constructor() { - private val musicStore = MusicStore.getInstance() - private val listeners = mutableListOf() - @Volatile private var internalPlayer: InternalPlayer? = null - @Volatile private var pendingAction: InternalPlayer.Action? = null - @Volatile private var isInitialized = false - +interface PlaybackStateManager { /** The current [Queue]. */ - val queue = Queue() + val queue: Queue /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ - @Volatile - var parent: MusicParent? = null // FIXME: Parent is interpreted wrong when nothing is playing. - private set - + val parent: MusicParent? /** The current [InternalPlayer] state. */ - @Volatile - var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) - private set + val playerState: InternalPlayer.State /** The current [RepeatMode] */ - @Volatile - var repeatMode = RepeatMode.NONE - set(value) { - field = value - notifyRepeatModeChanged() - } - /** - * The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable. - */ + var repeatMode: RepeatMode + /** The audio session ID of the internal player. Null if no internal player exists. */ val currentAudioSessionId: Int? - get() = internalPlayer?.audioSessionId /** * Add a [Listener] to this instance. This can be used to receive changes in the playback state. @@ -85,16 +61,7 @@ class PlaybackStateManager private constructor() { * @param listener The [Listener] to add. * @see Listener */ - @Synchronized - fun addListener(listener: Listener) { - if (isInitialized) { - listener.onNewPlayback(queue, parent) - listener.onRepeatChanged(repeatMode) - listener.onStateChanged(playerState) - } - - listeners.add(listener) - } + fun addListener(listener: Listener) /** * Remove a [Listener] from this instance, preventing it from receiving any further updates. @@ -102,10 +69,7 @@ class PlaybackStateManager private constructor() { * the first place. * @see Listener */ - @Synchronized - fun removeListener(listener: Listener) { - listeners.remove(listener) - } + fun removeListener(listener: Listener) /** * Register an [InternalPlayer] for this instance. This instance will handle translating the @@ -114,42 +78,15 @@ class PlaybackStateManager private constructor() { * @param internalPlayer The [InternalPlayer] to register. Will do nothing if already * registered. */ - @Synchronized - fun registerInternalPlayer(internalPlayer: InternalPlayer) { - if (this.internalPlayer != null) { - logW("Internal player is already registered") - return - } - - if (isInitialized) { - internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) - internalPlayer.seekTo(playerState.calculateElapsedPositionMs()) - // See if there's any action that has been queued. - requestAction(internalPlayer) - // Once initialized, try to synchronize with the player state it has created. - synchronizeState(internalPlayer) - } - - this.internalPlayer = internalPlayer - } + fun registerInternalPlayer(internalPlayer: InternalPlayer) /** - * Unregister the [InternalPlayer] from this instance, prevent it from recieving any further + * Unregister the [InternalPlayer] from this instance, prevent it from receiving any further * commands. * @param internalPlayer The [InternalPlayer] to unregister. Must be the current * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. */ - @Synchronized - fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { - if (this.internalPlayer !== internalPlayer) { - logW("Given internal player did not match current internal player") - return - } - - this.internalPlayer = null - } - - // --- PLAYING FUNCTIONS --- + fun unregisterInternalPlayer(internalPlayer: InternalPlayer) /** * Start new playback. @@ -159,190 +96,81 @@ class PlaybackStateManager private constructor() { * collection of "All [Song]s". * @param shuffled Whether to shuffle or not. */ - @Synchronized - fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { - val internalPlayer = internalPlayer ?: return - // Set up parent and queue - this.parent = parent - this.queue.start(song, queue, shuffled) - // Notify components of changes - notifyNewPlayback() - internalPlayer.loadSong(this.queue.currentSong, true) - // Played something, so we are initialized now - isInitialized = true - } - - // --- QUEUE FUNCTIONS --- + fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) /** * Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there is no * [Song] ahead to skip to. */ - @Synchronized - fun next() { - val internalPlayer = internalPlayer ?: return - var play = true - if (!queue.goto(queue.index + 1)) { - queue.goto(0) - play = false - } - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, play) - } + fun next() /** * Go to the previous [Song] in the queue. Will rewind if there are no previous [Song]s to skip * to, or if configured to do so. */ - @Synchronized - fun prev() { - val internalPlayer = internalPlayer ?: return - - // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] - if (internalPlayer.shouldRewindWithPrev) { - rewind() - setPlaying(true) - } else { - if (!queue.goto(queue.index - 1)) { - queue.goto(0) - } - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, true) - } - } + fun prev() /** * Play a [Song] at the given position in the queue. * @param index The position of the [Song] in the queue to start playing. */ - @Synchronized - fun goto(index: Int) { - val internalPlayer = internalPlayer ?: return - if (queue.goto(index)) { - notifyIndexMoved() - internalPlayer.loadSong(queue.currentSong, true) - } - } - - /** - * Add a [Song] to the top of the queue. - * @param song The [Song] to add. - */ - @Synchronized fun playNext(song: Song) = playNext(listOf(song)) + fun goto(index: Int) /** * Add [Song]s to the top of the queue. * @param songs The [Song]s to add. */ - @Synchronized - fun playNext(songs: List) { - val internalPlayer = internalPlayer ?: return - when (queue.playNext(songs)) { - Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) - Queue.ChangeResult.SONG -> { - // Enqueueing actually started a new playback session from all songs. - parent = null - internalPlayer.loadSong(queue.currentSong, true) - notifyNewPlayback() - } - Queue.ChangeResult.INDEX -> error("Unreachable") - } - } + fun playNext(songs: List) /** - * Add a [Song] to the end of the queue. + * Add a [Song] to the top of the queue. * @param song The [Song] to add. */ - @Synchronized fun addToQueue(song: Song) = addToQueue(listOf(song)) + fun playNext(song: Song) = playNext(listOf(song)) /** * Add [Song]s to the end of the queue. * @param songs The [Song]s to add. */ - @Synchronized - fun addToQueue(songs: List) { - val internalPlayer = internalPlayer ?: return - when (queue.addToQueue(songs)) { - Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) - Queue.ChangeResult.SONG -> { - // Enqueueing actually started a new playback session from all songs. - parent = null - internalPlayer.loadSong(queue.currentSong, true) - notifyNewPlayback() - } - Queue.ChangeResult.INDEX -> error("Unreachable") - } - } + fun addToQueue(songs: List) + + /** + * Add a [Song] to the end of the queue. + * @param song The [Song] to add. + */ + fun addToQueue(song: Song) = addToQueue(listOf(song)) /** * Move a [Song] in the queue. * @param src The position of the [Song] to move in the queue. * @param dst The destination position in the queue. */ - @Synchronized - fun moveQueueItem(src: Int, dst: Int) { - logD("Moving item $src to position $dst") - notifyQueueChanged(queue.move(src, dst)) - } + fun moveQueueItem(src: Int, dst: Int) /** * Remove a [Song] from the queue. * @param at The position of the [Song] to remove in the queue. */ - @Synchronized - fun removeQueueItem(at: Int) { - val internalPlayer = internalPlayer ?: return - logD("Removing item at $at") - val change = queue.remove(at) - if (change == Queue.ChangeResult.SONG) { - internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) - } - notifyQueueChanged(change) - } + fun removeQueueItem(at: Int) /** * (Re)shuffle or (Re)order this instance. * @param shuffled Whether to shuffle the queue or not. */ - @Synchronized - fun reorder(shuffled: Boolean) { - queue.reorder(shuffled) - notifyQueueReordered() - } - - // --- INTERNAL PLAYER FUNCTIONS --- + fun reorder(shuffled: Boolean) /** * Synchronize the state of this instance with the current [InternalPlayer]. * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. */ - @Synchronized - fun synchronizeState(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { - logW("Given internal player did not match current internal player") - return - } - - val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0) - if (newState != playerState) { - playerState = newState - notifyStateChanged() - } - } + fun synchronizeState(internalPlayer: InternalPlayer) /** * Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually. * @param action The [InternalPlayer.Action] to perform. */ - @Synchronized - fun startAction(action: InternalPlayer.Action) { - val internalPlayer = internalPlayer - if (internalPlayer == null || !internalPlayer.performAction(action)) { - logD("Internal player not present or did not consume action, waiting") - pendingAction = action - } - } + fun startAction(action: InternalPlayer.Action) /** * Request that the pending [InternalPlayer.Action] (if any) be passed to the given @@ -350,213 +178,37 @@ class PlaybackStateManager private constructor() { * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. */ - @Synchronized - fun requestAction(internalPlayer: InternalPlayer) { - if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { - logW("Given internal player did not match current internal player") - return - } - - if (pendingAction?.let(internalPlayer::performAction) == true) { - logD("Pending action consumed") - pendingAction = null - } - } + fun requestAction(internalPlayer: InternalPlayer) /** * Update whether playback is ongoing or not. * @param isPlaying Whether playback is ongoing or not. */ - fun setPlaying(isPlaying: Boolean) { - internalPlayer?.setPlaying(isPlaying) - } + fun setPlaying(isPlaying: Boolean) /** * Seek to the given position in the currently playing [Song]. * @param positionMs The position to seek to, in milliseconds. */ - @Synchronized - fun seekTo(positionMs: Long) { - internalPlayer?.seekTo(positionMs) - } + fun seekTo(positionMs: Long) /** Rewind to the beginning of the currently playing [Song]. */ fun rewind() = seekTo(0) - // --- PERSISTENCE FUNCTIONS --- + /** + * Converts the current state of this instance into a [SavedState]. + * @return An immutable [SavedState] that is analogous to the current state, or null if nothing + * is currently playing. + */ + fun toSavedState(): SavedState? /** - * Restore the previously saved state (if any) and apply it to the playback state. - * @param database The [PlaybackStateDatabase] to load from. - * @param force Whether to do a restore regardless of any prior playback state. - * @return If the state was restored, false otherwise. + * Restores this instance from the given [SavedState]. + * @param savedState The [SavedState] to restore from. + * @param destructive Whether to disregard the prior playback state and overwrite it with this + * [SavedState]. */ - suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean { - if (isInitialized && !force) { - // Already initialized and not forcing a restore, nothing to do. - return false - } - - val library = musicStore.library ?: return false - val internalPlayer = internalPlayer ?: return false - val state = - try { - withContext(Dispatchers.IO) { database.read(library) } - } catch (e: Exception) { - logE("Unable to restore playback state.") - logE(e.stackTraceToString()) - return false - } - - // Translate the state we have just read into a usable playback state for this - // instance. - return synchronized(this) { - // State could have changed while we were loading, so check if we were initialized - // now before applying the state. - if (state != null && (!isInitialized || force)) { - parent = state.parent - queue.applySavedState(state.queueState) - repeatMode = state.repeatMode - notifyNewPlayback() - notifyRepeatModeChanged() - // Continuing playback after drastic state updates is a bad idea, so pause. - internalPlayer.loadSong(queue.currentSong, false) - internalPlayer.seekTo(state.positionMs) - isInitialized = true - true - } else { - false - } - } - } - - /** - * Save the current state. - * @param database The [PlaybackStateDatabase] to save the state to. - * @return If state was saved, false otherwise. - */ - suspend fun saveState(database: PlaybackStateDatabase): Boolean { - logD("Saving state to DB") - // Create the saved state from the current playback state. - val state = - synchronized(this) { - queue.toSavedState()?.let { - PlaybackStateDatabase.SavedState( - parent = parent, - queueState = it, - positionMs = playerState.calculateElapsedPositionMs(), - repeatMode = repeatMode) - } - } - return try { - withContext(Dispatchers.IO) { database.write(state) } - true - } catch (e: Exception) { - logE("Unable to save playback state.") - logE(e.stackTraceToString()) - false - } - } - - /** - * Clear the current state. - * @param database The [PlaybackStateDatabase] to clear te state from - * @return If the state was cleared, false otherwise. - */ - suspend fun wipeState(database: PlaybackStateDatabase) = - try { - logD("Wiping state") - withContext(Dispatchers.IO) { database.write(null) } - true - } catch (e: Exception) { - logE("Unable to wipe playback state.") - logE(e.stackTraceToString()) - false - } - - /** - * Update the playback state to align with a new [Library]. - * @param newLibrary The new [Library] that was recently loaded. - */ - @Synchronized - fun sanitize(newLibrary: Library) { - if (!isInitialized) { - // Nothing playing, nothing to do. - logD("Not initialized, no need to sanitize") - return - } - - val internalPlayer = internalPlayer ?: return - - logD("Sanitizing state") - - // While we could just save and reload the state, we instead sanitize the state - // at runtime for better performance (and to sidestep a co-routine on behalf of the caller). - - // Sanitize parent - parent = - parent?.let { - when (it) { - is Album -> newLibrary.sanitize(it) - is Artist -> newLibrary.sanitize(it) - is Genre -> newLibrary.sanitize(it) - } - } - - // Sanitize the queue. - queue.toSavedState()?.let { state -> - queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) }) - } - - notifyNewPlayback() - - val oldPosition = playerState.calculateElapsedPositionMs() - // Continuing playback while also possibly doing drastic state updates is - // a bad idea, so pause. - internalPlayer.loadSong(queue.currentSong, false) - if (queue.currentSong != null) { - // Internal player may have reloaded the media item, re-seek to the previous position - seekTo(oldPosition) - } - } - - // --- CALLBACKS --- - - private fun notifyIndexMoved() { - for (callback in listeners) { - callback.onIndexMoved(queue) - } - } - - private fun notifyQueueChanged(change: Queue.ChangeResult) { - for (callback in listeners) { - callback.onQueueChanged(queue, change) - } - } - - private fun notifyQueueReordered() { - for (callback in listeners) { - callback.onQueueReordered(queue) - } - } - - private fun notifyNewPlayback() { - for (callback in listeners) { - callback.onNewPlayback(queue, parent) - } - } - - private fun notifyStateChanged() { - for (callback in listeners) { - callback.onStateChanged(playerState) - } - } - - private fun notifyRepeatModeChanged() { - for (callback in listeners) { - callback.onRepeatChanged(repeatMode) - } - } + fun applySavedState(savedState: SavedState, destructive: Boolean) /** * The interface for receiving updates from [PlaybackStateManager]. Add the listener to @@ -604,25 +256,318 @@ class PlaybackStateManager private constructor() { fun onRepeatChanged(repeatMode: RepeatMode) {} } - companion object { - @Volatile private var INSTANCE: PlaybackStateManager? = null + /** + * A condensed representation of the playback state that can be persisted. + * @param parent The [MusicParent] item currently being played from. + * @param queueState The [Queue.SavedState] + * @param positionMs The current position in the currently played song, in ms + * @param repeatMode The current [RepeatMode]. + */ + data class SavedState( + val parent: MusicParent?, + val queueState: Queue.SavedState, + val positionMs: Long, + val repeatMode: RepeatMode, + ) +} - /** - * Get a singleton instance. - * @return The (possibly newly-created) singleton instance. - */ - fun getInstance(): PlaybackStateManager { - val currentInstance = INSTANCE +class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { + private val listeners = mutableListOf() + @Volatile private var internalPlayer: InternalPlayer? = null + @Volatile private var pendingAction: InternalPlayer.Action? = null + @Volatile private var isInitialized = false - if (currentInstance != null) { - return currentInstance + override val queue = EditableQueue() + @Volatile + override var parent: MusicParent? = + null // FIXME: Parent is interpreted wrong when nothing is playing. + private set + @Volatile + override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0) + private set + @Volatile + override var repeatMode = RepeatMode.NONE + set(value) { + field = value + notifyRepeatModeChanged() + } + override val currentAudioSessionId: Int? + get() = internalPlayer?.audioSessionId + + @Synchronized + override fun addListener(listener: PlaybackStateManager.Listener) { + if (isInitialized) { + listener.onNewPlayback(queue, parent) + listener.onRepeatChanged(repeatMode) + listener.onStateChanged(playerState) + } + + listeners.add(listener) + } + + @Synchronized + override fun removeListener(listener: PlaybackStateManager.Listener) { + listeners.remove(listener) + } + + @Synchronized + override fun registerInternalPlayer(internalPlayer: InternalPlayer) { + if (this.internalPlayer != null) { + logW("Internal player is already registered") + return + } + + if (isInitialized) { + internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) + internalPlayer.seekTo(playerState.calculateElapsedPositionMs()) + // See if there's any action that has been queued. + requestAction(internalPlayer) + // Once initialized, try to synchronize with the player state it has created. + synchronizeState(internalPlayer) + } + + this.internalPlayer = internalPlayer + } + + @Synchronized + override fun unregisterInternalPlayer(internalPlayer: InternalPlayer) { + if (this.internalPlayer !== internalPlayer) { + logW("Given internal player did not match current internal player") + return + } + + this.internalPlayer = null + } + + // --- PLAYING FUNCTIONS --- + + @Synchronized + override fun play(song: Song?, parent: MusicParent?, queue: List, shuffled: Boolean) { + val internalPlayer = internalPlayer ?: return + // Set up parent and queue + this.parent = parent + this.queue.start(song, queue, shuffled) + // Notify components of changes + notifyNewPlayback() + internalPlayer.loadSong(this.queue.currentSong, true) + // Played something, so we are initialized now + isInitialized = true + } + + // --- QUEUE FUNCTIONS --- + + @Synchronized + override fun next() { + val internalPlayer = internalPlayer ?: return + var play = true + if (!queue.goto(queue.index + 1)) { + queue.goto(0) + play = repeatMode == RepeatMode.ALL + } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, play) + } + + @Synchronized + override fun prev() { + val internalPlayer = internalPlayer ?: return + + // If enabled, rewind before skipping back if the position is past 3 seconds [3000ms] + if (internalPlayer.shouldRewindWithPrev) { + rewind() + setPlaying(true) + } else { + if (!queue.goto(queue.index - 1)) { + queue.goto(0) } + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) + } + } - synchronized(this) { - val newInstance = PlaybackStateManager() - INSTANCE = newInstance - return newInstance + @Synchronized + override fun goto(index: Int) { + val internalPlayer = internalPlayer ?: return + if (queue.goto(index)) { + notifyIndexMoved() + internalPlayer.loadSong(queue.currentSong, true) + } + } + + @Synchronized + override fun playNext(songs: List) { + val internalPlayer = internalPlayer ?: return + when (queue.playNext(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() } + Queue.ChangeResult.INDEX -> error("Unreachable") + } + } + + @Synchronized + override fun addToQueue(songs: List) { + val internalPlayer = internalPlayer ?: return + when (queue.addToQueue(songs)) { + Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) + Queue.ChangeResult.SONG -> { + // Enqueueing actually started a new playback session from all songs. + parent = null + internalPlayer.loadSong(queue.currentSong, true) + notifyNewPlayback() + } + Queue.ChangeResult.INDEX -> error("Unreachable") + } + } + + @Synchronized + override fun moveQueueItem(src: Int, dst: Int) { + logD("Moving item $src to position $dst") + notifyQueueChanged(queue.move(src, dst)) + } + + @Synchronized + override fun removeQueueItem(at: Int) { + val internalPlayer = internalPlayer ?: return + logD("Removing item at $at") + val change = queue.remove(at) + if (change == Queue.ChangeResult.SONG) { + internalPlayer.loadSong(queue.currentSong, playerState.isPlaying) + } + notifyQueueChanged(change) + } + + @Synchronized + override fun reorder(shuffled: Boolean) { + queue.reorder(shuffled) + notifyQueueReordered() + } + + // --- INTERNAL PLAYER FUNCTIONS --- + + @Synchronized + override fun synchronizeState(internalPlayer: InternalPlayer) { + if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { + logW("Given internal player did not match current internal player") + return + } + + val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0) + if (newState != playerState) { + playerState = newState + notifyStateChanged() + } + } + + @Synchronized + override fun startAction(action: InternalPlayer.Action) { + val internalPlayer = internalPlayer + if (internalPlayer == null || !internalPlayer.performAction(action)) { + logD("Internal player not present or did not consume action, waiting") + pendingAction = action + } + } + + @Synchronized + override fun requestAction(internalPlayer: InternalPlayer) { + if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) { + logW("Given internal player did not match current internal player") + return + } + + if (pendingAction?.let(internalPlayer::performAction) == true) { + logD("Pending action consumed") + pendingAction = null + } + } + + @Synchronized + override fun setPlaying(isPlaying: Boolean) { + internalPlayer?.setPlaying(isPlaying) + } + + @Synchronized + override fun seekTo(positionMs: Long) { + internalPlayer?.seekTo(positionMs) + } + + // --- PERSISTENCE FUNCTIONS --- + + @Synchronized + override fun toSavedState() = + queue.toSavedState()?.let { + PlaybackStateManager.SavedState( + parent = parent, + queueState = it, + positionMs = playerState.calculateElapsedPositionMs(), + repeatMode = repeatMode) + } + + @Synchronized + override fun applySavedState( + savedState: PlaybackStateManager.SavedState, + destructive: Boolean + ) { + if (isInitialized && !destructive) { + return + } + val internalPlayer = internalPlayer ?: return + logD("Restoring state $savedState") + + parent = savedState.parent + queue.applySavedState(savedState.queueState) + repeatMode = savedState.repeatMode + notifyNewPlayback() + + // Continuing playback while also possibly doing drastic state updates is + // a bad idea, so pause. + internalPlayer.loadSong(queue.currentSong, false) + if (queue.currentSong != null) { + // Internal player may have reloaded the media item, re-seek to the previous position + seekTo(savedState.positionMs) + } + isInitialized = true + } + + // --- CALLBACKS --- + + private fun notifyIndexMoved() { + for (callback in listeners) { + callback.onIndexMoved(queue) + } + } + + private fun notifyQueueChanged(change: Queue.ChangeResult) { + for (callback in listeners) { + callback.onQueueChanged(queue, change) + } + } + + private fun notifyQueueReordered() { + for (callback in listeners) { + callback.onQueueReordered(queue) + } + } + + private fun notifyNewPlayback() { + for (callback in listeners) { + callback.onNewPlayback(queue, parent) + } + } + + private fun notifyStateChanged() { + for (callback in listeners) { + callback.onStateChanged(playerState) + } + } + + private fun notifyRepeatModeChanged() { + for (callback in listeners) { + callback.onRepeatChanged(repeatMode) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt index 090c81162..02e4ff557 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaButtonReceiver.kt @@ -22,15 +22,19 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.oxycblt.auxio.playback.state.PlaybackStateManager /** * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService]. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class MediaButtonReceiver : BroadcastReceiver() { + @Inject lateinit var playbackManager: PlaybackStateManager + override fun onReceive(context: Context, intent: Intent) { - val playbackManager = PlaybackStateManager.getInstance() if (playbackManager.queue.currentSong != null) { // We have a song, so we can assume that the service will start a foreground state. // At least, I hope. Again, *this is why we don't do this*. I cannot describe how diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index bd6900c51..c1a099636 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -27,28 +27,36 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.media.session.MediaButtonReceiver +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD /** * A component that mirrors the current playback state into the [MediaSessionCompat] and * [NotificationComponent]. - * @param context [Context] required to initialize components. - * @param listener [Listener] to forward notification updates to. * @author Alexander Capehart (OxygenCobalt) */ -class MediaSessionComponent(private val context: Context, private val listener: Listener) : +class MediaSessionComponent +@Inject +constructor( + @ApplicationContext private val context: Context, + private val bitmapProvider: BitmapProvider, + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, +) : MediaSessionCompat.Callback(), PlaybackStateManager.Listener, ImageSettings.Listener, @@ -59,11 +67,9 @@ class MediaSessionComponent(private val context: Context, private val listener: setQueueTitle(context.getString(R.string.lbl_queue)) } - private val playbackManager = PlaybackStateManager.getInstance() - private val playbackSettings = PlaybackSettings.from(context) - private val notification = NotificationComponent(context, mediaSession.sessionToken) - private val provider = BitmapProvider(context) + + private var listener: Listener? = null init { playbackManager.addListener(this) @@ -79,12 +85,21 @@ class MediaSessionComponent(private val context: Context, private val listener: MediaButtonReceiver.handleIntent(mediaSession, intent) } + /** + * Register a [Listener] for notification updates to this service. + * @param listener The [Listener] to register. + */ + fun registerListener(listener: Listener) { + this.listener = listener + } + /** * Release this instance, closing the [MediaSessionCompat] and preventing any further updates to * the [NotificationComponent]. */ fun release() { - provider.release() + listener = null + bitmapProvider.release() playbackSettings.unregisterListener(this) playbackManager.removeListener(this) mediaSession.apply { @@ -134,8 +149,8 @@ class MediaSessionComponent(private val context: Context, private val listener: override fun onStateChanged(state: InternalPlayer.State) { invalidateSessionState() notification.updatePlaying(playbackManager.playerState.isPlaying) - if (!provider.isBusy) { - listener.onPostNotification(notification) + if (!bitmapProvider.isBusy) { + listener?.onPostNotification(notification) } } @@ -271,7 +286,7 @@ class MediaSessionComponent(private val context: Context, private val listener: // Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used // several times. val title = song.resolveName(context) - val artist = song.resolveArtistContents(context) + val artist = song.artists.resolveNames(context) val builder = MediaMetadataCompat.Builder() .putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) @@ -281,14 +296,14 @@ class MediaSessionComponent(private val context: Context, private val listener: .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) .putText( MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, - song.album.resolveArtistContents(context)) + song.album.artists.resolveNames(context)) .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) .putText( METADATA_KEY_PARENT, parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)) - .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.resolveGenreContents(context)) + .putText(MediaMetadataCompat.METADATA_KEY_GENRE, song.genres.resolveNames(context)) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) .putText( @@ -300,14 +315,14 @@ class MediaSessionComponent(private val context: Context, private val listener: builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong()) } song.disc?.let { - builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.toLong()) + builder.putLong(MediaMetadataCompat.METADATA_KEY_DISC_NUMBER, it.number.toLong()) } song.date?.let { builder.putString(MediaMetadataCompat.METADATA_KEY_DATE, it.toString()) } // We are normally supposed to use URIs for album art, but that removes some of the // nice things we can do like square cropping or high quality covers. Instead, // we load a full-size bitmap into the media session and take the performance hit. - provider.load( + bitmapProvider.load( song, object : BitmapProvider.Target { override fun onCompleted(bitmap: Bitmap?) { @@ -316,7 +331,7 @@ class MediaSessionComponent(private val context: Context, private val listener: val metadata = builder.build() mediaSession.setMetadata(metadata) notification.updateMetadata(metadata) - listener.onPostNotification(notification) + listener?.onPostNotification(notification) } }) } @@ -334,7 +349,7 @@ class MediaSessionComponent(private val context: Context, private val listener: // as it's used to request a song to be played from the queue. .setMediaId(song.uid.toString()) .setTitle(song.resolveName(context)) - .setSubtitle(song.resolveArtistContents(context)) + .setSubtitle(song.artists.resolveNames(context)) // Since we usually have to load many songs into the queue, use the // MediaStore URI instead of loading a bitmap. .setIconUri(song.album.coverUri) @@ -402,8 +417,8 @@ class MediaSessionComponent(private val context: Context, private val listener: else -> notification.updateRepeatMode(playbackManager.repeatMode) } - if (!provider.isBusy) { - listener.onPostNotification(notification) + if (!bitmapProvider.isBusy) { + listener?.onPostNotification(notification) } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 08b820a5e..aa4497534 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -34,23 +34,25 @@ import com.google.android.exoplayer2.RenderersFactory import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.audio.AudioCapabilities import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer -import com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory +import com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer import com.google.android.exoplayer2.mediacodec.MediaCodecSelector import com.google.android.exoplayer2.source.DefaultMediaSourceFactory +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.oxycblt.auxio.BuildConfig +import org.oxycblt.auxio.music.AudioOnlyExtractors +import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.playback.PlaybackSettings +import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor import org.oxycblt.auxio.playback.state.InternalPlayer -import org.oxycblt.auxio.playback.state.PlaybackStateDatabase import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.service.ForegroundManager @@ -75,26 +77,28 @@ import org.oxycblt.auxio.widgets.WidgetProvider * * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class PlaybackService : Service(), Player.Listener, InternalPlayer, MediaSessionComponent.Listener, - MusicStore.Listener { + MusicRepository.Listener { // Player components private lateinit var player: ExoPlayer - private lateinit var replayGainProcessor: ReplayGainAudioProcessor + @Inject lateinit var replayGainProcessor: ReplayGainAudioProcessor // System backend components - private lateinit var mediaSessionComponent: MediaSessionComponent - private lateinit var widgetComponent: WidgetComponent + @Inject lateinit var mediaSessionComponent: MediaSessionComponent + @Inject lateinit var widgetComponent: WidgetComponent private val systemReceiver = PlaybackReceiver() - // Managers - private val playbackManager = PlaybackStateManager.getInstance() - private val musicStore = MusicStore.getInstance() - private lateinit var musicSettings: MusicSettings - private lateinit var playbackSettings: PlaybackSettings + // Shared components + @Inject lateinit var playbackManager: PlaybackStateManager + @Inject lateinit var playbackSettings: PlaybackSettings + @Inject lateinit var persistenceRepository: PersistenceRepository + @Inject lateinit var musicRepository: MusicRepository + @Inject lateinit var musicSettings: MusicSettings // State private lateinit var foregroundManager: ForegroundManager @@ -111,10 +115,9 @@ class PlaybackService : override fun onCreate() { super.onCreate() - // Initialize the player component. - replayGainProcessor = ReplayGainAudioProcessor(this) - // Enable constant bitrate seeking so that certain MP3s/AACs are seekable - val extractorsFactory = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true) + // Define our own extractors so we can exclude non-audio parsers. + // Ordering is derived from the DefaultExtractorsFactory's optimized ordering: + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. // Since Auxio is a music player, only specify an audio renderer to save // battery/apk size/cache size val audioRenderer = RenderersFactory { handler, _, audioListener, _, _ -> @@ -126,12 +129,12 @@ class PlaybackService : audioListener, AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES, replayGainProcessor), - LibflacAudioRenderer(handler, audioListener, replayGainProcessor)) + FfmpegAudioRenderer(handler, audioListener, replayGainProcessor)) } player = ExoPlayer.Builder(this, audioRenderer) - .setMediaSourceFactory(DefaultMediaSourceFactory(this, extractorsFactory)) + .setMediaSourceFactory(DefaultMediaSourceFactory(this, AudioOnlyExtractors)) // Enable automatic WakeLock support .setWakeMode(C.WAKE_MODE_LOCAL) .setAudioAttributes( @@ -144,16 +147,12 @@ class PlaybackService : .build() .also { it.addListener(this) } replayGainProcessor.addToListeners(player) - // Initialize the core service components - musicSettings = MusicSettings.from(this) - playbackSettings = PlaybackSettings.from(this) foregroundManager = ForegroundManager(this) // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. playbackManager.registerInternalPlayer(this) - musicStore.addListener(this) - widgetComponent = WidgetComponent(this) - mediaSessionComponent = MediaSessionComponent(this, this) + musicRepository.addListener(this) + mediaSessionComponent.registerListener(this) registerReceiver( systemReceiver, IntentFilter().apply { @@ -172,7 +171,7 @@ class PlaybackService : } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { - // Forward system media button sent by MediaButtonReciever to MediaSessionComponent + // Forward system media button sent by MediaButtonReceiver to MediaSessionComponent if (intent.action == Intent.ACTION_MEDIA_BUTTON) { mediaSessionComponent.handleMediaButtonIntent(intent) } @@ -191,7 +190,7 @@ class PlaybackService : // Pause just in case this destruction was unexpected. playbackManager.setPlaying(false) playbackManager.unregisterInternalPlayer(this) - musicStore.removeListener(this) + musicRepository.removeListener(this) unregisterReceiver(systemReceiver) serviceJob.cancel() @@ -331,15 +330,13 @@ class PlaybackService : // to save the current state as it's not long until this service (and likely the whole // app) is killed. logD("Saving playback state") - saveScope.launch { - playbackManager.saveState(PlaybackStateDatabase.getInstance(this@PlaybackService)) - } + saveScope.launch { persistenceRepository.saveState(playbackManager.toSavedState()) } } } override fun performAction(action: InternalPlayer.Action): Boolean { val library = - musicStore.library + musicRepository.library // No library, cannot do anything. ?: return false @@ -349,8 +346,9 @@ class PlaybackService : // Restore state -> Start a new restoreState job is InternalPlayer.Action.RestoreState -> { restoreScope.launch { - playbackManager.restoreState( - PlaybackStateDatabase.getInstance(this@PlaybackService), false) + persistenceRepository.readState(library)?.let { + playbackManager.applySavedState(it, false) + } } } // Shuffle all -> Start new playback from all songs diff --git a/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt b/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt index b290cc50c..a81abaec5 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/ui/AnimatedMaterialButton.kt @@ -22,6 +22,7 @@ import android.content.Context import android.util.AttributeSet import com.google.android.material.button.MaterialButton import org.oxycblt.auxio.R +import org.oxycblt.auxio.ui.RippleFixMaterialButton import org.oxycblt.auxio.util.getInteger /** @@ -32,7 +33,7 @@ import org.oxycblt.auxio.util.getInteger class AnimatedMaterialButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : - MaterialButton(context, attrs, defStyleAttr) { + RippleFixMaterialButton(context, attrs, defStyleAttr) { private var currentCornerRadiusRatio = 0f private var animator: ValueAnimator? = null diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 62c157bd8..2ec81b783 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -44,7 +44,7 @@ class SearchAdapter(private val listener: SelectableListListener) : is Album -> AlbumViewHolder.VIEW_TYPE is Artist -> ArtistViewHolder.VIEW_TYPE is Genre -> GenreViewHolder.VIEW_TYPE - is Header -> HeaderViewHolder.VIEW_TYPE + is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE else -> super.getItemViewType(position) } @@ -54,7 +54,7 @@ class SearchAdapter(private val listener: SelectableListListener) : AlbumViewHolder.VIEW_TYPE -> AlbumViewHolder.from(parent) ArtistViewHolder.VIEW_TYPE -> ArtistViewHolder.from(parent) GenreViewHolder.VIEW_TYPE -> GenreViewHolder.from(parent) - HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent) + BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent) else -> error("Invalid item type $viewType") } @@ -65,22 +65,13 @@ class SearchAdapter(private val listener: SelectableListListener) : is Album -> (holder as AlbumViewHolder).bind(item, listener) is Artist -> (holder as ArtistViewHolder).bind(item, listener) is Genre -> (holder as GenreViewHolder).bind(item, listener) - is Header -> (holder as HeaderViewHolder).bind(item) + is BasicHeader -> (holder as BasicHeaderViewHolder).bind(item) } } - override fun isItemFullWidth(position: Int) = getItem(position) is Header - - /** - * Make sure that the top header has a correctly configured divider visibility. This would - * normally be automatically done by the differ, but that results in a strange animation. - */ - fun pokeDividers() { - notifyItemChanged(0, PAYLOAD_UPDATE_DIVIDER) - } + override fun isItemFullWidth(position: Int) = getItem(position) is BasicHeader private companion object { - val PAYLOAD_UPDATE_DIVIDER = 102249124 /** A comparator that can be used with DiffUtil. */ val DIFF_CALLBACK = object : SimpleDiffCallback() { @@ -94,8 +85,8 @@ class SearchAdapter(private val listener: SelectableListListener) : ArtistViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) oldItem is Genre && newItem is Genre -> GenreViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) - oldItem is Header && newItem is Header -> - HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) + oldItem is BasicHeader && newItem is BasicHeader -> + BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) else -> false } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt new file mode 100644 index 000000000..d52b9ef9c --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchEngine.kt @@ -0,0 +1,117 @@ +/* + * 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.search + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.text.Normalizer +import javax.inject.Inject +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 + +/** + * Implements the fuzzy-ish searching algorithm used in the search view. + * @author Alexander Capehart + */ +interface SearchEngine { + /** + * Begin a search. + * @param items The items to search over. + * @param query The query to search for. + * @return A list of items filtered by the given query. + */ + suspend fun search(items: Items, query: String): Items + + /** + * Input/output data to use with [SearchEngine]. + * @param songs A list of [Song]s, null if empty. + * @param albums A list of [Album]s, null if empty. + * @param artists A list of [Artist]s, null if empty. + * @param genres A list of [Genre]s, null if empty. + */ + data class Items( + val songs: List?, + val albums: List?, + val artists: List?, + val genres: List? + ) +} + +class SearchEngineImpl @Inject constructor(@ApplicationContext private val context: Context) : + SearchEngine { + override suspend fun search(items: SearchEngine.Items, query: String) = + SearchEngine.Items( + songs = items.songs?.searchListImpl(query) { q, song -> song.path.name.contains(q) }, + albums = items.albums?.searchListImpl(query), + artists = items.artists?.searchListImpl(query), + genres = items.genres?.searchListImpl(query)) + + /** + * Search a given [Music] list. + * @param query The query to search for. The routine will compare this query to the names of + * each object in the list and + * @param fallback Additional comparison code to run if the item does not match the query + * initially. This can be used to compare against additional attributes to improve search result + * quality. + */ + private inline fun List.searchListImpl( + query: String, + fallback: (String, T) -> Boolean = { _, _ -> false } + ) = + filter { + // See if the plain resolved name matches the query. This works for most + // situations. + val name = it.resolveName(context) + if (name.contains(query, ignoreCase = true)) { + return@filter true + } + + // See if the sort name matches. This can sometimes be helpful as certain + // libraries + // will tag sort names to have a alphabetized version of the title. + val sortName = it.rawSortName + if (sortName != null && sortName.contains(query, ignoreCase = true)) { + return@filter true + } + + // As a last-ditch effort, see if the normalized name matches. This will replace + // any non-alphabetical characters with their alphabetical representations, + // which + // could make it match the query. + val normalizedName = + NORMALIZATION_SANITIZE_REGEX.replace( + Normalizer.normalize(name, Normalizer.Form.NFKD), "") + if (normalizedName.contains(query, ignoreCase = true)) { + return@filter true + } + + fallback(query, it) + } + .ifEmpty { null } + + private companion object { + /** + * Converts the output of [Normalizer] to remove any junk characters added by it's + * replacements. + */ + val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+") + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index eeaafb214..caf9c57ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -25,19 +25,25 @@ import android.view.inputmethod.InputMethodManager import androidx.core.view.isInvisible import androidx.core.view.postDelayed import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.google.android.material.transition.MaterialSharedAxis +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentSearchBinding import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment 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.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.util.* /** @@ -49,8 +55,12 @@ import org.oxycblt.auxio.util.* * * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class SearchFragment : ListFragment() { - private val searchModel: SearchViewModel by androidViewModels() + override val navModel: NavigationViewModel by activityViewModels() + override val playbackModel: PlaybackViewModel by activityViewModels() + override val selectionModel: SelectionViewModel by activityViewModels() + private val searchModel: SearchViewModel by viewModels() private val searchAdapter = SearchAdapter(this) private var imm: InputMethodManager? = null private var launchedKeyboard = false @@ -159,7 +169,6 @@ class SearchFragment : ListFragment() { // the query actually changes instead of once every re-creation event, but sadly // that doesn't seem possible. binding.searchRecycler.scrollToPosition(0) - searchAdapter.pokeDividers() } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchModule.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchModule.kt new file mode 100644 index 000000000..dd973e751 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchModule.kt @@ -0,0 +1,30 @@ +/* + * 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.search + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SearchModule { + @Binds fun engine(searchEngine: SearchEngineImpl): SearchEngine + @Binds fun settings(searchSettings: SearchSettingsImpl): SearchSettings +} diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt index 881bc8940..edd22439f 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchSettings.kt @@ -19,6 +19,8 @@ package org.oxycblt.auxio.search 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.music.MusicMode import org.oxycblt.auxio.settings.Settings @@ -30,27 +32,18 @@ import org.oxycblt.auxio.settings.Settings interface SearchSettings : Settings { /** The type of Music the search view is currently filtering to. */ var searchFilterMode: MusicMode? - - private class Real(context: Context) : Settings.Real(context), SearchSettings { - override var searchFilterMode: MusicMode? - get() = - MusicMode.fromIntCode( - sharedPreferences.getInt( - getString(R.string.set_key_search_filter), Int.MIN_VALUE)) - set(value) { - sharedPreferences.edit { - putInt( - getString(R.string.set_key_search_filter), value?.intCode ?: Int.MIN_VALUE) - apply() - } - } - } - - companion object { - /** - * Get a framework-backed implementation. - * @param context [Context] required. - */ - fun from(context: Context): SearchSettings = Real(context) - } +} + +class SearchSettingsImpl @Inject constructor(@ApplicationContext context: Context) : + Settings.Impl(context), SearchSettings { + override var searchFilterMode: MusicMode? + get() = + MusicMode.fromIntCode( + sharedPreferences.getInt(getString(R.string.set_key_search_filter), Int.MIN_VALUE)) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_search_filter), value?.intCode ?: Int.MIN_VALUE) + apply() + } + } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index 9341a7390..6fdf615fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -17,36 +17,38 @@ package org.oxycblt.auxio.search -import android.app.Application import androidx.annotation.IdRes -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import java.text.Normalizer +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.yield import org.oxycblt.auxio.R -import org.oxycblt.auxio.list.Header +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.model.Library import org.oxycblt.auxio.playback.PlaybackSettings -import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.logD /** - * An [AndroidViewModel] that keeps performs search operations and tracks their results. + * An [ViewModel] that keeps performs search operations and tracks their results. * @author Alexander Capehart (OxygenCobalt) */ -class SearchViewModel(application: Application) : - AndroidViewModel(application), MusicStore.Listener { - private val musicStore = MusicStore.getInstance() - private val searchSettings = SearchSettings.from(application) - private val playbackSettings = PlaybackSettings.from(application) +@HiltViewModel +class SearchViewModel +@Inject +constructor( + private val musicRepository: MusicRepository, + private val searchEngine: SearchEngine, + private val searchSettings: SearchSettings, + private val playbackSettings: PlaybackSettings, +) : ViewModel(), MusicRepository.Listener { private var lastQuery: String? = null private var currentSearchJob: Job? = null @@ -60,12 +62,12 @@ class SearchViewModel(application: Application) : get() = playbackSettings.inListPlaybackMode init { - musicStore.addListener(this) + musicRepository.addListener(this) } override fun onCleared() { super.onCleared() - musicStore.removeListener(this) + musicRepository.removeListener(this) } override fun onLibraryChanged(library: Library?) { @@ -85,7 +87,7 @@ class SearchViewModel(application: Application) : currentSearchJob?.cancel() lastQuery = query - val library = musicStore.library + val library = musicRepository.library if (query.isNullOrEmpty() || library == null) { logD("Search query is not applicable.") _searchResults.value = listOf() @@ -101,87 +103,43 @@ class SearchViewModel(application: Application) : } } - private fun searchImpl(library: Library, query: String): List { - val sort = Sort(Sort.Mode.ByName, true) + private suspend fun searchImpl(library: Library, query: String): List { val filterMode = searchSettings.searchFilterMode - val results = mutableListOf() - // Note: A null filter mode maps to the "All" filter option, hence the check. + val items = + if (filterMode == null) { + // A nulled filter mode means to not filter anything. + SearchEngine.Items(library.songs, library.albums, library.artists, library.genres) + } else { + SearchEngine.Items( + songs = if (filterMode == MusicMode.SONGS) library.songs else null, + albums = if (filterMode == MusicMode.ALBUMS) library.albums else null, + artists = if (filterMode == MusicMode.ARTISTS) library.artists else null, + genres = if (filterMode == MusicMode.GENRES) library.genres else null) + } - if (filterMode == null || filterMode == MusicMode.ARTISTS) { - library.artists.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_artists)) - results.addAll(sort.artists(it)) + val results = searchEngine.search(items, query) + + return buildList { + results.artists?.let { artists -> + add(BasicHeader(R.string.lbl_artists)) + addAll(SORT.artists(artists)) + } + results.albums?.let { albums -> + add(BasicHeader(R.string.lbl_albums)) + addAll(SORT.albums(albums)) + } + results.genres?.let { genres -> + add(BasicHeader(R.string.lbl_genres)) + addAll(SORT.genres(genres)) + } + results.songs?.let { songs -> + add(BasicHeader(R.string.lbl_songs)) + addAll(SORT.songs(songs)) } } - - if (filterMode == null || filterMode == MusicMode.ALBUMS) { - library.albums.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_albums)) - results.addAll(sort.albums(it)) - } - } - - if (filterMode == null || filterMode == MusicMode.GENRES) { - library.genres.searchListImpl(query)?.let { - results.add(Header(R.string.lbl_genres)) - results.addAll(sort.genres(it)) - } - } - - if (filterMode == null || filterMode == MusicMode.SONGS) { - library.songs - .searchListImpl(query) { q, song -> song.path.name.contains(q) } - ?.let { - results.add(Header(R.string.lbl_songs)) - results.addAll(sort.songs(it)) - } - } - - // Handle if we were canceled while searching. - return results } - /** - * Search a given [Music] list. - * @param query The query to search for. The routine will compare this query to the names of - * each object in the list and - * @param fallback Additional comparison code to run if the item does not match the query - * initially. This can be used to compare against additional attributes to improve search result - * quality. - */ - private inline fun List.searchListImpl( - query: String, - fallback: (String, T) -> Boolean = { _, _ -> false } - ) = - filter { - // See if the plain resolved name matches the query. This works for most situations. - val name = it.resolveName(context) - if (name.contains(query, ignoreCase = true)) { - return@filter true - } - - // See if the sort name matches. This can sometimes be helpful as certain libraries - // will tag sort names to have a alphabetized version of the title. - val sortName = it.rawSortName - if (sortName != null && sortName.contains(query, ignoreCase = true)) { - return@filter true - } - - // As a last-ditch effort, see if the normalized name matches. This will replace - // any non-alphabetical characters with their alphabetical representations, which - // could make it match the query. - val normalizedName = - NORMALIZATION_SANITIZE_REGEX.replace( - Normalizer.normalize(name, Normalizer.Form.NFKD), "") - if (normalizedName.contains(query, ignoreCase = true)) { - return@filter true - } - - fallback(query, it) - } - .ifEmpty { null } - /** * Returns the ID of the filter option to currently highlight. * @return A menu item ID of the filtering option selected. @@ -218,10 +176,6 @@ class SearchViewModel(application: Application) : } private companion object { - /** - * Converts the output of [Normalizer] to remove any junk characters added by it's - * replacements. - */ - val NORMALIZATION_SANITIZE_REGEX = Regex("\\p{InCombiningDiacriticalMarks}+") + val SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index aa94552d8..c5a01cfb5 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -28,6 +28,7 @@ import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.google.android.material.transition.MaterialFadeThrough +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentAboutBinding @@ -43,6 +44,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat * A [ViewBindingFragment] that displays information about the app and the current music library. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class AboutFragment : ViewBindingFragment() { private val musicModel: MusicViewModel by activityViewModels() diff --git a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt index 4bb3acecb..bc4622dc1 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/RootPreferenceFragment.kt @@ -24,19 +24,20 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialSharedAxis +import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.settings.ui.WrappedDialogPreference -import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.showToast /** * The [PreferenceFragmentCompat] that displays the root settings list. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class RootPreferenceFragment : BasePreferenceFragment(R.xml.preferences_root) { - private val playbackModel: PlaybackViewModel by androidActivityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() private val musicModel: MusicViewModel by activityViewModels() override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index 92a81fa26..5bc4448b9 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -54,7 +54,7 @@ interface Settings { * A framework-backed [Settings] implementation. * @param context [Context] required. */ - abstract class Real(private val context: Context) : + abstract class Impl(private val context: Context) : Settings, SharedPreferences.OnSharedPreferenceChangeListener { protected val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) @@ -85,6 +85,7 @@ interface Settings { sharedPreferences: SharedPreferences, key: String ) { + // FIXME: Settings initialization firing the listener. onSettingChanged(key, unlikelyToBeNull(listener)) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt index b38177d1e..1ee5e05e6 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt @@ -19,7 +19,9 @@ package org.oxycblt.auxio.settings.categories import androidx.navigation.fragment.findNavController import androidx.preference.Preference -import coil.Coil +import coil.ImageLoader +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.settings.BasePreferenceFragment import org.oxycblt.auxio.settings.ui.WrappedDialogPreference @@ -28,7 +30,10 @@ import org.oxycblt.auxio.settings.ui.WrappedDialogPreference * "Content" settings. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) { + @Inject lateinit var imageLoader: ImageLoader + override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_separators)) { findNavController().navigate(MusicPreferenceFragmentDirections.goToSeparatorsDialog()) @@ -39,7 +44,7 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) if (preference.key == getString(R.string.set_key_cover_mode)) { preference.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> - Coil.imageLoader(requireContext()).memoryCache?.clear() + imageLoader.memoryCache?.clear() true } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt index 443daff5b..4e564e9d5 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/UIPreferenceFragment.kt @@ -20,13 +20,22 @@ package org.oxycblt.auxio.settings.categories import androidx.appcompat.app.AppCompatDelegate import androidx.navigation.fragment.findNavController import androidx.preference.Preference +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.settings.BasePreferenceFragment import org.oxycblt.auxio.settings.ui.WrappedDialogPreference import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.isNight +/** + * Display preferences. + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) { + @Inject lateinit var uiSettings: UISettings + override fun onOpenDialogPreference(preference: WrappedDialogPreference) { if (preference.key == getString(R.string.set_key_accent)) { findNavController().navigate(UIPreferenceFragmentDirections.goToAccentDialog()) @@ -43,7 +52,7 @@ class UIPreferenceFragment : BasePreferenceFragment(R.xml.preferences_ui) { } } getString(R.string.set_key_accent) -> { - preference.summary = getString(UISettings.from(requireContext()).accent.name) + preference.summary = getString(uiSettings.accent.name) } getString(R.string.set_key_black_theme) -> { preference.onPreferenceChangeListener = diff --git a/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt index 30deeab85..5613e1ef3 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/ui/IntListPreferenceDialog.kt @@ -18,10 +18,13 @@ package org.oxycblt.auxio.settings.ui import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatButton import androidx.preference.PreferenceDialogFragmentCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.fixDoubleRipple /** * The companion dialog to [IntListPreference]. Use [from] to create an instance. @@ -32,6 +35,16 @@ class IntListPreferenceDialog : PreferenceDialogFragmentCompat() { get() = (preference as IntListPreference) private var pendingValueIndex = -1 + override fun onStart() { + super.onStart() + + (requireDialog() as AlertDialog).apply { + (getButton(AlertDialog.BUTTON_NEUTRAL) as AppCompatButton).fixDoubleRipple() + (getButton(AlertDialog.BUTTON_POSITIVE) as AppCompatButton).fixDoubleRipple() + (getButton(AlertDialog.BUTTON_NEGATIVE) as AppCompatButton).fixDoubleRipple() + } + } + override fun onCreateDialog(savedInstanceState: Bundle?) = // PreferenceDialogFragmentCompat does not allow us to customize the actual creation // of the alert dialog, so we have to override onCreateDialog and create a new dialog diff --git a/app/src/main/java/org/oxycblt/auxio/ui/RippleFixMaterialButton.kt b/app/src/main/java/org/oxycblt/auxio/ui/RippleFixMaterialButton.kt new file mode 100644 index 000000000..514858f63 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/RippleFixMaterialButton.kt @@ -0,0 +1,42 @@ +/* + * 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.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.annotation.AttrRes +import com.google.android.material.button.MaterialButton +import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.fixDoubleRipple + +/** + * Fixes an issue where double ripples appear on [MaterialButton] from AppCompat 1.5 afterwards due + * to a currently unfixed change. + * @author Alexander Capehart (OxygenCobalt) + */ +open class RippleFixMaterialButton +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = R.attr.materialButtonStyle +) : MaterialButton(context, attrs, defStyleAttr) { + init { + fixDoubleRipple() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UIModule.kt b/app/src/main/java/org/oxycblt/auxio/ui/UIModule.kt new file mode 100644 index 000000000..335459c17 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/UIModule.kt @@ -0,0 +1,29 @@ +/* + * 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.ui + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface UIModule { + @Binds fun settings(uiSettings: UISettingsImpl): UISettings +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt index 5e9de4b83..10f8bbc52 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/UISettings.kt @@ -21,6 +21,8 @@ import android.content.Context import android.os.Build import androidx.appcompat.app.AppCompatDelegate 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.ui.accent.Accent @@ -44,66 +46,59 @@ interface UISettings : Settings { /** Called when [roundMode] changes. */ fun onRoundModeChanged() } +} - private class Real(context: Context) : Settings.Real(context), UISettings { - override val theme: Int - get() = - sharedPreferences.getInt( - getString(R.string.set_key_theme), AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) +class UISettingsImpl @Inject constructor(@ApplicationContext context: Context) : + Settings.Impl(context), UISettings { + override val theme: Int + get() = + sharedPreferences.getInt( + getString(R.string.set_key_theme), AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - override val useBlackTheme: Boolean - get() = sharedPreferences.getBoolean(getString(R.string.set_key_black_theme), false) + override val useBlackTheme: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_black_theme), false) - override var accent: Accent - get() = - Accent.from( - sharedPreferences.getInt(getString(R.string.set_key_accent), Accent.DEFAULT)) - set(value) { - sharedPreferences.edit { - putInt(getString(R.string.set_key_accent), value.index) - apply() - } - } - - override val roundMode: Boolean - get() = sharedPreferences.getBoolean(getString(R.string.set_key_round_mode), false) - - override fun migrate() { - if (sharedPreferences.contains(OLD_KEY_ACCENT3)) { - logD("Migrating $OLD_KEY_ACCENT3") - - var accent = sharedPreferences.getInt(OLD_KEY_ACCENT3, 5) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Accents were previously frozen as soon as the OS was updated to android - // twelve, as dynamic colors were enabled by default. This is no longer the - // case, so we need to re-update the setting to dynamic colors here. - accent = 16 - } - - sharedPreferences.edit { - putInt(getString(R.string.set_key_accent), accent) - remove(OLD_KEY_ACCENT3) - apply() - } + override var accent: Accent + get() = + Accent.from( + sharedPreferences.getInt(getString(R.string.set_key_accent), Accent.DEFAULT)) + set(value) { + sharedPreferences.edit { + putInt(getString(R.string.set_key_accent), value.index) + apply() } } - override fun onSettingChanged(key: String, listener: Listener) { - if (key == getString(R.string.set_key_round_mode)) { - listener.onRoundModeChanged() - } - } + override val roundMode: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_round_mode), false) - private companion object { - const val OLD_KEY_ACCENT3 = "auxio_accent" + override fun migrate() { + if (sharedPreferences.contains(OLD_KEY_ACCENT3)) { + logD("Migrating $OLD_KEY_ACCENT3") + + var accent = sharedPreferences.getInt(OLD_KEY_ACCENT3, 5) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Accents were previously frozen as soon as the OS was updated to android + // twelve, as dynamic colors were enabled by default. This is no longer the + // case, so we need to re-update the setting to dynamic colors here. + accent = 16 + } + + sharedPreferences.edit { + putInt(getString(R.string.set_key_accent), accent) + remove(OLD_KEY_ACCENT3) + apply() + } } } - companion object { - /** - * Get a framework-backed implementation. - * @param context [Context] required. - */ - fun from(context: Context): UISettings = Real(context) + override fun onSettingChanged(key: String, listener: UISettings.Listener) { + if (key == getString(R.string.set_key_round_mode)) { + listener.onRoundModeChanged() + } + } + + private companion object { + const val OLD_KEY_ACCENT3 = "auxio_accent" } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt index ae53a8a7f..1b9a5eb47 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingDialogFragment.kt @@ -22,9 +22,11 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatButton import androidx.fragment.app.DialogFragment import androidx.viewbinding.ViewBinding import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.oxycblt.auxio.util.fixDoubleRipple import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.unlikelyToBeNull @@ -102,6 +104,16 @@ abstract class ViewBindingDialogFragment : DialogFragment() { logD("Fragment created") } + override fun onStart() { + super.onStart() + + (requireDialog() as AlertDialog).apply { + (getButton(AlertDialog.BUTTON_NEUTRAL) as AppCompatButton).fixDoubleRipple() + (getButton(AlertDialog.BUTTON_POSITIVE) as AppCompatButton).fixDoubleRipple() + (getButton(AlertDialog.BUTTON_NEGATIVE) as AppCompatButton).fixDoubleRipple() + } + } + final override fun onDestroyView() { super.onDestroyView() onDestroyBinding(unlikelyToBeNull(_binding)) diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt index c1ec29ad2..1c9a042f5 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/AccentCustomizeDialog.kt @@ -21,6 +21,8 @@ import android.os.Bundle import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog 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.DialogAccentBinding @@ -34,9 +36,11 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * A [ViewBindingDialogFragment] that allows the user to configure the current [Accent]. * @author Alexander Capehart (OxygenCobalt) */ +@AndroidEntryPoint class AccentCustomizeDialog : ViewBindingDialogFragment(), ClickableListListener { private var accentAdapter = AccentAdapter(this) + @Inject lateinit var uiSettings: UISettings override fun onCreateBinding(inflater: LayoutInflater) = DialogAccentBinding.inflate(inflater) @@ -44,14 +48,13 @@ class AccentCustomizeDialog : builder .setTitle(R.string.set_accent) .setPositiveButton(R.string.lbl_ok) { _, _ -> - val settings = UISettings.from(requireContext()) - if (accentAdapter.selectedAccent == settings.accent) { + if (accentAdapter.selectedAccent == uiSettings.accent) { // Nothing to do. return@setPositiveButton } logD("Applying new accent") - settings.accent = unlikelyToBeNull(accentAdapter.selectedAccent) + uiSettings.accent = unlikelyToBeNull(accentAdapter.selectedAccent) requireActivity().recreate() dismiss() } @@ -65,7 +68,7 @@ class AccentCustomizeDialog : if (savedInstanceState != null) { Accent.from(savedInstanceState.getInt(KEY_PENDING_ACCENT)) } else { - UISettings.from(requireContext()).accent + uiSettings.accent }) } diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt index de624a85d..85aa256da 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt @@ -39,6 +39,7 @@ import androidx.core.content.ContextCompat import kotlin.reflect.KClass import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.MainActivity +import org.oxycblt.auxio.R /** * Get a [LayoutInflater] instance from this [Context]. @@ -57,6 +58,25 @@ val Context.isNight val Context.isLandscape get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE +/** + * Concatenate values in a list together in a localized manner. + * @param context [Context] require.d + * @param map Function to map the [T] values to a string value to be concatenated. + */ +inline fun List.concatLocalized(context: Context, map: (T) -> String): String { + if (isEmpty()) { + // Nothing to do. + return "" + } + + var joined = map(first()) + for (i in 1..lastIndex) { + // Chain all previous values with the next value in the list with another delimiter. + joined = context.getString(R.string.fmt_list, joined, map(get(i))) + } + return joined +} + /** * @brief Get a plural resource. * @param pluralRes A plural resource ID. diff --git a/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt deleted file mode 100644 index fc029f105..000000000 --- a/app/src/main/java/org/oxycblt/auxio/util/DbUtil.kt +++ /dev/null @@ -1,81 +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.util - -import android.content.ContentValues -import android.database.Cursor -import android.database.sqlite.SQLiteDatabase -import androidx.core.database.sqlite.transaction - -/** - * Query all columns in the given [SQLiteDatabase] table, running the block when the [Cursor] is - * loaded. The block will be called with [use], allowing for automatic cleanup of [Cursor] - * resources. - * @param tableName The name of the table to query all columns in. - * @param block The code block to run with the loaded [Cursor]. - */ -inline fun SQLiteDatabase.queryAll(tableName: String, block: (Cursor) -> R) = - query(tableName, null, null, null, null, null, null)?.use(block) - -/** - * Create a table in an [SQLiteDatabase], if it does not already exist. - * @param name The name of the table to create. - * @param schema A block that adds a comma-separated list of SQL column declarations. - */ -inline fun SQLiteDatabase.createTable(name: String, schema: StringBuilder.() -> StringBuilder) { - val command = StringBuilder().append("CREATE TABLE IF NOT EXISTS $name(").schema().append(")") - execSQL(command.toString()) -} - -/** - * Safely write a list of items to an [SQLiteDatabase]. This will clear the prior list and write as - * much of the new list as possible. - * @param list The list of items to write. - * @param tableName The name of the table to write the items to. - * @param transform Code to transform an item into a corresponding [ContentValues] to the given - * table. - */ -inline fun SQLiteDatabase.writeList( - list: List, - tableName: String, - transform: (Int, T) -> ContentValues -) { - // Clear any prior items in the table. - transaction { delete(tableName, null, null) } - - var transactionPosition = 0 - while (transactionPosition < list.size) { - // Start at the current transaction position, if a transaction failed at any point, - // this value can be used to immediately start at the next item and continue writing - // the list without error. - var i = transactionPosition - transaction { - while (i < list.size) { - val values = transform(i, list[i]) - // Increment forward now so that if this insert fails, the transaction position - // will still start at the next i. - i++ - insert(tableName, null, values) - } - } - transactionPosition = i - logD( - "Wrote batch of ${T::class.simpleName} instances. " + - "Position is now at $transactionPosition") - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index 31fdfcae1..e81dc11d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -23,18 +23,13 @@ import android.graphics.drawable.Drawable import android.os.Build import android.view.View import android.view.WindowInsets -import androidx.activity.viewModels import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatButton import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.graphics.Insets import androidx.core.graphics.drawable.DrawableCompat import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.RecyclerView @@ -112,6 +107,17 @@ val ViewBinding.context: Context */ fun RecyclerView.canScroll() = computeVerticalScrollRange() > height +/** + * Fix the double ripple that appears in MaterialButton instances due to an issue with AppCompat 1.5 + * or higher. + */ +fun AppCompatButton.fixDoubleRipple() { + AppCompatButton::class.java.getDeclaredField("mBackgroundTintHelper").apply { + isAccessible = true + set(this@fixDoubleRipple, null) + } +} + /** * Get the [CoordinatorLayout.Behavior] of a [View], or null if the [View] is not part of a * [CoordinatorLayout] or does not have a [CoordinatorLayout.Behavior]. @@ -196,35 +202,6 @@ private fun Fragment.launch( viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(state, block) } } -/** - * An extension to [viewModels] that automatically provides an - * [ViewModelProvider.AndroidViewModelFactory]. Use whenever an [AndroidViewModel] is used. - */ -inline fun Fragment.androidViewModels() = - viewModels { ViewModelProvider.AndroidViewModelFactory(requireActivity().application) } - -/** - * An extension to [viewModels] that automatically provides an - * [ViewModelProvider.AndroidViewModelFactory]. Use whenever an [AndroidViewModel] is used. Note - * that this implementation is for an [AppCompatActivity], and thus makes this functionally - * equivalent in scope to [androidActivityViewModels]. - */ -inline fun AppCompatActivity.androidViewModels() = - viewModels { ViewModelProvider.AndroidViewModelFactory(application) } - -/** - * An extension to [activityViewModels] that automatically provides an - * [ViewModelProvider.AndroidViewModelFactory]. Use whenever an [AndroidViewModel] is used. - */ -inline fun Fragment.androidActivityViewModels() = - activityViewModels { - ViewModelProvider.AndroidViewModelFactory(requireActivity().application) - } - -/** The [Context] provided to an [AndroidViewModel]. */ -inline val AndroidViewModel.context: Context - get() = getApplication() - /** * Get the "System Bar" [Insets] in this [WindowInsets] instance in a version-compatible manner This * can be used to prevent [View] elements from intersecting with the navigation bars. diff --git a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt index 6441f94ba..71b963b38 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LangUtil.kt @@ -17,7 +17,7 @@ package org.oxycblt.auxio.util -import android.os.Looper +import java.util.UUID import kotlin.reflect.KClass import org.oxycblt.auxio.BuildConfig @@ -83,13 +83,13 @@ fun lazyReflectedMethod(clazz: KClass<*>, method: String) = lazy { } /** - * Assert that the execution is currently on a background thread. This is helpful for functions that - * don't necessarily require suspend, but still want to ensure that they are being called with a - * co-routine. - * @throws IllegalStateException If the execution is not on a background thread. + * Convert a [String] to a [UUID]. + * @return A [UUID] converted from the [String] value, or null if the value was not valid. + * @see UUID.fromString */ -fun requireBackgroundThread() { - check(Looper.myLooper() != Looper.getMainLooper()) { - "This operation must be ran on a background thread" +fun String.toUuidOrNull(): UUID? = + try { + UUID.fromString(this) + } catch (e: IllegalArgumentException) { + null } -} diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt index 180165d97..ebea97289 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetComponent.kt @@ -22,15 +22,17 @@ import android.graphics.Bitmap import android.os.Build import coil.request.ImageRequest import coil.transform.RoundedCornersTransformation +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.PlaybackStateManager -import org.oxycblt.auxio.playback.state.Queue import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.getDimenPixels @@ -39,16 +41,18 @@ import org.oxycblt.auxio.util.logD /** * A component that manages the "Now Playing" state. This is kept separate from the [WidgetProvider] * itself to prevent possible memory leaks and enable extension to more widgets in the future. - * @param context [Context] required to manage AppWidgetProviders. * @author Alexander Capehart (OxygenCobalt) */ -class WidgetComponent(private val context: Context) : - PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { - private val playbackManager = PlaybackStateManager.getInstance() - private val uiSettings = UISettings.from(context) - private val imageSettings = ImageSettings.from(context) +class WidgetComponent +@Inject +constructor( + @ApplicationContext private val context: Context, + private val imageSettings: ImageSettings, + private val bitmapProvider: BitmapProvider, + private val playbackManager: PlaybackStateManager, + private val uiSettings: UISettings +) : PlaybackStateManager.Listener, UISettings.Listener, ImageSettings.Listener { private val widgetProvider = WidgetProvider() - private val provider = BitmapProvider(context) init { playbackManager.addListener(this) @@ -61,7 +65,7 @@ class WidgetComponent(private val context: Context) : val song = playbackManager.queue.currentSong if (song == null) { logD("No song, resetting widget") - widgetProvider.update(context, null) + widgetProvider.update(context, uiSettings, null) return } @@ -70,7 +74,7 @@ class WidgetComponent(private val context: Context) : val repeatMode = playbackManager.repeatMode val isShuffled = playbackManager.queue.isShuffled - provider.load( + bitmapProvider.load( song, object : BitmapProvider.Target { override fun onConfigRequest(builder: ImageRequest.Builder): ImageRequest.Builder { @@ -101,17 +105,18 @@ class WidgetComponent(private val context: Context) : override fun onCompleted(bitmap: Bitmap?) { val state = PlaybackState(song, bitmap, isPlaying, repeatMode, isShuffled) - widgetProvider.update(context, state) + widgetProvider.update(context, uiSettings, state) } }) } /** Release this instance, preventing any further events from updating the widget instances. */ fun release() { - provider.release() + bitmapProvider.release() + imageSettings.unregisterListener(this) + playbackManager.removeListener(this) uiSettings.unregisterListener(this) widgetProvider.reset(context) - playbackManager.removeListener(this) } // --- CALLBACKS --- @@ -129,11 +134,11 @@ class WidgetComponent(private val context: Context) : /** * A condensed form of the playback state that is safe to use in AppWidgets. - * @param song [PlaybackStateManager.song] + * @param song [Queue.currentSong] * @param cover A pre-loaded album cover [Bitmap] for [song]. * @param isPlaying [PlaybackStateManager.playerState] * @param repeatMode [PlaybackStateManager.repeatMode] - * @param isShuffled [PlaybackStateManager.isShuffled] + * @param isShuffled [Queue.isShuffled] */ data class PlaybackState( val song: Song, diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index a333ec2f0..ca737e8ef 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -29,8 +29,10 @@ import android.view.View import android.widget.RemoteViews import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.system.PlaybackService +import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.util.* /** @@ -68,9 +70,10 @@ class WidgetProvider : AppWidgetProvider() { /** * Update the currently shown layout based on the given [WidgetComponent.PlaybackState] * @param context [Context] required to update the widget layout. + * @param uiSettings [UISettings] to obtain round mode configuration * @param state [WidgetComponent.PlaybackState] to show, or null if no playback is going on. */ - fun update(context: Context, state: WidgetComponent.PlaybackState?) { + fun update(context: Context, uiSettings: UISettings, state: WidgetComponent.PlaybackState?) { if (state == null) { // No state, use the default widget. reset(context) @@ -82,11 +85,11 @@ class WidgetProvider : AppWidgetProvider() { // the widget elements, plus some leeway for text sizing. val views = mapOf( - SizeF(180f, 100f) to newThinLayout(context, state), - SizeF(180f, 152f) to newSmallLayout(context, state), - SizeF(272f, 152f) to newWideLayout(context, state), - SizeF(180f, 272f) to newMediumLayout(context, state), - SizeF(272f, 272f) to newLargeLayout(context, state)) + SizeF(180f, 100f) to newThinLayout(context, uiSettings, state), + SizeF(180f, 152f) to newSmallLayout(context, uiSettings, state), + SizeF(272f, 152f) to newWideLayout(context, uiSettings, state), + SizeF(180f, 272f) to newMediumLayout(context, uiSettings, state), + SizeF(272f, 272f) to newLargeLayout(context, uiSettings, state)) // Manually update AppWidgetManager with the new views. val awm = AppWidgetManager.getInstance(context) @@ -124,79 +127,80 @@ class WidgetProvider : AppWidgetProvider() { // --- LAYOUTS --- - /** - * Create and configure a [RemoteViews] for [R.layout.widget_default], intended for situations - * where no other widget layout is applicable. - * @param context [Context] required to create the [RemoteViews]. - */ private fun newDefaultLayout(context: Context) = newRemoteViews(context, R.layout.widget_default) - /** - * Create and configure a [RemoteViews] for [R.layout.widget_thin], intended for extremely small - * grid sizes on phones in landscape mode. - * @param context [Context] required to create the [RemoteViews]. - */ - private fun newThinLayout(context: Context, state: WidgetComponent.PlaybackState) = + private fun newThinLayout( + context: Context, + uiSettings: UISettings, + state: WidgetComponent.PlaybackState + ) = newRemoteViews(context, R.layout.widget_thin) - .setupBackground(context) + .setupBackground( + uiSettings, + ) .setupPlaybackState(context, state) .setupTimelineControls(context, state) - /** - * Create and configure a [RemoteViews] for [R.layout.widget_small], intended to be a - * modestly-sized default layout for most devices. - * @param context [Context] required to create the [RemoteViews]. - */ - private fun newSmallLayout(context: Context, state: WidgetComponent.PlaybackState) = + private fun newSmallLayout( + context: Context, + uiSettings: UISettings, + state: WidgetComponent.PlaybackState + ) = newRemoteViews(context, R.layout.widget_small) - .setupBar(context) + .setupBar( + uiSettings, + ) .setupCover(context, state) .setupTimelineControls(context, state) - /** - * Create and configure a [RemoteViews] for [R.layout.widget_medium], intended to be a taller - * widget that shows more information about the currently playing song. - * @param context [Context] required to create the [RemoteViews]. - */ - private fun newMediumLayout(context: Context, state: WidgetComponent.PlaybackState) = + private fun newMediumLayout( + context: Context, + uiSettings: UISettings, + state: WidgetComponent.PlaybackState + ) = newRemoteViews(context, R.layout.widget_medium) - .setupBackground(context) + .setupBackground( + uiSettings, + ) .setupPlaybackState(context, state) .setupTimelineControls(context, state) - /** - * Create and configure a [RemoteViews] for [R.layout.widget_wide], intended to be a wider - * version of [R.layout.widget_small] that shows additional controls. - * @param context [Context] required to create the [RemoteViews]. - */ - private fun newWideLayout(context: Context, state: WidgetComponent.PlaybackState) = + private fun newWideLayout( + context: Context, + uiSettings: UISettings, + state: WidgetComponent.PlaybackState + ) = newRemoteViews(context, R.layout.widget_wide) - .setupBar(context) + .setupBar( + uiSettings, + ) .setupCover(context, state) .setupFullControls(context, state) - /** - * Create and configure a [RemoteViews] for [R.layout.widget_large], intended to be a wider - * version of [R.layout.widget_medium] that shows additional controls. - * @param context [Context] required to create the [RemoteViews]. - */ - private fun newLargeLayout(context: Context, state: WidgetComponent.PlaybackState) = + private fun newLargeLayout( + context: Context, + uiSettings: UISettings, + state: WidgetComponent.PlaybackState + ) = newRemoteViews(context, R.layout.widget_large) - .setupBackground(context) + .setupBackground( + uiSettings, + ) .setupPlaybackState(context, state) .setupFullControls(context, state) /** * Set up the control bar in a [RemoteViews] layout that contains one. This is a kind of * "floating" drawable that sits in front of the cover and contains the controls. - * @param context [Context] required to set up the view. */ - private fun RemoteViews.setupBar(context: Context): RemoteViews { + private fun RemoteViews.setupBar( + uiSettings: UISettings, + ): RemoteViews { // Below API 31, enable a rounded bar only if round mode is enabled. // On API 31+, the bar should always be round in order to fit in with other widgets. val background = - if (useRoundedRemoteViews(context)) { + if (useRoundedRemoteViews(uiSettings)) { R.drawable.ui_widget_bar_round } else { R.drawable.ui_widget_bar_system @@ -208,14 +212,15 @@ class WidgetProvider : AppWidgetProvider() { /** * Set up the background in a [RemoteViews] layout that contains one. This is largely * self-explanatory, being a solid-color background that sits behind the cover and controls. - * @param context [Context] required to set up the view. */ - private fun RemoteViews.setupBackground(context: Context): RemoteViews { + private fun RemoteViews.setupBackground( + uiSettings: UISettings, + ): RemoteViews { // Below API 31, enable a rounded background only if round mode is enabled. // On API 31+, the background should always be round in order to fit in with other // widgets. val background = - if (useRoundedRemoteViews(context)) { + if (useRoundedRemoteViews(uiSettings)) { R.drawable.ui_widget_bg_round } else { R.drawable.ui_widget_bg_system @@ -261,7 +266,7 @@ class WidgetProvider : AppWidgetProvider() { ): RemoteViews { setupCover(context, state) setTextViewText(R.id.widget_song, state.song.resolveName(context)) - setTextViewText(R.id.widget_artist, state.song.resolveArtistContents(context)) + setTextViewText(R.id.widget_artist, state.song.artists.resolveNames(context)) return this } diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt index 2fe319470..3a478b6ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetUtil.kt @@ -137,8 +137,8 @@ fun AppWidgetManager.updateAppWidgetCompat( /** * Returns whether rounded UI elements are appropriate for the widget, either based on the current * settings or if the widget has to fit in aesthetically with other widgets. - * @param context [Context] configuration to use. + * @param [uiSettings] [UISettings] required to obtain round mode configuration. * @return true if to use round mode, false otherwise. */ -fun useRoundedRemoteViews(context: Context) = - UISettings.from(context).roundMode || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S +fun useRoundedRemoteViews(uiSettings: UISettings) = + uiSettings.roundMode || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml index 6ba7e9b53..a7406768f 100644 --- a/app/src/main/res/layout-h480dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-h480dp/fragment_playback_panel.xml @@ -87,7 +87,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - -