Merge pull request #366 from OxygenCobalt/dev

Version 3.0.3
This commit is contained in:
Alexander Capehart 2023-02-21 17:43:53 +00:00 committed by GitHub
commit 23b04a7d7e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
217 changed files with 7275 additions and 5359 deletions

View file

@ -11,22 +11,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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 - name: Set up JDK 11
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
java-version: '11' java-version: '11'
distribution: 'temurin' distribution: 'temurin'
cache: gradle 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 - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Test app with Gradle - name: Test app with Gradle

2
.gitignore vendored
View file

@ -3,8 +3,6 @@
local.properties local.properties
build/ build/
release/ release/
srclibs/
libs/
# Studio # Studio
.idea/ .idea/

4
.gitmodules vendored Normal file
View file

@ -0,0 +1,4 @@
[submodule "ExoPlayer"]
path = ExoPlayer
url = https://github.com/OxygenCobalt/ExoPlayer.git
branch = auxio

View file

@ -1,5 +1,37 @@
# Changelog # 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 ## 3.0.2
#### What's New #### What's New

1
ExoPlayer Submodule

@ -0,0 +1 @@
Subproject commit 268d683bab060fff43e75732248416d9bf476ef3

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.0.2"> <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.2&color=0D5AF5"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.0.3&color=0D5AF5">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg"> <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 ## Building
Auxio relies on a custom version of ExoPlayer that enables some extra features. So, the build process is as follows: Auxio relies on a custom version of ExoPlayer that enables some extra features. This adds some caveats to
the build process:
1. `cd` into the project directory. 1. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
2. Run `python3 prebuild.py`, which installs ExoPlayer and it's extensions. download in the external code.
- The pre-build process only works with \*nix systems. On windows, this process must be done manually. 2. You are **unable** to build this project on windows, as the custom ExoPlayer build runs shell scripts that
3. Build the project normally in Android Studio. will only work on unix-based systems.
## Contributing ## Contributing

View file

@ -4,16 +4,24 @@ plugins {
id "androidx.navigation.safeargs.kotlin" id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless" id "com.diffplug.spotless"
id "kotlin-parcelize" id "kotlin-parcelize"
id "dagger.hilt.android.plugin"
id "kotlin-kapt"
id 'org.jetbrains.kotlin.android'
} }
android { android {
compileSdk 33 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" namespace "org.oxycblt.auxio"
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.0.2" versionName "3.0.3"
versionCode 26 versionCode 27
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
@ -21,14 +29,13 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_11
} }
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "11"
freeCompilerArgs += "-Xjvm-default=all" freeCompilerArgs += "-Xjvm-default=all"
} }
@ -42,17 +49,17 @@ android {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
} }
} }
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
} }
dependencies { dependencies {
@ -66,7 +73,7 @@ dependencies {
// General // General
// 1.4.0 is used in order to avoid a ripple bug in material components // 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.core:core-ktx:1.9.0"
implementation "androidx.activity:activity-ktx:1.6.1" implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5" implementation "androidx.fragment:fragment-ktx:1.5.5"
@ -75,6 +82,7 @@ dependencies {
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4" implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01" implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
implementation 'androidx.core:core-ktx:+'
// Lifecycle // Lifecycle
def lifecycle_version = "2.5.1" def lifecycle_version = "2.5.1"
@ -93,30 +101,38 @@ dependencies {
// Preferences // Preferences
implementation "androidx.preference:preference-ktx:1.2.0" 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 --- // --- THIRD PARTY ---
// Exoplayer // Exoplayer (Vendored)
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE PRE-BUILD SCRIPT. implementation project(":exoplayer-library-core")
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE. implementation project(":exoplayer-extension-ffmpeg")
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"])
// Image loading // Image loading
implementation "io.coil-kt:coil:2.1.0" implementation 'io.coil-kt:coil-base:2.2.2'
// Material // Material
// Locked below 1.7.0-alpha03 to avoid the same ripple bug // TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout can be worked around
implementation "com.google.android.material:material:1.7.0-alpha02" // 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 // Dependency Injection
debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1" 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" testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.4' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
} }
spotless { spotless {

View file

@ -22,15 +22,9 @@ import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader import dagger.hilt.android.HiltAndroidApp
import coil.ImageLoaderFactory import javax.inject.Inject
import coil.request.CachePolicy
import org.oxycblt.auxio.image.ImageSettings 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.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
@ -38,16 +32,22 @@ import org.oxycblt.auxio.ui.UISettings
* A simple, rational music player for android. * A simple, rational music player for android.
* @author Alexander Capehart (OxygenCobalt) * @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() { override fun onCreate() {
super.onCreate() super.onCreate()
// Migrate any settings that may have changed in an app update. // Migrate any settings that may have changed in an app update.
ImageSettings.from(this).migrate() imageSettings.migrate()
PlaybackSettings.from(this).migrate() playbackSettings.migrate()
UISettings.from(this).migrate() uiSettings.migrate()
// Adding static shortcuts in a dynamic manner is better than declaring them // Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release // manually, as it will properly handle the difference between debug and release
// Auxio instances. // Auxio instances.
// TODO: Switch to static shortcuts
ShortcutManagerCompat.addDynamicShortcuts( ShortcutManagerCompat.addDynamicShortcuts(
this, this,
listOf( listOf(
@ -61,22 +61,6 @@ class Auxio : Application(), ImageLoaderFactory {
.build())) .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 { companion object {
/** The [Intent] name for the "Shuffle All" shortcut. */ /** The [Intent] name for the "Shuffle All" shortcut. */
const val INTENT_KEY_SHORTCUT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE_ALL" const val INTENT_KEY_SHORTCUT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE_ALL"

View file

@ -31,8 +31,8 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST = 0xA002 const val VIEW_TYPE_ARTIST = 0xA002
/** GenreViewHolder */ /** GenreViewHolder */
const val VIEW_TYPE_GENRE = 0xA003 const val VIEW_TYPE_GENRE = 0xA003
/** HeaderViewHolder */ /** BasicHeaderViewHolder */
const val VIEW_TYPE_HEADER = 0xA004 const val VIEW_TYPE_BASIC_HEADER = 0xA004
/** SortHeaderViewHolder */ /** SortHeaderViewHolder */
const val VIEW_TYPE_SORT_HEADER = 0xA005 const val VIEW_TYPE_SORT_HEADER = 0xA005
/** AlbumDetailViewHolder */ /** AlbumDetailViewHolder */

View file

@ -20,17 +20,19 @@ package org.oxycblt.auxio
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.databinding.ActivityMainBinding import org.oxycblt.auxio.databinding.ActivityMainBinding
import org.oxycblt.auxio.music.system.IndexerService import org.oxycblt.auxio.music.system.IndexerService
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.InternalPlayer import org.oxycblt.auxio.playback.state.InternalPlayer
import org.oxycblt.auxio.playback.system.PlaybackService import org.oxycblt.auxio.playback.system.PlaybackService
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.androidViewModels
import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -50,8 +52,10 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class MainActivity : AppCompatActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -81,17 +85,16 @@ class MainActivity : AppCompatActivity() {
} }
private fun setupTheme() { private fun setupTheme() {
val settings = UISettings.from(this)
// Apply the theme configuration. // 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 // 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. // it's not possible to modify the themes at run-time.
if (isNight && settings.useBlackTheme) { if (isNight && uiSettings.useBlackTheme) {
logD("Applying black theme [accent ${settings.accent}]") logD("Applying black theme [accent ${uiSettings.accent}]")
setTheme(settings.accent.blackTheme) setTheme(uiSettings.accent.blackTheme)
} else { } else {
logD("Applying normal theme [accent ${settings.accent}]") logD("Applying normal theme [accent ${uiSettings.accent}]")
setTheme(settings.accent.theme) setTheme(uiSettings.accent.theme)
} }
} }

View file

@ -33,6 +33,7 @@ import androidx.navigation.fragment.findNavController
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.transition.MaterialFadeThrough import com.google.android.material.transition.MaterialFadeThrough
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.databinding.FragmentMainBinding
@ -52,11 +53,12 @@ import org.oxycblt.auxio.util.*
* high-level navigation features. * high-level navigation features.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class MainFragment : class MainFragment :
ViewBindingFragment<FragmentMainBinding>(), ViewBindingFragment<FragmentMainBinding>(),
ViewTreeObserver.OnPreDrawListener, ViewTreeObserver.OnPreDrawListener,
NavController.OnDestinationChangedListener { NavController.OnDestinationChangedListener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private val selectionModel: SelectionViewModel by activityViewModels() private val selectionModel: SelectionViewModel by activityViewModels()
private val callback = DynamicBackPressedCallback() private val callback = DynamicBackPressedCallback()

View file

@ -26,28 +26,36 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song 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.* import org.oxycblt.auxio.util.*
/** /**
* A [ListFragment] that shows information about an [Album]. * A [ListFragment] that shows information about an [Album].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class AlbumDetailFragment : class AlbumDetailFragment :
ListFragment<Song, FragmentDetailBinding>(), AlbumDetailAdapter.Listener { ListFragment<Song, FragmentDetailBinding>(), AlbumDetailAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels() 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 // 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. // as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs() private val args: AlbumDetailFragmentArgs by navArgs()
@ -143,14 +151,19 @@ class AlbumDetailFragment :
openMenu(anchor, R.menu.menu_album_sort) { openMenu(anchor, R.menu.menu_album_sort) {
val sort = detailModel.albumSongSort val sort = detailModel.albumSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 -> setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
detailModel.albumSongSort = detailModel.albumSongSort =
if (item.itemId == R.id.option_sort_asc) { when (item.itemId) {
sort.withAscending(item.isChecked) R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
} else { R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
} }
true true
} }

View file

@ -25,19 +25,23 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter
import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song 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.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -48,9 +52,13 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* A [ListFragment] that shows information about an [Artist]. * A [ListFragment] that shows information about an [Artist].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class ArtistDetailFragment : class ArtistDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> { ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels() 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 // 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. // as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs() private val args: ArtistDetailFragmentArgs by navArgs()
@ -159,15 +167,20 @@ class ArtistDetailFragment :
openMenu(anchor, R.menu.menu_artist_sort) { openMenu(anchor, R.menu.menu_artist_sort) {
val sort = detailModel.artistSongSort val sort = detailModel.artistSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 -> setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
detailModel.artistSongSort = detailModel.artistSongSort =
if (item.itemId == R.id.option_sort_asc) { when (item.itemId) {
sort.withAscending(item.isChecked) R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
} else { R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
} }
true true

View file

@ -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
)

View file

@ -17,12 +17,11 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.app.Application
import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -30,30 +29,32 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.R 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.Item
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
/** /**
* [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of * [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
* the current item they are showing, sub-data to display, and configuration. Since this ViewModel * current item they are showing, sub-data to display, and configuration.
* requires a context, it must be instantiated [AndroidViewModel]'s Factory.
* @param application [Application] context required to initialize certain information.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class DetailViewModel(application: Application) : @HiltViewModel
AndroidViewModel(application), MusicStore.Listener { class DetailViewModel
private val musicStore = MusicStore.getInstance() @Inject
private val musicSettings = MusicSettings.from(application) constructor(
private val playbackSettings = PlaybackSettings.from(application) 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 private var currentSongJob: Job? = null
// --- SONG --- // --- SONG ---
@ -63,9 +64,9 @@ class DetailViewModel(application: Application) :
val currentSong: StateFlow<Song?> val currentSong: StateFlow<Song?>
get() = _currentSong get() = _currentSong
private val _songProperties = MutableStateFlow<SongProperties?>(null) private val _songAudioInfo = MutableStateFlow<AudioInfo?>(null)
/** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */ /** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
val songProperties: StateFlow<SongProperties?> = _songProperties val songAudioInfo: StateFlow<AudioInfo?> = _songAudioInfo
// --- ALBUM --- // --- ALBUM ---
@ -136,11 +137,11 @@ class DetailViewModel(application: Application) :
get() = playbackSettings.inParentPlaybackMode get() = playbackSettings.inParentPlaybackMode
init { init {
musicStore.addListener(this) musicRepository.addListener(this)
} }
override fun onCleared() { override fun onCleared() {
musicStore.removeListener(this) musicRepository.removeListener(this)
} }
override fun onLibraryChanged(library: Library?) { override fun onLibraryChanged(library: Library?) {
@ -155,7 +156,7 @@ class DetailViewModel(application: Application) :
val song = currentSong.value val song = currentSong.value
if (song != null) { if (song != null) {
_currentSong.value = library.sanitize(song)?.also(::loadProperties) _currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo)
logD("Updated song to ${currentSong.value}") 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 * 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. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSongUid(uid: Music.UID) { fun setSongUid(uid: Music.UID) {
@ -189,7 +190,7 @@ class DetailViewModel(application: Application) :
return return
} }
logD("Opening Song [uid: $uid]") 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) _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 * Start a new job to load a given [Song]'s [AudioInfo]. Result is pushed to [songAudioInfo].
* [songProperties].
* @param song The song to load. * @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. // Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel() currentSongJob?.cancel()
_songProperties.value = null _songAudioInfo.value = null
currentSongJob = currentSongJob =
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val properties = this@DetailViewModel.loadPropertiesImpl(song) val info = audioInfoProvider.extract(song)
yield() 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) { private fun refreshAlbumList(album: Album) {
logD("Refreshing album data") logD("Refreshing album data")
val data = mutableListOf<Item>(album) 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. // songs up by disc and then delimit the groups by a disc header.
val songs = albumSongSort.songs(album.songs) val songs = albumSongSort.songs(album.songs)
// Songs without disc tags become part of Disc 1. // 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) { if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers") logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) { for (entry in byDisc.entries) {
data.add(DiscHeader(entry.key)) data.add(entry.key)
data.addAll(entry.value) data.addAll(entry.value)
} }
} else { } else {
@ -341,7 +280,7 @@ class DetailViewModel(application: Application) :
private fun refreshArtistList(artist: Artist) { private fun refreshArtistList(artist: Artist) {
logD("Refreshing artist data") logD("Refreshing artist data")
val data = mutableListOf<Item>(artist) 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 = val byReleaseGroup =
albums.groupBy { albums.groupBy {
@ -367,7 +306,7 @@ class DetailViewModel(application: Application) :
logD("Release groups for this artist: ${byReleaseGroup.keys}") logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) { for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
data.add(Header(entry.key.headerTitleRes)) data.add(BasicHeader(entry.key.headerTitleRes))
data.addAll(entry.value) data.addAll(entry.value)
} }
@ -385,7 +324,7 @@ class DetailViewModel(application: Application) :
logD("Refreshing genre data") logD("Refreshing genre data")
val data = mutableListOf<Item>(genre) val data = mutableListOf<Item>(genre)
// Genre is guaranteed to always have artists and songs. // 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.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs)) data.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSongSort.songs(genre.songs)) data.addAll(genreSongSort.songs(genre.songs))

View file

@ -25,20 +25,24 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter
import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.list.adapter.BasicListInstructions 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song 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.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD 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]. * A [ListFragment] that shows information for a particular [Genre].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class GenreDetailFragment : class GenreDetailFragment :
ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> { ListFragment<Music, FragmentDetailBinding>(), DetailAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels() 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 // 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. // as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs() private val args: GenreDetailFragmentArgs by navArgs()
@ -158,14 +166,19 @@ class GenreDetailFragment :
openMenu(anchor, R.menu.menu_genre_sort) { openMenu(anchor, R.menu.menu_genre_sort) {
val sort = detailModel.genreSongSort val sort = detailModel.genreSongSort
unlikelyToBeNull(menu.findItem(sort.mode.itemId)).isChecked = true 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 -> setOnMenuItemClickListener { item ->
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
detailModel.genreSongSort = detailModel.genreSongSort =
if (item.itemId == R.id.option_sort_asc) { when (item.itemId) {
sort.withAscending(item.isChecked) R.id.option_sort_asc -> sort.withDirection(Sort.Direction.ASCENDING)
} else { R.id.option_sort_dec -> sort.withDirection(Sort.Direction.DESCENDING)
sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId))) else -> sort.withMode(unlikelyToBeNull(Sort.Mode.fromItemId(item.itemId)))
} }
true true
} }

View file

@ -17,30 +17,40 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.format.Formatter import android.text.format.Formatter
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isInvisible import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSongDetailBinding 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.Song
import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.concatLocalized
/** /**
* A [ViewBindingDialogFragment] that shows information about a Song. * A [ViewBindingDialogFragment] that shows information about a Song.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() { 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 // 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. // as a UID, as that is the only safe way to parcel an song.
private val args: SongDetailDialogArgs by navArgs() private val args: SongDetailDialogArgs by navArgs()
private val detailAdapter = SongPropertyAdapter()
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogSongDetailBinding.inflate(inflater) DialogSongDetailBinding.inflate(inflater)
@ -52,48 +62,72 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) { override fun onBindingCreated(binding: DialogSongDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState) super.onBindingCreated(binding, savedInstanceState)
binding.detailProperties.adapter = detailAdapter
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSongUid(args.itemUid) 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) { if (song == null) {
// Song we were showing no longer exists. // Song we were showing no longer exists.
findNavController().navigateUp() findNavController().navigateUp()
return return
} }
val binding = requireBinding() if (info != null) {
if (properties != null) {
// Finished loading Song properties, populate and show the list of Song information.
binding.detailLoading.isInvisible = true
binding.detailContainer.isInvisible = false
val context = requireContext() val context = requireContext()
binding.detailFileName.setText(song.path.name) detailAdapter.submitList(
binding.detailRelativeDir.setText(song.path.parent.resolveName(context)) buildList {
binding.detailFormat.setText(properties.resolvedMimeType.resolveName(context)) add(SongProperty(R.string.lbl_name, song.zipName(context)))
binding.detailSize.setText(Formatter.formatFileSize(context, song.size)) add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
binding.detailDuration.setText(song.durationMs.formatDurationMs(true)) add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
if (properties.bitrateKbps != null) { song.date?.let { add(SongProperty(R.string.lbl_date, it.resolveDate(context))) }
binding.detailBitrate.setText( song.track?.let {
getString(R.string.fmt_bitrate, properties.bitrateKbps)) add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
} else { }
binding.detailBitrate.setText(R.string.def_bitrate) song.disc?.let {
} val formattedNumber = getString(R.string.fmt_number, it.number)
val zipped =
if (properties.sampleRateHz != null) { if (it.name != null) {
binding.detailSampleRate.setText( getString(R.string.fmt_zipped_names, formattedNumber, it.name)
getString(R.string.fmt_sample_rate, properties.sampleRateHz)) } else {
} else { formattedNumber
binding.detailSampleRate.setText(R.string.def_sample_rate) }
} add(SongProperty(R.string.lbl_disc, zipped))
} else { }
// Loading is still on-going, don't show anything yet. add(SongProperty(R.string.lbl_file_name, song.path.name))
binding.detailLoading.isInvisible = false add(
binding.detailContainer.isInvisible = true SongProperty(
R.string.lbl_relative_path, song.path.parent.resolveName(context)))
info.resolvedMimeType.resolveName(context)?.let {
SongProperty(R.string.lbl_format, it)
}
add(
SongProperty(
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
info.bitrateKbps?.let {
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
}
info.sampleRateHz?.let {
add(
SongProperty(
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
}
},
BasicListInstructions.REPLACE)
} }
} }
private fun <T : Music> T.zipName(context: Context) =
if (rawSortName != null) {
getString(R.string.fmt_zipped_names, resolveName(context), rawSortName)
} else {
resolveName(context)
}
private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }
} }

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable 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.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDetailBinding import org.oxycblt.auxio.databinding.ItemDetailBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.detail.DiscHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song 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.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
@ -60,7 +63,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
when (getItem(position)) { when (getItem(position)) {
// Support the Album header, sub-headers for each disc, and special album songs. // Support the Album header, sub-headers for each disc, and special album songs.
is Album -> AlbumDetailViewHolder.VIEW_TYPE is Album -> AlbumDetailViewHolder.VIEW_TYPE
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE is Disc -> DiscViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
@ -68,7 +71,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent) AlbumDetailViewHolder.VIEW_TYPE -> AlbumDetailViewHolder.from(parent)
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent) DiscViewHolder.VIEW_TYPE -> DiscViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent) AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType) else -> super.onCreateViewHolder(parent, viewType)
} }
@ -77,7 +80,7 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
super.onBindViewHolder(holder, position) super.onBindViewHolder(holder, position)
when (val item = getItem(position)) { when (val item = getItem(position)) {
is Album -> (holder as AlbumDetailViewHolder).bind(item, listener) 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) 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. // The album and disc headers should be full-width in all configurations.
val item = getItem(position) val item = getItem(position)
return item is Album || item is DiscHeader return item is Album || item is Disc
} }
private companion object { private companion object {
@ -99,8 +102,8 @@ class AlbumDetailAdapter(private val listener: Listener) : DetailAdapter(listene
return when { return when {
oldItem is Album && newItem is Album -> oldItem is Album && newItem is Album ->
AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) AlbumDetailViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is DiscHeader && newItem is DiscHeader -> oldItem is Disc && newItem is Disc ->
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) DiscViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song -> oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) 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 // Artist name maps to the subhead text
binding.detailSubhead.apply { 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 // Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed. // name is pressed.
@ -172,7 +175,7 @@ private class AlbumDetailViewHolder private constructor(private val binding: Ite
object : SimpleDiffCallback<Album>() { object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) = override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.areArtistContentsTheSame(newItem) && oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.dates == newItem.dates && oldItem.dates == newItem.dates &&
oldItem.songs.size == newItem.songs.size && oldItem.songs.size == newItem.songs.size &&
oldItem.durationMs == newItem.durationMs && 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 * A [RecyclerView.ViewHolder] that displays a [Disc] to delimit different disc groups. Use [from]
* [from] to create an instance. * to create an instance.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) : private class DiscViewHolder(private val binding: ItemDiscHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
* @param discHeader The new [DiscHeader] to bind. * @param disc The new [disc] to bind.
*/ */
fun bind(discHeader: DiscHeader) { fun bind(disc: Disc) {
binding.discNo.text = binding.context.getString(R.string.fmt_disc_no, discHeader.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 { companion object {
@ -206,13 +213,13 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
* @return A new instance. * @return A new instance.
*/ */
fun from(parent: View) = fun from(parent: View) =
DiscHeaderViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater)) DiscViewHolder(ItemDiscHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<DiscHeader>() { object : SimpleDiffCallback<Disc>() {
override fun areContentsTheSame(oldItem: DiscHeader, newItem: DiscHeader) = override fun areContentsTheSame(oldItem: Disc, newItem: Disc) =
oldItem.disc == newItem.disc oldItem.number == newItem.number && oldItem.name == newItem.name
} }
} }
} }

View file

@ -30,10 +30,7 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater 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 // Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply { binding.detailSubhead.apply {
isVisible = true isVisible = true
text = artist.resolveGenreContents(binding.context) text = artist.genres.resolveNames(context)
} }
// Song and album counts map to the info // Song and album counts map to the info
@ -168,7 +165,7 @@ private class ArtistDetailViewHolder private constructor(private val binding: It
object : SimpleDiffCallback<Artist>() { object : SimpleDiffCallback<Artist>() {
override fun areContentsTheSame(oldItem: Artist, newItem: Artist) = override fun areContentsTheSame(oldItem: Artist, newItem: Artist) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.areGenreContentsTheSame(newItem) && oldItem.genres.areRawNamesTheSame(newItem.genres) &&
oldItem.albums.size == newItem.albums.size && oldItem.albums.size == newItem.albums.size &&
oldItem.songs.size == newItem.songs.size oldItem.songs.size == newItem.songs.size
} }

View file

@ -19,12 +19,13 @@ package org.oxycblt.auxio.detail.recycler
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.appcompat.widget.TooltipCompat import androidx.appcompat.widget.TooltipCompat
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding 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.Header
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
@ -52,21 +53,21 @@ abstract class DetailAdapter(
override fun getItemViewType(position: Int) = override fun getItemViewType(position: Int) =
when (getItem(position)) { when (getItem(position)) {
// Implement support for headers and sort headers // Implement support for headers and sort headers
is Header -> HeaderViewHolder.VIEW_TYPE is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position) else -> super.getItemViewType(position)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) { when (viewType) {
HeaderViewHolder.VIEW_TYPE -> HeaderViewHolder.from(parent) BasicHeaderViewHolder.VIEW_TYPE -> BasicHeaderViewHolder.from(parent)
SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent) SortHeaderViewHolder.VIEW_TYPE -> SortHeaderViewHolder.from(parent)
else -> error("Invalid item type $viewType") else -> error("Invalid item type $viewType")
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = getItem(position)) { 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) is SortHeader -> (holder as SortHeaderViewHolder).bind(item, listener)
} }
} }
@ -74,7 +75,7 @@ abstract class DetailAdapter(
override fun isItemFullWidth(position: Int): Boolean { override fun isItemFullWidth(position: Int): Boolean {
// Headers should be full-width in all configurations. // Headers should be full-width in all configurations.
val item = getItem(position) val item = getItem(position)
return item is Header || item is SortHeader return item is BasicHeader || item is SortHeader
} }
/** An extended [SelectableListListener] for [DetailAdapter] implementations. */ /** An extended [SelectableListListener] for [DetailAdapter] implementations. */
@ -105,8 +106,8 @@ abstract class DetailAdapter(
object : SimpleDiffCallback<Item>() { object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean { override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when { return when {
oldItem is Header && newItem is Header -> oldItem is BasicHeader && newItem is BasicHeader ->
HeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is SortHeader && newItem is SortHeader -> oldItem is SortHeader && newItem is SortHeader ->
SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem) SortHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
else -> false else -> false
@ -117,8 +118,15 @@ abstract class DetailAdapter(
} }
/** /**
* A [RecyclerView.ViewHolder] that displays a [SortHeader], a variation on [Header] that adds a * A header variation that displays a button to open a sort menu.
* button opening a menu for sorting. Use [from] to create an instance. * @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) * @author Alexander Capehart (OxygenCobalt)
*/ */
private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) : private class SortHeaderViewHolder(private val binding: ItemSortHeaderBinding) :

View file

@ -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
}
}
}

View file

@ -22,6 +22,7 @@ import android.util.AttributeSet
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.updatePadding
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
/** /**
@ -38,7 +39,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
// Prevent excessive layouts by using translation instead of padding. // Prevent excessive layouts by using translation instead of padding.
translationY = -insets.systemBarInsetsCompat.bottom.toFloat() updatePadding(bottom = insets.systemBarInsetsCompat.bottom)
return insets return insets
} }
} }

View file

@ -23,6 +23,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.core.view.updatePadding 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.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field import java.lang.reflect.Field
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig 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.GenreListFragment
import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy 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.SelectionFragment
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.music.system.Indexer
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
@ -62,9 +66,12 @@ import org.oxycblt.auxio.util.*
* to other views. * to other views.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class HomeFragment : class HomeFragment :
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener { 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 musicModel: MusicViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private var storagePermissionLauncher: ActivityResultLauncher<String>? = null private var storagePermissionLauncher: ActivityResultLauncher<String>? = null
@ -98,7 +105,10 @@ class HomeFragment :
// --- UI SETUP --- // --- UI SETUP ---
binding.homeAppbar.addOnOffsetChangedListener(this) 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 // Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources // using a ColorStateList in the resources
@ -207,11 +217,18 @@ class HomeFragment :
// Junk click event when opening the menu // Junk click event when opening the menu
} }
R.id.option_sort_asc -> { R.id.option_sort_asc -> {
item.isChecked = !item.isChecked item.isChecked = true
homeModel.setSortForCurrentTab( homeModel.setSortForCurrentTab(
homeModel homeModel
.getSortForTab(homeModel.currentTabMode.value) .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 -> { else -> {
// Sorting option was selected, mark it as selected and update the mode // 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 // Only allow sorting by name, count, and duration for artists
MusicMode.ARTISTS -> { id -> MusicMode.ARTISTS -> { id ->
id == R.id.option_sort_asc || id == R.id.option_sort_asc ||
id == R.id.option_sort_dec ||
id == R.id.option_sort_name || id == R.id.option_sort_name ||
id == R.id.option_sort_count || id == R.id.option_sort_count ||
id == R.id.option_sort_duration id == R.id.option_sort_duration
@ -271,6 +289,7 @@ class HomeFragment :
// Only allow sorting by name, count, and duration for genres // Only allow sorting by name, count, and duration for genres
MusicMode.GENRES -> { id -> MusicMode.GENRES -> { id ->
id == R.id.option_sort_asc || id == R.id.option_sort_asc ||
id == R.id.option_sort_dec ||
id == R.id.option_sort_name || id == R.id.option_sort_name ||
id == R.id.option_sort_count || id == R.id.option_sort_count ||
id == R.id.option_sort_duration id == R.id.option_sort_duration
@ -286,7 +305,10 @@ class HomeFragment :
// Check the ascending option and corresponding sort option to align with // Check the ascending option and corresponding sort option to align with
// the current sort of the tab. // the current sort of the tab.
if (option.itemId == toHighlight.mode.itemId || 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 option.isChecked = true
} }

View file

@ -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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -15,17 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.home
/** import dagger.Binds
* Represents the result of an extraction operation. import dagger.Module
* @author Alexander Capehart (OxygenCobalt) import dagger.hilt.InstallIn
*/ import dagger.hilt.components.SingletonComponent
enum class ExtractionResult {
/** A raw song was successfully extracted from the cache. */ @Module
CACHED, @InstallIn(SingletonComponent::class)
/** A raw song was successfully extracted from parsing it's file. */ interface HomeModule {
PARSED, @Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
/** A raw song could not be parsed. */
NONE
} }

View file

@ -19,6 +19,8 @@ package org.oxycblt.auxio.home
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
@ -40,39 +42,30 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
/** Called when the [shouldHideCollaborators] configuration changes. */ /** Called when the [shouldHideCollaborators] configuration changes. */
fun onHideCollaboratorsChanged() fun onHideCollaboratorsChanged()
} }
}
private class Real(context: Context) : Settings.Real<Listener>(context), HomeSettings { class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
override var homeTabs: Array<Tab> Settings.Impl<HomeSettings.Listener>(context), HomeSettings {
get() = override var homeTabs: Array<Tab>
Tab.fromIntCode( get() =
sharedPreferences.getInt( Tab.fromIntCode(
getString(R.string.set_key_home_tabs), Tab.SEQUENCE_DEFAULT)) sharedPreferences.getInt(
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT)) getString(R.string.set_key_home_tabs), Tab.SEQUENCE_DEFAULT))
set(value) { ?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
sharedPreferences.edit { set(value) {
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(value)) sharedPreferences.edit {
apply() putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(value))
} apply()
}
override val shouldHideCollaborators: Boolean
get() =
sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
override fun onSettingChanged(key: String, listener: Listener) {
when (key) {
getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
getString(R.string.set_key_hide_collaborators) ->
listener.onHideCollaboratorsChanged()
} }
} }
}
companion object { override val shouldHideCollaborators: Boolean
/** get() = sharedPreferences.getBoolean(getString(R.string.set_key_hide_collaborators), false)
* Get a framework-backed implementation.
* @param context [Context] required. override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
*/ when (key) {
fun from(context: Context): HomeSettings = Real(context) getString(R.string.set_key_home_tabs) -> listener.onTabsChanged()
getString(R.string.set_key_hide_collaborators) -> listener.onHideCollaboratorsChanged()
}
} }
} }

View file

@ -17,15 +17,15 @@
package org.oxycblt.auxio.home package org.oxycblt.auxio.home
import android.app.Application import androidx.lifecycle.ViewModel
import androidx.lifecycle.AndroidViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.logD 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. * The ViewModel for managing the tab data and lists of the home view.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class HomeViewModel(application: Application) : @HiltViewModel
AndroidViewModel(application), MusicStore.Listener, HomeSettings.Listener { class HomeViewModel
private val musicStore = MusicStore.getInstance() @Inject
private val homeSettings = HomeSettings.from(application) constructor(
private val musicSettings = MusicSettings.from(application) private val homeSettings: HomeSettings,
private val playbackSettings = PlaybackSettings.from(application) private val playbackSettings: PlaybackSettings,
private val musicRepository: MusicRepository,
private val musicSettings: MusicSettings
) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener {
private val _songsList = MutableStateFlow(listOf<Song>()) private val _songsList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ /** 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 val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
init { init {
musicStore.addListener(this) musicRepository.addListener(this)
homeSettings.registerListener(this) homeSettings.registerListener(this)
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
musicStore.removeListener(this) musicRepository.removeListener(this)
homeSettings.unregisterListener(this) homeSettings.unregisterListener(this)
} }
@ -130,7 +133,7 @@ class HomeViewModel(application: Application) :
override fun onHideCollaboratorsChanged() { override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents // Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update. // of the library, consider it a library update.
onLibraryChanged(musicStore.library) onLibraryChanged(musicRepository.library)
} }
/** /**

View file

@ -23,6 +23,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding 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.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment 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.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.* 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.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
/** /**
* A [ListFragment] that shows a list of [Album]s. * A [ListFragment] that shows a list of [Album]s.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class AlbumListFragment : class AlbumListFragment :
ListFragment<Album, FragmentHomeListBinding>(), ListFragment<Album, FragmentHomeListBinding>(),
FastScrollRecyclerView.Listener, FastScrollRecyclerView.Listener,
FastScrollRecyclerView.PopupProvider { FastScrollRecyclerView.PopupProvider {
private val homeModel: HomeViewModel by activityViewModels() 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) private val albumAdapter = AlbumAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text // Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64) private val formatterSb = StringBuilder(64)

View file

@ -22,22 +22,26 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment 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.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.selection.SelectionViewModel
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent 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.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.nonZeroOrNull 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. * A [ListFragment] that shows a list of [Artist]s.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class ArtistListFragment : class ArtistListFragment :
ListFragment<Artist, FragmentHomeListBinding>(), ListFragment<Artist, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener { FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels() 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) private val artistAdapter = ArtistAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =

View file

@ -22,33 +22,41 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment 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.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.GenreViewHolder 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.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent 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.playback.formatDurationMs
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
/** /**
* A [ListFragment] that shows a list of [Genre]s. * A [ListFragment] that shows a list of [Genre]s.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class GenreListFragment : class GenreListFragment :
ListFragment<Genre, FragmentHomeListBinding>(), ListFragment<Genre, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener { FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels() 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) private val genreAdapter = GenreAdapter(this)
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =

View file

@ -23,6 +23,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding 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.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.*
import org.oxycblt.auxio.list.ListFragment 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.BasicListInstructions
import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder 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.Music
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song 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.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
/** /**
* A [ListFragment] that shows a list of [Song]s. * A [ListFragment] that shows a list of [Song]s.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class SongListFragment : class SongListFragment :
ListFragment<Song, FragmentHomeListBinding>(), ListFragment<Song, FragmentHomeListBinding>(),
FastScrollRecyclerView.PopupProvider, FastScrollRecyclerView.PopupProvider,
FastScrollRecyclerView.Listener { FastScrollRecyclerView.Listener {
private val homeModel: HomeViewModel by activityViewModels() 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) private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text // Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64) private val formatterSb = StringBuilder(64)

View file

@ -22,6 +22,8 @@ import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogTabsBinding 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. * A [ViewBindingDialogFragment] that allows the user to modify the home [Tab] configuration.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class TabCustomizeDialog : class TabCustomizeDialog :
ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> { ViewBindingDialogFragment<DialogTabsBinding>(), EditableListListener<Tab> {
private val tabAdapter = TabAdapter(this) private val tabAdapter = TabAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null
@Inject lateinit var homeSettings: HomeSettings
override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = DialogTabsBinding.inflate(inflater)
@ -46,13 +50,13 @@ class TabCustomizeDialog :
.setTitle(R.string.set_lib_tabs) .setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
logD("Committing tab changes") logD("Committing tab changes")
HomeSettings.from(requireContext()).homeTabs = tabAdapter.tabs homeSettings.homeTabs = tabAdapter.tabs
} }
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
} }
override fun onBindingCreated(binding: DialogTabsBinding, savedInstanceState: Bundle?) { 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. // Try to restore a pending tab configuration that was saved prior.
if (savedInstanceState != null) { if (savedInstanceState != null) {
val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS)) val savedTabs = Tab.fromIntCode(savedInstanceState.getInt(KEY_TABS))

View file

@ -20,10 +20,12 @@ package org.oxycblt.auxio.image
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Size 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.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
@ -38,7 +40,12 @@ import org.oxycblt.auxio.music.Song
* @param context [Context] required to load images. * @param context [Context] required to load images.
* @author Alexander Capehart (OxygenCobalt) * @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. * 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 = { onSuccess = {
synchronized(this) { synchronized(this) {
if (currentHandle == handle) { 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. // this result.
target.onCompleted(it.toBitmap()) target.onCompleted(it.toBitmap())
} }
@ -103,13 +110,13 @@ class BitmapProvider(private val context: Context) {
onError = { onError = {
synchronized(this) { synchronized(this) {
if (currentHandle == handle) { 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. // this result.
target.onCompleted(null) 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. */ /** Release this instance, cancelling any currently running operations. */

View 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()
}

View file

@ -19,6 +19,8 @@ package org.oxycblt.auxio.image
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -35,53 +37,46 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
/** Called when [coverMode] changes. */ /** Called when [coverMode] changes. */
fun onCoverModeChanged() {} fun onCoverModeChanged() {}
} }
}
private class Real(context: Context) : Settings.Real<Listener>(context), ImageSettings { class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
override val coverMode: CoverMode Settings.Impl<ImageSettings.Listener>(context), ImageSettings {
get() = override val coverMode: CoverMode
CoverMode.fromIntCode( get() =
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) CoverMode.fromIntCode(
?: CoverMode.MEDIA_STORE sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.MEDIA_STORE
override fun migrate() { override fun migrate() {
// Show album covers and Ignore MediaStore covers were unified in 3.0.0 // Show album covers and Ignore MediaStore covers were unified in 3.0.0
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) || if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) { sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
logD("Migrating cover settings") logD("Migrating cover settings")
val mode = val mode =
when { when {
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) -> !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
CoverMode.MEDIA_STORE CoverMode.MEDIA_STORE
else -> CoverMode.QUALITY else -> CoverMode.QUALITY
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_cover_mode), mode.intCode)
remove(OLD_KEY_SHOW_COVERS)
remove(OLD_KEY_QUALITY_COVERS)
} }
}
}
override fun onSettingChanged(key: String, listener: Listener) { sharedPreferences.edit {
if (key == getString(R.string.set_key_cover_mode)) { putInt(getString(R.string.set_key_cover_mode), mode.intCode)
listOf(key, listener) remove(OLD_KEY_SHOW_COVERS)
remove(OLD_KEY_QUALITY_COVERS)
} }
} }
private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
}
} }
companion object { override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
/** if (key == getString(R.string.set_key_cover_mode)) {
* Get a framework-backed implementation. listener.onCoverModeChanged()
* @param context [Context] required. }
*/ }
fun from(context: Context): ImageSettings = Real(context)
private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
} }
} }

View file

@ -26,6 +26,8 @@ import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.widget.ImageViewCompat import androidx.core.widget.ImageViewCompat
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.max import kotlin.math.max
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
@ -41,6 +43,7 @@ import org.oxycblt.auxio.util.getDrawableCompat
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class PlaybackIndicatorView class PlaybackIndicatorView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : 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 indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF() private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = 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 * 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) { set(value) {
field = value field = value
(background as? MaterialShapeDrawable)?.let { bg -> (background as? MaterialShapeDrawable)?.let { bg ->
if (UISettings.from(context).roundMode) { if (uiSettings.roundMode) {
bg.setCornerSize(value) bg.setCornerSize(value)
} else { } else {
bg.setCornerSize(0f) bg.setCornerSize(0f)

View file

@ -29,9 +29,12 @@ import androidx.annotation.StringRes
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import coil.dispose import coil.ImageLoader
import coil.load import coil.request.ImageRequest
import coil.util.CoilUtils
import com.google.android.material.shape.MaterialShapeDrawable 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.R
import org.oxycblt.auxio.image.extractor.SquareFrameTransform import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
@ -53,10 +56,14 @@ import org.oxycblt.auxio.util.getDrawableCompat
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class StyledImageView class StyledImageView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppCompatImageView(context, attrs, defStyleAttr) { AppCompatImageView(context, attrs, defStyleAttr) {
@Inject lateinit var imageLoader: ImageLoader
@Inject lateinit var uiSettings: UISettings
init { init {
// Load view attributes // Load view attributes
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
@ -81,7 +88,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background = background =
MaterialShapeDrawable().apply { MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg) 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. // Only use the specified corner radius when round mode is enabled.
setCornerSize(cornerRadius) setCornerSize(cornerRadius)
} }
@ -120,13 +127,16 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* field for the name of the [Music]. * field for the name of the [Music].
*/ */
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) { 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 of any previous image request and load a new image.
dispose() CoilUtils.dispose(this)
load(music) { imageLoader.enqueue(request)
error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
transformations(SquareFrameTransform.INSTANCE)
}
// Update the content description to the specified resource. // Update the content description to the specified resource.
contentDescription = context.getString(descRes, music.resolveName(context)) contentDescription = context.getString(descRes, music.resolveName(context))
} }

View file

@ -27,15 +27,17 @@ import coil.fetch.SourceResult
import coil.key.Keyer import coil.key.Keyer
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
import okio.buffer import okio.buffer
import okio.source 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.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.library.Sort
/** /**
* A [Keyer] implementation for [Music] data. * A [Keyer] implementation for [Music] data.
@ -57,9 +59,13 @@ class MusicKeyer : Keyer<Music> {
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class AlbumCoverFetcher 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? = override suspend fun fetch(): FetchResult? =
Covers.fetch(context, album)?.run { Covers.fetch(context, imageSettings, album)?.run {
SourceResult( SourceResult(
source = ImageSource(source().buffer(), context), source = ImageSource(source().buffer(), context),
mimeType = null, 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. */ /** 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) = 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. */ /** 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) = 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 class ArtistImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings,
private val size: Size, private val size: Size,
private val artist: Artist private val artist: Artist
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { override suspend fun fetch(): FetchResult? {
// Pick the "most prominent" albums (i.e albums with the most songs) to show in the image. // 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 albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums)
val results = albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, album) } val results =
albums.mapAtMostNotNull(4) { album -> Covers.fetch(context, imageSettings, album) }
return Images.createMosaic(context, results, size) return Images.createMosaic(context, results, size)
} }
/** [Fetcher.Factory] implementation. */ /** [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) = 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 class GenreImageFetcher
private constructor( private constructor(
private val context: Context, private val context: Context,
private val imageSettings: ImageSettings,
private val size: Size, private val size: Size,
private val genre: Genre private val genre: Genre
) : Fetcher { ) : Fetcher {
override suspend fun fetch(): FetchResult? { 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) return Images.createMosaic(context, results, size)
} }
/** [Fetcher.Factory] implementation. */ /** [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) = override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
GenreImageFetcher(options.context, options.size, data) GenreImageFetcher(options.context, imageSettings, options.size, data)
} }
} }

View file

@ -24,6 +24,7 @@ import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.flac.PictureFrame import com.google.android.exoplayer2.metadata.flac.PictureFrame
import com.google.android.exoplayer2.metadata.id3.ApicFrame import com.google.android.exoplayer2.metadata.id3.ApicFrame
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -31,6 +32,7 @@ import kotlinx.coroutines.withContext
import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.AudioOnlyExtractors
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -42,13 +44,14 @@ object Covers {
/** /**
* Fetch an album cover, respecting the current cover configuration. * Fetch an album cover, respecting the current cover configuration.
* @param context [Context] required to load the image. * @param context [Context] required to load the image.
* @param imageSettings [ImageSettings] required to obtain configuration information.
* @param album [Album] to load the cover from. * @param album [Album] to load the cover from.
* @return An [InputStream] of image data if the cover loading was successful, null if the cover * @return An [InputStream] of image data if the cover loading was successful, null if the cover
* loading failed or should not occur. * 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 { return try {
when (ImageSettings.from(context).coverMode) { when (imageSettings.coverMode) {
CoverMode.OFF -> null CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album) CoverMode.MEDIA_STORE -> fetchMediaStoreCovers(context, album)
CoverMode.QUALITY -> fetchQualityCovers(context, album) CoverMode.QUALITY -> fetchQualityCovers(context, album)
@ -102,7 +105,9 @@ object Covers {
*/ */
private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? { private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? {
val uri = album.songs[0].uri 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. // 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 // This is bad for a co-routine, as it prevents cancellation and by extension

View file

@ -24,6 +24,16 @@ interface Item
/** /**
* A "header" used for delimiting groups of data. * 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

View file

@ -21,7 +21,8 @@ import android.view.MenuItem
import android.view.View import android.view.View
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import androidx.appcompat.widget.PopupMenu 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.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.MainFragmentDirections
@ -39,7 +40,7 @@ import org.oxycblt.auxio.util.showToast
*/ */
abstract class ListFragment<in T : Music, VB : ViewBinding> : abstract class ListFragment<in T : Music, VB : ViewBinding> :
SelectionFragment<VB>(), SelectableListListener<T> { SelectionFragment<VB>(), SelectableListListener<T> {
protected val navModel: NavigationViewModel by activityViewModels() protected abstract val navModel: NavigationViewModel
private var currentMenu: PopupMenu? = null private var currentMenu: PopupMenu? = null
override fun onDestroyBinding(binding: VB) { override fun onDestroyBinding(binding: VB) {
@ -238,6 +239,8 @@ abstract class ListFragment<in T : Music, VB : ViewBinding> :
currentMenu = currentMenu =
PopupMenu(requireContext(), anchor).apply { PopupMenu(requireContext(), anchor).apply {
inflate(menuRes) inflate(menuRes)
logD(menu is SupportMenu)
MenuCompat.setGroupDividerEnabled(menu, true)
block() block()
setOnDismissListener { currentMenu = null } setOnDismissListener { currentMenu = null }
show() show()

View file

@ -15,15 +15,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.library package org.oxycblt.auxio.list
import androidx.annotation.IdRes import androidx.annotation.IdRes
import kotlin.math.max import kotlin.math.max
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Sort.Mode
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.library.Sort.Mode import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.metadata.Disc
/** /**
* A sorting method. * 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. * 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 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) * @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. * Create a new [Sort] with the same [mode], but a different [Direction].
* @param isAscending Whether the new sort should be in ascending order or not. * @param direction The new [Direction] to sort in.
* @return A new sort with the same mode, but with the new [isAscending] value applied. * @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. * @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. * Sort a list of [Song]s.
* @param songs The list of [Song]s. * @param songs The list of [Song]s.
* @return A new list of [Song]s sorted by this [Sort]'s configuration. * @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() val mutable = songs.toMutableList()
songsInPlace(mutable) songsInPlace(mutable)
return mutable return mutable
@ -65,7 +66,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param albums The list of [Album]s. * @param albums The list of [Album]s.
* @return A new list of [Album]s sorted by this [Sort]'s configuration. * @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() val mutable = albums.toMutableList()
albumsInPlace(mutable) albumsInPlace(mutable)
return mutable return mutable
@ -76,7 +77,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param artists The list of [Artist]s. * @param artists The list of [Artist]s.
* @return A new list of [Artist]s sorted by this [Sort]'s configuration. * @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() val mutable = artists.toMutableList()
artistsInPlace(mutable) artistsInPlace(mutable)
return mutable return mutable
@ -87,7 +88,7 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @param genres The list of [Genre]s. * @param genres The list of [Genre]s.
* @return A new list of [Genre]s sorted by this [Sort]'s configuration. * @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() val mutable = genres.toMutableList()
genresInPlace(mutable) genresInPlace(mutable)
return 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. * Sort a *mutable* list of [Song]s in-place using this [Sort]'s configuration.
* @param songs The [Song]s to sort. * @param songs The [Song]s to sort.
*/ */
private fun songsInPlace(songs: MutableList<Song>) { private fun songsInPlace(songs: MutableList<out Song>) {
songs.sortWith(mode.getSongComparator(isAscending)) songs.sortWith(mode.getSongComparator(direction))
} }
/** /**
* Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration. * Sort a *mutable* list of [Album]s in-place using this [Sort]'s configuration.
* @param albums The [Album]s to sort. * @param albums The [Album]s to sort.
*/ */
private fun albumsInPlace(albums: MutableList<Album>) { private fun albumsInPlace(albums: MutableList<out Album>) {
albums.sortWith(mode.getAlbumComparator(isAscending)) albums.sortWith(mode.getAlbumComparator(direction))
} }
/** /**
* Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration. * Sort a *mutable* list of [Artist]s in-place using this [Sort]'s configuration.
* @param artists The [Album]s to sort. * @param artists The [Album]s to sort.
*/ */
private fun artistsInPlace(artists: MutableList<Artist>) { private fun artistsInPlace(artists: MutableList<out Artist>) {
artists.sortWith(mode.getArtistComparator(isAscending)) artists.sortWith(mode.getArtistComparator(direction))
} }
/** /**
* Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration. * Sort a *mutable* list of [Genre]s in-place using this [Sort]'s configuration.
* @param genres The [Genre]s to sort. * @param genres The [Genre]s to sort.
*/ */
private fun genresInPlace(genres: MutableList<Genre>) { private fun genresInPlace(genres: MutableList<out Genre>) {
genres.sortWith(mode.getGenreComparator(isAscending)) 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 // 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 // representing if the sort is in ascending or descending order, and M is the
// integer representation of the sort mode. // 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 { sealed class Mode {
/** The integer representation of this sort mode. */ /** The integer representation of this sort mode. */
abstract val intCode: Int 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]. * 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]. * @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() throw UnsupportedOperationException()
} }
/** /**
* Get a [Comparator] that sorts [Album]s according to this [Mode]. * 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]. * @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() throw UnsupportedOperationException()
} }
/** /**
* Return a [Comparator] that sorts [Artist]s according to this [Mode]. * 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]. * @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() throw UnsupportedOperationException()
} }
/** /**
* Return a [Comparator] that sorts [Genre]s according to this [Mode]. * 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]. * @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() throw UnsupportedOperationException()
} }
@ -188,17 +195,17 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_name get() = R.id.option_sort_name
override fun getSongComparator(isAscending: Boolean) = override fun getSongComparator(direction: Direction) =
compareByDynamic(isAscending, BasicComparator.SONG) compareByDynamic(direction, BasicComparator.SONG)
override fun getAlbumComparator(isAscending: Boolean) = override fun getAlbumComparator(direction: Direction) =
compareByDynamic(isAscending, BasicComparator.ALBUM) compareByDynamic(direction, BasicComparator.ALBUM)
override fun getArtistComparator(isAscending: Boolean) = override fun getArtistComparator(direction: Direction) =
compareByDynamic(isAscending, BasicComparator.ARTIST) compareByDynamic(direction, BasicComparator.ARTIST)
override fun getGenreComparator(isAscending: Boolean) = override fun getGenreComparator(direction: Direction) =
compareByDynamic(isAscending, BasicComparator.GENRE) compareByDynamic(direction, BasicComparator.GENRE)
} }
/** /**
@ -212,10 +219,10 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_album get() = R.id.option_sort_album
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, BasicComparator.ALBUM) { it.album }, compareByDynamic(direction, BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
} }
@ -231,18 +238,18 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_artist get() = R.id.option_sort_artist
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists }, compareByDynamic(direction, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates }, compareByDescending(NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album }, compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> = override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, ListComparator.ARTISTS) { it.artists }, compareByDynamic(direction, ListComparator.ARTISTS) { it.artists },
compareByDescending(NullableComparator.DATE_RANGE) { it.dates }, compareByDescending(NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM)) compareBy(BasicComparator.ALBUM))
} }
@ -259,17 +266,17 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_year get() = R.id.option_sort_year
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.album.dates }, compareByDynamic(direction, NullableComparator.DATE_RANGE) { it.album.dates },
compareByDescending(BasicComparator.ALBUM) { it.album }, compareByDescending(BasicComparator.ALBUM) { it.album },
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> = override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.DATE_RANGE) { it.dates }, compareByDynamic(direction, NullableComparator.DATE_RANGE) { it.dates },
compareBy(BasicComparator.ALBUM)) compareBy(BasicComparator.ALBUM))
} }
@ -281,25 +288,22 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_duration get() = R.id.option_sort_duration
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending) { it.durationMs }, compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.SONG))
compareBy(BasicComparator.SONG))
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> = override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending) { it.durationMs }, compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.ALBUM))
compareBy(BasicComparator.ALBUM))
override fun getArtistComparator(isAscending: Boolean): Comparator<Artist> = override fun getArtistComparator(direction: Direction): Comparator<Artist> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.LONG) { it.durationMs }, compareByDynamic(direction, NullableComparator.LONG) { it.durationMs },
compareBy(BasicComparator.ARTIST)) compareBy(BasicComparator.ARTIST))
override fun getGenreComparator(isAscending: Boolean): Comparator<Genre> = override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending) { it.durationMs }, compareByDynamic(direction) { it.durationMs }, compareBy(BasicComparator.GENRE))
compareBy(BasicComparator.GENRE))
} }
/** /**
@ -313,20 +317,18 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_count get() = R.id.option_sort_count
override fun getAlbumComparator(isAscending: Boolean): Comparator<Album> = override fun getAlbumComparator(direction: Direction): Comparator<Album> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending) { it.songs.size }, compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.ALBUM))
compareBy(BasicComparator.ALBUM))
override fun getArtistComparator(isAscending: Boolean): Comparator<Artist> = override fun getArtistComparator(direction: Direction): Comparator<Artist> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.INT) { it.songs.size }, compareByDynamic(direction, NullableComparator.INT) { it.songs.size },
compareBy(BasicComparator.ARTIST)) compareBy(BasicComparator.ARTIST))
override fun getGenreComparator(isAscending: Boolean): Comparator<Genre> = override fun getGenreComparator(direction: Direction): Comparator<Genre> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending) { it.songs.size }, compareByDynamic(direction) { it.songs.size }, compareBy(BasicComparator.GENRE))
compareBy(BasicComparator.GENRE))
} }
/** /**
@ -340,9 +342,9 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_disc get() = R.id.option_sort_disc
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator( MultiComparator(
compareByDynamic(isAscending, NullableComparator.INT) { it.disc }, compareByDynamic(direction, NullableComparator.DISC) { it.disc },
compareBy(NullableComparator.INT) { it.track }, compareBy(NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
} }
@ -358,10 +360,10 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_track get() = R.id.option_sort_track
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator( MultiComparator(
compareBy(NullableComparator.INT) { it.disc }, compareBy(NullableComparator.DISC) { it.disc },
compareByDynamic(isAscending, NullableComparator.INT) { it.track }, compareByDynamic(direction, NullableComparator.INT) { it.track },
compareBy(BasicComparator.SONG)) compareBy(BasicComparator.SONG))
} }
@ -377,48 +379,47 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
override val itemId: Int override val itemId: Int
get() = R.id.option_sort_date_added get() = R.id.option_sort_date_added
override fun getSongComparator(isAscending: Boolean): Comparator<Song> = override fun getSongComparator(direction: Direction): Comparator<Song> =
MultiComparator( 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( MultiComparator(
compareByDynamic(isAscending) { album -> album.dateAdded }, compareByDynamic(direction) { album -> album.dateAdded },
compareBy(BasicComparator.ALBUM)) compareBy(BasicComparator.ALBUM))
} }
/** /**
* Utility function to create a [Comparator] in a dynamic way determined by [isAscending]. * Utility function to create a [Comparator] in a dynamic way determined by [direction].
* @param isAscending Whether to sort in ascending or descending order. * @param direction The [Direction] to sort in.
* @see compareBy * @see compareBy
* @see compareByDescending * @see compareByDescending
*/ */
protected inline fun <T : Music, K : Comparable<K>> compareByDynamic( protected inline fun <T : Music, K : Comparable<K>> compareByDynamic(
isAscending: Boolean, direction: Direction,
crossinline selector: (T) -> K crossinline selector: (T) -> K
) = ) =
if (isAscending) { when (direction) {
compareBy(selector) Direction.ASCENDING -> compareBy(selector)
} else { Direction.DESCENDING -> compareByDescending(selector)
compareByDescending(selector)
} }
/** /**
* Utility function to create a [Comparator] in a dynamic way determined by [isAscending] * Utility function to create a [Comparator] in a dynamic way determined by [direction]
* @param isAscending Whether to sort in ascending or descending order. * @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap. * @param comparator A [Comparator] to wrap.
* @return A new [Comparator] with the specified configuration. * @return A new [Comparator] with the specified configuration.
* @see compareBy * @see compareBy
* @see compareByDescending * @see compareByDescending
*/ */
protected fun <T : Music> compareByDynamic( protected fun <T : Music> compareByDynamic(
isAscending: Boolean, direction: Direction,
comparator: Comparator<in T> 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] * Utility function to create a [Comparator] a dynamic way determined by [direction]
* @param isAscending Whether to sort in ascending or descending order. * @param direction The [Direction] to sort in.
* @param comparator A [Comparator] to wrap. * @param comparator A [Comparator] to wrap.
* @param selector Called to obtain a specific attribute to sort by. * @param selector Called to obtain a specific attribute to sort by.
* @return A new [Comparator] with the specified configuration. * @return A new [Comparator] with the specified configuration.
@ -426,14 +427,13 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
* @see compareByDescending * @see compareByDescending
*/ */
protected inline fun <T : Music, K> compareByDynamic( protected inline fun <T : Music, K> compareByDynamic(
isAscending: Boolean, direction: Direction,
comparator: Comparator<in K>, comparator: Comparator<in K>,
crossinline selector: (T) -> K crossinline selector: (T) -> K
) = ) =
if (isAscending) { when (direction) {
compareBy(comparator, selector) Direction.ASCENDING -> compareBy(comparator, selector)
} else { Direction.DESCENDING -> compareByDescending(comparator, selector)
compareByDescending(comparator, selector)
} }
/** /**
@ -545,6 +545,8 @@ data class Sort(val mode: Mode, val isAscending: Boolean) {
val INT = NullableComparator<Int>() val INT = NullableComparator<Int>()
/** A re-usable instance configured for [Long]s. */ /** A re-usable instance configured for [Long]s. */
val LONG = NullableComparator<Long>() 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. */ /** A re-usable instance configured for [Date.Range]s. */
val DATE_RANGE = NullableComparator<Date.Range>() 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 { companion object {
/** /**
* Convert a [Sort] integer representation into an instance. * 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 // 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 // representing on if the mode is ascending or descending, and M is the integer
// representation of the sort mode. // 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 val mode = Mode.fromIntCode(intCode.shr(1)) ?: return null
return Sort(mode, isAscending) return Sort(mode, direction)
} }
} }
} }

View file

@ -63,7 +63,7 @@ interface ListDiffer<T, I> {
class Async<T>(private val diffCallback: DiffUtil.ItemCallback<T>) : class Async<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() { Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<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>) : class Blocking<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() { Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<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) protected abstract fun replaceList(newList: List<T>, onDone: () -> Unit)
} }
private class RealAsyncListDiffer<T>( private class AsyncListDifferImpl<T>(
updateCallback: ListUpdateCallback, updateCallback: ListUpdateCallback,
diffCallback: DiffUtil.ItemCallback<T> diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<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 updateCallback: ListUpdateCallback,
private val diffCallback: DiffUtil.ItemCallback<T> private val diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<T>() { ) : BasicListDiffer<T>() {

View file

@ -24,7 +24,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemHeaderBinding import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding 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.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
@ -49,7 +49,7 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
listener.bind(song, this, menuButton = binding.songMenu) listener.bind(song, this, menuButton = binding.songMenu)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -76,7 +76,8 @@ class SongViewHolder private constructor(private val binding: ItemSongBinding) :
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Song>() { object : SimpleDiffCallback<Song>() {
override fun areContentsTheSame(oldItem: Song, newItem: 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) listener.bind(album, this, menuButton = binding.parentMenu)
binding.parentImage.bind(album) binding.parentImage.bind(album)
binding.parentName.text = album.resolveName(binding.context) 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) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {
@ -124,7 +125,7 @@ class AlbumViewHolder private constructor(private val binding: ItemParentBinding
object : SimpleDiffCallback<Album>() { object : SimpleDiffCallback<Album>() {
override fun areContentsTheSame(oldItem: Album, newItem: Album) = override fun areContentsTheSame(oldItem: Album, newItem: Album) =
oldItem.rawName == newItem.rawName && oldItem.rawName == newItem.rawName &&
oldItem.areArtistContentsTheSame(newItem) && oldItem.artists.areRawNamesTheSame(newItem.artists) &&
oldItem.releaseType == newItem.releaseType 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class HeaderViewHolder private constructor(private val binding: ItemHeaderBinding) : class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
/** /**
* Bind new data to this instance. * Bind new data to this instance.
* @param header The new [Header] to bind. * @param basicHeader The new [BasicHeader] to bind.
*/ */
fun bind(header: Header) { fun bind(basicHeader: BasicHeader) {
logD(binding.context.getString(header.titleRes)) logD(binding.context.getString(basicHeader.titleRes))
binding.title.text = binding.context.getString(header.titleRes) binding.title.text = binding.context.getString(basicHeader.titleRes)
} }
companion object { companion object {
/** Unique ID for this ViewHolder type. */ /** 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. * Create a new instance.
@ -265,13 +266,15 @@ class HeaderViewHolder private constructor(private val binding: ItemHeaderBindin
* @return A new instance. * @return A new instance.
*/ */
fun from(parent: View) = fun from(parent: View) =
HeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater)) BasicHeaderViewHolder(ItemHeaderBinding.inflate(parent.context.inflater))
/** A comparator that can be used with DiffUtil. */ /** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK = val DIFF_CALLBACK =
object : SimpleDiffCallback<Header>() { object : SimpleDiffCallback<BasicHeader>() {
override fun areContentsTheSame(oldItem: Header, newItem: Header): Boolean = override fun areContentsTheSame(
oldItem.titleRes == newItem.titleRes oldItem: BasicHeader,
newItem: BasicHeader
): Boolean = oldItem.titleRes == newItem.titleRes
} }
} }
} }

View file

@ -20,12 +20,10 @@ package org.oxycblt.auxio.list.selection
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
/** /**
@ -34,8 +32,8 @@ import org.oxycblt.auxio.util.showToast
*/ */
abstract class SelectionFragment<VB : ViewBinding> : abstract class SelectionFragment<VB : ViewBinding> :
ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener { ViewBindingFragment<VB>(), Toolbar.OnMenuItemClickListener {
protected val selectionModel: SelectionViewModel by activityViewModels() protected abstract val selectionModel: SelectionViewModel
protected val playbackModel: PlaybackViewModel by androidActivityViewModels() protected abstract val playbackModel: PlaybackViewModel
/** /**
* Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by * Get the [SelectionToolbarOverlay] of the concrete Fragment to be automatically managed by

View file

@ -18,26 +18,27 @@
package org.oxycblt.auxio.list.selection package org.oxycblt.auxio.list.selection
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.library.Library
/** /**
* A [ViewModel] that manages the current selection. * A [ViewModel] that manages the current selection.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class SelectionViewModel : ViewModel(), MusicStore.Listener { @HiltViewModel
private val musicStore = MusicStore.getInstance() class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.Listener {
private val _selected = MutableStateFlow(listOf<Music>()) private val _selected = MutableStateFlow(listOf<Music>())
/** the currently selected items. These are ordered in earliest selected and latest selected. */ /** the currently selected items. These are ordered in earliest selected and latest selected. */
val selected: StateFlow<List<Music>> val selected: StateFlow<List<Music>>
get() = _selected get() = _selected
init { init {
musicStore.addListener(this) musicRepository.addListener(this)
} }
override fun onLibraryChanged(library: Library?) { override fun onLibraryChanged(library: Library?) {
@ -60,7 +61,7 @@ class SelectionViewModel : ViewModel(), MusicStore.Listener {
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
musicStore.removeListener(this) musicRepository.removeListener(this)
} }
/** /**

View file

@ -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))
}

View file

@ -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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -15,50 +15,43 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:Suppress("PropertyName", "FunctionName")
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import java.security.MessageDigest import java.security.MessageDigest
import java.text.CollationKey import java.text.CollationKey
import java.text.Collator
import java.util.UUID import java.util.UUID
import kotlin.math.max import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.library.Sort import org.oxycblt.auxio.music.metadata.Date
import org.oxycblt.auxio.music.parsing.parseId3GenreNames import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.parsing.parseMultiValue import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.tags.Date import org.oxycblt.auxio.music.storage.Path
import org.oxycblt.auxio.music.tags.ReleaseType import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
// --- MUSIC MODELS ---
/** /**
* Abstract music data. This contains universal information about all concrete music * Abstract music data. This contains universal information about all concrete music
* implementations, such as identification information and names. * implementations, such as identification information and names.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed class Music : Item { sealed interface Music : Item {
/** /**
* A unique identifier for this music item. * A unique identifier for this music item.
* @see UID * @see UID
*/ */
abstract val uid: UID val uid: UID
/** /**
* The raw name of this item as it was extracted from the file-system. Will be null if the * The raw name of this item as it was extracted from the file-system. Will be null if the
* item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName]. * item's name is unknown. When showing this item in a UI, avoid this in favor of [resolveName].
*/ */
abstract val rawName: String? val rawName: String?
/** /**
* Returns a name suitable for use in the app UI. This should be favored over [rawName] in * Returns a name suitable for use in the app UI. This should be favored over [rawName] in
@ -67,14 +60,14 @@ sealed class Music : Item {
* @return A human-readable string representing the name of this music. In the case that the * @return A human-readable string representing the name of this music. In the case that the
* item does not have a name, an analogous "Unknown X" name is returned. * item does not have a name, an analogous "Unknown X" name is returned.
*/ */
abstract fun resolveName(context: Context): String fun resolveName(context: Context): String
/** /**
* The raw sort name of this item as it was extracted from the file-system. This can be used not * The raw sort name of this item as it was extracted from the file-system. This can be used not
* only when sorting music, but also trying to locate music based on a fuzzy search by the user. * only when sorting music, but also trying to locate music based on a fuzzy search by the user.
* Will be null if the item has no known sort name. * Will be null if the item has no known sort name.
*/ */
abstract val rawSortName: String? val rawSortName: String?
/** /**
* A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a * A [CollationKey] derived from [rawName] and [rawSortName] that can be used to sort items in a
@ -85,62 +78,7 @@ sealed class Music : Item {
* - If the string begins with an article, such as "the", it will be stripped, as is usually * - If the string begins with an article, such as "the", it will be stripped, as is usually
* convention for sorting media. This is not internationalized. * convention for sorting media. This is not internationalized.
*/ */
abstract val collationKey: CollationKey? val collationKey: CollationKey?
/**
* Finalize this item once the music library has been fully constructed. This is where any final
* ordering or sanity checking should occur. **This function is internal to the music package.
* Do not use it elsewhere.**
*/
abstract fun _finalize()
/**
* Provided implementation to create a [CollationKey] in the way described by [collationKey].
* This should be used in all overrides of all [CollationKey].
* @return A [CollationKey] that follows the specification described by [collationKey].
*/
protected fun makeCollationKeyImpl(): CollationKey? {
val sortName =
(rawSortName ?: rawName)?.run {
when {
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
else -> this
}
}
return COLLATOR.getCollationKey(sortName)
}
/**
* Join a list of [Music]'s resolved names into a string in a localized manner, using
* [R.string.fmt_list].
* @param context [Context] required to obtain localized formatting.
* @param values The list of [Music] to format.
* @return A single string consisting of the values delimited by a localized separator.
*/
protected fun resolveNames(context: Context, values: List<Music>): String {
if (values.isEmpty()) {
// Nothing to do.
return ""
}
var joined = values.first().resolveName(context)
for (i in 1..values.lastIndex) {
// Chain all previous values with the next value in the list with another delimiter.
joined = context.getString(R.string.fmt_list, joined, values[i].resolveName(context))
}
return joined
}
// Note: We solely use the UID in comparisons so that certain items that differ in all
// but UID are treated differently.
override fun hashCode() = uid.hashCode()
override fun equals(other: Any?) =
other is Music && javaClass == other.javaClass && uid == other.uid
/** /**
* A unique identifier for a piece of music. * A unique identifier for a piece of music.
@ -192,6 +130,7 @@ sealed class Music : Item {
private enum class Format(val namespace: String) { private enum class Format(val namespace: String) {
/** @see auxio */ /** @see auxio */
AUXIO("org.oxycblt.auxio"), AUXIO("org.oxycblt.auxio"),
/** @see musicBrainz */ /** @see musicBrainz */
MUSICBRAINZ("org.musicbrainz") MUSICBRAINZ("org.musicbrainz")
} }
@ -281,799 +220,156 @@ sealed class Music : Item {
} }
} }
} }
private companion object {
/** Cached collator instance re-used with [makeCollationKeyImpl]. */
val COLLATOR: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY }
}
} }
/** /**
* An abstract grouping of [Song]s and other [Music] data. * An abstract grouping of [Song]s and other [Music] data.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
sealed class MusicParent : Music() { sealed interface MusicParent : Music {
/** The [Song]s in this this group. */ /** The child [Song]s of this [MusicParent]. */
abstract val songs: List<Song> val songs: List<Song>
// Note: Append song contents to MusicParent equality so that Groups with
// the same UID but different contents are not equal.
override fun hashCode() = 31 * uid.hashCode() + songs.hashCode()
override fun equals(other: Any?) =
other is MusicParent &&
javaClass == other.javaClass &&
uid == other.uid &&
songs == other.songs
} }
/** /**
* A song. Perhaps the foundation of the entirety of Auxio. * A song.
* @param raw The [Song.Raw] to derive the member data from.
* @param musicSettings [MusicSettings] to perform further user-configured parsing.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Song constructor(raw: Raw, musicSettings: MusicSettings) : Music() { interface Song : Music {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.toUuidOrNull()?.let { UID.musicBrainz(MusicMode.SONGS, it) }
?: UID.auxio(MusicMode.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings.
update(raw.name)
update(raw.albumName)
update(raw.date)
update(raw.track)
update(raw.disc)
update(raw.artistNames)
update(raw.albumArtistNames)
}
override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
override val rawSortName = raw.sortName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName
/** The track number. Will be null if no valid track number was present in the metadata. */ /** The track number. Will be null if no valid track number was present in the metadata. */
val track = raw.track val track: Int?
/** The [Disc] number. Will be null if no valid disc number was present in the metadata. */
/** The disc number. Will be null if no valid disc number was present in the metadata. */ val disc: Disc?
val disc = raw.disc
/** The release [Date]. Will be null if no valid date was present in the metadata. */ /** The release [Date]. Will be null if no valid date was present in the metadata. */
val date = raw.date val date: Date?
/** /**
* The URI to the audio file that this instance was created from. This can be used to access the * The URI to the audio file that this instance was created from. This can be used to access the
* audio file in a way that is scoped-storage-safe. * audio file in a way that is scoped-storage-safe.
*/ */
val uri = requireNotNull(raw.mediaStoreId) { "Invalid raw: No id" }.toAudioUri() val uri: Uri
/** /**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored * The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file. * instead for accessing the audio file.
*/ */
val path = val path: Path
Path(
name = requireNotNull(raw.fileName) { "Invalid raw: No display name" },
parent = requireNotNull(raw.directory) { "Invalid raw: No parent directory" })
/** The [MimeType] of the audio file. Only intended for display. */ /** The [MimeType] of the audio file. Only intended for display. */
val mimeType = val mimeType: MimeType
MimeType(
fromExtension = requireNotNull(raw.extensionMimeType) { "Invalid raw: No mime type" },
fromFormat = null)
/** The size of the audio file, in bytes. */ /** The size of the audio file, in bytes. */
val size = requireNotNull(raw.size) { "Invalid raw: No size" } val size: Long
/** The duration of the audio file, in milliseconds. */ /** The duration of the audio file, in milliseconds. */
val durationMs = requireNotNull(raw.durationMs) { "Invalid raw: No duration" } val durationMs: Long
/** The date the audio file was added to the device, as a unix epoch timestamp. */ /** The date the audio file was added to the device, as a unix epoch timestamp. */
val dateAdded = requireNotNull(raw.dateAdded) { "Invalid raw: No date added" } val dateAdded: Long
private var _album: Album? = null
/** /**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used * The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead. * instead.
*/ */
val album: Album val album: Album
get() = unlikelyToBeNull(_album)
private val artistMusicBrainzIds = raw.artistMusicBrainzIds.parseMultiValue(musicSettings)
private val artistNames = raw.artistNames.parseMultiValue(musicSettings)
private val artistSortNames = raw.artistSortNames.parseMultiValue(musicSettings)
private val rawArtists =
artistNames.mapIndexed { i, name ->
Artist.Raw(
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
artistSortNames.getOrNull(i))
}
private val albumArtistMusicBrainzIds =
raw.albumArtistMusicBrainzIds.parseMultiValue(musicSettings)
private val albumArtistNames = raw.albumArtistNames.parseMultiValue(musicSettings)
private val albumArtistSortNames = raw.albumArtistSortNames.parseMultiValue(musicSettings)
private val rawAlbumArtists =
albumArtistNames.mapIndexed { i, name ->
Artist.Raw(
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
albumArtistSortNames.getOrNull(i))
}
private val _artists = mutableListOf<Artist>()
/** /**
* The parent [Artist]s of this [Song]. Is often one, but there can be multiple if more than one * The parent [Artist]s of this [Song]. Is often one, but there can be multiple if more than one
* [Artist] name was specified in the metadata. Unliked [Album], artists are prioritized for * [Artist] name was specified in the metadata. Unliked [Album], artists are prioritized for
* this field. * this field.
*/ */
val artists: List<Artist> val artists: List<Artist>
get() = _artists
/**
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName]. formatter.
*/
fun resolveArtistContents(context: Context) = resolveNames(context, artists)
/**
* Checks if the [Artist] *display* of this [Song] and another [Song] are equal. This will only
* compare surface-level names, and not [Music.UID]s.
* @param other The [Song] to compare to.
* @return True if the [Artist] displays are equal, false otherwise
*/
fun areArtistContentsTheSame(other: Song): Boolean {
for (i in 0 until max(artists.size, other.artists.size)) {
val a = artists.getOrNull(i) ?: return false
val b = other.artists.getOrNull(i) ?: return false
if (a.rawName != b.rawName) {
return false
}
}
return true
}
private val _genres = mutableListOf<Genre>()
/** /**
* The parent [Genre]s of this [Song]. Is often one, but there can be multiple if more than one * The parent [Genre]s of this [Song]. Is often one, but there can be multiple if more than one
* [Genre] name was specified in the metadata. * [Genre] name was specified in the metadata.
*/ */
val genres: List<Genre> val genres: List<Genre>
get() = _genres
/**
* Resolves one or more [Genre]s into a single piece human-readable names.
* @param context [Context] required for [resolveName].
*/
fun resolveGenreContents(context: Context) = resolveNames(context, genres)
// --- INTERNAL FIELDS ---
/**
* The [Album.Raw] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album]. **This is only meant for use within the music package.**
*/
val _rawAlbum =
Album.Raw(
mediaStoreId = requireNotNull(raw.albumMediaStoreId) { "Invalid raw: No album id" },
musicBrainzId = raw.albumMusicBrainzId?.toUuidOrNull(),
name = requireNotNull(raw.albumName) { "Invalid raw: No album name" },
sortName = raw.albumSortName,
releaseType = ReleaseType.parse(raw.releaseTypes.parseMultiValue(musicSettings)),
rawArtists =
rawAlbumArtists.ifEmpty { rawArtists }.ifEmpty { listOf(Artist.Raw(null, null)) })
/**
* The [Artist.Raw] instances collated by the [Song]. The artists of the song take priority,
* followed by the album artists. If there are no artists, this field will be a single "unknown"
* [Artist.Raw]. This can be used to group up [Song]s into an [Artist]. **This is only meant for
* use within the music package.**
*/
val _rawArtists = rawArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(Artist.Raw()) }
/**
* The [Genre.Raw] instances collated by the [Song]. This can be used to group up [Song]s into a
* [Genre]. ID3v2 Genre names are automatically converted to their resolved names. **This is
* only meant for use within the music package.**
*/
val _rawGenres =
raw.genreNames
.parseId3GenreNames(musicSettings)
.map { Genre.Raw(it) }
.ifEmpty { listOf(Genre.Raw()) }
/**
* Links this [Song] with a parent [Album].
* @param album The parent [Album] to link to. **This is only meant for use within the music
* package.**
*/
fun _link(album: Album) {
_album = album
}
/**
* Links this [Song] with a parent [Artist].
* @param artist The parent [Artist] to link to. **This is only meant for use within the music
* package.**
*/
fun _link(artist: Artist) {
_artists.add(artist)
}
/**
* Links this [Song] with a parent [Genre].
* @param genre The parent [Genre] to link to. **This is only meant for use within the music
* package.**
*/
fun _link(genre: Genre) {
_genres.add(genre)
}
override fun _finalize() {
checkNotNull(_album) { "Malformed song: No album" }
check(_artists.isNotEmpty()) { "Malformed song: No artists" }
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
_artists[i] = other
}
check(_genres.isNotEmpty()) { "Malformed song: No genres" }
for (i in _genres.indices) {
// Non-destructively reorder the linked genres so that they align with
// the genre ordering within the song metadata.
val newIdx = _genres[i]._getOriginalPositionIn(_rawGenres)
val other = _genres[newIdx]
_genres[newIdx] = _genres[i]
_genres[i] = other
}
}
/**
* Raw information about a [Song] obtained from the filesystem/Extractor instances. **This is
* only meant for use within the music package.**
*/
class Raw
constructor(
/**
* The ID of the [Song]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file.
*/
var mediaStoreId: Long? = null,
/** @see Song.dateAdded */
var dateAdded: Long? = null,
/** The latest date the [Song]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long? = null,
/** @see Song.path */
var fileName: String? = null,
/** @see Song.path */
var directory: Directory? = null,
/** @see Song.size */
var size: Long? = null,
/** @see Song.durationMs */
var durationMs: Long? = null,
/** @see Song.mimeType */
var extensionMimeType: String? = null,
/** @see Music.UID */
var musicBrainzId: String? = null,
/** @see Music.rawName */
var name: String? = null,
/** @see Music.rawSortName */
var sortName: String? = null,
/** @see Song.track */
var track: Int? = null,
/** @see Song.disc */
var disc: Int? = null,
/** @see Song.date */
var date: Date? = null,
/** @see Album.Raw.mediaStoreId */
var albumMediaStoreId: Long? = null,
/** @see Album.Raw.musicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see Album.Raw.name */
var albumName: String? = null,
/** @see Album.Raw.sortName */
var albumSortName: String? = null,
/** @see Album.Raw.releaseType */
var releaseTypes: List<String> = listOf(),
/** @see Artist.Raw.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see Artist.Raw.name */
var artistNames: List<String> = listOf(),
/** @see Artist.Raw.sortName */
var artistSortNames: List<String> = listOf(),
/** @see Artist.Raw.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see Artist.Raw.name */
var albumArtistNames: List<String> = listOf(),
/** @see Artist.Raw.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see Genre.Raw.name */
var genreNames: List<String> = listOf()
)
} }
/** /**
* An abstract release group. While it may be called an album, it encompasses other types of * An abstract release group. While it may be called an album, it encompasses other types of
* releases like singles, EPs, and compilations. * releases like singles, EPs, and compilations.
* @param raw The [Album.Raw] to derive the member data from.
* @param songs The [Song]s that are a part of this [Album]. These items will be linked to this
* [Album].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Album constructor(raw: Raw, override val songs: List<Song>) : MusicParent() { interface Album : MusicParent {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ALBUMS, it) }
?: UID.auxio(MusicMode.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(raw.name)
update(raw.rawArtists.map { it.name })
}
override val rawName = raw.name
override val rawSortName = raw.sortName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName
/** The [Date.Range] that [Song]s in the [Album] were released. */ /** The [Date.Range] that [Song]s in the [Album] were released. */
val dates = Date.Range.from(songs.mapNotNull { it.date }) val dates: Date.Range?
/** /**
* The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to * The [ReleaseType] of this album, signifying the type of release it actually is. Defaults to
* [ReleaseType.Album]. * [ReleaseType.Album].
*/ */
val releaseType = raw.releaseType ?: ReleaseType.Album(null) val releaseType: ReleaseType
/** /**
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the * The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
* cost of image quality. * cost of image quality.
*/ */
val coverUri = raw.mediaStoreId.toCoverUri() val coverUri: Uri
/** The duration of all songs in the album, in milliseconds. */ /** The duration of all songs in the album, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */ /** The earliest date a song in this album was added, as a unix epoch timestamp. */
val dateAdded: Long val dateAdded: Long
init {
var totalDuration: Long = 0
var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency.
for (song in songs) {
song._link(this)
if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded
}
totalDuration += song.durationMs
}
durationMs = totalDuration
dateAdded = earliestDateAdded
}
private val _artists = mutableListOf<Artist>()
/** /**
* The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than * The parent [Artist]s of this [Album]. Is often one, but there can be multiple if more than
* one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists * one [Artist] name was specified in the metadata of the [Song]'s. Unlike [Song], album artists
* are prioritized for this field. * are prioritized for this field.
*/ */
val artists: List<Artist> val artists: List<Artist>
get() = _artists
/**
* Resolves one or more [Artist]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
fun resolveArtistContents(context: Context) = resolveNames(context, artists)
/**
* Checks if the [Artist] *display* of this [Album] and another [Album] are equal. This will
* only compare surface-level names, and not [Music.UID]s.
* @param other The [Album] to compare to.
* @return True if the [Artist] displays are equal, false otherwise
*/
fun areArtistContentsTheSame(other: Album): Boolean {
for (i in 0 until max(artists.size, other.artists.size)) {
val a = artists.getOrNull(i) ?: return false
val b = other.artists.getOrNull(i) ?: return false
if (a.rawName != b.rawName) {
return false
}
}
return true
}
// --- INTERNAL FIELDS ---
/**
* The [Artist.Raw] instances collated by the [Album]. The album artists of the song take
* priority, followed by the artists. If there are no artists, this field will be a single
* "unknown" [Artist.Raw]. This can be used to group up [Album]s into an [Artist]. **This is
* only meant for use within the music package.**
*/
val _rawArtists = raw.rawArtists
/**
* Links this [Album] with a parent [Artist].
* @param artist The parent [Artist] to link to. **This is only meant for use within the music
* package.**
*/
fun _link(artist: Artist) {
_artists.add(artist)
}
override fun _finalize() {
check(songs.isNotEmpty()) { "Malformed album: Empty" }
check(_artists.isNotEmpty()) { "Malformed album: No artists" }
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
val newIdx = _artists[i]._getOriginalPositionIn(_rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
_artists[i] = other
}
}
/**
* Raw information about an [Album] obtained from the component [Song] instances. **This is only
* meant for use within the music package.**
*/
class Raw(
/**
* The ID of the [Album]'s grouping, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the system-provided cover art.
*/
val mediaStoreId: Long,
/** @see Music.uid */
val musicBrainzId: UUID?,
/** @see Music.rawName */
val name: String,
/** @see Music.rawSortName */
val sortName: String?,
/** @see Album.releaseType */
val releaseType: ReleaseType?,
/** @see Artist.Raw.name */
val rawArtists: List<Artist.Raw>
) {
// Albums are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Albums with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase album name and lowercase
// artist name. This allows for case-insensitive artist/album grouping, which can be common
// for albums/artists that have different naming (ex. "RAMMSTEIN" vs. "Rammstein").
// Cache the hash-code for HashMap efficiency.
private val hashCode =
musicBrainzId?.hashCode() ?: (31 * name.lowercase().hashCode() + rawArtists.hashCode())
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
name.equals(other.name, true) && rawArtists == other.rawArtists
else -> false
}
}
} }
/** /**
* An abstract artist. These are actually a combination of the artist and album artist tags from * An abstract artist. These are actually a combination of the artist and album artist tags from
* within the library, derived from [Song]s and [Album]s respectively. * within the library, derived from [Song]s and [Album]s respectively.
* @param raw The [Artist.Raw] to derive the member data from.
* @param songAlbums A list of the [Song]s and [Album]s that are a part of this [Artist], either
* through artist or album artist tags. Providing [Song]s to the artist is optional. These instances
* will be linked to this [Artist].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Artist constructor(private val raw: Raw, songAlbums: List<Music>) : MusicParent() { interface Artist : MusicParent {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
raw.musicBrainzId?.let { UID.musicBrainz(MusicMode.ARTISTS, it) }
?: UID.auxio(MusicMode.ARTISTS) { update(raw.name) }
override val rawName = raw.name
override val rawSortName = raw.sortName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_artist)
override val songs: List<Song>
/** /**
* All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist * All of the [Album]s this artist is credited to. Note that any [Song] credited to this artist
* will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus * will have it's [Album] considered to be "indirectly" linked to this [Artist], and thus
* included in this list. * included in this list.
*/ */
val albums: List<Album> val albums: List<Album>
/** /**
* The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no * The duration of all [Song]s in the artist, in milliseconds. Will be null if there are no
* songs. * songs.
*/ */
val durationMs: Long? val durationMs: Long?
/** /**
* Whether this artist is considered a "collaborator", i.e it is not directly credited on any * Whether this artist is considered a "collaborator", i.e it is not directly credited on any
* [Album]. * [Album].
*/ */
val isCollaborator: Boolean val isCollaborator: Boolean
/** The [Genre]s of this artist. */
init { val genres: List<Genre>
val distinctSongs = mutableSetOf<Song>()
val distinctAlbums = mutableSetOf<Album>()
var noAlbums = true
for (music in songAlbums) {
when (music) {
is Song -> {
music._link(this)
distinctSongs.add(music)
distinctAlbums.add(music.album)
}
is Album -> {
music._link(this)
distinctAlbums.add(music)
noAlbums = false
}
else -> error("Unexpected input music ${music::class.simpleName}")
}
}
songs = distinctSongs.toList()
albums = distinctAlbums.toList()
durationMs = songs.sumOf { it.durationMs }.nonZeroOrNull()
isCollaborator = noAlbums
}
private lateinit var genres: List<Genre>
/**
* Resolves one or more [Genre]s into a single piece of human-readable names.
* @param context [Context] required for [resolveName].
*/
fun resolveGenreContents(context: Context) = resolveNames(context, genres)
/**
* Checks if the [Genre] *display* of this [Artist] and another [Artist] are equal. This will
* only compare surface-level names, and not [Music.UID]s.
* @param other The [Artist] to compare to.
* @return True if the [Genre] displays are equal, false otherwise
*/
fun areGenreContentsTheSame(other: Artist): Boolean {
for (i in 0 until max(genres.size, other.genres.size)) {
val a = genres.getOrNull(i) ?: return false
val b = other.genres.getOrNull(i) ?: return false
if (a.rawName != b.rawName) {
return false
}
}
return true
}
// --- INTERNAL METHODS ---
/**
* Returns the original position of this [Artist]'s [Artist.Raw] within the given [Artist.Raw]
* list. This can be used to create a consistent ordering within child [Artist] lists based on
* the original tag order.
* @param rawArtists The [Artist.Raw] instances to check. It is assumed that this [Artist]'s
* [Artist.Raw] will be within the list.
* @return The index of the [Artist]'s [Artist.Raw] within the list. **This is only meant for
* use within the music package.**
*/
fun _getOriginalPositionIn(rawArtists: List<Raw>) = rawArtists.indexOf(raw)
override fun _finalize() {
check(songs.isNotEmpty() || albums.isNotEmpty()) { "Malformed artist: Empty" }
genres =
Sort(Sort.Mode.ByName, true)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
}
/**
* Raw information about an [Artist] obtained from the component [Song] and [Album] instances.
* **This is only meant for use within the music package.**
*/
class Raw(
/** @see Music.UID */
val musicBrainzId: UUID? = null,
/** @see Music.rawName */
val name: String? = null,
/** @see Music.rawSortName */
val sortName: String? = null
) {
// Artists are grouped as follows:
// - If we have a MusicBrainz ID, only group by it. This allows different Artists with the
// same name to be differentiated, which is common in large libraries.
// - If we do not have a MusicBrainz ID, compare by the lowercase name. This allows artist
// grouping to be case-insensitive.
// Cache the hashCode for HashMap efficiency.
private val hashCode = musicBrainzId?.hashCode() ?: name?.lowercase().hashCode()
// Compare names and MusicBrainz IDs in order to differentiate artists with the
// same name in large libraries.
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
musicBrainzId != null && other.musicBrainzId != null ->
musicBrainzId == other.musicBrainzId
musicBrainzId == null && other.musicBrainzId == null ->
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
else -> false
}
}
} }
/** /**
* A genre of [Song]s. * A genre.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Genre constructor(private val raw: Raw, override val songs: List<Song>) : MusicParent() { interface Genre : MusicParent {
override val uid = UID.auxio(MusicMode.GENRES) { update(raw.name) }
override val rawName = raw.name
override val rawSortName = rawName
override val collationKey = makeCollationKeyImpl()
override fun resolveName(context: Context) = rawName ?: context.getString(R.string.def_genre)
/** The albums indirectly linked to by the [Song]s of this [Genre]. */ /** The albums indirectly linked to by the [Song]s of this [Genre]. */
val albums: List<Album> val albums: List<Album>
/** The artists indirectly linked to by the [Artist]s of this [Genre]. */ /** The artists indirectly linked to by the [Artist]s of this [Genre]. */
val artists: List<Artist> val artists: List<Artist>
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
}
init { /**
val distinctAlbums = mutableSetOf<Album>() * Run [Music.resolveName] on each instance in the given list and concatenate them into a [String]
val distinctArtists = mutableSetOf<Artist>() * in a localized manner.
var totalDuration = 0L * @param context [Context] required
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.resolveName(context) }
for (song in songs) { /**
song._link(this) * Returns if [Music.rawName] matches for each item in a list. Useful for scenarios where the
distinctAlbums.add(song.album) * display information of an item must be compared without a context.
distinctArtists.addAll(song.artists) * @param other The list of items to compare to.
totalDuration += song.durationMs * @return True if they are the same (by [Music.rawName]), false otherwise.
*/
fun <T : Music> List<T>.areRawNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.rawName != b.rawName) {
return false
} }
albums =
Sort(Sort.Mode.ByName, true).albums(distinctAlbums).sortedByDescending { album ->
album.songs.count { it.genres.contains(this) }
}
artists = Sort(Sort.Mode.ByName, true).artists(distinctArtists)
durationMs = totalDuration
} }
// --- INTERNAL METHODS --- return true
/**
* Returns the original position of this [Genre]'s [Genre.Raw] within the given [Genre.Raw]
* list. This can be used to create a consistent ordering within child [Genre] lists based on
* the original tag order.
* @param rawGenres The [Genre.Raw] instances to check. It is assumed that this [Genre]'s
* [Genre.Raw] will be within the list.
* @return The index of the [Genre]'s [Genre.Raw] within the list. **This is only meant for use
* within the music package.**
*/
fun _getOriginalPositionIn(rawGenres: List<Raw>) = rawGenres.indexOf(raw)
override fun _finalize() {
check(songs.isNotEmpty()) { "Malformed genre: Empty" }
}
/**
* Raw information about a [Genre] obtained from the component [Song] instances. **This is only
* meant for use within the music package.**
*/
class Raw(
/** @see Music.rawName */
val name: String? = null
) {
// Only group by the lowercase genre name. This allows Genre grouping to be
// case-insensitive, which may be helpful in some libraries with different ways of
// formatting genres.
// Cache the hashCode for HashMap efficiency.
private val hashCode = name?.lowercase().hashCode()
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is Raw &&
when {
name != null && other.name != null -> name.equals(other.name, true)
name == null && other.name == null -> true
else -> false
}
}
}
// --- MUSIC UID CREATION UTILITIES ---
/**
* Convert a [String] to a [UUID].
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
* @see UUID.fromString
*/
private fun String.toUuidOrNull(): UUID? =
try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
}
/**
* Update a [MessageDigest] with a lowercase [String].
* @param string The [String] to hash. If null, it will not be hashed.
*/
@VisibleForTesting
fun MessageDigest.update(string: String?) {
if (string != null) {
update(string.lowercase().toByteArray())
} else {
update(0)
}
}
/**
* Update a [MessageDigest] with the string representation of a [Date].
* @param date The [Date] to hash. If null, nothing will be done.
*/
@VisibleForTesting
fun MessageDigest.update(date: Date?) {
if (date != null) {
update(date.toString().toByteArray())
} else {
update(0)
}
}
/**
* Update a [MessageDigest] with the lowercase versions of all of the input [String]s.
* @param strings The [String]s to hash. If a [String] is null, it will not be hashed.
*/
@VisibleForTesting
fun MessageDigest.update(strings: List<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)
}
} }

View 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
}

View file

@ -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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -17,7 +17,8 @@
package org.oxycblt.auxio.music 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. * A repository granting access to the music library.
@ -28,22 +29,13 @@ import org.oxycblt.auxio.music.library.Library
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicStore private constructor() { interface MusicRepository {
private val listeners = mutableListOf<Listener>()
/** /**
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This * 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 * can change, so it's highly recommended to not access this directly and instead rely on
* [Listener]. * [Listener].
*/ */
@Volatile var library: Library?
var library: Library? = null
set(value) {
field = value
for (callback in listeners) {
callback.onLibraryChanged(library)
}
}
/** /**
* Add a [Listener] to this instance. This can be used to receive changes in the music 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. * @param listener The [Listener] to add.
* @see Listener * @see Listener
*/ */
@Synchronized fun addListener(listener: Listener)
fun addListener(listener: Listener) {
listener.onLibraryChanged(library)
listeners.add(listener)
}
/** /**
* Remove a [Listener] from this instance, preventing it from receiving any further updates. * Remove a [Listener] from this instance, preventing it from receiving any further updates.
@ -63,12 +51,9 @@ class MusicStore private constructor() {
* the first place. * the first place.
* @see Listener * @see Listener
*/ */
@Synchronized fun removeListener(listener: Listener)
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
/** A listener for changes in the music library. */ /** A listener for changes in [MusicRepository] */
interface Listener { interface Listener {
/** /**
* Called when the current [Library] has changed. * Called when the current [Library] has changed.
@ -76,25 +61,28 @@ class MusicStore private constructor() {
*/ */
fun onLibraryChanged(library: Library?) fun onLibraryChanged(library: Library?)
} }
}
companion object { class MusicRepositoryImpl @Inject constructor() : MusicRepository {
@Volatile private var INSTANCE: MusicStore? = null private val listeners = mutableListOf<MusicRepository.Listener>()
/** @Volatile
* Get a singleton instance. override var library: Library? = null
* @return The (possibly newly-created) singleton instance. set(value) {
*/ field = value
fun getInstance(): MusicStore { for (callback in listeners) {
val currentInstance = INSTANCE callback.onLibraryChanged(library)
if (currentInstance != null) {
return currentInstance
}
synchronized(this) {
val newInstance = MusicStore()
INSTANCE = newInstance
return newInstance
} }
} }
@Synchronized
override fun addListener(listener: MusicRepository.Listener) {
listener.onLibraryChanged(library)
listeners.add(listener)
}
@Synchronized
override fun removeListener(listener: MusicRepository.Listener) {
listeners.remove(listener)
} }
} }

View file

@ -20,8 +20,10 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.os.storage.StorageManager import android.os.storage.StorageManager
import androidx.core.content.edit import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R 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.Directory
import org.oxycblt.auxio.music.storage.MusicDirectories import org.oxycblt.auxio.music.storage.MusicDirectories
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
@ -40,6 +42,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
val shouldBeObserving: Boolean val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */ /** A [String] of characters representing the desired characters to denote multi-value tags. */
var multiValueSeparators: String var multiValueSeparators: String
/** Whether to trim english articles with song sort names. */
val automaticSortNames: Boolean
/** The [Sort] mode used in [Song] lists. */ /** The [Sort] mode used in [Song] lists. */
var songSort: Sort var songSort: Sort
/** The [Sort] mode used in [Album] lists. */ /** The [Sort] mode used in [Album] lists. */
@ -61,164 +65,158 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
/** Called when the [shouldBeObserving] configuration has changed. */ /** Called when the [shouldBeObserving] configuration has changed. */
fun onObservingChanged() {} fun onObservingChanged() {}
} }
}
private class Real(context: Context) : Settings.Real<Listener>(context), MusicSettings { class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
private val storageManager = context.getSystemServiceCompat(StorageManager::class) Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
override var musicDirs: MusicDirectories override var musicDirs: MusicDirectories
get() { get() {
val dirs = val dirs =
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) (sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null)
?: emptySet()) ?: emptySet())
.mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) } .mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) }
return MusicDirectories( return MusicDirectories(
dirs, dirs,
sharedPreferences.getBoolean( sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
getString(R.string.set_key_music_dirs_include), false)) }
} set(value) {
set(value) { sharedPreferences.edit {
sharedPreferences.edit { putStringSet(
putStringSet( getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs), value.dirs.map(Directory::toDocumentTreeUri).toSet())
value.dirs.map(Directory::toDocumentTreeUri).toSet()) putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude)
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) apply()
apply()
}
}
override val excludeNonMusic: Boolean
get() =
sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
override val shouldBeObserving: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
override var multiValueSeparators: String
// Differ from convention and store a string of separator characters instead of an int
// code. This makes it easier to use and more extendable.
get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: ""
set(value) {
sharedPreferences.edit {
putString(getString(R.string.set_key_separators), value)
apply()
}
}
override var songSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_songs_sort), value.intCode)
apply()
}
}
override var albumSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_albums_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_albums_sort), value.intCode)
apply()
}
}
override var artistSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_artists_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_artists_sort), value.intCode)
apply()
}
}
override var genreSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_genres_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_genres_sort), value.intCode)
apply()
}
}
override var albumSongSort: Sort
get() {
var sort =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDisc, true)
// Correct legacy album sort modes to Disc
if (sort.mode is Sort.Mode.ByName) {
sort = sort.withMode(Sort.Mode.ByDisc)
}
return sort
}
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_album_songs_sort), value.intCode)
apply()
}
}
override var artistSongSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDate, false)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_artist_songs_sort), value.intCode)
apply()
}
}
override var genreSongSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
apply()
}
}
override fun onSettingChanged(key: String, listener: Listener) {
when (key) {
getString(R.string.set_key_exclude_non_music),
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_separators) -> listener.onIndexingSettingChanged()
getString(R.string.set_key_observing) -> listener.onObservingChanged()
} }
} }
}
companion object { override val excludeNonMusic: Boolean
/** get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
* Get a framework-backed implementation.
* @param context [Context] required. override val shouldBeObserving: Boolean
*/ get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
fun from(context: Context): MusicSettings = Real(context)
override var multiValueSeparators: String
// Differ from convention and store a string of separator characters instead of an int
// code. This makes it easier to use and more extendable.
get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: ""
set(value) {
sharedPreferences.edit {
putString(getString(R.string.set_key_separators), value)
apply()
}
}
override val automaticSortNames: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true)
override var songSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_songs_sort), value.intCode)
apply()
}
}
override var albumSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_albums_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_albums_sort), value.intCode)
apply()
}
}
override var artistSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_artists_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_artists_sort), value.intCode)
apply()
}
}
override var genreSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_genres_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_genres_sort), value.intCode)
apply()
}
}
override var albumSongSort: Sort
get() {
var sort =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDisc, Sort.Direction.ASCENDING)
// Correct legacy album sort modes to Disc
if (sort.mode is Sort.Mode.ByName) {
sort = sort.withMode(Sort.Mode.ByDisc)
}
return sort
}
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_album_songs_sort), value.intCode)
apply()
}
}
override var artistSongSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_artist_songs_sort), value.intCode)
apply()
}
}
override var genreSongSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
apply()
}
}
override fun onSettingChanged(key: String, listener: MusicSettings.Listener) {
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
// (just need to manipulate data)
when (key) {
getString(R.string.set_key_exclude_non_music),
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_separators),
getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged()
getString(R.string.set_key_observing) -> listener.onObservingChanged()
}
} }
} }

View file

@ -18,6 +18,8 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.system.Indexer 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. * A [ViewModel] providing data specific to the music loading process.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicViewModel : ViewModel(), Indexer.Listener { @HiltViewModel
private val indexer = Indexer.getInstance() class MusicViewModel @Inject constructor(private val indexer: Indexer) :
ViewModel(), Indexer.Listener {
private val _indexerState = MutableStateFlow<Indexer.State?>(null) private val _indexerState = MutableStateFlow<Indexer.State?>(null)
/** The current music loading state, or null if no loading is going on. */ /** The current music loading state, or null if no loading is going on. */

View 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)
}
}

View 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()
}

View 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
}
}

View file

@ -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
}
}
}
}

View file

@ -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()

View 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)
}
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.tags package org.oxycblt.auxio.music.metadata
import android.content.Context import android.content.Context
import java.text.ParseException import java.text.ParseException

View 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)
}

View file

@ -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
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.tags package org.oxycblt.auxio.music.metadata
import org.oxycblt.auxio.R 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. * future release.
*/ */
object Mixtape : ReleaseType() { object Mixtape : ReleaseType() {
@ -141,7 +141,7 @@ sealed class ReleaseType {
/** A release consisting of a live performance */ /** A release consisting of a live performance */
LIVE, 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 REMIX
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.parsing package org.oxycblt.auxio.music.metadata
/** /**
* Defines the allowed separator characters that can be used to delimit multi-value tags. * Defines the allowed separator characters that can be used to delimit multi-value tags.

View file

@ -15,13 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.parsing package org.oxycblt.auxio.music.metadata
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.checkbox.MaterialCheckBox 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.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogSeparatorsBinding import org.oxycblt.auxio.databinding.DialogSeparatorsBinding
@ -33,7 +35,10 @@ import org.oxycblt.auxio.ui.ViewBindingDialogFragment
* split tags with multiple values. * split tags with multiple values.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() { class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
@Inject lateinit var musicSettings: MusicSettings
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogSeparatorsBinding.inflate(inflater) DialogSeparatorsBinding.inflate(inflater)
@ -42,7 +47,7 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
.setTitle(R.string.set_separators) .setTitle(R.string.set_separators)
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ -> .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 // the corresponding CheckBox for each character instead of doing an iteration
// through the separator list for each CheckBox. // through the separator list for each CheckBox.
(savedInstanceState?.getString(KEY_PENDING_SEPARATORS) (savedInstanceState?.getString(KEY_PENDING_SEPARATORS)
?: MusicSettings.from(requireContext()).multiValueSeparators) ?: musicSettings.multiValueSeparators)
.forEach { .forEach {
when (it) { when (it) {
Separators.COMMA -> binding.separatorComma.isChecked = true Separators.COMMA -> binding.separatorComma.isChecked = true

View file

@ -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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -15,103 +15,87 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.metadata
import android.content.Context import android.content.Context
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import kotlinx.coroutines.flow.flow import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
import org.oxycblt.auxio.music.Song import dagger.hilt.android.qualifiers.ApplicationContext
import org.oxycblt.auxio.music.parsing.parseId3v2Position 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.storage.toAudioUri
import org.oxycblt.auxio.music.tags.Date
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
/** /**
* The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the * 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 * 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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MetadataExtractor( interface TagExtractor {
private val context: Context,
private val mediaStoreExtractor: MediaStoreExtractor
) {
// We can parallelize MetadataRetriever Futures to work around it's speed issues,
// producing similar throughput's to other kinds of manual metadata extraction.
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
/** /**
* Initialize this extractor. This actually initializes the sub-extractors that this instance * Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will
* relies on. * terminate as soon as [incompleteSongs] is closed.
* @return The amount of music that is expected to be loaded. * @param incompleteSongs A [Channel] of incomplete songs to process.
* @param completeSongs A [Channel] to send completed songs to.
*/ */
fun init() = mediaStoreExtractor.init().count suspend fun consume(incompleteSongs: Channel<RawSong>, completeSongs: Channel<RawSong>)
}
/** class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) :
* Finalize the Extractor by writing the newly-loaded [Song.Raw]s back into the cache, alongside TagExtractor {
* freeing up memory. override suspend fun consume(
* @param rawSongs The songs to write into the cache. incompleteSongs: Channel<RawSong>,
*/ completeSongs: Channel<RawSong>
fun finalize(rawSongs: List<Song.Raw>) = mediaStoreExtractor.finalize(rawSongs) ) {
// We can parallelize MetadataRetriever Futures to work around it's speed issues,
// producing similar throughput's to other kinds of manual metadata extraction.
val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
/** for (song in incompleteSongs) {
* Returns a flow that parses all [Song.Raw] instances queued by the sub-extractors. This will
* first delegate to the sub-extractors before parsing the metadata itself.
* @return A flow of [Song.Raw] instances.
*/
fun extract() = flow {
while (true) {
val raw = Song.Raw()
when (mediaStoreExtractor.populate(raw)) {
ExtractionResult.NONE -> break
ExtractionResult.PARSED -> {}
ExtractionResult.CACHED -> {
// Avoid running the expensive parsing process on songs we can already
// restore from the cache.
emit(raw)
continue
}
}
// Spin until there is an open slot we can insert a task in.
spin@ while (true) { spin@ while (true) {
for (i in taskPool.indices) { for (i in taskPool.indices) {
val task = taskPool[i] val task = taskPool[i]
if (task != null) { if (task != null) {
val finishedRaw = task.get() val finishedRawSong = task.get()
if (finishedRaw != null) { if (finishedRawSong != null) {
emit(finishedRaw) completeSongs.send(finishedRawSong)
taskPool[i] = Task(context, raw) yield()
break@spin } else {
continue
} }
} else {
taskPool[i] = Task(context, raw)
break@spin
} }
taskPool[i] = Task(context, song)
break@spin
} }
} }
} }
spin@ while (true) { do {
// Spin until all of the remaining tasks are complete. var ongoingTasks = false
for (i in taskPool.indices) { for (i in taskPool.indices) {
val task = taskPool[i] val task = taskPool[i]
if (task != null) { if (task != null) {
val finishedRaw = task.get() ?: continue@spin val finishedRawSong = task.get()
emit(finishedRaw) if (finishedRawSong != null) {
taskPool[i] = null completeSongs.send(finishedRawSong)
taskPool[i] = null
yield()
} else {
ongoingTasks = true
}
} }
} }
} while (ongoingTasks)
break completeSongs.close()
}
} }
private companion object { 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 context [Context] required to open the audio file.
* @param raw [Song.Raw] to process. * @param rawSong [RawSong] to process.
* @author Alexander Capehart (OxygenCobalt) * @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 // 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 // (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely. // listener is used, instead crashing the app entirely.
private val future = private val future =
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(
context, DefaultMediaSourceFactory(context, AudioOnlyExtractors),
MediaItem.fromUri( 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. * 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) { if (!future.isDone) {
// Not done yet, nothing to do. // Not done yet, nothing to do.
return null return null
@ -149,13 +133,13 @@ class Task(context: Context, private val raw: Song.Raw) {
try { try {
future.get()[0].getFormat(0) future.get()[0].getFormat(0)
} catch (e: Exception) { } catch (e: Exception) {
logW("Unable to extract metadata for ${raw.name}") logW("Unable to extract metadata for ${rawSong.name}")
logW(e.stackTraceToString()) logW(e.stackTraceToString())
null null
} }
if (format == null) { if (format == null) {
logD("Nothing could be extracted for ${raw.name}") logD("Nothing could be extracted for ${rawSong.name}")
return raw return rawSong
} }
val metadata = format.metadata val metadata = format.metadata
@ -164,28 +148,29 @@ class Task(context: Context, private val raw: Song.Raw) {
populateWithId3v2(textTags.id3v2) populateWithId3v2(textTags.id3v2)
populateWithVorbis(textTags.vorbis) populateWithVorbis(textTags.vorbis)
} else { } 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 * @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
* values. * values.
*/ */
private fun populateWithId3v2(textFrames: Map<String, List<String>>) { private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
// Song // Song
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] } textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() }
textFrames["TIT2"]?.let { raw.name = it[0] } textFrames["TIT2"]?.let { rawSong.name = it.first() }
textFrames["TSOT"]?.let { raw.sortName = it[0] } textFrames["TSOT"]?.let { rawSong.sortName = it.first() }
// Track. Only parse out the track number and ignore the total tracks value. // Track.
textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it } textFrames["TRCK"]?.run { first().parseId3v2PositionField() }?.let { rawSong.track = it }
// Disc. Only parse out the disc number and ignore the total discs value. // Disc and it's subtitle name.
textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it } 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 // 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 // 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["TDRC"]?.run { Date.from(first()) }
?: textFrames["TDRL"]?.run { Date.from(first()) } ?: textFrames["TDRL"]?.run { Date.from(first()) }
?: parseId3v23Date(textFrames)) ?: parseId3v23Date(textFrames))
?.let { raw.date = it } ?.let { rawSong.date = it }
// Album // Album
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] } textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() }
textFrames["TALB"]?.let { raw.albumName = it[0] } textFrames["TALB"]?.let { rawSong.albumName = it.first() }
textFrames["TSOA"]?.let { raw.albumSortName = it[0] } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() }
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let { (textFrames["TXXX:musicbrainz album type"]
raw.releaseTypes = it ?: textFrames["TXXX:releasetype"] ?: textFrames["GRP1"])
} ?.let { rawSong.releaseTypes = it }
// Artist // Artist
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it } textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it }
(textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { raw.artistNames = it } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it }
(textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let { raw.artistSortNames = it } (textFrames["TXXX:artists_sort"] ?: textFrames["TSOP"])?.let {
rawSong.artistSortNames = it
}
// Album artist // Album artist
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it } textFrames["TXXX:musicbrainz album artist id"]?.let {
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let { raw.albumArtistNames = it } rawSong.albumArtistMusicBrainzIds = it
}
(textFrames["TXXX:albumartists"] ?: textFrames["TPE2"])?.let {
rawSong.albumArtistNames = it
}
(textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let { (textFrames["TXXX:albumartists_sort"] ?: textFrames["TSO2"])?.let {
raw.albumArtistSortNames = it rawSong.albumArtistSortNames = it
} }
// Genre // 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 ?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
val tdat = textFrames["TDAT"] 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 // TDAT frames consist of a 4-digit string where the first two digits are
// the month and the last two digits are the day. // the month and the last two digits are the day.
val mm = tdat[0].substring(0..1).toInt() val mm = tdat.first().substring(0..1).toInt()
val dd = tdat[0].substring(2..3).toInt() val dd = tdat.first().substring(2..3).toInt()
val time = textFrames["TIME"] 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 // 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 // the hour and the last two digits are the minutes. No second value is
// possible. // possible.
val hh = time[0].substring(0..1).toInt() val hh = time.first().substring(0..1).toInt()
val mi = time[0].substring(2..3).toInt() val mi = time.first().substring(2..3).toInt()
// Able to return a full date. // Able to return a full date.
Date.from(year, mm, dd, hh, mi) Date.from(year, mm, dd, hh, mi)
} else { } 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. * @param comments A mapping between vorbis comment names and one or more vorbis comment values.
*/ */
private fun populateWithVorbis(comments: Map<String, List<String>>) { private fun populateWithVorbis(comments: Map<String, List<String>>) {
// Song // Song
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] } comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() }
comments["title"]?.let { raw.name = it[0] } comments["title"]?.let { rawSong.name = it.first() }
comments["titlesort"]?.let { raw.sortName = it[0] } comments["titlesort"]?.let { rawSong.sortName = it.first() }
// Track. The total tracks value is in a different comment, so we can just // Track.
// convert the entirety of this comment into a number. parseVorbisPositionField(
comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it } 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 // Disc and it's subtitle name.
// convert the entirety of this comment into a number. parseVorbisPositionField(
comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it } 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 // Vorbis dates are less complicated, but there are still several types
// Our hierarchy for dates is as such: // 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["originaldate"]?.run { Date.from(first()) }
?: comments["date"]?.run { Date.from(first()) } ?: comments["date"]?.run { Date.from(first()) }
?: comments["year"]?.run { Date.from(first()) }) ?: comments["year"]?.run { Date.from(first()) })
?.let { raw.date = it } ?.let { rawSong.date = it }
// Album // Album
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] } comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() }
comments["album"]?.let { raw.albumName = it[0] } comments["album"]?.let { rawSong.albumName = it.first() }
comments["albumsort"]?.let { raw.albumSortName = it[0] } comments["albumsort"]?.let { rawSong.albumSortName = it.first() }
comments["releasetype"]?.let { raw.releaseTypes = it } comments["releasetype"]?.let { rawSong.releaseTypes = it }
// Artist // Artist
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it } comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it }
(comments["artists"] ?: comments["artist"])?.let { raw.artistNames = it } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it }
(comments["artists_sort"] ?: comments["artistsort"])?.let { raw.artistSortNames = it } (comments["artists_sort"] ?: comments["artistsort"])?.let { rawSong.artistSortNames = it }
// Album artist // Album artist
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it } comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it }
(comments["albumartists"] ?: comments["albumartist"])?.let { raw.albumArtistNames = it } (comments["albumartists"] ?: comments["albumartist"])?.let { rawSong.albumArtistNames = it }
(comments["albumartists_sort"] ?: comments["albumartistsort"])?.let { (comments["albumartists_sort"] ?: comments["albumartistsort"])?.let {
raw.albumArtistSortNames = it rawSong.albumArtistSortNames = it
} }
// Genre // Genre
comments["genre"]?.let { raw.genreNames = it } comments["genre"]?.let { rawSong.genreNames = it }
} }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.parsing package org.oxycblt.auxio.music.metadata
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.util.nonZeroOrNull 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. * 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. * @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> { private fun String.maybeParseBySeparators(settings: MusicSettings): List<String> {
@ -107,12 +107,45 @@ private fun String.maybeParseBySeparators(settings: MusicSettings): List<String>
/// --- ID3v2 PARSING --- /// --- ID3v2 PARSING ---
/** /**
* Parse the number out of a ID3v2-style number + total position [String] field. These fields * Parse an ID3v2-style position + total [String] field. These fields consist of a number and an
* consist of a number and an (optional) total value delimited by a /. * (optional) total value delimited by a /.
* @return The number value extracted from the string field, or null if the value could not be * @return The position value extracted from the string field, or null if:
* parsed or if the value was zero. * - 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 * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer

View file

@ -15,13 +15,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.extractor package org.oxycblt.auxio.music.metadata
import com.google.android.exoplayer2.metadata.Metadata import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.InternalFrame import com.google.android.exoplayer2.metadata.id3.InternalFrame
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment 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. * Processing wrapper for [Metadata] that allows organized access to text-based audio tags.

View file

@ -15,11 +15,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.library package org.oxycblt.auxio.music.model
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.useQuery import org.oxycblt.auxio.music.storage.useQuery
@ -29,27 +30,89 @@ import org.oxycblt.auxio.util.logD
* Organized music library information. * Organized music library information.
* *
* This class allows for the creation of a well-formed music library graph from raw song * 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 * @author Alexander Capehart
*/ */
class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) { interface Library {
/** All [Song]s that were detected on the device. */ /** All [Song]s in this [Library]. */
val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }.distinct()) val songs: List<Song>
/** All [Album]s found on the device. */ /** All [Album]s in this [Library]. */
val albums = buildAlbums(songs) val albums: List<Album>
/** All [Artist]s found on the device. */ /** All [Artist]s in this [Library]. */
val artists = buildArtists(songs, albums) val artists: List<Artist>
/** All [Genre]s found on the device. */ /** All [Genre]s in this [Library]. */
val genres = buildGenres(songs) 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. // Use a mapping to make finding information based on it's UID much faster.
private val uidMap = buildMap { private val uidMap = buildMap {
for (music in (songs + albums + artists + genres)) { songs.forEach { put(it.uid, it.finalize()) }
// Finalize all music in the same mapping creation loop for efficiency. albums.forEach { put(it.uid, it.finalize()) }
music._finalize() artists.forEach { put(it.uid, it.finalize()) }
this[music.uid] = music 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 * @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]. * 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
/** override fun sanitize(song: Song) = find<Song>(song.uid)
* 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 <T : MusicParent> sanitize(parent: T) = find<T>(parent.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 findSongForUri(context: Context, uri: Uri) =
* 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) =
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor -> uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst() cursor.moveToFirst()
@ -106,18 +139,30 @@ class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
songs.find { it.path.name == displayName && it.size == size } 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. * 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 * @param songs The [Song]s to build [Album]s from. These will be linked with their respective
* [Album]s when created. * [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 * @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. * 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 // 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. // grouped songs to Album values. Album.Raw will handle the actual grouping rules.
val songsByAlbum = songs.groupBy { it._rawAlbum } val songsByAlbum = songs.groupBy { it.rawAlbum }
val albums = songsByAlbum.map { Album(it.key, it.value) } val albums = songsByAlbum.map { AlbumImpl(it.key, settings, it.value) }
logD("Successfully built ${albums.size} albums") logD("Successfully built ${albums.size} albums")
return 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 * @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 * one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created. * 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 * @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
* of [Song]s and [Album]s. * 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, // Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists. // 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 (song in songs) {
for (rawArtist in song._rawArtists) { for (rawArtist in song.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song) musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
} }
} }
for (album in albums) { for (album in albums) {
for (rawArtist in album._rawArtists) { for (rawArtist in album.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album) musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
} }
} }
// Convert the combined mapping into artist instances. // 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") logD("Successfully built ${artists.size} artists")
return 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 * @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 * one or more [Genre] instances. These will be linked with their respective [Genre]s when
* created. * created.
* @param settings [MusicSettings] to obtain user parsing configuration.
* @return A non-empty list of [Genre]s. * @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, // Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres. // 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 (song in songs) {
for (rawGenre in song._rawGenres) { for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song) songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
} }
} }
// Convert the mapping into genre instances. // 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") logD("Successfully built ${genres.size} genres")
return genres return genres
} }

View 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)
}

View 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
}
}

View file

@ -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 * 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 * 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. * directory.
* @return A URI [String] abiding by the document tree specification, or null if the [Directory] * @return A URI [String] abiding by the document tree specification, or null if the [Directory]
* is not valid. * 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". * Resolve the mime type into a human-readable format name, such as "Ogg Vorbis".
* @param context [Context] required to obtain human-readable strings. * @param context [Context] required to obtain human-readable strings.
* @return A human-readable name for this mime type. Will first try [fromFormat], then falling * @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 * back to [fromExtension], and then null if that fails.
* placeholder "No Format" string.
*/ */
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. // We try our best to produce a more readable name for the common audio formats.
val formatName = val formatName =
when (fromFormat) { 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_VORBIS -> R.string.cdc_vorbis
MediaFormat.MIMETYPE_AUDIO_OPUS -> R.string.cdc_opus MediaFormat.MIMETYPE_AUDIO_OPUS -> R.string.cdc_opus
MediaFormat.MIMETYPE_AUDIO_FLAC -> R.string.cdc_flac 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. // We don't give a name to more unpopular formats.
else -> -1 else -> -1
} }
@ -199,8 +200,6 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) {
} else { } else {
// Fall back to the extension if we can't find a special name for this format. // Fall back to the extension if we can't find a special name for this format.
MimeTypeMap.getSingleton().getExtensionFromMimeType(fromExtension)?.uppercase() MimeTypeMap.getSingleton().getExtensionFromMimeType(fromExtension)?.uppercase()
// Fall back to a placeholder if even that fails.
?: context.getString(R.string.def_codec)
} }
} }
} }

View file

@ -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)

View file

@ -28,6 +28,8 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
@ -41,11 +43,13 @@ import org.oxycblt.auxio.util.showToast
* Dialog that manages the music dirs setting. * Dialog that manages the music dirs setting.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class MusicDirsDialog : class MusicDirsDialog :
ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener { ViewBindingDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this) private val dirAdapter = DirectoryAdapter(this)
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
private var storageManager: StorageManager? = null private var storageManager: StorageManager? = null
@Inject lateinit var musicSettings: MusicSettings
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicDirsBinding.inflate(inflater) DialogMusicDirsBinding.inflate(inflater)
@ -55,11 +59,10 @@ class MusicDirsDialog :
.setTitle(R.string.set_dirs) .setTitle(R.string.set_dirs)
.setNegativeButton(R.string.lbl_cancel, null) .setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ -> .setPositiveButton(R.string.lbl_save) { _, _ ->
val settings = MusicSettings.from(requireContext())
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding())) val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
if (settings.musicDirs != newDirs) { if (musicSettings.musicDirs != newDirs) {
logD("Committing changes") logD("Committing changes")
settings.musicDirs = newDirs musicSettings.musicDirs = newDirs
} }
} }
} }
@ -96,7 +99,7 @@ class MusicDirsDialog :
itemAnimator = null itemAnimator = null
} }
var dirs = MusicSettings.from(context).musicDirs var dirs = musicSettings.musicDirs
if (savedInstanceState != null) { if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
if (pendingDirs != null) { if (pendingDirs != null) {

View 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.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)
}

View file

@ -22,14 +22,24 @@ import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.util.LinkedList
import javax.inject.Inject
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.extractor.* import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.library.Library 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.logD
import org.oxycblt.auxio.util.logE import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW 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 * 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 * 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 * than is usually needed. Use [MusicRepository] instead if you do not need to work with the exact
* loading state. * music loading state.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Indexer private constructor() { interface Indexer {
@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
/** Whether music loading is occurring or not. */ /** Whether music loading is occurring or not. */
val isIndexing: Boolean val isIndexing: Boolean
get() = indexingState != null
/** /**
* Whether this instance has not completed a loading process and is not currently loading music. * 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 * This often occurs early in an app's lifecycle, and consumers should try to avoid showing any
* state when this flag is true. * state when this flag is true.
*/ */
val isIndeterminate: Boolean val isIndeterminate: Boolean
get() = lastResponse == null && indexingState == null
/** /**
* Register a [Controller] for this instance. This instance will handle any commands to start * 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. * [Listener] methods to initialize the instance with the current state.
* @param controller The [Controller] to register. Will do nothing if already registered. * @param controller The [Controller] to register. Will do nothing if already registered.
*/ */
@Synchronized fun registerController(controller: Controller)
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
}
/** /**
* Unregister the [Controller] from this instance, prevent it from recieving any further * 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 * @param controller The [Controller] to unregister. Must be the current [Controller]. Does
* nothing if invoked by another [Controller] implementation. * nothing if invoked by another [Controller] implementation.
*/ */
@Synchronized fun unregisterController(controller: Controller)
fun unregisterController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) {
logW("Given controller did not match current controller")
return
}
this.controller = null
}
/** /**
* Register the [Listener] for this instance. This can be used to receive rapid-fire updates to * 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. * [Listener] methods to initialize the instance with the current state.
* @param listener The [Listener] to add. * @param listener The [Listener] to add.
*/ */
@Synchronized fun registerListener(listener: Listener)
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
}
/** /**
* Unregister a [Listener] from this instance, preventing it from recieving any further updates. * 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. * invoked by another [Listener] implementation.
* @see Listener * @see Listener
*/ */
@Synchronized fun unregisterListener(listener: Listener)
fun unregisterListener(listener: Listener) {
if (BuildConfig.DEBUG && this.listener !== listener) {
logW("Given controller did not match current controller")
return
}
this.listener = null
}
/** /**
* Start the indexing process. This should be done from in the background from [Controller]'s * 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 context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still * @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. * 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) { fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job
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)
}
/** /**
* Request that the music library should be reloaded. This should be used by components that do * 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] * not manage the indexing process in order to signal that the [Indexer.Controller] should call
* eventually. * [index] eventually.
* @param withCache Whether to use the cache when loading music. Does nothing if there is no * @param withCache Whether to use the cache when loading music. Does nothing if there is no
* [Controller]. * [Indexer.Controller].
*/ */
@Synchronized fun requestReindex(withCache: Boolean)
fun requestReindex(withCache: Boolean) {
logD("Requesting reindex")
controller?.onStartIndexing(withCache)
}
/** /**
* Reset the current loading state to signal that the instance is not loading. This should be * 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. * called by [Controller] after it's indexing co-routine was cancelled.
*/ */
@Synchronized fun reset()
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)
}
}
}
/** Represents the current state of [Indexer]. */ /** Represents the current state of [Indexer]. */
sealed class State { sealed class State {
@ -357,8 +172,8 @@ class Indexer private constructor() {
* A listener for rapid-fire changes in the music loading state. * 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. * 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 * Otherwise, [MusicRepository.Listener] is highly recommended due to it's updates only
* the [Library]. * consisting of the [Library].
*/ */
interface Listener { interface Listener {
/** /**
@ -388,8 +203,6 @@ class Indexer private constructor() {
} }
companion object { companion object {
@Volatile private var INSTANCE: Indexer? = null
/** /**
* A version-compatible identifier for the read external storage permission required by the * A version-compatible identifier for the read external storage permission required by the
* system to load audio. * system to load audio.
@ -401,21 +214,218 @@ class Indexer private constructor() {
} else { } else {
Manifest.permission.READ_EXTERNAL_STORAGE Manifest.permission.READ_EXTERNAL_STORAGE
} }
}
}
/** class IndexerImpl
* Get a singleton instance. @Inject
* @return The (possibly newly-created) singleton instance. constructor(
*/ private val musicSettings: MusicSettings,
fun getInstance(): Indexer { private val cacheRepository: CacheRepository,
val currentInstance = INSTANCE private val mediaStoreExtractor: MediaStoreExtractor,
if (currentInstance != null) { private val tagExtractor: TagExtractor
return currentInstance ) : 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()
}
/**
* Emit a new [Indexer.State.Indexing] state. This can be used to signal the current state of
* the music loading process to external code. Assumes that the callee has already checked if
* they have not been canceled and thus have the ability to emit a new state.
* @param indexing The new [Indexer.Indexing] state to emit, or null if no loading process is
* occurring.
*/
@Synchronized
private fun emitIndexing(indexing: Indexer.Indexing?) {
indexingState = indexing
// If we have canceled the loading process, we want to revert to a previous completion
// whenever possible to prevent state inconsistency.
val state =
indexingState?.let { Indexer.State.Indexing(it) }
?: lastResponse?.let { Indexer.State.Complete(it) }
controller?.onIndexerStateChanged(state)
listener?.onIndexerStateChanged(state)
}
/**
* Emit a new [Indexer.State.Complete] state. This can be used to signal the completion of the
* music loading process to external code. Will check if the callee has not been canceled and
* thus has the ability to emit a new state
* @param result The new [Result] to emit, representing the outcome of the music loading
* process.
*/
private suspend fun emitCompletion(result: Result<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) { synchronized(this) {
val newInstance = Indexer() // Do not check for redundancy here, as we actually need to notify a switch
INSTANCE = newInstance // from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
return newInstance 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)
} }
} }
} }

View file

@ -25,14 +25,15 @@ import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import android.provider.MediaStore 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.service.ForegroundManager import org.oxycblt.auxio.service.ForegroundManager
@ -53,10 +54,13 @@ import org.oxycblt.auxio.util.logD
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
private val indexer = Indexer.getInstance() @Inject lateinit var imageLoader: ImageLoader
private val musicStore = MusicStore.getInstance() @Inject lateinit var musicRepository: MusicRepository
private val playbackManager = PlaybackStateManager.getInstance() @Inject lateinit var indexer: Indexer
@Inject lateinit var musicSettings: MusicSettings
@Inject lateinit var playbackManager: PlaybackStateManager
private val serviceJob = Job() private val serviceJob = Job()
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
private var currentIndexJob: Job? = null private var currentIndexJob: Job? = null
@ -65,7 +69,6 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
private lateinit var observingNotification: ObservingNotification private lateinit var observingNotification: ObservingNotification
private lateinit var wakeLock: PowerManager.WakeLock private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var indexerContentObserver: SystemContentObserver private lateinit var indexerContentObserver: SystemContentObserver
private lateinit var settings: MusicSettings
override fun onCreate() { override fun onCreate() {
super.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 // 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. // condition to cause us to load music before we were fully initialize.
indexerContentObserver = SystemContentObserver() indexerContentObserver = SystemContentObserver()
settings = MusicSettings.from(this) musicSettings.registerListener(this)
settings.registerListener(this)
indexer.registerController(this) indexer.registerController(this)
// An indeterminate indexer and a missing library implies we are extremely early // An indeterminate indexer and a missing library implies we are extremely early
// in app initialization so start loading music. // 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") logD("No library present and no previous response, indexing music now")
onStartIndexing(true) onStartIndexing(true)
} }
@ -105,7 +107,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
// Then cancel the listener-dependent components to ensure that stray reloading // Then cancel the listener-dependent components to ensure that stray reloading
// events will not occur. // events will not occur.
indexerContentObserver.release() indexerContentObserver.release()
settings.unregisterListener(this) musicSettings.unregisterListener(this)
indexer.unregisterController(this) indexer.unregisterController(this)
// Then cancel any remaining music loading jobs. // Then cancel any remaining music loading jobs.
serviceJob.cancel() serviceJob.cancel()
@ -121,7 +123,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
indexer.reset() indexer.reset()
} }
// Start a new music loading job on a co-routine. // 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?) { 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.Indexing -> updateActiveSession(state.indexing)
is Indexer.State.Complete -> { is Indexer.State.Complete -> {
val newLibrary = state.result.getOrNull() val newLibrary = state.result.getOrNull()
if (newLibrary != null && newLibrary != musicStore.library) { if (newLibrary != null && newLibrary != musicRepository.library) {
logD("Applying new library") logD("Applying new library")
// We only care if the newly-loaded library is going to replace a previously // We only care if the newly-loaded library is going to replace a previously
// loaded library. // loaded library.
if (musicStore.library != null) { if (musicRepository.library != null) {
// Wipe possibly-invalidated outdated covers // Wipe possibly-invalidated outdated covers
imageLoader.memoryCache?.clear() imageLoader.memoryCache?.clear()
// Clear invalid models from PlaybackStateManager. This is not connected // Clear invalid models from PlaybackStateManager. This is not connected
// to a listener as it is bad practice for a shared object to attach to // to a listener as it is bad practice for a shared object to attach to
// the listener system of another. // 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. // 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 // On errors, while we would want to show a notification that displays the
// error, that requires the Android 13 notification permission, which is not // 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. * currently monitoring the music library for changes.
*/ */
private fun updateIdleSession() { private fun updateIdleSession() {
if (settings.shouldBeObserving) { if (musicSettings.shouldBeObserving) {
// There are a few reasons why we stay in the foreground with automatic rescanning: // 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 // 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 // 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() { override fun run() {
// Check here if we should even start a reindex. This is much less bug-prone than // 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. // registering and de-registering this component as this setting changes.
if (settings.shouldBeObserving) { if (musicSettings.shouldBeObserving) {
onStartIndexing(true) onStartIndexing(true)
} }
} }

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.picker
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup

View file

@ -15,12 +15,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.picker
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.NavigationViewModel 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. * An [ArtistPickerDialog] intended for when [Artist] navigation is ambiguous.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class ArtistNavigationPickerDialog : ArtistPickerDialog() { class ArtistNavigationPickerDialog : ArtistPickerDialog() {
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
// Information about what Song to show choices for is initially within the navigation arguments // Information about what Song to show choices for is initially within the navigation arguments

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.picker
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -23,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
@ -36,6 +37,7 @@ import org.oxycblt.auxio.util.collectImmediately
* to choose from. * to choose from.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
abstract class ArtistPickerDialog : abstract class ArtistPickerDialog :
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Artist> { ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Artist> {
protected val pickerModel: PickerViewModel by viewModels() protected val pickerModel: PickerViewModel by viewModels()

View file

@ -15,16 +15,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.picker
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.requireIs import org.oxycblt.auxio.util.requireIs
import org.oxycblt.auxio.util.unlikelyToBeNull 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. * An [ArtistPickerDialog] intended for when [Artist] playback is ambiguous.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class ArtistPlaybackPickerDialog : ArtistPickerDialog() { 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 // 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. // as UIDs, as that is the only safe way to parcel a Song.
private val args: ArtistPlaybackPickerDialogArgs by navArgs() private val args: ArtistPlaybackPickerDialogArgs by navArgs()

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.picker
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup

View file

@ -15,15 +15,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.picker
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicPickerBinding import org.oxycblt.auxio.databinding.DialogMusicPickerBinding
import org.oxycblt.auxio.list.ClickableListListener 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.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.requireIs import org.oxycblt.auxio.util.requireIs
import org.oxycblt.auxio.util.unlikelyToBeNull 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. * A picker [ViewBindingDialogFragment] intended for when [Genre] playback is ambiguous.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class GenrePlaybackPickerDialog : class GenrePlaybackPickerDialog :
ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Genre> { ViewBindingDialogFragment<DialogMusicPickerBinding>(), ClickableListListener<Genre> {
private val pickerModel: PickerViewModel by viewModels() 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 // 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. // as UIDs, as that is the only safe way to parcel a Song.
private val args: GenrePlaybackPickerDialogArgs by navArgs() private val args: GenrePlaybackPickerDialogArgs by navArgs()

View file

@ -15,14 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.music.picker package org.oxycblt.auxio.picker
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.util.unlikelyToBeNull 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. * contain the music themselves and then exit if the library changes.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PickerViewModel : ViewModel(), MusicStore.Listener { @HiltViewModel
private val musicStore = MusicStore.getInstance() class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
ViewModel(), MusicRepository.Listener {
private val _currentItem = MutableStateFlow<Music?>(null) private val _currentItem = MutableStateFlow<Music?>(null)
/** The current item whose artists should be shown in the picker. Null if there is no item. */ /** 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 get() = _genreChoices
override fun onCleared() { override fun onCleared() {
musicStore.removeListener(this) musicRepository.removeListener(this)
} }
override fun onLibraryChanged(library: Library?) { 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. * @param uid The [Music.UID] of the [Song] to update to.
*/ */
fun setItemUid(uid: Music.UID) { fun setItemUid(uid: Music.UID) {
val library = unlikelyToBeNull(musicStore.library) val library = unlikelyToBeNull(musicRepository.library)
_currentItem.value = library.find(uid) _currentItem.value = library.find(uid)
refreshChoices() refreshChoices()
} }

View file

@ -20,14 +20,15 @@ package org.oxycblt.auxio.playback
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat 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. * A [ViewBindingFragment] that shows the current playback state in a compact manner.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() { class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
@ -121,7 +123,7 @@ class PlaybackBarFragment : ViewBindingFragment<FragmentPlaybackBarBinding>() {
val binding = requireBinding() val binding = requireBinding()
binding.playbackCover.bind(song) binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context) 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() binding.playbackProgressBar.max = song.durationMs.msToDs().toInt()
} }
} }

View file

@ -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
}

View file

@ -28,17 +28,18 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.MainFragmentDirections import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.state.RepeatMode
import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.playback.ui.StyledSeekBar
import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.ui.NavigationViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -48,11 +49,12 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* available controls. * available controls.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class PlaybackPanelFragment : class PlaybackPanelFragment :
ViewBindingFragment<FragmentPlaybackPanelBinding>(), ViewBindingFragment<FragmentPlaybackPanelBinding>(),
Toolbar.OnMenuItemClickListener, Toolbar.OnMenuItemClickListener,
StyledSeekBar.Listener { StyledSeekBar.Listener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val navModel: NavigationViewModel by activityViewModels() private val navModel: NavigationViewModel by activityViewModels()
private var equalizerLauncher: ActivityResultLauncher<Intent>? = null private var equalizerLauncher: ActivityResultLauncher<Intent>? = null
@ -183,7 +185,7 @@ class PlaybackPanelFragment :
val context = requireContext() val context = requireContext()
binding.playbackCover.bind(song) binding.playbackCover.bind(song)
binding.playbackSong.text = song.resolveName(context) 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.playbackAlbum.text = song.album.resolveName(context)
binding.playbackSeekBar.durationDs = song.durationMs.msToDs() binding.playbackSeekBar.durationDs = song.durationMs.msToDs()
} }

View file

@ -19,6 +19,8 @@ package org.oxycblt.auxio.playback
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicMode
@ -65,154 +67,143 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
/** Called when [notificationAction] has changed. */ /** Called when [notificationAction] has changed. */
fun onNotificationActionChanged() {} fun onNotificationActionChanged() {}
} }
}
private class Real(context: Context) : Settings.Real<Listener>(context), PlaybackSettings { class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
override val inListPlaybackMode: MusicMode Settings.Impl<PlaybackSettings.Listener>(context), PlaybackSettings {
get() = override val inListPlaybackMode: MusicMode
MusicMode.fromIntCode( get() =
sharedPreferences.getInt( MusicMode.fromIntCode(
getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE)) sharedPreferences.getInt(
getString(R.string.set_key_in_list_playback_mode), Int.MIN_VALUE))
?: MusicMode.SONGS
override val inParentPlaybackMode: MusicMode?
get() =
MusicMode.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE))
override val barAction: ActionMode
get() =
ActionMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE))
?: ActionMode.NEXT
override val notificationAction: ActionMode
get() =
ActionMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_notif_action), Int.MIN_VALUE))
?: ActionMode.REPEAT
override val headsetAutoplay: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false)
override val replayGainMode: ReplayGainMode
get() =
ReplayGainMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
?: ReplayGainMode.DYNAMIC
override var replayGainPreAmp: ReplayGainPreAmp
get() =
ReplayGainPreAmp(
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f),
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f))
set(value) {
sharedPreferences.edit {
putFloat(getString(R.string.set_key_pre_amp_with), value.with)
putFloat(getString(R.string.set_key_pre_amp_without), value.without)
apply()
}
}
override val keepShuffle: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true)
override val rewindWithPrev: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true)
override val pauseOnRepeat: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false)
override fun migrate() {
// "Use alternate notification action" was converted to an ActionMode setting in 3.0.0.
if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) {
logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION")
val mode =
if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) {
ActionMode.SHUFFLE
} else {
ActionMode.REPEAT
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_notif_action), mode.intCode)
remove(OLD_KEY_ALT_NOTIF_ACTION)
apply()
}
}
// PlaybackMode was converted to MusicMode in 3.0.0
fun Int.migratePlaybackMode() =
when (this) {
// Convert PlaybackMode into MusicMode
IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES
else -> null
}
if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) {
logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE")
val mode =
sharedPreferences
.getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS)
.migratePlaybackMode()
?: MusicMode.SONGS ?: MusicMode.SONGS
override val inParentPlaybackMode: MusicMode? sharedPreferences.edit {
get() = putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode)
MusicMode.fromIntCode( remove(OLD_KEY_LIB_PLAYBACK_MODE)
sharedPreferences.getInt( apply()
getString(R.string.set_key_in_parent_playback_mode), Int.MIN_VALUE))
override val barAction: ActionMode
get() =
ActionMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_bar_action), Int.MIN_VALUE))
?: ActionMode.NEXT
override val notificationAction: ActionMode
get() =
ActionMode.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_notif_action), Int.MIN_VALUE))
?: ActionMode.REPEAT
override val headsetAutoplay: Boolean
get() =
sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false)
override val replayGainMode: ReplayGainMode
get() =
ReplayGainMode.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
?: ReplayGainMode.DYNAMIC
override var replayGainPreAmp: ReplayGainPreAmp
get() =
ReplayGainPreAmp(
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_with), 0f),
sharedPreferences.getFloat(getString(R.string.set_key_pre_amp_without), 0f))
set(value) {
sharedPreferences.edit {
putFloat(getString(R.string.set_key_pre_amp_with), value.with)
putFloat(getString(R.string.set_key_pre_amp_without), value.without)
apply()
}
}
override val keepShuffle: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_keep_shuffle), true)
override val rewindWithPrev: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_rewind_prev), true)
override val pauseOnRepeat: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_repeat_pause), false)
override fun migrate() {
// "Use alternate notification action" was converted to an ActionMode setting in 3.0.0.
if (sharedPreferences.contains(OLD_KEY_ALT_NOTIF_ACTION)) {
logD("Migrating $OLD_KEY_ALT_NOTIF_ACTION")
val mode =
if (sharedPreferences.getBoolean(OLD_KEY_ALT_NOTIF_ACTION, false)) {
ActionMode.SHUFFLE
} else {
ActionMode.REPEAT
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_notif_action), mode.intCode)
remove(OLD_KEY_ALT_NOTIF_ACTION)
apply()
}
}
// PlaybackMode was converted to MusicMode in 3.0.0
fun Int.migratePlaybackMode() =
when (this) {
// Convert PlaybackMode into MusicMode
IntegerTable.PLAYBACK_MODE_ALL_SONGS -> MusicMode.SONGS
IntegerTable.PLAYBACK_MODE_IN_ARTIST -> MusicMode.ARTISTS
IntegerTable.PLAYBACK_MODE_IN_ALBUM -> MusicMode.ALBUMS
IntegerTable.PLAYBACK_MODE_IN_GENRE -> MusicMode.GENRES
else -> null
}
if (sharedPreferences.contains(OLD_KEY_LIB_PLAYBACK_MODE)) {
logD("Migrating $OLD_KEY_LIB_PLAYBACK_MODE")
val mode =
sharedPreferences
.getInt(OLD_KEY_LIB_PLAYBACK_MODE, IntegerTable.PLAYBACK_MODE_ALL_SONGS)
.migratePlaybackMode()
?: MusicMode.SONGS
sharedPreferences.edit {
putInt(getString(R.string.set_key_in_list_playback_mode), mode.intCode)
remove(OLD_KEY_LIB_PLAYBACK_MODE)
apply()
}
}
if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) {
logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE")
val mode =
sharedPreferences
.getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE)
.migratePlaybackMode()
sharedPreferences.edit {
putInt(
getString(R.string.set_key_in_parent_playback_mode),
mode?.intCode ?: Int.MIN_VALUE)
remove(OLD_KEY_DETAIL_PLAYBACK_MODE)
apply()
}
} }
} }
override fun onSettingChanged(key: String, listener: Listener) { if (sharedPreferences.contains(OLD_KEY_DETAIL_PLAYBACK_MODE)) {
when (key) { logD("Migrating $OLD_KEY_DETAIL_PLAYBACK_MODE")
getString(R.string.set_key_replay_gain),
getString(R.string.set_key_pre_amp_with),
getString(R.string.set_key_pre_amp_without) ->
listener.onReplayGainSettingsChanged()
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
}
}
private companion object { val mode =
const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION" sharedPreferences
const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2" .getInt(OLD_KEY_DETAIL_PLAYBACK_MODE, Int.MIN_VALUE)
const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode" .migratePlaybackMode()
sharedPreferences.edit {
putInt(
getString(R.string.set_key_in_parent_playback_mode),
mode?.intCode ?: Int.MIN_VALUE)
remove(OLD_KEY_DETAIL_PLAYBACK_MODE)
apply()
}
} }
} }
companion object { override fun onSettingChanged(key: String, listener: PlaybackSettings.Listener) {
/** when (key) {
* Get a framework-backed implementation. getString(R.string.set_key_replay_gain),
* @param context [Context] required. getString(R.string.set_key_pre_amp_with),
*/ getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged()
fun from(context: Context): PlaybackSettings = Real(context) getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
}
}
private companion object {
const val OLD_KEY_ALT_NOTIF_ACTION = "KEY_ALT_NOTIF_ACTION"
const val OLD_KEY_LIB_PLAYBACK_MODE = "KEY_SONG_PLAY_MODE2"
const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode"
} }
} }

View file

@ -17,28 +17,34 @@
package org.oxycblt.auxio.playback package org.oxycblt.auxio.playback
import android.app.Application import androidx.lifecycle.ViewModel
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.* 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.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) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaybackViewModel(application: Application) : @HiltViewModel
AndroidViewModel(application), PlaybackStateManager.Listener { class PlaybackViewModel
private val musicSettings = MusicSettings.from(application) @Inject
private val playbackSettings = PlaybackSettings.from(application) constructor(
private val playbackManager = PlaybackStateManager.getInstance() private val playbackManager: PlaybackStateManager,
private val musicStore = MusicStore.getInstance() 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 var lastPositionJob: Job? = null
private val _song = MutableStateFlow<Song?>(null) private val _song = MutableStateFlow<Song?>(null)
@ -277,7 +283,7 @@ class PlaybackViewModel(application: Application) :
check(song == null || parent == null || parent.songs.contains(song)) { check(song == null || parent == null || parent.songs.contains(song)) {
"Song to play not in parent" "Song to play not in parent"
} }
val library = musicStore.library ?: return val library = musicRepository.library ?: return
val sort = val sort =
when (parent) { when (parent) {
is Genre -> musicSettings.genreSongSort is Genre -> musicSettings.genreSongSort
@ -428,8 +434,7 @@ class PlaybackViewModel(application: Application) :
*/ */
fun savePlaybackState(onDone: (Boolean) -> Unit) { fun savePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val saved = playbackManager.saveState(PlaybackStateDatabase.getInstance(context)) onDone(persistenceRepository.saveState(playbackManager.toSavedState()))
onDone(saved)
} }
} }
@ -438,10 +443,7 @@ class PlaybackViewModel(application: Application) :
* @param onDone Called when the wipe is completed with true if successful, and false otherwise. * @param onDone Called when the wipe is completed with true if successful, and false otherwise.
*/ */
fun wipePlaybackState(onDone: (Boolean) -> Unit) { fun wipePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch { onDone(persistenceRepository.saveState(null)) }
val wiped = playbackManager.wipeState(PlaybackStateDatabase.getInstance(context))
onDone(wiped)
}
} }
/** /**
@ -451,9 +453,16 @@ class PlaybackViewModel(application: Application) :
*/ */
fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) { fun tryRestorePlaybackState(onDone: (Boolean) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val restored = val library = musicRepository.library
playbackManager.restoreState(PlaybackStateDatabase.getInstance(context), true) if (library != null) {
onDone(restored) val savedState = persistenceRepository.readState(library)
if (savedState != null) {
playbackManager.applySavedState(savedState, true)
onDone(true)
return@launch
}
}
onDone(false)
} }
} }

View file

@ -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"
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.oxycblt.auxio.playback.state package org.oxycblt.auxio.playback.queue
import kotlin.random.Random import kotlin.random.Random
import kotlin.random.nextInt import kotlin.random.nextInt
@ -36,30 +36,82 @@ import org.oxycblt.auxio.music.Song
* *
* @author OxygenCobalt * @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 heap = mutableListOf<Song>()
@Volatile private var orderedMapping = mutableListOf<Int>() @Volatile private var orderedMapping = mutableListOf<Int>()
@Volatile private var shuffledMapping = mutableListOf<Int>() @Volatile private var shuffledMapping = mutableListOf<Int>()
/** The index of the currently playing [Song] in the current mapping. */
@Volatile @Volatile
var index = -1 override var index = -1
private set private set
/** The currently playing [Song]. */ override val currentSong: Song?
val currentSong: Song?
get() = get() =
shuffledMapping shuffledMapping
.ifEmpty { orderedMapping.ifEmpty { null } } .ifEmpty { orderedMapping.ifEmpty { null } }
?.getOrNull(index) ?.getOrNull(index)
?.let(heap::get) ?.let(heap::get)
/** Whether this queue is shuffled. */ override val isShuffled: Boolean
val isShuffled: Boolean
get() = shuffledMapping.isNotEmpty() get() = shuffledMapping.isNotEmpty()
/** override fun resolve() =
* Resolve this queue into a more conventional list of [Song]s.
* @return A list of [Song] corresponding to the current queue mapping.
*/
fun resolve() =
if (currentSong != null) { if (currentSong != null) {
shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } }
} else { } else {
@ -134,14 +186,15 @@ class Queue {
/** /**
* Add [Song]s to the top of the queue. Will start playback if nothing is playing. * Add [Song]s to the top of the queue. Will start playback if nothing is playing.
* @param songs The [Song]s to add. * @param songs The [Song]s to add.
* @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there * @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or
* was no prior playback and these enqueued [Song]s start new playback. * [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()) { if (orderedMapping.isEmpty()) {
// No playback, start playing these songs. // No playback, start playing these songs.
start(songs[0], songs, false) start(songs[0], songs, false)
return ChangeResult.SONG return Queue.ChangeResult.SONG
} }
val heapIndices = songs.map(::addSongToHeap) val heapIndices = songs.map(::addSongToHeap)
@ -156,20 +209,21 @@ class Queue {
orderedMapping.addAll(index + 1, heapIndices) orderedMapping.addAll(index + 1, heapIndices)
} }
check() check()
return ChangeResult.MAPPING return Queue.ChangeResult.MAPPING
} }
/** /**
* Add [Song]s to the end of the queue. Will start playback if nothing is playing. * Add [Song]s to the end of the queue. Will start playback if nothing is playing.
* @param songs The [Song]s to add. * @param songs The [Song]s to add.
* @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there * @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or
* was no prior playback and these enqueued [Song]s start new playback. * [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()) { if (orderedMapping.isEmpty()) {
// No playback, start playing these songs. // No playback, start playing these songs.
start(songs[0], songs, false) start(songs[0], songs, false)
return ChangeResult.SONG return Queue.ChangeResult.SONG
} }
val heapIndices = songs.map(::addSongToHeap) val heapIndices = songs.map(::addSongToHeap)
@ -179,18 +233,18 @@ class Queue {
shuffledMapping.addAll(heapIndices) shuffledMapping.addAll(heapIndices)
} }
check() check()
return ChangeResult.MAPPING return Queue.ChangeResult.MAPPING
} }
/** /**
* Move a [Song] at the given position to a new position. * Move a [Song] at the given position to a new position.
* @param src The position of the [Song] to move. * @param src The position of the [Song] to move.
* @param dst The destination position of the [Song]. * @param dst The destination position of the [Song].
* @return [ChangeResult.MAPPING] if the move occurred after the current index, * @return [Queue.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 * [Queue.ChangeResult.INDEX] if the move occurred before or at the current index, requiring it
* mutated. * to be mutated.
*/ */
fun move(src: Int, dst: Int): ChangeResult { fun move(src: Int, dst: Int): Queue.ChangeResult {
if (shuffledMapping.isNotEmpty()) { if (shuffledMapping.isNotEmpty()) {
// Move songs only in the shuffled mapping. There is no sane analogous form of // Move songs only in the shuffled mapping. There is no sane analogous form of
// this for the ordered mapping. // this for the ordered mapping.
@ -210,21 +264,21 @@ class Queue {
else -> { else -> {
// Nothing to do. // Nothing to do.
check() check()
return ChangeResult.MAPPING return Queue.ChangeResult.MAPPING
} }
} }
check() check()
return ChangeResult.INDEX return Queue.ChangeResult.INDEX
} }
/** /**
* Remove a [Song] at the given position. * Remove a [Song] at the given position.
* @param at The position of the [Song] to remove. * @param at The position of the [Song] to remove.
* @return [ChangeResult.MAPPING] if the removed [Song] was after the current index, * @return [Queue.ChangeResult.MAPPING] if the removed [Song] was after the current index,
* [ChangeResult.INDEX] if the removed [Song] was before the current index, and * [Queue.ChangeResult.INDEX] if the removed [Song] was before the current index, and
* [ChangeResult.SONG] if the currently playing [Song] was removed. * [Queue.ChangeResult.SONG] if the currently playing [Song] was removed.
*/ */
fun remove(at: Int): ChangeResult { fun remove(at: Int): Queue.ChangeResult {
if (shuffledMapping.isNotEmpty()) { if (shuffledMapping.isNotEmpty()) {
// Remove the specified index in the shuffled mapping and the analogous song in the // Remove the specified index in the shuffled mapping and the analogous song in the
// ordered mapping. // ordered mapping.
@ -242,34 +296,34 @@ class Queue {
val result = val result =
when { when {
// We just removed the currently playing song. // 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 was ahead of removed song, shift back to preserve consistency.
index > at -> { index > at -> {
index -= 1 index -= 1
ChangeResult.INDEX Queue.ChangeResult.INDEX
} }
// Nothing to do // Nothing to do
else -> ChangeResult.MAPPING else -> Queue.ChangeResult.MAPPING
} }
check() check()
return result return result
} }
/** /**
* Convert the current state of this instance into a [SavedState]. * Convert the current state of this instance into a [Queue.SavedState].
* @return A new [SavedState] reflecting the exact state of the queue when called. * @return A new [Queue.SavedState] reflecting the exact state of the queue when called.
*/ */
fun toSavedState() = fun toSavedState() =
currentSong?.let { song -> currentSong?.let { song ->
SavedState( Queue.SavedState(
heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid) heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid)
} }
/** /**
* Update this instance from the given [SavedState]. * Update this instance from the given [Queue.SavedState].
* @param savedState A [SavedState] with a valid queue representation. * @param savedState A [Queue.SavedState] with a valid queue representation.
*/ */
fun applySavedState(savedState: SavedState) { fun applySavedState(savedState: Queue.SavedState) {
val adjustments = mutableListOf<Int?>() val adjustments = mutableListOf<Int?>()
var currentShift = 0 var currentShift = 0
for (song in savedState.heap) { for (song in savedState.heap) {
@ -345,49 +399,4 @@ class Queue {
"Queue inconsistency detected: Shuffled mapping indices out of heap bounds" "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
}
} }

View file

@ -33,6 +33,7 @@ import org.oxycblt.auxio.list.adapter.ListDiffer
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
/** /**
@ -149,7 +150,7 @@ class QueueSongViewHolder private constructor(private val binding: ItemQueueSong
listener.bind(song, this, bodyView, binding.songDragHandle) listener.bind(song, this, bodyView, binding.songDragHandle)
binding.songAlbumCover.bind(song) binding.songAlbumCover.bind(song)
binding.songName.text = song.resolveName(binding.context) 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 swiping this ViewHolder if it's being re-bound, ensure that the background is
// not visible. See QueueDragCallback for why this is done. // not visible. See QueueDragCallback for why this is done.
binding.background.isInvisible = true binding.background.isInvisible = true

View file

@ -24,6 +24,7 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentQueueBinding import org.oxycblt.auxio.databinding.FragmentQueueBinding
import org.oxycblt.auxio.list.EditableListListener 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.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
/** /**
* A [ViewBindingFragment] that displays an editable queue. * A [ViewBindingFragment] that displays an editable queue.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> { class QueueFragment : ViewBindingFragment<FragmentQueueBinding>(), EditableListListener<Song> {
private val queueModel: QueueViewModel by activityViewModels() private val queueModel: QueueViewModel by activityViewModels()
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val queueAdapter = QueueAdapter(this) private val queueAdapter = QueueAdapter(this)
private var touchHelper: ItemTouchHelper? = null private var touchHelper: ItemTouchHelper? = null

View file

@ -18,21 +18,23 @@
package org.oxycblt.auxio.playback.queue package org.oxycblt.auxio.playback.queue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.BasicListInstructions
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.state.PlaybackStateManager 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. * A [ViewModel] that manages the current queue state and allows navigation through the queue.
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class QueueViewModel : ViewModel(), PlaybackStateManager.Listener { @HiltViewModel
private val playbackManager = PlaybackStateManager.getInstance() class QueueViewModel @Inject constructor(private val playbackManager: PlaybackStateManager) :
ViewModel(), PlaybackStateManager.Listener {
private val _queue = MutableStateFlow(listOf<Song>()) private val _queue = MutableStateFlow(listOf<Song>())
/** The current queue. */ /** The current queue. */

View file

@ -21,6 +21,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPreAmpBinding 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]. * aa [ViewBindingDialogFragment] that allows user configuration of the current [ReplayGainPreAmp].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() { class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
@Inject lateinit var playbackSettings: PlaybackSettings
override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = DialogPreAmpBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) { override fun onConfigDialog(builder: AlertDialog.Builder) {
@ -39,11 +44,11 @@ class PreAmpCustomizeDialog : ViewBindingDialogFragment<DialogPreAmpBinding>() {
.setTitle(R.string.set_pre_amp) .setTitle(R.string.set_pre_amp)
.setPositiveButton(R.string.lbl_ok) { _, _ -> .setPositiveButton(R.string.lbl_ok) { _, _ ->
val binding = requireBinding() val binding = requireBinding()
PlaybackSettings.from(requireContext()).replayGainPreAmp = playbackSettings.replayGainPreAmp =
ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value) ReplayGainPreAmp(binding.withTagsSlider.value, binding.withoutTagsSlider.value)
} }
.setNeutralButton(R.string.lbl_reset) { _, _ -> .setNeutralButton(R.string.lbl_reset) { _, _ ->
PlaybackSettings.from(requireContext()).replayGainPreAmp = ReplayGainPreAmp(0f, 0f) playbackSettings.replayGainPreAmp = ReplayGainPreAmp(0f, 0f)
} }
.setNegativeButton(R.string.lbl_cancel, null) .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 // 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 // settings. After this, the sliders save their own state, so we do not need to
// do any restore behavior. // do any restore behavior.
val preAmp = PlaybackSettings.from(requireContext()).replayGainPreAmp val preAmp = playbackSettings.replayGainPreAmp
binding.withTagsSlider.value = preAmp.with binding.withTagsSlider.value = preAmp.with
binding.withoutTagsSlider.value = preAmp.without binding.withoutTagsSlider.value = preAmp.without
} }

View file

@ -17,7 +17,6 @@
package org.oxycblt.auxio.playback.replaygain package org.oxycblt.auxio.playback.replaygain
import android.content.Context
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.Format import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.Player 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.audio.BaseAudioProcessor
import com.google.android.exoplayer2.util.MimeTypes import com.google.android.exoplayer2.util.MimeTypes
import java.nio.ByteBuffer import java.nio.ByteBuffer
import javax.inject.Inject
import kotlin.math.pow import kotlin.math.pow
import org.oxycblt.auxio.music.Album 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.PlaybackSettings
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
@ -43,10 +43,12 @@ import org.oxycblt.auxio.util.logD
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class ReplayGainAudioProcessor(context: Context) : class ReplayGainAudioProcessor
BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener { @Inject
private val playbackManager = PlaybackStateManager.getInstance() constructor(
private val playbackSettings = PlaybackSettings.from(context) private val playbackManager: PlaybackStateManager,
private val playbackSettings: PlaybackSettings
) : BaseAudioProcessor(), Player.Listener, PlaybackSettings.Listener {
private var lastFormat: Format? = null private var lastFormat: Format? = null
private var volume = 1f private var volume = 1f

View file

@ -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
}
}
}
}

View file

@ -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 * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -17,17 +17,14 @@
package org.oxycblt.auxio.playback.state package org.oxycblt.auxio.playback.state
import kotlinx.coroutines.Dispatchers import javax.inject.Inject
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.playback.queue.EditableQueue
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
* Core playback state controller class. * 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. * MediaSession is poorly designed. This class instead ful-fills this role.
* *
* This should ***NOT*** be used outside of the playback module. * This should ***NOT*** be used outside of the playback module.
* - If you want to use the playback state in the UI, use * - If you want to use the playback state in the UI, use PlaybackViewModel as it can withstand
* [org.oxycblt.auxio.playback.PlaybackViewModel] as it can withstand volatile UIs. * volatile UIs.
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use * - 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 * Internal consumers should usually use [Listener], however the component that manages the player
* itself should instead use [InternalPlayer]. * itself should instead use [InternalPlayer].
* *
* All access should be done with [PlaybackStateManager.getInstance].
*
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class PlaybackStateManager private constructor() { interface PlaybackStateManager {
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
/** The current [Queue]. */ /** The current [Queue]. */
val queue = Queue() val queue: Queue
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */ /** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
@Volatile val parent: MusicParent?
var parent: MusicParent? = null // FIXME: Parent is interpreted wrong when nothing is playing.
private set
/** The current [InternalPlayer] state. */ /** The current [InternalPlayer] state. */
@Volatile val playerState: InternalPlayer.State
var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
private set
/** The current [RepeatMode] */ /** The current [RepeatMode] */
@Volatile var repeatMode: RepeatMode
var repeatMode = RepeatMode.NONE /** The audio session ID of the internal player. Null if no internal player exists. */
set(value) {
field = value
notifyRepeatModeChanged()
}
/**
* The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable.
*/
val currentAudioSessionId: Int? val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId
/** /**
* Add a [Listener] to this instance. This can be used to receive changes in the playback state. * 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. * @param listener The [Listener] to add.
* @see Listener * @see Listener
*/ */
@Synchronized fun addListener(listener: Listener)
fun addListener(listener: Listener) {
if (isInitialized) {
listener.onNewPlayback(queue, parent)
listener.onRepeatChanged(repeatMode)
listener.onStateChanged(playerState)
}
listeners.add(listener)
}
/** /**
* Remove a [Listener] from this instance, preventing it from receiving any further updates. * Remove a [Listener] from this instance, preventing it from receiving any further updates.
@ -102,10 +69,7 @@ class PlaybackStateManager private constructor() {
* the first place. * the first place.
* @see Listener * @see Listener
*/ */
@Synchronized fun removeListener(listener: Listener)
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
/** /**
* Register an [InternalPlayer] for this instance. This instance will handle translating the * 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 * @param internalPlayer The [InternalPlayer] to register. Will do nothing if already
* registered. * registered.
*/ */
@Synchronized fun registerInternalPlayer(internalPlayer: InternalPlayer)
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
}
/** /**
* 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. * commands.
* @param internalPlayer The [InternalPlayer] to unregister. Must be the current * @param internalPlayer The [InternalPlayer] to unregister. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/ */
@Synchronized fun unregisterInternalPlayer(internalPlayer: InternalPlayer)
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
if (this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
this.internalPlayer = null
}
// --- PLAYING FUNCTIONS ---
/** /**
* Start new playback. * Start new playback.
@ -159,190 +96,81 @@ class PlaybackStateManager private constructor() {
* collection of "All [Song]s". * collection of "All [Song]s".
* @param shuffled Whether to shuffle or not. * @param shuffled Whether to shuffle or not.
*/ */
@Synchronized fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean)
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 ---
/** /**
* Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there is no * 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. * [Song] ahead to skip to.
*/ */
@Synchronized fun next()
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)
}
/** /**
* Go to the previous [Song] in the queue. Will rewind if there are no previous [Song]s to skip * 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. * to, or if configured to do so.
*/ */
@Synchronized fun prev()
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)
}
}
/** /**
* Play a [Song] at the given position in the queue. * Play a [Song] at the given position in the queue.
* @param index The position of the [Song] in the queue to start playing. * @param index The position of the [Song] in the queue to start playing.
*/ */
@Synchronized fun goto(index: Int)
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))
/** /**
* Add [Song]s to the top of the queue. * Add [Song]s to the top of the queue.
* @param songs The [Song]s to add. * @param songs The [Song]s to add.
*/ */
@Synchronized fun playNext(songs: List<Song>)
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")
}
}
/** /**
* Add a [Song] to the end of the queue. * Add a [Song] to the top of the queue.
* @param song The [Song] to add. * @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. * Add [Song]s to the end of the queue.
* @param songs The [Song]s to add. * @param songs The [Song]s to add.
*/ */
@Synchronized fun addToQueue(songs: List<Song>)
fun addToQueue(songs: List<Song>) {
val internalPlayer = internalPlayer ?: return /**
when (queue.addToQueue(songs)) { * Add a [Song] to the end of the queue.
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING) * @param song The [Song] to add.
Queue.ChangeResult.SONG -> { */
// Enqueueing actually started a new playback session from all songs. fun addToQueue(song: Song) = addToQueue(listOf(song))
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
}
}
/** /**
* Move a [Song] in the queue. * Move a [Song] in the queue.
* @param src The position of the [Song] to move in the queue. * @param src The position of the [Song] to move in the queue.
* @param dst The destination position in the queue. * @param dst The destination position in the queue.
*/ */
@Synchronized fun moveQueueItem(src: Int, dst: Int)
fun moveQueueItem(src: Int, dst: Int) {
logD("Moving item $src to position $dst")
notifyQueueChanged(queue.move(src, dst))
}
/** /**
* Remove a [Song] from the queue. * Remove a [Song] from the queue.
* @param at The position of the [Song] to remove in the queue. * @param at The position of the [Song] to remove in the queue.
*/ */
@Synchronized fun removeQueueItem(at: Int)
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)
}
/** /**
* (Re)shuffle or (Re)order this instance. * (Re)shuffle or (Re)order this instance.
* @param shuffled Whether to shuffle the queue or not. * @param shuffled Whether to shuffle the queue or not.
*/ */
@Synchronized fun reorder(shuffled: Boolean)
fun reorder(shuffled: Boolean) {
queue.reorder(shuffled)
notifyQueueReordered()
}
// --- INTERNAL PLAYER FUNCTIONS ---
/** /**
* Synchronize the state of this instance with the current [InternalPlayer]. * Synchronize the state of this instance with the current [InternalPlayer].
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/ */
@Synchronized fun synchronizeState(internalPlayer: InternalPlayer)
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()
}
}
/** /**
* Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually. * Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually.
* @param action The [InternalPlayer.Action] to perform. * @param action The [InternalPlayer.Action] to perform.
*/ */
@Synchronized fun startAction(action: InternalPlayer.Action)
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
}
}
/** /**
* Request that the pending [InternalPlayer.Action] (if any) be passed to the given * 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 * @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation. * [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/ */
@Synchronized fun requestAction(internalPlayer: InternalPlayer)
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
}
}
/** /**
* Update whether playback is ongoing or not. * Update whether playback is ongoing or not.
* @param isPlaying Whether playback is ongoing or not. * @param isPlaying Whether playback is ongoing or not.
*/ */
fun setPlaying(isPlaying: Boolean) { fun setPlaying(isPlaying: Boolean)
internalPlayer?.setPlaying(isPlaying)
}
/** /**
* Seek to the given position in the currently playing [Song]. * Seek to the given position in the currently playing [Song].
* @param positionMs The position to seek to, in milliseconds. * @param positionMs The position to seek to, in milliseconds.
*/ */
@Synchronized fun seekTo(positionMs: Long)
fun seekTo(positionMs: Long) {
internalPlayer?.seekTo(positionMs)
}
/** Rewind to the beginning of the currently playing [Song]. */ /** Rewind to the beginning of the currently playing [Song]. */
fun rewind() = seekTo(0) 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. * Restores this instance from the given [SavedState].
* @param database The [PlaybackStateDatabase] to load from. * @param savedState The [SavedState] to restore from.
* @param force Whether to do a restore regardless of any prior playback state. * @param destructive Whether to disregard the prior playback state and overwrite it with this
* @return If the state was restored, false otherwise. * [SavedState].
*/ */
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean { fun applySavedState(savedState: SavedState, destructive: 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)
}
}
/** /**
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to * The interface for receiving updates from [PlaybackStateManager]. Add the listener to
@ -604,25 +256,318 @@ class PlaybackStateManager private constructor() {
fun onRepeatChanged(repeatMode: RepeatMode) {} fun onRepeatChanged(repeatMode: RepeatMode) {}
} }
companion object { /**
@Volatile private var INSTANCE: PlaybackStateManager? = null * A condensed representation of the playback state that can be persisted.
* @param parent The [MusicParent] item currently being played from.
* @param queueState The [Queue.SavedState]
* @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode].
*/
data class SavedState(
val parent: MusicParent?,
val queueState: Queue.SavedState,
val positionMs: Long,
val repeatMode: RepeatMode,
)
}
/** class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
* Get a singleton instance. private val listeners = mutableListOf<PlaybackStateManager.Listener>()
* @return The (possibly newly-created) singleton instance. @Volatile private var internalPlayer: InternalPlayer? = null
*/ @Volatile private var pendingAction: InternalPlayer.Action? = null
fun getInstance(): PlaybackStateManager { @Volatile private var isInitialized = false
val currentInstance = INSTANCE
if (currentInstance != null) { override val queue = EditableQueue()
return currentInstance @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(this) { @Synchronized
val newInstance = PlaybackStateManager() override fun goto(index: Int) {
INSTANCE = newInstance val internalPlayer = internalPlayer ?: return
return newInstance 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)
} }
} }
} }

View file

@ -22,15 +22,19 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.playback.state.PlaybackStateManager
/** /**
* A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService]. * A [BroadcastReceiver] that forwards [Intent.ACTION_MEDIA_BUTTON] [Intent]s to [PlaybackService].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
@AndroidEntryPoint
class MediaButtonReceiver : BroadcastReceiver() { class MediaButtonReceiver : BroadcastReceiver() {
@Inject lateinit var playbackManager: PlaybackStateManager
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val playbackManager = PlaybackStateManager.getInstance()
if (playbackManager.queue.currentSong != null) { if (playbackManager.queue.currentSong != null) {
// We have a song, so we can assume that the service will start a foreground state. // 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 // At least, I hope. Again, *this is why we don't do this*. I cannot describe how

View file

@ -27,28 +27,36 @@ import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import androidx.media.session.MediaButtonReceiver import androidx.media.session.MediaButtonReceiver
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.BitmapProvider import org.oxycblt.auxio.image.BitmapProvider
import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.ActionMode
import org.oxycblt.auxio.playback.PlaybackSettings 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.InternalPlayer
import org.oxycblt.auxio.playback.state.PlaybackStateManager 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.playback.state.RepeatMode
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* A component that mirrors the current playback state into the [MediaSessionCompat] and * A component that mirrors the current playback state into the [MediaSessionCompat] and
* [NotificationComponent]. * [NotificationComponent].
* @param context [Context] required to initialize components.
* @param listener [Listener] to forward notification updates to.
* @author Alexander Capehart (OxygenCobalt) * @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(), MediaSessionCompat.Callback(),
PlaybackStateManager.Listener, PlaybackStateManager.Listener,
ImageSettings.Listener, ImageSettings.Listener,
@ -59,11 +67,9 @@ class MediaSessionComponent(private val context: Context, private val listener:
setQueueTitle(context.getString(R.string.lbl_queue)) 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 notification = NotificationComponent(context, mediaSession.sessionToken)
private val provider = BitmapProvider(context)
private var listener: Listener? = null
init { init {
playbackManager.addListener(this) playbackManager.addListener(this)
@ -79,12 +85,21 @@ class MediaSessionComponent(private val context: Context, private val listener:
MediaButtonReceiver.handleIntent(mediaSession, intent) 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 * Release this instance, closing the [MediaSessionCompat] and preventing any further updates to
* the [NotificationComponent]. * the [NotificationComponent].
*/ */
fun release() { fun release() {
provider.release() listener = null
bitmapProvider.release()
playbackSettings.unregisterListener(this) playbackSettings.unregisterListener(this)
playbackManager.removeListener(this) playbackManager.removeListener(this)
mediaSession.apply { mediaSession.apply {
@ -134,8 +149,8 @@ class MediaSessionComponent(private val context: Context, private val listener:
override fun onStateChanged(state: InternalPlayer.State) { override fun onStateChanged(state: InternalPlayer.State) {
invalidateSessionState() invalidateSessionState()
notification.updatePlaying(playbackManager.playerState.isPlaying) notification.updatePlaying(playbackManager.playerState.isPlaying)
if (!provider.isBusy) { if (!bitmapProvider.isBusy) {
listener.onPostNotification(notification) 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 // Populate MediaMetadataCompat. For efficiency, cache some fields that are re-used
// several times. // several times.
val title = song.resolveName(context) val title = song.resolveName(context)
val artist = song.resolveArtistContents(context) val artist = song.artists.resolveNames(context)
val builder = val builder =
MediaMetadataCompat.Builder() MediaMetadataCompat.Builder()
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, title) .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_ARTIST, artist)
.putText( .putText(
MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST,
song.album.resolveArtistContents(context)) song.album.artists.resolveNames(context))
.putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist) .putText(MediaMetadataCompat.METADATA_KEY_AUTHOR, artist)
.putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist) .putText(MediaMetadataCompat.METADATA_KEY_COMPOSER, artist)
.putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist) .putText(MediaMetadataCompat.METADATA_KEY_WRITER, artist)
.putText( .putText(
METADATA_KEY_PARENT, METADATA_KEY_PARENT,
parent?.resolveName(context) ?: context.getString(R.string.lbl_all_songs)) 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_TITLE, title)
.putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist) .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, artist)
.putText( .putText(
@ -300,14 +315,14 @@ class MediaSessionComponent(private val context: Context, private val listener:
builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong()) builder.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, it.toLong())
} }
song.disc?.let { 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()) } 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 // 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, // 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. // we load a full-size bitmap into the media session and take the performance hit.
provider.load( bitmapProvider.load(
song, song,
object : BitmapProvider.Target { object : BitmapProvider.Target {
override fun onCompleted(bitmap: Bitmap?) { override fun onCompleted(bitmap: Bitmap?) {
@ -316,7 +331,7 @@ class MediaSessionComponent(private val context: Context, private val listener:
val metadata = builder.build() val metadata = builder.build()
mediaSession.setMetadata(metadata) mediaSession.setMetadata(metadata)
notification.updateMetadata(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. // as it's used to request a song to be played from the queue.
.setMediaId(song.uid.toString()) .setMediaId(song.uid.toString())
.setTitle(song.resolveName(context)) .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 // Since we usually have to load many songs into the queue, use the
// MediaStore URI instead of loading a bitmap. // MediaStore URI instead of loading a bitmap.
.setIconUri(song.album.coverUri) .setIconUri(song.album.coverUri)
@ -402,8 +417,8 @@ class MediaSessionComponent(private val context: Context, private val listener:
else -> notification.updateRepeatMode(playbackManager.repeatMode) else -> notification.updateRepeatMode(playbackManager.repeatMode)
} }
if (!provider.isBusy) { if (!bitmapProvider.isBusy) {
listener.onPostNotification(notification) listener?.onPostNotification(notification)
} }
} }

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