commit
23b04a7d7e
217 changed files with 7275 additions and 5359 deletions
14
.github/workflows/android.yml
vendored
14
.github/workflows/android.yml
vendored
|
@ -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
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -3,8 +3,6 @@
|
|||
local.properties
|
||||
build/
|
||||
release/
|
||||
srclibs/
|
||||
libs/
|
||||
|
||||
# Studio
|
||||
.idea/
|
||||
|
|
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
[submodule "ExoPlayer"]
|
||||
path = ExoPlayer
|
||||
url = https://github.com/OxygenCobalt/ExoPlayer.git
|
||||
branch = auxio
|
32
CHANGELOG.md
32
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
|
||||
|
|
1
ExoPlayer
Submodule
1
ExoPlayer
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 268d683bab060fff43e75732248416d9bf476ef3
|
16
README.md
16
README.md
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.2">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.2&color=0D5AF5">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.3">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.3&color=0D5AF5">
|
||||
</a>
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg">
|
||||
|
@ -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
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<FragmentMainBinding>(),
|
||||
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()
|
||||
|
|
|
@ -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<Song, FragmentDetailBinding>(), 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
|
||||
}
|
||||
|
|
|
@ -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<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
|
||||
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
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
)
|
|
@ -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<Song?>
|
||||
get() = _currentSong
|
||||
|
||||
private val _songProperties = MutableStateFlow<SongProperties?>(null)
|
||||
/** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */
|
||||
val songProperties: StateFlow<SongProperties?> = _songProperties
|
||||
private val _songAudioInfo = MutableStateFlow<AudioInfo?>(null)
|
||||
/** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
|
||||
val songAudioInfo: StateFlow<AudioInfo?> = _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<Song>(uid)?.also(::loadProperties)
|
||||
_currentSong.value = requireMusic<Song>(uid)?.also(::refreshAudioInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -234,86 +235,24 @@ class DetailViewModel(application: Application) :
|
|||
_currentGenre.value = requireMusic<Genre>(uid)?.also(::refreshGenreList)
|
||||
}
|
||||
|
||||
private fun <T : Music> requireMusic(uid: Music.UID) = musicStore.library?.find<T>(uid)
|
||||
private fun <T : Music> requireMusic(uid: Music.UID) = musicRepository.library?.find<T>(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<Item>(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<Item>(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<Item>(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))
|
||||
|
|
|
@ -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<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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<DialogSongDetailBinding>() {
|
||||
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<DialogSongDetailBinding>() {
|
|||
|
||||
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))
|
||||
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 {
|
||||
binding.detailBitrate.setText(R.string.def_bitrate)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.sampleRateHz != null) {
|
||||
binding.detailSampleRate.setText(
|
||||
getString(R.string.fmt_sample_rate, properties.sampleRateHz))
|
||||
private fun <T : Music> T.zipName(context: Context) =
|
||||
if (rawSortName != null) {
|
||||
getString(R.string.fmt_zipped_names, resolveName(context), rawSortName)
|
||||
} 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
|
||||
}
|
||||
resolveName(context)
|
||||
}
|
||||
|
||||
private fun <T : Music> List<T>.zipNames(context: Context) =
|
||||
concatLocalized(context) { it.zipName(context) }
|
||||
}
|
||||
|
|
|
@ -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<Album>() {
|
||||
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<DiscHeader>() {
|
||||
override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) =
|
||||
oldItem.disc == newItem.disc
|
||||
object : SimpleDiffCallback<Disc>() {
|
||||
override fun areContentsTheSame(oldItem: Disc, newItem: Disc) =
|
||||
oldItem.number == newItem.number && oldItem.name == newItem.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Artist>() {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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<Item>() {
|
||||
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) :
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<SongProperty, BasicListInstructions, SongPropertyViewHolder>(
|
||||
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<SongProperty>() {
|
||||
override fun areContentsTheSame(oldItem: SongProperty, newItem: SongProperty) =
|
||||
oldItem.name == newItem.name && oldItem.value == newItem.value
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<FragmentHomeBinding>(), 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<String>? = 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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
|
@ -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,8 +42,10 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
|
|||
/** Called when the [shouldHideCollaborators] configuration changes. */
|
||||
fun onHideCollaboratorsChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private class Real(context: Context) : Settings.Real<Listener>(context), HomeSettings {
|
||||
class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
|
||||
Settings.Impl<HomeSettings.Listener>(context), HomeSettings {
|
||||
override var homeTabs: Array<Tab>
|
||||
get() =
|
||||
Tab.fromIntCode(
|
||||
|
@ -56,23 +60,12 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
|
|||
}
|
||||
|
||||
override val shouldHideCollaborators: Boolean
|
||||
get() =
|
||||
sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
|
||||
|
||||
override fun onSettingChanged(key: String, listener: Listener) {
|
||||
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()
|
||||
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get a framework-backed implementation.
|
||||
* @param context [Context] required.
|
||||
*/
|
||||
fun from(context: Context): HomeSettings = Real(context)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Song>())
|
||||
/** 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<Boolean> = _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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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<Album, FragmentHomeListBinding>(),
|
||||
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)
|
||||
|
|
|
@ -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<Artist, FragmentHomeListBinding>(),
|
||||
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) =
|
||||
|
|
|
@ -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<Genre, FragmentHomeListBinding>(),
|
||||
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) =
|
||||
|
|
|
@ -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<Song, FragmentHomeListBinding>(),
|
||||
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)
|
||||
|
|
|
@ -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<DialogTabsBinding>(), EditableListListener<Tab> {
|
||||
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))
|
||||
|
|
|
@ -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. */
|
||||
|
|
68
app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt
Normal file
68
app/src/main/java/org/oxycblt/auxio/image/ImageModule.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
|
@ -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,8 +37,10 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
|
|||
/** Called when [coverMode] changes. */
|
||||
fun onCoverModeChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
private class Real(context: Context) : Settings.Real<Listener>(context), ImageSettings {
|
||||
class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
|
||||
Settings.Impl<ImageSettings.Listener>(context), ImageSettings {
|
||||
override val coverMode: CoverMode
|
||||
get() =
|
||||
CoverMode.fromIntCode(
|
||||
|
@ -65,9 +69,9 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: Listener) {
|
||||
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
|
||||
if (key == getString(R.string.set_key_cover_mode)) {
|
||||
listOf(key, listener)
|
||||
listener.onCoverModeChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -76,12 +80,3 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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<Music> {
|
|||
* @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<Song> {
|
||||
class SongFactory @Inject constructor(private val imageSettings: ImageSettings) :
|
||||
Fetcher.Factory<Song> {
|
||||
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<Album> {
|
||||
class AlbumFactory @Inject constructor(private val imageSettings: ImageSettings) :
|
||||
Fetcher.Factory<Album> {
|
||||
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<Artist> {
|
||||
class Factory @Inject constructor(private val imageSettings: ImageSettings) :
|
||||
Fetcher.Factory<Artist> {
|
||||
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<Genre> {
|
||||
class Factory @Inject constructor(private val imageSettings: ImageSettings) :
|
||||
Fetcher.Factory<Genre> {
|
||||
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
|
||||
GenreImageFetcher(options.context, options.size, data)
|
||||
GenreImageFetcher(options.context, imageSettings, options.size, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<in T : Music, VB : ViewBinding> :
|
||||
SelectionFragment<VB>(), SelectableListListener<T> {
|
||||
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<in T : Music, VB : ViewBinding> :
|
|||
currentMenu =
|
||||
PopupMenu(requireContext(), anchor).apply {
|
||||
inflate(menuRes)
|
||||
logD(menu is SupportMenu)
|
||||
MenuCompat.setGroupDividerEnabled(menu, true)
|
||||
block()
|
||||
setOnDismissListener { currentMenu = null }
|
||||
show()
|
||||
|
|
|
@ -15,15 +15,16 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song>): List<Song> {
|
||||
fun <T : Song> songs(songs: Collection<T>): List<T> {
|
||||
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<Album>): List<Album> {
|
||||
fun <T : Album> albums(albums: Collection<T>): List<T> {
|
||||
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<Artist>): List<Artist> {
|
||||
fun <T : Artist> artists(artists: Collection<T>): List<T> {
|
||||
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<Genre>): List<Genre> {
|
||||
fun <T : Genre> genres(genres: Collection<T>): List<T> {
|
||||
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<Song>) {
|
||||
songs.sortWith(mode.getSongComparator(isAscending))
|
||||
private fun songsInPlace(songs: MutableList<out Song>) {
|
||||
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<Album>) {
|
||||
albums.sortWith(mode.getAlbumComparator(isAscending))
|
||||
private fun albumsInPlace(albums: MutableList<out Album>) {
|
||||
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<Artist>) {
|
||||
artists.sortWith(mode.getArtistComparator(isAscending))
|
||||
private fun artistsInPlace(artists: MutableList<out Artist>) {
|
||||
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<Genre>) {
|
||||
genres.sortWith(mode.getGenreComparator(isAscending))
|
||||
private fun genresInPlace(genres: MutableList<out Genre>) {
|
||||
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<Song> {
|
||||
open fun getSongComparator(direction: Direction): Comparator<Song> {
|
||||
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<Album> {
|
||||
open fun getAlbumComparator(direction: Direction): Comparator<Album> {
|
||||
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<Artist> {
|
||||
open fun getArtistComparator(direction: Direction): Comparator<Artist> {
|
||||
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<Genre> {
|
||||
open fun getGenreComparator(direction: Direction): Comparator<Genre> {
|
||||
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<Song> =
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
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<Song> =
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
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<Album> =
|
||||
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
|
||||
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<Song> =
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
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<Album> =
|
||||
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
|
||||
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<Song> =
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.durationMs },
|
||||
compareBy(BasicComparator.SONG))
|
||||
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.SONG))
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.durationMs },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.ALBUM))
|
||||
|
||||
override fun getArtistComparator(isAscending: Boolean): Comparator<Artist> =
|
||||
override fun getArtistComparator(direction: Direction): Comparator<Artist> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.LONG) { it.durationMs },
|
||||
compareByDynamic(direction, NullableComparator.LONG) { it.durationMs },
|
||||
compareBy(BasicComparator.ARTIST))
|
||||
|
||||
override fun getGenreComparator(isAscending: Boolean): Comparator<Genre> =
|
||||
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
|
||||
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<Album> =
|
||||
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.songs.size },
|
||||
compareBy(BasicComparator.ALBUM))
|
||||
compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.ALBUM))
|
||||
|
||||
override fun getArtistComparator(isAscending: Boolean): Comparator<Artist> =
|
||||
override fun getArtistComparator(direction: Direction): Comparator<Artist> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending, NullableComparator.INT) { it.songs.size },
|
||||
compareByDynamic(direction, NullableComparator.INT) { it.songs.size },
|
||||
compareBy(BasicComparator.ARTIST))
|
||||
|
||||
override fun getGenreComparator(isAscending: Boolean): Comparator<Genre> =
|
||||
override fun getGenreComparator(direction: Direction): Comparator<Genre> =
|
||||
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<Song> =
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
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<Song> =
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
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<Song> =
|
||||
override fun getSongComparator(direction: Direction): Comparator<Song> =
|
||||
MultiComparator(
|
||||
compareByDynamic(isAscending) { it.dateAdded }, compareBy(BasicComparator.SONG))
|
||||
compareByDynamic(direction) { it.dateAdded }, compareBy(BasicComparator.SONG))
|
||||
|
||||
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> =
|
||||
override fun getAlbumComparator(direction: Direction): Comparator<Album> =
|
||||
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 <T : Music, K : Comparable<K>> 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 <T : Music> compareByDynamic(
|
||||
isAscending: Boolean,
|
||||
direction: Direction,
|
||||
comparator: Comparator<in T>
|
||||
): Comparator<T> = compareByDynamic(isAscending, comparator) { it }
|
||||
): Comparator<T> = 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 <T : Music, K> compareByDynamic(
|
||||
isAscending: Boolean,
|
||||
direction: Direction,
|
||||
comparator: Comparator<in K>,
|
||||
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<Int>()
|
||||
/** A re-usable instance configured for [Long]s. */
|
||||
val LONG = NullableComparator<Long>()
|
||||
/** A re-usable instance configured for [Disc]s */
|
||||
val DISC = NullableComparator<Disc>()
|
||||
/** A re-usable instance configured for [Date.Range]s. */
|
||||
val DATE_RANGE = NullableComparator<Date.Range>()
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -63,7 +63,7 @@ interface ListDiffer<T, I> {
|
|||
class Async<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
|
||||
Factory<T, BasicListInstructions>() {
|
||||
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
|
||||
RealAsyncListDiffer(AdapterListUpdateCallback(adapter), diffCallback)
|
||||
AsyncListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,7 +75,7 @@ interface ListDiffer<T, I> {
|
|||
class Blocking<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
|
||||
Factory<T, BasicListInstructions>() {
|
||||
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
|
||||
RealBlockingListDiffer(AdapterListUpdateCallback(adapter), diffCallback)
|
||||
BlockingListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -113,7 +113,7 @@ private abstract class BasicListDiffer<T> : ListDiffer<T, BasicListInstructions>
|
|||
protected abstract fun replaceList(newList: List<T>, onDone: () -> Unit)
|
||||
}
|
||||
|
||||
private class RealAsyncListDiffer<T>(
|
||||
private class AsyncListDifferImpl<T>(
|
||||
updateCallback: ListUpdateCallback,
|
||||
diffCallback: DiffUtil.ItemCallback<T>
|
||||
) : BasicListDiffer<T>() {
|
||||
|
@ -132,7 +132,7 @@ private class RealAsyncListDiffer<T>(
|
|||
}
|
||||
}
|
||||
|
||||
private class RealBlockingListDiffer<T>(
|
||||
private class BlockingListDifferImpl<T>(
|
||||
private val updateCallback: ListUpdateCallback,
|
||||
private val diffCallback: DiffUtil.ItemCallback<T>
|
||||
) : BasicListDiffer<T>() {
|
||||
|
|
|
@ -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<Song>() {
|
||||
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<Album>() {
|
||||
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<Header>() {
|
||||
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean =
|
||||
oldItem.titleRes == newItem.titleRes
|
||||
object : SimpleDiffCallback<BasicHeader>() {
|
||||
override fun areContentsTheSame(
|
||||
oldItem: BasicHeader,
|
||||
newItem: BasicHeader
|
||||
): Boolean = oldItem.titleRes == newItem.titleRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<VB : ViewBinding> :
|
||||
ViewBindingFragment<VB>(), 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
|
||||
|
|
|
@ -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<Music>())
|
||||
/** the currently selected items. These are ordered in earliest selected and latest selected. */
|
||||
val selected: StateFlow<List<Music>>
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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))
|
||||
}
|
File diff suppressed because it is too large
Load diff
34
app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt
Normal file
34
app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
|
@ -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<Listener>()
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
class MusicRepositoryImpl @Inject constructor() : MusicRepository {
|
||||
private val listeners = mutableListOf<MusicRepository.Listener>()
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
|
@ -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<MusicSettings.Listener> {
|
|||
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,8 +65,10 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
/** Called when the [shouldBeObserving] configuration has changed. */
|
||||
fun onObservingChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
private class Real(context: Context) : Settings.Real<Listener>(context), MusicSettings {
|
||||
class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
|
||||
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
|
||||
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
|
||||
|
||||
override var musicDirs: MusicDirectories
|
||||
|
@ -73,8 +79,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
.mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) }
|
||||
return MusicDirectories(
|
||||
dirs,
|
||||
sharedPreferences.getBoolean(
|
||||
getString(R.string.set_key_music_dirs_include), false))
|
||||
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
|
||||
}
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
|
@ -87,8 +92,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
}
|
||||
|
||||
override val excludeNonMusic: Boolean
|
||||
get() =
|
||||
sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
|
||||
|
||||
override val shouldBeObserving: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
|
||||
|
@ -104,11 +108,14 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
}
|
||||
}
|
||||
|
||||
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, true)
|
||||
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_songs_sort), value.intCode)
|
||||
|
@ -119,9 +126,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
override var albumSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_albums_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, true)
|
||||
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)
|
||||
|
@ -132,9 +138,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
override var artistSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_artists_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, true)
|
||||
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)
|
||||
|
@ -145,9 +150,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
override var genreSort: Sort
|
||||
get() =
|
||||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_genres_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, true)
|
||||
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)
|
||||
|
@ -161,7 +165,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByDisc, true)
|
||||
?: Sort(Sort.Mode.ByDisc, Sort.Direction.ASCENDING)
|
||||
|
||||
// Correct legacy album sort modes to Disc
|
||||
if (sort.mode is Sort.Mode.ByName) {
|
||||
|
@ -182,7 +186,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByDate, false)
|
||||
?: Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_artist_songs_sort), value.intCode)
|
||||
|
@ -195,7 +199,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
Sort.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE))
|
||||
?: Sort(Sort.Mode.ByName, true)
|
||||
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
|
||||
|
@ -203,22 +207,16 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: Listener) {
|
||||
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) -> listener.onIndexingSettingChanged()
|
||||
getString(R.string.set_key_separators),
|
||||
getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged()
|
||||
getString(R.string.set_key_observing) -> listener.onObservingChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get a framework-backed implementation.
|
||||
* @param context [Context] required.
|
||||
*/
|
||||
fun from(context: Context): MusicSettings = Real(context)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Indexer.State?>(null)
|
||||
/** The current music loading state, or null if no loading is going on. */
|
||||
|
|
173
app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
vendored
Normal file
173
app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt
vendored
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.cache
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import org.oxycblt.auxio.music.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<CachedSong>
|
||||
@Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs()
|
||||
@Insert suspend fun insertSongs(songs: List<CachedSong>)
|
||||
}
|
||||
|
||||
@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<String> = listOf(),
|
||||
/** @see RawSong.artistMusicBrainzIds */
|
||||
var artistMusicBrainzIds: List<String> = listOf(),
|
||||
/** @see RawSong.artistNames */
|
||||
var artistNames: List<String> = listOf(),
|
||||
/** @see RawSong.artistSortNames */
|
||||
var artistSortNames: List<String> = listOf(),
|
||||
/** @see RawSong.albumArtistMusicBrainzIds */
|
||||
var albumArtistMusicBrainzIds: List<String> = listOf(),
|
||||
/** @see RawSong.albumArtistNames */
|
||||
var albumArtistNames: List<String> = listOf(),
|
||||
/** @see RawSong.albumArtistSortNames */
|
||||
var albumArtistSortNames: List<String> = listOf(),
|
||||
/** @see RawSong.genreNames */
|
||||
var genreNames: List<String> = listOf()
|
||||
) {
|
||||
fun copyToRaw(rawSong: RawSong): 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<String>) =
|
||||
values.joinToString(";") { it.replace(";", "\\;") }
|
||||
|
||||
@TypeConverter
|
||||
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
|
||||
|
||||
@TypeConverter fun fromDate(date: Date?) = date?.toString()
|
||||
|
||||
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
|
||||
}
|
||||
|
||||
companion object {
|
||||
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)
|
||||
}
|
||||
}
|
50
app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt
vendored
Normal file
50
app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt
vendored
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
111
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal file
111
app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt
vendored
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<RawSong>)
|
||||
}
|
||||
|
||||
class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: CachedSongsDao) :
|
||||
CacheRepository {
|
||||
override suspend fun readCache(): Cache? =
|
||||
try {
|
||||
// Faster to load the whole database into memory than do a query on each
|
||||
// populate call.
|
||||
CacheImpl(cachedSongsDao.readSongs())
|
||||
} catch (e: Exception) {
|
||||
logE("Unable to load cache database.")
|
||||
logE(e.stackTraceToString())
|
||||
null
|
||||
}
|
||||
|
||||
override suspend fun writeCache(rawSongs: List<RawSong>) {
|
||||
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<CachedSong>) : Cache {
|
||||
private val cacheMap = buildMap {
|
||||
for (cachedSong in cachedSongs) {
|
||||
put(cachedSong.mediaStoreId, cachedSong)
|
||||
}
|
||||
}
|
||||
|
||||
override var invalidated = false
|
||||
override fun populate(rawSong: RawSong): Boolean {
|
||||
|
||||
// For a cached raw song to be used, it must exist within the cache and have matching
|
||||
// addition and modification timestamps. Technically the addition timestamp doesn't
|
||||
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
|
||||
// check for it anyway.
|
||||
val cachedSong = cacheMap[rawSong.mediaStoreId]
|
||||
if (cachedSong != null &&
|
||||
cachedSong.dateAdded == rawSong.dateAdded &&
|
||||
cachedSong.dateModified == rawSong.dateModified) {
|
||||
cachedSong.copyToRaw(rawSong)
|
||||
return true
|
||||
}
|
||||
|
||||
// We could not populate this song. This means our cache is stale and should be
|
||||
// re-written with newly-loaded music.
|
||||
invalidated = true
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song.Raw>)
|
||||
|
||||
/**
|
||||
* 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<Song.Raw>) {
|
||||
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<Long, Song.Raw>? = 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<Song.Raw>) {
|
||||
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<Long, Song.Raw> {
|
||||
requireBackgroundThread()
|
||||
val start = System.currentTimeMillis()
|
||||
val map = mutableMapOf<Long, Song.Raw>()
|
||||
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<Song.Raw>) {
|
||||
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<String>.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Long, String>()
|
||||
|
||||
/**
|
||||
* 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<StorageVolume>()
|
||||
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<String>()
|
||||
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<Song.Raw>) {
|
||||
// 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<String>
|
||||
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<String>): 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 <unknown>, 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<String>
|
||||
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<String>): 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<String>
|
||||
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<String>): 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<String>
|
||||
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<String>
|
||||
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()
|
123
app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt
Normal file
123
app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.tags
|
||||
package org.oxycblt.auxio.music.metadata
|
||||
|
||||
import android.content.Context
|
||||
import java.text.ParseException
|
31
app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt
Normal file
31
app/src/main/java/org/oxycblt/auxio/music/metadata/Disc.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Disc> {
|
||||
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)
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.
|
|
@ -15,13 +15,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<DialogSeparatorsBinding>() {
|
||||
@Inject lateinit var musicSettings: MusicSettings
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogSeparatorsBinding.inflate(inflater)
|
||||
|
||||
|
@ -42,7 +47,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
|
|||
.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<DialogSeparatorsBinding>() {
|
|||
// 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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
interface TagExtractor {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>)
|
||||
}
|
||||
|
||||
class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
TagExtractor {
|
||||
override suspend fun consume(
|
||||
incompleteSongs: Channel<RawSong>,
|
||||
completeSongs: Channel<RawSong>
|
||||
) {
|
||||
// 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<Task?> = arrayOfNulls(TASK_CAPACITY)
|
||||
val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun init() = mediaStoreExtractor.init().count
|
||||
|
||||
/**
|
||||
* 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<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
for (song in incompleteSongs) {
|
||||
spin@ while (true) {
|
||||
for (i in taskPool.indices) {
|
||||
val task = taskPool[i]
|
||||
if (task != null) {
|
||||
val finishedRawSong = task.get()
|
||||
if (finishedRawSong != null) {
|
||||
completeSongs.send(finishedRawSong)
|
||||
yield()
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Spin until there is an open slot we can insert a task in.
|
||||
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
|
||||
}
|
||||
} else {
|
||||
taskPool[i] = Task(context, raw)
|
||||
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)
|
||||
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<String, List<String>>) {
|
||||
// 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<String, List<String>>) {
|
||||
// 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 }
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String>.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<String> {
|
||||
|
@ -107,12 +107,45 @@ private fun String.maybeParseBySeparators(settings: MusicSettings): List<String>
|
|||
/// --- 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
|
|
@ -15,13 +15,12 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.
|
|
@ -15,11 +15,12 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song.Raw>, 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<Song>
|
||||
/** All [Album]s in this [Library]. */
|
||||
val albums: List<Album>
|
||||
/** All [Artist]s in this [Library]. */
|
||||
val artists: List<Artist>
|
||||
/** All [Genre]s in this [Library]. */
|
||||
val genres: List<Genre>
|
||||
|
||||
/**
|
||||
* 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 <T : Music> 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 <T : MusicParent> 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<RawSong>, settings: MusicSettings): Library =
|
||||
LibraryImpl(rawSongs, settings)
|
||||
}
|
||||
}
|
||||
|
||||
private class LibraryImpl(rawSongs: List<RawSong>, 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<Song.Raw>, 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 <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
|
||||
@Suppress("UNCHECKED_CAST") override fun <T : Music> 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>(song.uid)
|
||||
override fun sanitize(song: Song) = find<Song>(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>(album.uid)
|
||||
override fun <T : MusicParent> sanitize(parent: T) = find<T>(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>(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>(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<Song.Raw>, 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<RawSong>, 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<Song>): List<Album> {
|
||||
private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
|
||||
// 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<Song.Raw>, 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<Song>, albums: List<Album>): List<Artist> {
|
||||
private fun buildArtists(
|
||||
songs: List<SongImpl>,
|
||||
albums: List<AlbumImpl>,
|
||||
settings: MusicSettings
|
||||
): List<ArtistImpl> {
|
||||
// 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<Artist.Raw, MutableList<Music>>()
|
||||
val musicByArtist = mutableMapOf<RawArtist, MutableList<Music>>()
|
||||
|
||||
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<Song.Raw>, 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<Song>): List<Genre> {
|
||||
private fun buildGenres(songs: List<SongImpl>, settings: MusicSettings): List<GenreImpl> {
|
||||
// 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<Genre.Raw, MutableList<Song>>()
|
||||
val songsByGenre = mutableMapOf<RawGenre, MutableList<SongImpl>>()
|
||||
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
|
||||
}
|
542
app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt
Normal file
542
app/src/main/java/org/oxycblt/auxio/music/model/MusicImpl.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<ArtistImpl>()
|
||||
override val artists: List<Artist>
|
||||
get() = _artists
|
||||
|
||||
private val _genres = mutableListOf<GenreImpl>()
|
||||
override val genres: List<Genre>
|
||||
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<SongImpl>
|
||||
) : 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<ArtistImpl>()
|
||||
override val artists: List<Artist>
|
||||
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<Music>
|
||||
) : 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<Song>
|
||||
|
||||
override val albums: List<Album>
|
||||
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<Genre>
|
||||
|
||||
init {
|
||||
val distinctSongs = mutableSetOf<Song>()
|
||||
val distinctAlbums = mutableSetOf<Album>()
|
||||
|
||||
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<RawArtist>) = 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<SongImpl>
|
||||
) : 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<Album>
|
||||
override val artists: List<Artist>
|
||||
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<Album>()
|
||||
val distinctArtists = mutableSetOf<Artist>()
|
||||
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<RawGenre>) = 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<String?>) {
|
||||
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)
|
||||
}
|
200
app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt
Normal file
200
app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<String> = listOf(),
|
||||
/** @see RawArtist.musicBrainzId */
|
||||
var artistMusicBrainzIds: List<String> = listOf(),
|
||||
/** @see RawArtist.name */
|
||||
var artistNames: List<String> = listOf(),
|
||||
/** @see RawArtist.sortName */
|
||||
var artistSortNames: List<String> = listOf(),
|
||||
/** @see RawArtist.musicBrainzId */
|
||||
var albumArtistMusicBrainzIds: List<String> = listOf(),
|
||||
/** @see RawArtist.name */
|
||||
var albumArtistNames: List<String> = listOf(),
|
||||
/** @see RawArtist.sortName */
|
||||
var albumArtistSortNames: List<String> = listOf(),
|
||||
/** @see RawGenre.name */
|
||||
var genreNames: List<String> = 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<RawArtist>
|
||||
) {
|
||||
// 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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<RawSong>,
|
||||
completeSongs: Channel<RawSong>
|
||||
)
|
||||
|
||||
/** 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<String>()
|
||||
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<Long, String>()
|
||||
|
||||
// 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<RawSong>,
|
||||
completeSongs: Channel<RawSong>
|
||||
) {
|
||||
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<String>
|
||||
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<String>): Boolean
|
||||
|
||||
protected abstract fun wrapQuery(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>
|
||||
): MediaStoreExtractor.Query
|
||||
|
||||
abstract class Query(
|
||||
protected val cursor: Cursor,
|
||||
private val genreNamesMap: Map<Long, String>
|
||||
) : 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 <unknown>, 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<String>
|
||||
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<String>): 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<Long, String>,
|
||||
): MediaStoreExtractor.Query =
|
||||
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
|
||||
|
||||
private class Query(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>,
|
||||
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<String>
|
||||
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<String>): 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<Long, String>,
|
||||
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<String>
|
||||
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
||||
|
||||
override fun wrapQuery(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>
|
||||
): MediaStoreExtractor.Query =
|
||||
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
|
||||
|
||||
private class Query(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>,
|
||||
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<String>
|
||||
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<Long, String>
|
||||
): MediaStoreExtractor.Query =
|
||||
Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class))
|
||||
|
||||
private class Query(
|
||||
cursor: Cursor,
|
||||
genreNamesMap: Map<Long, String>,
|
||||
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)
|
|
@ -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<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
|
||||
private val dirAdapter = DirectoryAdapter(this)
|
||||
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = 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) {
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
|
@ -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<Library>? = 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<Song.Raw> {
|
||||
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<Song.Raw>()
|
||||
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<Library>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<Library>? = 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<RawSong>(Channel.UNLIMITED)
|
||||
val incompleteSongs = Channel<RawSong>(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<RawSong>()
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a singleton instance.
|
||||
* @return The (possibly newly-created) singleton instance.
|
||||
* 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.
|
||||
*/
|
||||
fun getInstance(): Indexer {
|
||||
val currentInstance = INSTANCE
|
||||
if (currentInstance != null) {
|
||||
return currentInstance
|
||||
@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<Library>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.picker
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
|
@ -15,12 +15,13 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<DialogMusicPickerBinding>(), ClickableListListener<Artist> {
|
||||
protected val pickerModel: PickerViewModel by viewModels()
|
|
@ -15,16 +15,17 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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()
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.picker
|
||||
package org.oxycblt.auxio.picker
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
|
@ -15,15 +15,17 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<DialogMusicPickerBinding>(), ClickableListListener<Genre> {
|
||||
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()
|
|
@ -15,14 +15,15 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Music?>(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()
|
||||
}
|
|
@ -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<FragmentPlaybackBarBinding>() {
|
||||
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<FragmentPlaybackBarBinding>() {
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
|
@ -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<FragmentPlaybackPanelBinding>(),
|
||||
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<Intent>? = 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()
|
||||
}
|
||||
|
|
|
@ -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,8 +67,10 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
|
|||
/** Called when [notificationAction] has changed. */
|
||||
fun onNotificationActionChanged() {}
|
||||
}
|
||||
}
|
||||
|
||||
private class Real(context: Context) : Settings.Real<Listener>(context), PlaybackSettings {
|
||||
class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
|
||||
Settings.Impl<PlaybackSettings.Listener>(context), PlaybackSettings {
|
||||
override val inListPlaybackMode: MusicMode
|
||||
get() =
|
||||
MusicMode.fromIntCode(
|
||||
|
@ -89,19 +93,16 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
|
|||
override val notificationAction: ActionMode
|
||||
get() =
|
||||
ActionMode.fromIntCode(
|
||||
sharedPreferences.getInt(
|
||||
getString(R.string.set_key_notif_action), Int.MIN_VALUE))
|
||||
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)
|
||||
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))
|
||||
sharedPreferences.getInt(getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
|
||||
?: ReplayGainMode.DYNAMIC
|
||||
|
||||
override var replayGainPreAmp: ReplayGainPreAmp
|
||||
|
@ -191,12 +192,11 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onSettingChanged(key: String, listener: Listener) {
|
||||
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_pre_amp_without) -> listener.onReplayGainSettingsChanged()
|
||||
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
|
||||
}
|
||||
}
|
||||
|
@ -207,12 +207,3 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
|
|||
const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Get a framework-backed implementation.
|
||||
* @param context [Context] required.
|
||||
*/
|
||||
fun from(context: Context): PlaybackSettings = Real(context)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Song?>(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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<QueueHeapItem>
|
||||
|
||||
/**
|
||||
* 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<QueueMappingItem>
|
||||
|
||||
/** 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<QueueHeapItem>)
|
||||
|
||||
/**
|
||||
* 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<QueueMappingItem>)
|
||||
}
|
||||
|
||||
@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"
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<QueueHeapItem>
|
||||
val mapping: List<QueueMappingItem>
|
||||
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<Int>()
|
||||
val shuffledMapping = mutableListOf<Int>()
|
||||
for (entry in mapping) {
|
||||
orderedMapping.add(entry.orderedIndex)
|
||||
shuffledMapping.add(entry.shuffledIndex)
|
||||
}
|
||||
|
||||
val parent = playbackState.parentUid?.let { library.find<MusicParent>(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
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Song>
|
||||
|
||||
/**
|
||||
* 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<Song?>,
|
||||
val orderedMapping: List<Int>,
|
||||
val shuffledMapping: List<Int>,
|
||||
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<Song>()
|
||||
@Volatile private var orderedMapping = mutableListOf<Int>()
|
||||
@Volatile private var shuffledMapping = mutableListOf<Int>()
|
||||
/** 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<Song>): ChangeResult {
|
||||
fun playNext(songs: List<Song>): 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<Song>): ChangeResult {
|
||||
fun addToQueue(songs: List<Song>): 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<Int?>()
|
||||
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<Song?>,
|
||||
val orderedMapping: List<Int>,
|
||||
val shuffledMapping: List<Int>,
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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<FragmentQueueBinding>(), EditableListListener<Song> {
|
||||
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
|
||||
|
||||
|
|
|
@ -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<Song>())
|
||||
/** The current queue. */
|
||||
|
|
|
@ -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<DialogPreAmpBinding>() {
|
||||
@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<DialogPreAmpBinding>() {
|
|||
.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<DialogPreAmpBinding>() {
|
|||
// 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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<MusicParent>(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<Song?>()
|
||||
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<Int?>()
|
||||
val shuffledMapping = mutableListOf<Int?>()
|
||||
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<Song?>,
|
||||
/** @see Queue.SavedState.orderedMapping */
|
||||
val orderedMapping: List<Int>,
|
||||
/** @see Queue.SavedState.shuffledMapping */
|
||||
val shuffledMapping: List<Int>
|
||||
)
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Listener>()
|
||||
@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<Song>, 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<Song>, 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<Song>) {
|
||||
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<Song>)
|
||||
|
||||
/**
|
||||
* 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<Song>) {
|
||||
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<Song>)
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Get a singleton instance.
|
||||
* @return The (possibly newly-created) singleton instance.
|
||||
* 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].
|
||||
*/
|
||||
fun getInstance(): PlaybackStateManager {
|
||||
val currentInstance = INSTANCE
|
||||
|
||||
if (currentInstance != null) {
|
||||
return currentInstance
|
||||
data class SavedState(
|
||||
val parent: MusicParent?,
|
||||
val queueState: Queue.SavedState,
|
||||
val positionMs: Long,
|
||||
val repeatMode: RepeatMode,
|
||||
)
|
||||
}
|
||||
|
||||
synchronized(this) {
|
||||
val newInstance = PlaybackStateManager()
|
||||
INSTANCE = newInstance
|
||||
return newInstance
|
||||
}
|
||||
class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
|
||||
private val listeners = mutableListOf<PlaybackStateManager.Listener>()
|
||||
@Volatile private var internalPlayer: InternalPlayer? = null
|
||||
@Volatile private var pendingAction: InternalPlayer.Action? = null
|
||||
@Volatile private var isInitialized = false
|
||||
|
||||
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<Song>, 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
|
||||
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<Song>) {
|
||||
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<Song>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue