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
steps:
- uses: actions/checkout@v3
- name: Clone repository
uses: actions/checkout@v3
- name: Clone submodules
run: git submodule update --init --recursive
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
cache: gradle
- name: Set up NDK r21e
uses: nttld/setup-ndk@v1.2.0
id: setup-ndk
with:
ndk-version: r21e
add-to-path: false
- run: python3 prebuild.py
env:
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Test app with Gradle

2
.gitignore vendored
View file

@ -3,8 +3,6 @@
local.properties
build/
release/
srclibs/
libs/
# Studio
.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
## 3.0.3
#### What's New
- Added support for disc subtitles
- Added support for ALAC files
- Song properties view now shows tags
- Added option to control whether articles like "the" are ignored when sorting
#### What's Improved
- Will now accept zeroed track/disc numbers in the presence of non-zero total
track/disc fields
- Music loading has been made slightly faster
- Improved sort menu usability
- Fall back to `TXXX:RELEASETYPE` on ID3v2 files
- Switches and checkboxes have been mildly visually refreshed
#### What's Fixed
- Fixed non-functioning "repeat all" repeat mode
- Fixed visual clipping of shuffle button shadow
- Fixed SeekBar remaining in a "stuck" state if gesture navigation was used
while selecting it.
#### Dev/Meta
- Started using dependency injection
- Started code obsfucation
- Only bundle audio-related extractors with ExoPlayer
- Switched to Room for database management
- Updated to MDC 1.8.0 alpha-01
- Updated to AGP 7.4.1
- Updated to Gradle 8.0
- Updated to ExoPlayer 2.18.3
## 3.0.2
#### What's New

1
ExoPlayer Submodule

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

View file

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

View file

@ -4,16 +4,24 @@ plugins {
id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize"
id "dagger.hilt.android.plugin"
id "kotlin-kapt"
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 33
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
// it here so that binary stripping will work.
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
// NDK use is unified
ndkVersion = "23.2.8568313"
namespace "org.oxycblt.auxio"
defaultConfig {
applicationId namespace
versionName "3.0.2"
versionCode 26
versionName "3.0.3"
versionCode 27
minSdk 21
targetSdk 33
@ -21,14 +29,13 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
// ExoPlayer, AndroidX, and Material Components all need Java 8 to compile.
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "1.8"
jvmTarget = "11"
freeCompilerArgs += "-Xjvm-default=all"
}
@ -42,17 +49,17 @@ android {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
}
buildFeatures {
viewBinding true
}
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
}
dependencies {
@ -66,7 +73,7 @@ dependencies {
// General
// 1.4.0 is used in order to avoid a ripple bug in material components
implementation "androidx.appcompat:appcompat:1.4.0"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.core:core-ktx:1.9.0"
implementation "androidx.activity:activity-ktx:1.6.1"
implementation "androidx.fragment:fragment-ktx:1.5.5"
@ -75,6 +82,7 @@ dependencies {
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
implementation 'androidx.core:core-ktx:+'
// Lifecycle
def lifecycle_version = "2.5.1"
@ -93,30 +101,38 @@ dependencies {
// Preferences
implementation "androidx.preference:preference-ktx:1.2.0"
// Database
def room_version = '2.5.0'
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// --- THIRD PARTY ---
// Exoplayer
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE PRE-BUILD SCRIPT.
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
implementation("com.google.android.exoplayer:exoplayer-core:2.18.2") {
exclude group: "com.google.android.exoplayer", module: "exoplayer-extractor"
}
implementation fileTree(dir: "libs", include: ["library-*.aar"])
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
// Exoplayer (Vendored)
implementation project(":exoplayer-library-core")
implementation project(":exoplayer-extension-ffmpeg")
// Image loading
implementation "io.coil-kt:coil:2.1.0"
implementation 'io.coil-kt:coil-base:2.2.2'
// Material
// Locked below 1.7.0-alpha03 to avoid the same ripple bug
implementation "com.google.android.material:material:1.7.0-alpha02"
// TODO: Stuck on 1.8.0-alpha01 until ripple bug with tab layout can be worked around
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
// PR a fix.
implementation "com.google.android.material:material:1.8.0-alpha01"
// Development
debugImplementation "com.squareup.leakcanary:leakcanary-android:2.9.1"
// Dependency Injection
def dagger_version = '2.45'
implementation "com.google.dagger:dagger:$dagger_version"
kapt "com.google.dagger:dagger-compiler:$dagger_version"
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
// Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation "junit:junit:4.13.2"
androidTestImplementation 'androidx.test.ext:junit:1.1.4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
spotless {

View file

@ -22,15 +22,9 @@ import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.request.CachePolicy
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.image.extractor.AlbumCoverFetcher
import org.oxycblt.auxio.image.extractor.ArtistImageFetcher
import org.oxycblt.auxio.image.extractor.ErrorCrossfadeTransitionFactory
import org.oxycblt.auxio.image.extractor.GenreImageFetcher
import org.oxycblt.auxio.image.extractor.MusicKeyer
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
@ -38,16 +32,22 @@ import org.oxycblt.auxio.ui.UISettings
* A simple, rational music player for android.
* @author Alexander Capehart (OxygenCobalt)
*/
class Auxio : Application(), ImageLoaderFactory {
@HiltAndroidApp
class Auxio : Application() {
@Inject lateinit var imageSettings: ImageSettings
@Inject lateinit var playbackSettings: PlaybackSettings
@Inject lateinit var uiSettings: UISettings
override fun onCreate() {
super.onCreate()
// Migrate any settings that may have changed in an app update.
ImageSettings.from(this).migrate()
PlaybackSettings.from(this).migrate()
UISettings.from(this).migrate()
imageSettings.migrate()
playbackSettings.migrate()
uiSettings.migrate()
// Adding static shortcuts in a dynamic manner is better than declaring them
// manually, as it will properly handle the difference between debug and release
// Auxio instances.
// TODO: Switch to static shortcuts
ShortcutManagerCompat.addDynamicShortcuts(
this,
listOf(
@ -61,22 +61,6 @@ class Auxio : Application(), ImageLoaderFactory {
.build()))
}
override fun newImageLoader() =
ImageLoader.Builder(applicationContext)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(MusicKeyer())
add(AlbumCoverFetcher.SongFactory())
add(AlbumCoverFetcher.AlbumFactory())
add(ArtistImageFetcher.Factory())
add(GenreImageFetcher.Factory())
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())
// Not downloading anything, so no disk-caching
.diskCachePolicy(CachePolicy.DISABLED)
.build()
companion object {
/** The [Intent] name for the "Shuffle All" shortcut. */
const val INTENT_KEY_SHORTCUT_SHUFFLE = BuildConfig.APPLICATION_ID + ".action.SHUFFLE_ALL"

View file

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

View file

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

View file

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

View file

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

View file

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

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
import android.app.Application
import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@ -30,30 +29,32 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.detail.recycler.SortHeader
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.music.storage.MimeType
import org.oxycblt.auxio.music.tags.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioInfo
import org.oxycblt.auxio.music.metadata.Disc
import org.oxycblt.auxio.music.metadata.ReleaseType
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.*
/**
* [AndroidViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of
* the current item they are showing, sub-data to display, and configuration. Since this ViewModel
* requires a context, it must be instantiated [AndroidViewModel]'s Factory.
* @param application [Application] context required to initialize certain information.
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
* current item they are showing, sub-data to display, and configuration.
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailViewModel(application: Application) :
AndroidViewModel(application), MusicStore.Listener {
private val musicStore = MusicStore.getInstance()
private val musicSettings = MusicSettings.from(application)
private val playbackSettings = PlaybackSettings.from(application)
@HiltViewModel
class DetailViewModel
@Inject
constructor(
private val musicRepository: MusicRepository,
private val audioInfoProvider: AudioInfo.Provider,
private val musicSettings: MusicSettings,
private val playbackSettings: PlaybackSettings
) : ViewModel(), MusicRepository.Listener {
private var currentSongJob: Job? = null
// --- SONG ---
@ -63,9 +64,9 @@ class DetailViewModel(application: Application) :
val currentSong: StateFlow<Song?>
get() = _currentSong
private val _songProperties = MutableStateFlow<SongProperties?>(null)
/** The [SongProperties] of the currently shown [Song]. Null if not loaded yet. */
val songProperties: StateFlow<SongProperties?> = _songProperties
private val _songAudioInfo = MutableStateFlow<AudioInfo?>(null)
/** The [AudioInfo] of the currently shown [Song]. Null if not loaded yet. */
val songAudioInfo: StateFlow<AudioInfo?> = _songAudioInfo
// --- ALBUM ---
@ -136,11 +137,11 @@ class DetailViewModel(application: Application) :
get() = playbackSettings.inParentPlaybackMode
init {
musicStore.addListener(this)
musicRepository.addListener(this)
}
override fun onCleared() {
musicStore.removeListener(this)
musicRepository.removeListener(this)
}
override fun onLibraryChanged(library: Library?) {
@ -155,7 +156,7 @@ class DetailViewModel(application: Application) :
val song = currentSong.value
if (song != null) {
_currentSong.value = library.sanitize(song)?.also(::loadProperties)
_currentSong.value = library.sanitize(song)?.also(::refreshAudioInfo)
logD("Updated song to ${currentSong.value}")
}
@ -180,7 +181,7 @@ class DetailViewModel(application: Application) :
/**
* Set a new [currentSong] from it's [Music.UID]. If the [Music.UID] differs, [currentSong] and
* [songProperties] will be updated to align with the new [Song].
* [songAudioInfo] will be updated to align with the new [Song].
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSongUid(uid: Music.UID) {
@ -189,7 +190,7 @@ class DetailViewModel(application: Application) :
return
}
logD("Opening Song [uid: $uid]")
_currentSong.value = requireMusic<Song>(uid)?.also(::loadProperties)
_currentSong.value = requireMusic<Song>(uid)?.also(::refreshAudioInfo)
}
/**
@ -234,86 +235,24 @@ class DetailViewModel(application: Application) :
_currentGenre.value = requireMusic<Genre>(uid)?.also(::refreshGenreList)
}
private fun <T : Music> requireMusic(uid: Music.UID) = musicStore.library?.find<T>(uid)
private fun <T : Music> requireMusic(uid: Music.UID) = musicRepository.library?.find<T>(uid)
/**
* Start a new job to load a given [Song]'s [SongProperties]. Result is pushed to
* [songProperties].
* Start a new job to load a given [Song]'s [AudioInfo]. Result is pushed to [songAudioInfo].
* @param song The song to load.
*/
private fun loadProperties(song: Song) {
private fun refreshAudioInfo(song: Song) {
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_songProperties.value = null
_songAudioInfo.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val properties = this@DetailViewModel.loadPropertiesImpl(song)
val info = audioInfoProvider.extract(song)
yield()
_songProperties.value = properties
_songAudioInfo.value = info
}
}
private fun loadPropertiesImpl(song: Song): SongProperties {
// While we would use ExoPlayer to extract this information, it doesn't support
// common data like bit rate in progressive data sources due to there being no
// demand. Thus, we are stuck with the inferior OS-provided MediaExtractor.
val extractor = MediaExtractor()
try {
extractor.setDataSource(context, song.uri, emptyMap())
} catch (e: Exception) {
// Can feasibly fail with invalid file formats. Note that this isn't considered
// an error condition in the UI, as there is still plenty of other song information
// that we can show.
logW("Unable to extract song attributes.")
logW(e.stackTraceToString())
return SongProperties(null, null, song.mimeType)
}
// Get the first track from the extractor (This is basically always the only
// track we need to analyze).
val format = extractor.getTrackFormat(0)
// Accessing fields can throw an exception if the fields are not present, and
// the new method for using default values is not available on lower API levels.
// So, we are forced to handle the exception and map it to a saner null value.
val bitrate =
try {
// Convert bytes-per-second to kilobytes-per-second.
format.getInteger(MediaFormat.KEY_BIT_RATE) / 1000
} catch (e: NullPointerException) {
logD("Unable to extract bit rate field")
null
}
val sampleRate =
try {
format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
} catch (e: NullPointerException) {
logE("Unable to extract sample rate field")
null
}
val resolvedMimeType =
if (song.mimeType.fromFormat != null) {
// ExoPlayer was already able to populate the format.
song.mimeType
} else {
// ExoPlayer couldn't populate the format somehow, populate it here.
val formatMimeType =
try {
format.getString(MediaFormat.KEY_MIME)
} catch (e: NullPointerException) {
logE("Unable to extract mime type field")
null
}
MimeType(song.mimeType.fromExtension, formatMimeType)
}
return SongProperties(bitrate, sampleRate, resolvedMimeType)
}
private fun refreshAlbumList(album: Album) {
logD("Refreshing album data")
val data = mutableListOf<Item>(album)
@ -323,11 +262,11 @@ class DetailViewModel(application: Application) :
// songs up by disc and then delimit the groups by a disc header.
val songs = albumSongSort.songs(album.songs)
// Songs without disc tags become part of Disc 1.
val byDisc = songs.groupBy { it.disc ?: 1 }
val byDisc = songs.groupBy { it.disc ?: Disc(1, null) }
if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) {
data.add(DiscHeader(entry.key))
data.add(entry.key)
data.addAll(entry.value)
}
} else {
@ -341,7 +280,7 @@ class DetailViewModel(application: Application) :
private fun refreshArtistList(artist: Artist) {
logD("Refreshing artist data")
val data = mutableListOf<Item>(artist)
val albums = Sort(Sort.Mode.ByDate, false).albums(artist.albums)
val albums = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING).albums(artist.albums)
val byReleaseGroup =
albums.groupBy {
@ -367,7 +306,7 @@ class DetailViewModel(application: Application) :
logD("Release groups for this artist: ${byReleaseGroup.keys}")
for (entry in byReleaseGroup.entries.sortedBy { it.key }) {
data.add(Header(entry.key.headerTitleRes))
data.add(BasicHeader(entry.key.headerTitleRes))
data.addAll(entry.value)
}
@ -385,7 +324,7 @@ class DetailViewModel(application: Application) :
logD("Refreshing genre data")
val data = mutableListOf<Item>(genre)
// Genre is guaranteed to always have artists and songs.
data.add(Header(R.string.lbl_artists))
data.add(BasicHeader(R.string.lbl_artists))
data.addAll(genre.artists)
data.add(SortHeader(R.string.lbl_songs))
data.addAll(genreSongSort.songs(genre.songs))

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,10 +20,12 @@ package org.oxycblt.auxio.image
import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Size
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.image.extractor.SquareFrameTransform
import org.oxycblt.auxio.music.Song
@ -38,7 +40,12 @@ import org.oxycblt.auxio.music.Song
* @param context [Context] required to load images.
* @author Alexander Capehart (OxygenCobalt)
*/
class BitmapProvider(private val context: Context) {
class BitmapProvider
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader
) {
/**
* An extension of [Disposable] with an additional [Target] to deliver the final [Bitmap] to.
*/
@ -94,7 +101,7 @@ class BitmapProvider(private val context: Context) {
onSuccess = {
synchronized(this) {
if (currentHandle == handle) {
// Has not been superceded by a new request, can deliver
// Has not been superseded by a new request, can deliver
// this result.
target.onCompleted(it.toBitmap())
}
@ -103,13 +110,13 @@ class BitmapProvider(private val context: Context) {
onError = {
synchronized(this) {
if (currentHandle == handle) {
// Has not been superceded by a new request, can deliver
// Has not been superseded by a new request, can deliver
// this result.
target.onCompleted(null)
}
}
})
currentRequest = Request(context.imageLoader.enqueue(imageRequest.build()), target)
currentRequest = Request(imageLoader.enqueue(imageRequest.build()), target)
}
/** Release this instance, cancelling any currently running operations. */

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 androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
@ -35,8 +37,10 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
/** Called when [coverMode] changes. */
fun onCoverModeChanged() {}
}
}
private class Real(context: Context) : Settings.Real<Listener>(context), ImageSettings {
class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
Settings.Impl<ImageSettings.Listener>(context), ImageSettings {
override val coverMode: CoverMode
get() =
CoverMode.fromIntCode(
@ -65,9 +69,9 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
}
}
override fun onSettingChanged(key: String, listener: Listener) {
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
if (key == getString(R.string.set_key_cover_mode)) {
listOf(key, listener)
listener.onCoverModeChanged()
}
}
@ -76,12 +80,3 @@ interface ImageSettings : Settings<ImageSettings.Listener> {
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
}
}
companion object {
/**
* Get a framework-backed implementation.
* @param context [Context] required.
*/
fun from(context: Context): ImageSettings = Real(context)
}
}

View file

@ -26,6 +26,8 @@ import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.widget.ImageViewCompat
import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.UISettings
@ -41,6 +43,7 @@ import org.oxycblt.auxio.util.getDrawableCompat
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class PlaybackIndicatorView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
@ -52,6 +55,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
@Inject lateinit var uiSettings: UISettings
/**
* The corner radius of this view. This allows the outer ImageGroup to apply it's corner radius
@ -61,7 +65,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
set(value) {
field = value
(background as? MaterialShapeDrawable)?.let { bg ->
if (UISettings.from(context).roundMode) {
if (uiSettings.roundMode) {
bg.setCornerSize(value)
} else {
bg.setCornerSize(0f)

View file

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

View file

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

View file

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

View file

@ -24,6 +24,16 @@ interface Item
/**
* A "header" used for delimiting groups of data.
* @param titleRes The string resource used for the header's title.
* @author Alexander Capehart (OxygenCobalt)
*/
data class Header(@StringRes val titleRes: Int) : Item
interface Header : Item {
/** The string resource used for the header's title. */
val titleRes: Int
}
/**
* A basic header with no additional actions.
* @param titleRes The string resource used for the header's title.
* @author Alexander Capehart (OxygenCobalt)
*/
data class BasicHeader(@StringRes override val titleRes: Int) : Header

View file

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

View file

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

View file

@ -63,7 +63,7 @@ interface ListDiffer<T, I> {
class Async<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
RealAsyncListDiffer(AdapterListUpdateCallback(adapter), diffCallback)
AsyncListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
/**
@ -75,7 +75,7 @@ interface ListDiffer<T, I> {
class Blocking<T>(private val diffCallback: DiffUtil.ItemCallback<T>) :
Factory<T, BasicListInstructions>() {
override fun new(adapter: RecyclerView.Adapter<*>): ListDiffer<T, BasicListInstructions> =
RealBlockingListDiffer(AdapterListUpdateCallback(adapter), diffCallback)
BlockingListDifferImpl(AdapterListUpdateCallback(adapter), diffCallback)
}
}
@ -113,7 +113,7 @@ private abstract class BasicListDiffer<T> : ListDiffer<T, BasicListInstructions>
protected abstract fun replaceList(newList: List<T>, onDone: () -> Unit)
}
private class RealAsyncListDiffer<T>(
private class AsyncListDifferImpl<T>(
updateCallback: ListUpdateCallback,
diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<T>() {
@ -132,7 +132,7 @@ private class RealAsyncListDiffer<T>(
}
}
private class RealBlockingListDiffer<T>(
private class BlockingListDifferImpl<T>(
private val updateCallback: ListUpdateCallback,
private val diffCallback: DiffUtil.ItemCallback<T>
) : BasicListDiffer<T>() {

View file

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

View file

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

View file

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

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

File diff suppressed because it is too large Load diff

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

View file

@ -20,8 +20,10 @@ package org.oxycblt.auxio.music
import android.content.Context
import android.os.storage.StorageManager
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.library.Sort
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.MusicDirectories
import org.oxycblt.auxio.settings.Settings
@ -40,6 +42,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */
var multiValueSeparators: String
/** Whether to trim english articles with song sort names. */
val automaticSortNames: Boolean
/** The [Sort] mode used in [Song] lists. */
var songSort: Sort
/** The [Sort] mode used in [Album] lists. */
@ -61,8 +65,10 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
/** Called when the [shouldBeObserving] configuration has changed. */
fun onObservingChanged() {}
}
}
private class Real(context: Context) : Settings.Real<Listener>(context), MusicSettings {
class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
private val storageManager = context.getSystemServiceCompat(StorageManager::class)
override var musicDirs: MusicDirectories
@ -73,8 +79,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
.mapNotNull { Directory.fromDocumentTreeUri(storageManager, it) }
return MusicDirectories(
dirs,
sharedPreferences.getBoolean(
getString(R.string.set_key_music_dirs_include), false))
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
}
set(value) {
sharedPreferences.edit {
@ -87,8 +92,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
}
override val excludeNonMusic: Boolean
get() =
sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
override val shouldBeObserving: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
@ -104,11 +108,14 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
}
}
override val automaticSortNames: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_auto_sort_names), true)
override var songSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_songs_sort), value.intCode)
@ -119,9 +126,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
override var albumSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_albums_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
sharedPreferences.getInt(getString(R.string.set_key_albums_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_albums_sort), value.intCode)
@ -132,9 +138,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
override var artistSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_artists_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
sharedPreferences.getInt(getString(R.string.set_key_artists_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_artists_sort), value.intCode)
@ -145,9 +150,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
override var genreSort: Sort
get() =
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_genres_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
sharedPreferences.getInt(getString(R.string.set_key_genres_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_genres_sort), value.intCode)
@ -161,7 +165,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_album_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDisc, true)
?: Sort(Sort.Mode.ByDisc, Sort.Direction.ASCENDING)
// Correct legacy album sort modes to Disc
if (sort.mode is Sort.Mode.ByName) {
@ -182,7 +186,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_artist_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByDate, false)
?: Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_artist_songs_sort), value.intCode)
@ -195,7 +199,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
Sort.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_genre_songs_sort), Int.MIN_VALUE))
?: Sort(Sort.Mode.ByName, true)
?: Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
set(value) {
sharedPreferences.edit {
putInt(getString(R.string.set_key_genre_songs_sort), value.intCode)
@ -203,22 +207,16 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
}
}
override fun onSettingChanged(key: String, listener: Listener) {
override fun onSettingChanged(key: String, listener: MusicSettings.Listener) {
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
// (just need to manipulate data)
when (key) {
getString(R.string.set_key_exclude_non_music),
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_separators) -> listener.onIndexingSettingChanged()
getString(R.string.set_key_separators),
getString(R.string.set_key_auto_sort_names) -> listener.onIndexingSettingChanged()
getString(R.string.set_key_observing) -> listener.onObservingChanged()
}
}
}
companion object {
/**
* Get a framework-backed implementation.
* @param context [Context] required.
*/
fun from(context: Context): MusicSettings = Real(context)
}
}

View file

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

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/>.
*/
package org.oxycblt.auxio.music.tags
package org.oxycblt.auxio.music.metadata
import android.content.Context
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/>.
*/
package org.oxycblt.auxio.music.tags
package org.oxycblt.auxio.music.metadata
import org.oxycblt.auxio.R
@ -125,7 +125,7 @@ sealed class ReleaseType {
}
/**
* A Mix-tape. These are usually [EP]-sized releases of music made to promote an [Artist] or a
* A Mix-tape. These are usually [EP]-sized releases of music made to promote an Artist or a
* future release.
*/
object Mixtape : ReleaseType() {
@ -141,7 +141,7 @@ sealed class ReleaseType {
/** A release consisting of a live performance */
LIVE,
/** A release consisting of another [Artist]s remix of a prior performance. */
/** A release consisting of another Artists remix of a prior performance. */
REMIX
}

View file

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

View file

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

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

View file

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.parsing
package org.oxycblt.auxio.music.metadata
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.util.nonZeroOrNull
@ -96,7 +96,7 @@ fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
/**
* Attempt to parse a string by the user's separator preferences.
* @param settings [Settings] required to obtain user separator configuration.
* @param settings [MusicSettings] required to obtain user separator configuration.
* @return A list of one or more [String]s that were split up by the user-defined separators.
*/
private fun String.maybeParseBySeparators(settings: MusicSettings): List<String> {
@ -107,12 +107,45 @@ private fun String.maybeParseBySeparators(settings: MusicSettings): List<String>
/// --- ID3v2 PARSING ---
/**
* Parse the number out of a ID3v2-style number + total position [String] field. These fields
* consist of a number and an (optional) total value delimited by a /.
* @return The number value extracted from the string field, or null if the value could not be
* parsed or if the value was zero.
* Parse an ID3v2-style position + total [String] field. These fields consist of a number and an
* (optional) total value delimited by a /.
* @return The position value extracted from the string field, or null if:
* - The position could not be parsed
* - The position was zeroed AND the total value was not present/zeroed
* @see transformPositionField
*/
fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
fun String.parseId3v2PositionField() =
split('/', limit = 2).let {
transformPositionField(it[0].toIntOrNull(), it.getOrNull(1)?.toIntOrNull())
}
/**
* Parse a vorbis-style position + total field. These fields consist of two fields for the position
* and total numbers.
* @param pos The position value, or null if not present.
* @param total The total value, if not present.
* @return The position value extracted from the field, or null if:
* - The position could not be parsed
* - The position was zeroed AND the total value was not present/zeroed
* @see transformPositionField
*/
fun parseVorbisPositionField(pos: String?, total: String?) =
transformPositionField(pos?.toIntOrNull(), total?.toIntOrNull())
/**
* Transform a raw position + total field into a position a way that tolerates placeholder values.
* @param pos The position value, or null if not present.
* @param total The total value, if not present.
* @return The position value extracted from the field, or null if:
* - The position could not be parsed
* - The position was zeroed AND the total value was not present/zeroed
*/
fun transformPositionField(pos: Int?, total: Int?) =
if (pos != null && (pos > 0 || (total?.nonZeroOrNull() != null))) {
pos
} else {
null
}
/**
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer

View file

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

View file

@ -15,11 +15,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.library
package org.oxycblt.auxio.music.model
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.useQuery
@ -29,27 +30,89 @@ import org.oxycblt.auxio.util.logD
* Organized music library information.
*
* This class allows for the creation of a well-formed music library graph from raw song
* information. It's generally not expected to create this yourself and instead use [MusicStore].
* information. It's generally not expected to create this yourself and instead use
* [MusicRepository].
*
* @author Alexander Capehart
*/
class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
/** All [Song]s that were detected on the device. */
val songs = Sort(Sort.Mode.ByName, true).songs(rawSongs.map { Song(it, settings) }.distinct())
/** All [Album]s found on the device. */
val albums = buildAlbums(songs)
/** All [Artist]s found on the device. */
val artists = buildArtists(songs, albums)
/** All [Genre]s found on the device. */
val genres = buildGenres(songs)
interface Library {
/** All [Song]s in this [Library]. */
val songs: List<Song>
/** All [Album]s in this [Library]. */
val albums: List<Album>
/** All [Artist]s in this [Library]. */
val artists: List<Artist>
/** All [Genre]s in this [Library]. */
val genres: List<Genre>
/**
* Finds a [Music] item [T] in the library by it's [Music.UID].
* @param uid The [Music.UID] to search for.
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
* the [Music.UID] did not correspond to a [T].
*/
fun <T : Music> find(uid: Music.UID): T?
/**
* Convert a [Song] from an another library into a [Song] in this [Library].
* @param song The [Song] to convert.
* @return The analogous [Song] in this [Library], or null if it does not exist.
*/
fun sanitize(song: Song): Song?
/**
* Convert a [MusicParent] from an another library into a [MusicParent] in this [Library].
* @param parent The [MusicParent] to convert.
* @return The analogous [Album] in this [Library], or null if it does not exist.
*/
fun <T : MusicParent> sanitize(parent: T): T?
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri): Song?
companion object {
/**
* Create an instance of [Library].
* @param rawSongs [RawSong]s to create the library out of.
* @param settings [MusicSettings] required.
*/
fun from(rawSongs: List<RawSong>, settings: MusicSettings): Library =
LibraryImpl(rawSongs, settings)
}
}
private class LibraryImpl(rawSongs: List<RawSong>, settings: MusicSettings) : Library {
override val songs = buildSongs(rawSongs, settings)
override val albums = buildAlbums(songs, settings)
override val artists = buildArtists(songs, albums, settings)
override val genres = buildGenres(songs, settings)
// Use a mapping to make finding information based on it's UID much faster.
private val uidMap = buildMap {
for (music in (songs + albums + artists + genres)) {
// Finalize all music in the same mapping creation loop for efficiency.
music._finalize()
this[music.uid] = music
songs.forEach { put(it.uid, it.finalize()) }
albums.forEach { put(it.uid, it.finalize()) }
artists.forEach { put(it.uid, it.finalize()) }
genres.forEach { put(it.uid, it.finalize()) }
}
override fun equals(other: Any?) =
other is Library &&
other.songs == songs &&
other.albums == albums &&
other.artists == artists &&
other.genres == genres
override fun hashCode(): Int {
var hashCode = songs.hashCode()
hashCode = hashCode * 31 + albums.hashCode()
hashCode = hashCode * 31 + artists.hashCode()
hashCode = hashCode * 31 + genres.hashCode()
return hashCode
}
/**
@ -58,43 +121,13 @@ class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
* @return The [T] corresponding to the given [Music.UID], or null if nothing could be found or
* the [Music.UID] did not correspond to a [T].
*/
@Suppress("UNCHECKED_CAST") fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
@Suppress("UNCHECKED_CAST") override fun <T : Music> find(uid: Music.UID) = uidMap[uid] as? T
/**
* Convert a [Song] from an another library into a [Song] in this [Library].
* @param song The [Song] to convert.
* @return The analogous [Song] in this [Library], or null if it does not exist.
*/
fun sanitize(song: Song) = find<Song>(song.uid)
override fun sanitize(song: Song) = find<Song>(song.uid)
/**
* Convert a [Album] from an another library into a [Album] in this [Library].
* @param album The [Album] to convert.
* @return The analogous [Album] in this [Library], or null if it does not exist.
*/
fun sanitize(album: Album) = find<Album>(album.uid)
override fun <T : MusicParent> sanitize(parent: T) = find<T>(parent.uid)
/**
* Convert a [Artist] from an another library into a [Artist] in this [Library].
* @param artist The [Artist] to convert.
* @return The analogous [Artist] in this [Library], or null if it does not exist.
*/
fun sanitize(artist: Artist) = find<Artist>(artist.uid)
/**
* Convert a [Genre] from an another library into a [Genre] in this [Library].
* @param genre The [Genre] to convert.
* @return The analogous [Genre] in this [Library], or null if it does not exist.
*/
fun sanitize(genre: Genre) = find<Genre>(genre.uid)
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri) =
override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst()
@ -106,18 +139,30 @@ class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
songs.find { it.path.name == displayName && it.size == size }
}
/**
* Build a list [SongImpl]s from the given [RawSong].
* @param rawSongs The [RawSong]s to build the [SongImpl]s from.
* @param settings [MusicSettings] to obtain user parsing configuration.
* @return A sorted list of [SongImpl]s derived from the [RawSong] that should be suitable for
* grouping.
*/
private fun buildSongs(rawSongs: List<RawSong>, settings: MusicSettings) =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.songs(rawSongs.map { SongImpl(it, settings) }.distinct())
/**
* Build a list of [Album]s from the given [Song]s.
* @param songs The [Song]s to build [Album]s from. These will be linked with their respective
* [Album]s when created.
* @param settings [MusicSettings] to obtain user parsing configuration.
* @return A non-empty list of [Album]s. These [Album]s will be incomplete and must be linked
* with parent [Artist] instances in order to be usable.
*/
private fun buildAlbums(songs: List<Song>): List<Album> {
private fun buildAlbums(songs: List<SongImpl>, settings: MusicSettings): List<AlbumImpl> {
// Group songs by their singular raw album, then map the raw instances and their
// grouped songs to Album values. Album.Raw will handle the actual grouping rules.
val songsByAlbum = songs.groupBy { it._rawAlbum }
val albums = songsByAlbum.map { Album(it.key, it.value) }
val songsByAlbum = songs.groupBy { it.rawAlbum }
val albums = songsByAlbum.map { AlbumImpl(it.key, settings, it.value) }
logD("Successfully built ${albums.size} albums")
return albums
}
@ -132,28 +177,33 @@ class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
* @param albums The [Album]s to build [Artist]s from. One [Album] can result in the creation of
* one or more [Artist] instances. These will be linked with their respective [Artist]s when
* created.
* @param settings [MusicSettings] to obtain user parsing configuration.
* @return A non-empty list of [Artist]s. These [Artist]s will consist of the combined groupings
* of [Song]s and [Album]s.
*/
private fun buildArtists(songs: List<Song>, albums: List<Album>): List<Artist> {
private fun buildArtists(
songs: List<SongImpl>,
albums: List<AlbumImpl>,
settings: MusicSettings
): List<ArtistImpl> {
// Add every raw artist credited to each Song/Album to the grouping. This way,
// different multi-artist combinations are not treated as different artists.
val musicByArtist = mutableMapOf<Artist.Raw, MutableList<Music>>()
val musicByArtist = mutableMapOf<RawArtist, MutableList<Music>>()
for (song in songs) {
for (rawArtist in song._rawArtists) {
for (rawArtist in song.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(song)
}
}
for (album in albums) {
for (rawArtist in album._rawArtists) {
for (rawArtist in album.rawArtists) {
musicByArtist.getOrPut(rawArtist) { mutableListOf() }.add(album)
}
}
// Convert the combined mapping into artist instances.
val artists = musicByArtist.map { Artist(it.key, it.value) }
val artists = musicByArtist.map { ArtistImpl(it.key, settings, it.value) }
logD("Successfully built ${artists.size} artists")
return artists
}
@ -163,20 +213,21 @@ class Library(rawSongs: List<Song.Raw>, settings: MusicSettings) {
* @param [songs] The [Song]s to build [Genre]s from. One [Song] can result in the creation of
* one or more [Genre] instances. These will be linked with their respective [Genre]s when
* created.
* @param settings [MusicSettings] to obtain user parsing configuration.
* @return A non-empty list of [Genre]s.
*/
private fun buildGenres(songs: List<Song>): List<Genre> {
private fun buildGenres(songs: List<SongImpl>, settings: MusicSettings): List<GenreImpl> {
// Add every raw genre credited to each Song to the grouping. This way,
// different multi-genre combinations are not treated as different genres.
val songsByGenre = mutableMapOf<Genre.Raw, MutableList<Song>>()
val songsByGenre = mutableMapOf<RawGenre, MutableList<SongImpl>>()
for (song in songs) {
for (rawGenre in song._rawGenres) {
for (rawGenre in song.rawGenres) {
songsByGenre.getOrPut(rawGenre) { mutableListOf() }.add(song)
}
}
// Convert the mapping into genre instances.
val genres = songsByGenre.map { Genre(it.key, it.value) }
val genres = songsByGenre.map { GenreImpl(it.key, settings, it.value) }
logD("Successfully built ${genres.size} genres")
return genres
}

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
* violation of the document tree URI contract, but it's also the only one can sensibly work
* with these uris in the UI, and it doesn't exactly matter since we never write or read
* with these uris in the UI, and it doesn't exactly matter since we never write or read to
* directory.
* @return A URI [String] abiding by the document tree specification, or null if the [Directory]
* is not valid.
@ -142,10 +142,9 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) {
* Resolve the mime type into a human-readable format name, such as "Ogg Vorbis".
* @param context [Context] required to obtain human-readable strings.
* @return A human-readable name for this mime type. Will first try [fromFormat], then falling
* back to [fromExtension], then falling back to the extension name, and then finally a
* placeholder "No Format" string.
* back to [fromExtension], and then null if that fails.
*/
fun resolveName(context: Context): String {
fun resolveName(context: Context): String? {
// We try our best to produce a more readable name for the common audio formats.
val formatName =
when (fromFormat) {
@ -157,6 +156,8 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) {
MediaFormat.MIMETYPE_AUDIO_VORBIS -> R.string.cdc_vorbis
MediaFormat.MIMETYPE_AUDIO_OPUS -> R.string.cdc_opus
MediaFormat.MIMETYPE_AUDIO_FLAC -> R.string.cdc_flac
// TODO: Add ALAC to this as soon as I can stop using MediaFormat for
// extracting metadata and just use ExoPlayer.
// We don't give a name to more unpopular formats.
else -> -1
}
@ -199,8 +200,6 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) {
} else {
// Fall back to the extension if we can't find a special name for this format.
MimeTypeMap.getSingleton().getExtensionFromMimeType(fromExtension)?.uppercase()
// Fall back to a placeholder if even that fails.
?: context.getString(R.string.def_codec)
}
}
}

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

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.os.Build
import androidx.core.content.ContextCompat
import java.util.LinkedList
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.extractor.*
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.music.cache.CacheRepository
import org.oxycblt.auxio.music.metadata.TagExtractor
import org.oxycblt.auxio.music.model.Library
import org.oxycblt.auxio.music.model.RawSong
import org.oxycblt.auxio.music.storage.MediaStoreExtractor
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
@ -39,28 +49,20 @@ import org.oxycblt.auxio.util.logW
*
* This class provides low-level access into the exact state of the music loading process. **This
* class should not be used in most cases.** It is highly volatile and provides far more information
* than is usually needed. Use [MusicStore] instead if you do not need to work with the exact music
* loading state.
* than is usually needed. Use [MusicRepository] instead if you do not need to work with the exact
* music loading state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class Indexer private constructor() {
@Volatile private var lastResponse: Result<Library>? = null
@Volatile private var indexingState: Indexing? = null
@Volatile private var controller: Controller? = null
@Volatile private var listener: Listener? = null
interface Indexer {
/** Whether music loading is occurring or not. */
val isIndexing: Boolean
get() = indexingState != null
/**
* Whether this instance has not completed a loading process and is not currently loading music.
* This often occurs early in an app's lifecycle, and consumers should try to avoid showing any
* state when this flag is true.
*/
val isIndeterminate: Boolean
get() = lastResponse == null && indexingState == null
/**
* Register a [Controller] for this instance. This instance will handle any commands to start
@ -68,19 +70,7 @@ class Indexer private constructor() {
* [Listener] methods to initialize the instance with the current state.
* @param controller The [Controller] to register. Will do nothing if already registered.
*/
@Synchronized
fun registerController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller != null) {
logW("Controller is already registered")
return
}
// Initialize the controller with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller.onIndexerStateChanged(currentState)
this.controller = controller
}
fun registerController(controller: Controller)
/**
* Unregister the [Controller] from this instance, prevent it from recieving any further
@ -88,15 +78,7 @@ class Indexer private constructor() {
* @param controller The [Controller] to unregister. Must be the current [Controller]. Does
* nothing if invoked by another [Controller] implementation.
*/
@Synchronized
fun unregisterController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) {
logW("Given controller did not match current controller")
return
}
this.controller = null
}
fun unregisterController(controller: Controller)
/**
* Register the [Listener] for this instance. This can be used to receive rapid-fire updates to
@ -104,19 +86,7 @@ class Indexer private constructor() {
* [Listener] methods to initialize the instance with the current state.
* @param listener The [Listener] to add.
*/
@Synchronized
fun registerListener(listener: Listener) {
if (BuildConfig.DEBUG && this.listener != null) {
logW("Listener is already registered")
return
}
// Initialize the listener with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
listener.onIndexerStateChanged(currentState)
this.listener = listener
}
fun registerListener(listener: Listener)
/**
* Unregister a [Listener] from this instance, preventing it from recieving any further updates.
@ -124,15 +94,7 @@ class Indexer private constructor() {
* invoked by another [Listener] implementation.
* @see Listener
*/
@Synchronized
fun unregisterListener(listener: Listener) {
if (BuildConfig.DEBUG && this.listener !== listener) {
logW("Given controller did not match current controller")
return
}
this.listener = null
}
fun unregisterListener(listener: Listener)
/**
* Start the indexing process. This should be done from in the background from [Controller]'s
@ -140,172 +102,25 @@ class Indexer private constructor() {
* @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
* be written, but no cache entries will be loaded into the new library.
* @param scope The [CoroutineScope] to run the indexing job in.
* @return The [Job] stacking the indexing status.
*/
suspend fun index(context: Context, withCache: Boolean) {
val result =
try {
val start = System.currentTimeMillis()
val library = indexImpl(context, withCache)
logD(
"Music indexing completed successfully in " +
"${System.currentTimeMillis() - start}ms")
Result.success(library)
} catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled")
throw e
} catch (e: Exception) {
// Music loading process failed due to something we have not handled.
logE("Music indexing failed")
logE(e.stackTraceToString())
Result.failure(e)
}
emitCompletion(result)
}
fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job
/**
* Request that the music library should be reloaded. This should be used by components that do
* not manage the indexing process in order to signal that the [Controller] should call [index]
* eventually.
* not manage the indexing process in order to signal that the [Indexer.Controller] should call
* [index] eventually.
* @param withCache Whether to use the cache when loading music. Does nothing if there is no
* [Controller].
* [Indexer.Controller].
*/
@Synchronized
fun requestReindex(withCache: Boolean) {
logD("Requesting reindex")
controller?.onStartIndexing(withCache)
}
fun requestReindex(withCache: Boolean)
/**
* Reset the current loading state to signal that the instance is not loading. This should be
* called by [Controller] after it's indexing co-routine was cancelled.
*/
@Synchronized
fun reset() {
logD("Cancelling last job")
emitIndexing(null)
}
/**
* Internal implementation of the music loading process.
* @param context [Context] required to load music.
* @param withCache Whether to use the cache or not when loading. If false, the cache will still
* be written, but no cache entries will be loaded into the new library.
* @return A newly-loaded [Library].
* @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
* @throws NoMusicException If no music was found on the device.
*/
private suspend fun indexImpl(context: Context, withCache: Boolean): Library {
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
// No permissions, signal that we can't do anything.
throw NoPermissionException()
}
// Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music
// experience.
val cacheDatabase =
if (withCache) {
ReadWriteCacheExtractor(context)
} else {
WriteOnlyCacheExtractor(context)
}
val mediaStoreExtractor =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
Api30MediaStoreExtractor(context, cacheDatabase)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
Api29MediaStoreExtractor(context, cacheDatabase)
else -> Api21MediaStoreExtractor(context, cacheDatabase)
}
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() }
// Build the rest of the music library from the song list. This is much more powerful
// and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis()
val library = Library(rawSongs, MusicSettings.from(context))
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return library
}
/**
* Load a list of [Song]s from the device.
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
* instances.
* @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked
* with parent [Album], [Artist], and [Genre] items in order to be usable.
*/
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> {
logD("Starting indexing process")
val start = System.currentTimeMillis()
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
// how long a media database query will take.
emitIndexing(Indexing.Indeterminate)
val total = metadataExtractor.init()
yield()
// Note: We use a set here so we can eliminate song duplicates.
val rawSongs = mutableListOf<Song.Raw>()
metadataExtractor.extract().collect { rawSong ->
rawSongs.add(rawSong)
// Now we can signal a defined progress by showing how many songs we have
// loaded, and the projected amount of songs we found in the library
// (obtained by the extractors)
yield()
emitIndexing(Indexing.Songs(rawSongs.size, total))
}
// Finalize the extractors with the songs we have now loaded. There is no ETA
// on this process, so go back to an indeterminate state.
emitIndexing(Indexing.Indeterminate)
metadataExtractor.finalize(rawSongs)
logD(
"Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms")
return rawSongs
}
/**
* Emit a new [State.Indexing] state. This can be used to signal the current state of the music
* loading process to external code. Assumes that the callee has already checked if they have
* not been canceled and thus have the ability to emit a new state.
* @param indexing The new [Indexing] state to emit, or null if no loading process is occurring.
*/
@Synchronized
private fun emitIndexing(indexing: Indexing?) {
indexingState = indexing
// If we have canceled the loading process, we want to revert to a previous completion
// whenever possible to prevent state inconsistency.
val state =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller?.onIndexerStateChanged(state)
listener?.onIndexerStateChanged(state)
}
/**
* Emit a new [State.Complete] state. This can be used to signal the completion of the music
* loading process to external code. Will check if the callee has not been canceled and thus has
* the ability to emit a new state
* @param result The new [Result] to emit, representing the outcome of the music loading
* process.
*/
private suspend fun emitCompletion(result: Result<Library>) {
yield()
// Swap to the Main thread so that downstream callbacks don't crash from being on
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
withContext(Dispatchers.Main) {
synchronized(this) {
// Do not check for redundancy here, as we actually need to notify a switch
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
lastResponse = result
indexingState = null
// Signal that the music loading process has been completed.
val state = State.Complete(result)
controller?.onIndexerStateChanged(state)
listener?.onIndexerStateChanged(state)
}
}
}
fun reset()
/** Represents the current state of [Indexer]. */
sealed class State {
@ -357,8 +172,8 @@ class Indexer private constructor() {
* A listener for rapid-fire changes in the music loading state.
*
* This is only useful for code that absolutely must show the current loading process.
* Otherwise, [MusicStore.Listener] is highly recommended due to it's updates only consisting of
* the [Library].
* Otherwise, [MusicRepository.Listener] is highly recommended due to it's updates only
* consisting of the [Library].
*/
interface Listener {
/**
@ -388,8 +203,6 @@ class Indexer private constructor() {
}
companion object {
@Volatile private var INSTANCE: Indexer? = null
/**
* A version-compatible identifier for the read external storage permission required by the
* system to load audio.
@ -401,21 +214,218 @@ class Indexer private constructor() {
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
}
class IndexerImpl
@Inject
constructor(
private val musicSettings: MusicSettings,
private val cacheRepository: CacheRepository,
private val mediaStoreExtractor: MediaStoreExtractor,
private val tagExtractor: TagExtractor
) : Indexer {
@Volatile private var lastResponse: Result<Library>? = null
@Volatile private var indexingState: Indexer.Indexing? = null
@Volatile private var controller: Indexer.Controller? = null
@Volatile private var listener: Indexer.Listener? = null
override val isIndexing: Boolean
get() = indexingState != null
override val isIndeterminate: Boolean
get() = lastResponse == null && indexingState == null
@Synchronized
override fun registerController(controller: Indexer.Controller) {
if (BuildConfig.DEBUG && this.controller != null) {
logW("Controller is already registered")
return
}
// Initialize the controller with the current state.
val currentState =
indexingState?.let { Indexer.State.Indexing(it) }
?: lastResponse?.let { Indexer.State.Complete(it) }
controller.onIndexerStateChanged(currentState)
this.controller = controller
}
@Synchronized
override fun unregisterController(controller: Indexer.Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) {
logW("Given controller did not match current controller")
return
}
this.controller = null
}
@Synchronized
override fun registerListener(listener: Indexer.Listener) {
if (BuildConfig.DEBUG && this.listener != null) {
logW("Listener is already registered")
return
}
// Initialize the listener with the current state.
val currentState =
indexingState?.let { Indexer.State.Indexing(it) }
?: lastResponse?.let { Indexer.State.Complete(it) }
listener.onIndexerStateChanged(currentState)
this.listener = listener
}
@Synchronized
override fun unregisterListener(listener: Indexer.Listener) {
if (BuildConfig.DEBUG && this.listener !== listener) {
logW("Given controller did not match current controller")
return
}
this.listener = null
}
override fun index(context: Context, withCache: Boolean, scope: CoroutineScope) =
scope.launch {
val result =
try {
val start = System.currentTimeMillis()
val library = indexImpl(context, withCache, this)
logD(
"Music indexing completed successfully in " +
"${System.currentTimeMillis() - start}ms")
Result.success(library)
} catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine.
logD("Loading routine was cancelled")
throw e
} catch (e: Exception) {
// Music loading process failed due to something we have not handled.
logE("Music indexing failed")
logE(e.stackTraceToString())
Result.failure(e)
}
emitCompletion(result)
}
@Synchronized
override fun requestReindex(withCache: Boolean) {
logD("Requesting reindex")
controller?.onStartIndexing(withCache)
}
@Synchronized
override fun reset() {
logD("Cancelling last job")
emitIndexing(null)
}
private suspend fun indexImpl(
context: Context,
withCache: Boolean,
scope: CoroutineScope
): Library {
if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
logE("Permission check failed")
// No permissions, signal that we can't do anything.
throw Indexer.NoPermissionException()
}
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
// how long a media database query will take.
emitIndexing(Indexer.Indexing.Indeterminate)
// Do the initial query of the cache and media databases in parallel.
logD("Starting queries")
val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() }
val cache =
if (withCache) {
cacheRepository.readCache()
} else {
null
}
val query = mediaStoreQueryJob.await()
// Now start processing the queried song information in parallel. Songs that can't be
// received from the cache are consisted incomplete and pushed to a separate channel
// that will eventually be processed into completed raw songs.
logD("Starting song discovery")
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
val mediaStoreJob =
scope.async {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
}
val metadataJob = scope.async { tagExtractor.consume(incompleteSongs, completeSongs) }
// Await completed raw songs as they are processed.
val rawSongs = LinkedList<RawSong>()
for (rawSong in completeSongs) {
rawSongs.add(rawSong)
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal))
}
// These should be no-ops
mediaStoreJob.await()
metadataJob.await()
if (rawSongs.isEmpty()) {
logE("Music library was empty")
throw Indexer.NoMusicException()
}
// Successfully loaded the library, now save the cache and create the library in
// parallel.
logD("Discovered ${rawSongs.size} songs, starting finalization")
emitIndexing(Indexer.Indexing.Indeterminate)
val libraryJob = scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) }
if (cache == null || cache.invalidated) {
cacheRepository.writeCache(rawSongs)
}
return libraryJob.await()
}
/**
* Get a singleton instance.
* @return The (possibly newly-created) singleton instance.
* Emit a new [Indexer.State.Indexing] state. This can be used to signal the current state of
* the music loading process to external code. Assumes that the callee has already checked if
* they have not been canceled and thus have the ability to emit a new state.
* @param indexing The new [Indexer.Indexing] state to emit, or null if no loading process is
* occurring.
*/
fun getInstance(): Indexer {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
@Synchronized
private fun emitIndexing(indexing: Indexer.Indexing?) {
indexingState = indexing
// If we have canceled the loading process, we want to revert to a previous completion
// whenever possible to prevent state inconsistency.
val state =
indexingState?.let { Indexer.State.Indexing(it) }
?: lastResponse?.let { Indexer.State.Complete(it) }
controller?.onIndexerStateChanged(state)
listener?.onIndexerStateChanged(state)
}
/**
* Emit a new [Indexer.State.Complete] state. This can be used to signal the completion of the
* music loading process to external code. Will check if the callee has not been canceled and
* thus has the ability to emit a new state
* @param result The new [Result] to emit, representing the outcome of the music loading
* process.
*/
private suspend fun emitCompletion(result: Result<Library>) {
yield()
// Swap to the Main thread so that downstream callbacks don't crash from being on
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
withContext(Dispatchers.Main) {
synchronized(this) {
val newInstance = Indexer()
INSTANCE = newInstance
return newInstance
// Do not check for redundancy here, as we actually need to notify a switch
// from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete.
lastResponse = result
indexingState = null
// Signal that the music loading process has been completed.
val state = Indexer.State.Complete(result)
controller?.onIndexerStateChanged(state)
listener?.onIndexerStateChanged(state)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -19,6 +19,8 @@ package org.oxycblt.auxio.playback
import android.content.Context
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicMode
@ -65,8 +67,10 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
/** Called when [notificationAction] has changed. */
fun onNotificationActionChanged() {}
}
}
private class Real(context: Context) : Settings.Real<Listener>(context), PlaybackSettings {
class PlaybackSettingsImpl @Inject constructor(@ApplicationContext context: Context) :
Settings.Impl<PlaybackSettings.Listener>(context), PlaybackSettings {
override val inListPlaybackMode: MusicMode
get() =
MusicMode.fromIntCode(
@ -89,19 +93,16 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
override val notificationAction: ActionMode
get() =
ActionMode.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_notif_action), Int.MIN_VALUE))
sharedPreferences.getInt(getString(R.string.set_key_notif_action), Int.MIN_VALUE))
?: ActionMode.REPEAT
override val headsetAutoplay: Boolean
get() =
sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false)
get() = sharedPreferences.getBoolean(getString(R.string.set_key_headset_autoplay), false)
override val replayGainMode: ReplayGainMode
get() =
ReplayGainMode.fromIntCode(
sharedPreferences.getInt(
getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
sharedPreferences.getInt(getString(R.string.set_key_replay_gain), Int.MIN_VALUE))
?: ReplayGainMode.DYNAMIC
override var replayGainPreAmp: ReplayGainPreAmp
@ -191,12 +192,11 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
}
}
override fun onSettingChanged(key: String, listener: Listener) {
override fun onSettingChanged(key: String, listener: PlaybackSettings.Listener) {
when (key) {
getString(R.string.set_key_replay_gain),
getString(R.string.set_key_pre_amp_with),
getString(R.string.set_key_pre_amp_without) ->
listener.onReplayGainSettingsChanged()
getString(R.string.set_key_pre_amp_without) -> listener.onReplayGainSettingsChanged()
getString(R.string.set_key_notif_action) -> listener.onNotificationActionChanged()
}
}
@ -207,12 +207,3 @@ interface PlaybackSettings : Settings<PlaybackSettings.Listener> {
const val OLD_KEY_DETAIL_PLAYBACK_MODE = "auxio_detail_song_play_mode"
}
}
companion object {
/**
* Get a framework-backed implementation.
* @param context [Context] required.
*/
fun from(context: Context): PlaybackSettings = Real(context)
}
}

View file

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

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/>.
*/
package org.oxycblt.auxio.playback.state
package org.oxycblt.auxio.playback.queue
import kotlin.random.Random
import kotlin.random.nextInt
@ -36,30 +36,82 @@ import org.oxycblt.auxio.music.Song
*
* @author OxygenCobalt
*/
class Queue {
interface Queue {
/** The index of the currently playing [Song] in the current mapping. */
val index: Int
/** The currently playing [Song]. */
val currentSong: Song?
/** Whether this queue is shuffled. */
val isShuffled: Boolean
/**
* Resolve this queue into a more conventional list of [Song]s.
* @return A list of [Song] corresponding to the current queue mapping.
*/
fun resolve(): List<Song>
/**
* Represents the possible changes that can occur during certain queue mutation events. The
* precise meanings of these differ somewhat depending on the type of mutation done.
*/
enum class ChangeResult {
/** Only the mapping has changed. */
MAPPING,
/** The mapping has changed, and the index also changed to align with it. */
INDEX,
/**
* The current song has changed, possibly alongside the mapping and index depending on the
* context.
*/
SONG
}
/**
* An immutable representation of the queue state.
* @param heap The heap of [Song]s that are/were used in the queue. This can be modified with
* null values to represent [Song]s that were "lost" from the heap without having to change
* other values.
* @param orderedMapping The mapping of the [heap] to an ordered queue.
* @param shuffledMapping The mapping of the [heap] to a shuffled queue.
* @param index The index of the currently playing [Song] at the time of serialization.
* @param songUid The [Music.UID] of the [Song] that was originally at [index].
*/
class SavedState(
val heap: List<Song?>,
val orderedMapping: List<Int>,
val shuffledMapping: List<Int>,
val index: Int,
val songUid: Music.UID,
) {
/**
* Remaps the [heap] of this instance based on the given mapping function and copies it into
* a new [SavedState].
* @param transform Code to remap the existing [Song] heap into a new [Song] heap. This
* **MUST** be the same size as the original heap. [Song] instances that could not be
* converted should be replaced with null in the new heap.
* @throws IllegalStateException If the invariant specified by [transform] is violated.
*/
inline fun remap(transform: (Song?) -> Song?) =
SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid)
}
}
class EditableQueue : Queue {
@Volatile private var heap = mutableListOf<Song>()
@Volatile private var orderedMapping = mutableListOf<Int>()
@Volatile private var shuffledMapping = mutableListOf<Int>()
/** The index of the currently playing [Song] in the current mapping. */
@Volatile
var index = -1
override var index = -1
private set
/** The currently playing [Song]. */
val currentSong: Song?
override val currentSong: Song?
get() =
shuffledMapping
.ifEmpty { orderedMapping.ifEmpty { null } }
?.getOrNull(index)
?.let(heap::get)
/** Whether this queue is shuffled. */
val isShuffled: Boolean
override val isShuffled: Boolean
get() = shuffledMapping.isNotEmpty()
/**
* Resolve this queue into a more conventional list of [Song]s.
* @return A list of [Song] corresponding to the current queue mapping.
*/
fun resolve() =
override fun resolve() =
if (currentSong != null) {
shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } }
} else {
@ -134,14 +186,15 @@ class Queue {
/**
* Add [Song]s to the top of the queue. Will start playback if nothing is playing.
* @param songs The [Song]s to add.
* @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there
* was no prior playback and these enqueued [Song]s start new playback.
* @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or
* [Queue.ChangeResult.SONG] if there was no prior playback and these enqueued [Song]s start new
* playback.
*/
fun playNext(songs: List<Song>): ChangeResult {
fun playNext(songs: List<Song>): Queue.ChangeResult {
if (orderedMapping.isEmpty()) {
// No playback, start playing these songs.
start(songs[0], songs, false)
return ChangeResult.SONG
return Queue.ChangeResult.SONG
}
val heapIndices = songs.map(::addSongToHeap)
@ -156,20 +209,21 @@ class Queue {
orderedMapping.addAll(index + 1, heapIndices)
}
check()
return ChangeResult.MAPPING
return Queue.ChangeResult.MAPPING
}
/**
* Add [Song]s to the end of the queue. Will start playback if nothing is playing.
* @param songs The [Song]s to add.
* @return [ChangeResult.MAPPING] if added to an existing queue, or [ChangeResult.SONG] if there
* was no prior playback and these enqueued [Song]s start new playback.
* @return [Queue.ChangeResult.MAPPING] if added to an existing queue, or
* [Queue.ChangeResult.SONG] if there was no prior playback and these enqueued [Song]s start new
* playback.
*/
fun addToQueue(songs: List<Song>): ChangeResult {
fun addToQueue(songs: List<Song>): Queue.ChangeResult {
if (orderedMapping.isEmpty()) {
// No playback, start playing these songs.
start(songs[0], songs, false)
return ChangeResult.SONG
return Queue.ChangeResult.SONG
}
val heapIndices = songs.map(::addSongToHeap)
@ -179,18 +233,18 @@ class Queue {
shuffledMapping.addAll(heapIndices)
}
check()
return ChangeResult.MAPPING
return Queue.ChangeResult.MAPPING
}
/**
* Move a [Song] at the given position to a new position.
* @param src The position of the [Song] to move.
* @param dst The destination position of the [Song].
* @return [ChangeResult.MAPPING] if the move occurred after the current index,
* [ChangeResult.INDEX] if the move occurred before or at the current index, requiring it to be
* mutated.
* @return [Queue.ChangeResult.MAPPING] if the move occurred after the current index,
* [Queue.ChangeResult.INDEX] if the move occurred before or at the current index, requiring it
* to be mutated.
*/
fun move(src: Int, dst: Int): ChangeResult {
fun move(src: Int, dst: Int): Queue.ChangeResult {
if (shuffledMapping.isNotEmpty()) {
// Move songs only in the shuffled mapping. There is no sane analogous form of
// this for the ordered mapping.
@ -210,21 +264,21 @@ class Queue {
else -> {
// Nothing to do.
check()
return ChangeResult.MAPPING
return Queue.ChangeResult.MAPPING
}
}
check()
return ChangeResult.INDEX
return Queue.ChangeResult.INDEX
}
/**
* Remove a [Song] at the given position.
* @param at The position of the [Song] to remove.
* @return [ChangeResult.MAPPING] if the removed [Song] was after the current index,
* [ChangeResult.INDEX] if the removed [Song] was before the current index, and
* [ChangeResult.SONG] if the currently playing [Song] was removed.
* @return [Queue.ChangeResult.MAPPING] if the removed [Song] was after the current index,
* [Queue.ChangeResult.INDEX] if the removed [Song] was before the current index, and
* [Queue.ChangeResult.SONG] if the currently playing [Song] was removed.
*/
fun remove(at: Int): ChangeResult {
fun remove(at: Int): Queue.ChangeResult {
if (shuffledMapping.isNotEmpty()) {
// Remove the specified index in the shuffled mapping and the analogous song in the
// ordered mapping.
@ -242,34 +296,34 @@ class Queue {
val result =
when {
// We just removed the currently playing song.
index == at -> ChangeResult.SONG
index == at -> Queue.ChangeResult.SONG
// Index was ahead of removed song, shift back to preserve consistency.
index > at -> {
index -= 1
ChangeResult.INDEX
Queue.ChangeResult.INDEX
}
// Nothing to do
else -> ChangeResult.MAPPING
else -> Queue.ChangeResult.MAPPING
}
check()
return result
}
/**
* Convert the current state of this instance into a [SavedState].
* @return A new [SavedState] reflecting the exact state of the queue when called.
* Convert the current state of this instance into a [Queue.SavedState].
* @return A new [Queue.SavedState] reflecting the exact state of the queue when called.
*/
fun toSavedState() =
currentSong?.let { song ->
SavedState(
Queue.SavedState(
heap.toList(), orderedMapping.toList(), shuffledMapping.toList(), index, song.uid)
}
/**
* Update this instance from the given [SavedState].
* @param savedState A [SavedState] with a valid queue representation.
* Update this instance from the given [Queue.SavedState].
* @param savedState A [Queue.SavedState] with a valid queue representation.
*/
fun applySavedState(savedState: SavedState) {
fun applySavedState(savedState: Queue.SavedState) {
val adjustments = mutableListOf<Int?>()
var currentShift = 0
for (song in savedState.heap) {
@ -345,49 +399,4 @@ class Queue {
"Queue inconsistency detected: Shuffled mapping indices out of heap bounds"
}
}
/**
* An immutable representation of the queue state.
* @param heap The heap of [Song]s that are/were used in the queue. This can be modified with
* null values to represent [Song]s that were "lost" from the heap without having to change
* other values.
* @param orderedMapping The mapping of the [heap] to an ordered queue.
* @param shuffledMapping The mapping of the [heap] to a shuffled queue.
* @param index The index of the currently playing [Song] at the time of serialization.
* @param songUid The [Music.UID] of the [Song] that was originally at [index].
*/
class SavedState(
val heap: List<Song?>,
val orderedMapping: List<Int>,
val shuffledMapping: List<Int>,
val index: Int,
val songUid: Music.UID,
) {
/**
* Remaps the [heap] of this instance based on the given mapping function and copies it into
* a new [SavedState].
* @param transform Code to remap the existing [Song] heap into a new [Song] heap. This
* **MUST** be the same size as the original heap. [Song] instances that could not be
* converted should be replaced with null in the new heap.
* @throws IllegalStateException If the invariant specified by [transform] is violated.
*/
inline fun remap(transform: (Song?) -> Song?) =
SavedState(heap.map(transform), orderedMapping, shuffledMapping, index, songUid)
}
/**
* Represents the possible changes that can occur during certain queue mutation events. The
* precise meanings of these differ somewhat depending on the type of mutation done.
*/
enum class ChangeResult {
/** Only the mapping has changed. */
MAPPING,
/** The mapping has changed, and the index also changed to align with it. */
INDEX,
/**
* The current song has changed, possibly alongside the mapping and index depending on the
* context.
*/
SONG
}
}

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

View file

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

View file

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

View file

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

View file

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

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
* it under the terms of the GNU General Public License as published by
@ -17,17 +17,14 @@
package org.oxycblt.auxio.playback.state
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.library.Library
import org.oxycblt.auxio.playback.state.PlaybackStateManager.Listener
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.queue.EditableQueue
import org.oxycblt.auxio.playback.queue.Queue
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* Core playback state controller class.
@ -36,48 +33,27 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* MediaSession is poorly designed. This class instead ful-fills this role.
*
* This should ***NOT*** be used outside of the playback module.
* - If you want to use the playback state in the UI, use
* [org.oxycblt.auxio.playback.PlaybackViewModel] as it can withstand volatile UIs.
* - If you want to use the playback state in the UI, use PlaybackViewModel as it can withstand
* volatile UIs.
* - If you want to use the playback state with the ExoPlayer instance or system-side things, use
* [org.oxycblt.auxio.playback.system.PlaybackService].
* PlaybackService.
*
* Internal consumers should usually use [Listener], however the component that manages the player
* itself should instead use [InternalPlayer].
*
* All access should be done with [PlaybackStateManager.getInstance].
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance()
private val listeners = mutableListOf<Listener>()
@Volatile private var internalPlayer: InternalPlayer? = null
@Volatile private var pendingAction: InternalPlayer.Action? = null
@Volatile private var isInitialized = false
interface PlaybackStateManager {
/** The current [Queue]. */
val queue = Queue()
val queue: Queue
/** The [MusicParent] currently being played. Null if playback is occurring from all songs. */
@Volatile
var parent: MusicParent? = null // FIXME: Parent is interpreted wrong when nothing is playing.
private set
val parent: MusicParent?
/** The current [InternalPlayer] state. */
@Volatile
var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
private set
val playerState: InternalPlayer.State
/** The current [RepeatMode] */
@Volatile
var repeatMode = RepeatMode.NONE
set(value) {
field = value
notifyRepeatModeChanged()
}
/**
* The current audio session ID of the internal player. Null if [InternalPlayer] is unavailable.
*/
var repeatMode: RepeatMode
/** The audio session ID of the internal player. Null if no internal player exists. */
val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId
/**
* Add a [Listener] to this instance. This can be used to receive changes in the playback state.
@ -85,16 +61,7 @@ class PlaybackStateManager private constructor() {
* @param listener The [Listener] to add.
* @see Listener
*/
@Synchronized
fun addListener(listener: Listener) {
if (isInitialized) {
listener.onNewPlayback(queue, parent)
listener.onRepeatChanged(repeatMode)
listener.onStateChanged(playerState)
}
listeners.add(listener)
}
fun addListener(listener: Listener)
/**
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
@ -102,10 +69,7 @@ class PlaybackStateManager private constructor() {
* the first place.
* @see Listener
*/
@Synchronized
fun removeListener(listener: Listener) {
listeners.remove(listener)
}
fun removeListener(listener: Listener)
/**
* Register an [InternalPlayer] for this instance. This instance will handle translating the
@ -114,42 +78,15 @@ class PlaybackStateManager private constructor() {
* @param internalPlayer The [InternalPlayer] to register. Will do nothing if already
* registered.
*/
@Synchronized
fun registerInternalPlayer(internalPlayer: InternalPlayer) {
if (this.internalPlayer != null) {
logW("Internal player is already registered")
return
}
if (isInitialized) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
// See if there's any action that has been queued.
requestAction(internalPlayer)
// Once initialized, try to synchronize with the player state it has created.
synchronizeState(internalPlayer)
}
this.internalPlayer = internalPlayer
}
fun registerInternalPlayer(internalPlayer: InternalPlayer)
/**
* Unregister the [InternalPlayer] from this instance, prevent it from recieving any further
* Unregister the [InternalPlayer] from this instance, prevent it from receiving any further
* commands.
* @param internalPlayer The [InternalPlayer] to unregister. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/
@Synchronized
fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
if (this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
this.internalPlayer = null
}
// --- PLAYING FUNCTIONS ---
fun unregisterInternalPlayer(internalPlayer: InternalPlayer)
/**
* Start new playback.
@ -159,190 +96,81 @@ class PlaybackStateManager private constructor() {
* collection of "All [Song]s".
* @param shuffled Whether to shuffle or not.
*/
@Synchronized
fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
val internalPlayer = internalPlayer ?: return
// Set up parent and queue
this.parent = parent
this.queue.start(song, queue, shuffled)
// Notify components of changes
notifyNewPlayback()
internalPlayer.loadSong(this.queue.currentSong, true)
// Played something, so we are initialized now
isInitialized = true
}
// --- QUEUE FUNCTIONS ---
fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean)
/**
* Go to the next [Song] in the queue. Will go to the first [Song] in the queue if there is no
* [Song] ahead to skip to.
*/
@Synchronized
fun next() {
val internalPlayer = internalPlayer ?: return
var play = true
if (!queue.goto(queue.index + 1)) {
queue.goto(0)
play = false
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, play)
}
fun next()
/**
* Go to the previous [Song] in the queue. Will rewind if there are no previous [Song]s to skip
* to, or if configured to do so.
*/
@Synchronized
fun prev() {
val internalPlayer = internalPlayer ?: return
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
if (internalPlayer.shouldRewindWithPrev) {
rewind()
setPlaying(true)
} else {
if (!queue.goto(queue.index - 1)) {
queue.goto(0)
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
fun prev()
/**
* Play a [Song] at the given position in the queue.
* @param index The position of the [Song] in the queue to start playing.
*/
@Synchronized
fun goto(index: Int) {
val internalPlayer = internalPlayer ?: return
if (queue.goto(index)) {
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
/**
* Add a [Song] to the top of the queue.
* @param song The [Song] to add.
*/
@Synchronized fun playNext(song: Song) = playNext(listOf(song))
fun goto(index: Int)
/**
* Add [Song]s to the top of the queue.
* @param songs The [Song]s to add.
*/
@Synchronized
fun playNext(songs: List<Song>) {
val internalPlayer = internalPlayer ?: return
when (queue.playNext(songs)) {
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING)
Queue.ChangeResult.SONG -> {
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
}
}
fun playNext(songs: List<Song>)
/**
* Add a [Song] to the end of the queue.
* Add a [Song] to the top of the queue.
* @param song The [Song] to add.
*/
@Synchronized fun addToQueue(song: Song) = addToQueue(listOf(song))
fun playNext(song: Song) = playNext(listOf(song))
/**
* Add [Song]s to the end of the queue.
* @param songs The [Song]s to add.
*/
@Synchronized
fun addToQueue(songs: List<Song>) {
val internalPlayer = internalPlayer ?: return
when (queue.addToQueue(songs)) {
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING)
Queue.ChangeResult.SONG -> {
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
}
}
fun addToQueue(songs: List<Song>)
/**
* Add a [Song] to the end of the queue.
* @param song The [Song] to add.
*/
fun addToQueue(song: Song) = addToQueue(listOf(song))
/**
* Move a [Song] in the queue.
* @param src The position of the [Song] to move in the queue.
* @param dst The destination position in the queue.
*/
@Synchronized
fun moveQueueItem(src: Int, dst: Int) {
logD("Moving item $src to position $dst")
notifyQueueChanged(queue.move(src, dst))
}
fun moveQueueItem(src: Int, dst: Int)
/**
* Remove a [Song] from the queue.
* @param at The position of the [Song] to remove in the queue.
*/
@Synchronized
fun removeQueueItem(at: Int) {
val internalPlayer = internalPlayer ?: return
logD("Removing item at $at")
val change = queue.remove(at)
if (change == Queue.ChangeResult.SONG) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
}
notifyQueueChanged(change)
}
fun removeQueueItem(at: Int)
/**
* (Re)shuffle or (Re)order this instance.
* @param shuffled Whether to shuffle the queue or not.
*/
@Synchronized
fun reorder(shuffled: Boolean) {
queue.reorder(shuffled)
notifyQueueReordered()
}
// --- INTERNAL PLAYER FUNCTIONS ---
fun reorder(shuffled: Boolean)
/**
* Synchronize the state of this instance with the current [InternalPlayer].
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/
@Synchronized
fun synchronizeState(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0)
if (newState != playerState) {
playerState = newState
notifyStateChanged()
}
}
fun synchronizeState(internalPlayer: InternalPlayer)
/**
* Start a [InternalPlayer.Action] for the current [InternalPlayer] to handle eventually.
* @param action The [InternalPlayer.Action] to perform.
*/
@Synchronized
fun startAction(action: InternalPlayer.Action) {
val internalPlayer = internalPlayer
if (internalPlayer == null || !internalPlayer.performAction(action)) {
logD("Internal player not present or did not consume action, waiting")
pendingAction = action
}
}
fun startAction(action: InternalPlayer.Action)
/**
* Request that the pending [InternalPlayer.Action] (if any) be passed to the given
@ -350,213 +178,37 @@ class PlaybackStateManager private constructor() {
* @param internalPlayer The [InternalPlayer] to synchronize with. Must be the current
* [InternalPlayer]. Does nothing if invoked by another [InternalPlayer] implementation.
*/
@Synchronized
fun requestAction(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
if (pendingAction?.let(internalPlayer::performAction) == true) {
logD("Pending action consumed")
pendingAction = null
}
}
fun requestAction(internalPlayer: InternalPlayer)
/**
* Update whether playback is ongoing or not.
* @param isPlaying Whether playback is ongoing or not.
*/
fun setPlaying(isPlaying: Boolean) {
internalPlayer?.setPlaying(isPlaying)
}
fun setPlaying(isPlaying: Boolean)
/**
* Seek to the given position in the currently playing [Song].
* @param positionMs The position to seek to, in milliseconds.
*/
@Synchronized
fun seekTo(positionMs: Long) {
internalPlayer?.seekTo(positionMs)
}
fun seekTo(positionMs: Long)
/** Rewind to the beginning of the currently playing [Song]. */
fun rewind() = seekTo(0)
// --- PERSISTENCE FUNCTIONS ---
/**
* Converts the current state of this instance into a [SavedState].
* @return An immutable [SavedState] that is analogous to the current state, or null if nothing
* is currently playing.
*/
fun toSavedState(): SavedState?
/**
* Restore the previously saved state (if any) and apply it to the playback state.
* @param database The [PlaybackStateDatabase] to load from.
* @param force Whether to do a restore regardless of any prior playback state.
* @return If the state was restored, false otherwise.
* Restores this instance from the given [SavedState].
* @param savedState The [SavedState] to restore from.
* @param destructive Whether to disregard the prior playback state and overwrite it with this
* [SavedState].
*/
suspend fun restoreState(database: PlaybackStateDatabase, force: Boolean): Boolean {
if (isInitialized && !force) {
// Already initialized and not forcing a restore, nothing to do.
return false
}
val library = musicStore.library ?: return false
val internalPlayer = internalPlayer ?: return false
val state =
try {
withContext(Dispatchers.IO) { database.read(library) }
} catch (e: Exception) {
logE("Unable to restore playback state.")
logE(e.stackTraceToString())
return false
}
// Translate the state we have just read into a usable playback state for this
// instance.
return synchronized(this) {
// State could have changed while we were loading, so check if we were initialized
// now before applying the state.
if (state != null && (!isInitialized || force)) {
parent = state.parent
queue.applySavedState(state.queueState)
repeatMode = state.repeatMode
notifyNewPlayback()
notifyRepeatModeChanged()
// Continuing playback after drastic state updates is a bad idea, so pause.
internalPlayer.loadSong(queue.currentSong, false)
internalPlayer.seekTo(state.positionMs)
isInitialized = true
true
} else {
false
}
}
}
/**
* Save the current state.
* @param database The [PlaybackStateDatabase] to save the state to.
* @return If state was saved, false otherwise.
*/
suspend fun saveState(database: PlaybackStateDatabase): Boolean {
logD("Saving state to DB")
// Create the saved state from the current playback state.
val state =
synchronized(this) {
queue.toSavedState()?.let {
PlaybackStateDatabase.SavedState(
parent = parent,
queueState = it,
positionMs = playerState.calculateElapsedPositionMs(),
repeatMode = repeatMode)
}
}
return try {
withContext(Dispatchers.IO) { database.write(state) }
true
} catch (e: Exception) {
logE("Unable to save playback state.")
logE(e.stackTraceToString())
false
}
}
/**
* Clear the current state.
* @param database The [PlaybackStateDatabase] to clear te state from
* @return If the state was cleared, false otherwise.
*/
suspend fun wipeState(database: PlaybackStateDatabase) =
try {
logD("Wiping state")
withContext(Dispatchers.IO) { database.write(null) }
true
} catch (e: Exception) {
logE("Unable to wipe playback state.")
logE(e.stackTraceToString())
false
}
/**
* Update the playback state to align with a new [Library].
* @param newLibrary The new [Library] that was recently loaded.
*/
@Synchronized
fun sanitize(newLibrary: Library) {
if (!isInitialized) {
// Nothing playing, nothing to do.
logD("Not initialized, no need to sanitize")
return
}
val internalPlayer = internalPlayer ?: return
logD("Sanitizing state")
// While we could just save and reload the state, we instead sanitize the state
// at runtime for better performance (and to sidestep a co-routine on behalf of the caller).
// Sanitize parent
parent =
parent?.let {
when (it) {
is Album -> newLibrary.sanitize(it)
is Artist -> newLibrary.sanitize(it)
is Genre -> newLibrary.sanitize(it)
}
}
// Sanitize the queue.
queue.toSavedState()?.let { state ->
queue.applySavedState(state.remap { newLibrary.sanitize(unlikelyToBeNull(it)) })
}
notifyNewPlayback()
val oldPosition = playerState.calculateElapsedPositionMs()
// Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause.
internalPlayer.loadSong(queue.currentSong, false)
if (queue.currentSong != null) {
// Internal player may have reloaded the media item, re-seek to the previous position
seekTo(oldPosition)
}
}
// --- CALLBACKS ---
private fun notifyIndexMoved() {
for (callback in listeners) {
callback.onIndexMoved(queue)
}
}
private fun notifyQueueChanged(change: Queue.ChangeResult) {
for (callback in listeners) {
callback.onQueueChanged(queue, change)
}
}
private fun notifyQueueReordered() {
for (callback in listeners) {
callback.onQueueReordered(queue)
}
}
private fun notifyNewPlayback() {
for (callback in listeners) {
callback.onNewPlayback(queue, parent)
}
}
private fun notifyStateChanged() {
for (callback in listeners) {
callback.onStateChanged(playerState)
}
}
private fun notifyRepeatModeChanged() {
for (callback in listeners) {
callback.onRepeatChanged(repeatMode)
}
}
fun applySavedState(savedState: SavedState, destructive: Boolean)
/**
* The interface for receiving updates from [PlaybackStateManager]. Add the listener to
@ -604,25 +256,318 @@ class PlaybackStateManager private constructor() {
fun onRepeatChanged(repeatMode: RepeatMode) {}
}
companion object {
@Volatile private var INSTANCE: PlaybackStateManager? = null
/**
* Get a singleton instance.
* @return The (possibly newly-created) singleton instance.
* A condensed representation of the playback state that can be persisted.
* @param parent The [MusicParent] item currently being played from.
* @param queueState The [Queue.SavedState]
* @param positionMs The current position in the currently played song, in ms
* @param repeatMode The current [RepeatMode].
*/
fun getInstance(): PlaybackStateManager {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
data class SavedState(
val parent: MusicParent?,
val queueState: Queue.SavedState,
val positionMs: Long,
val repeatMode: RepeatMode,
)
}
synchronized(this) {
val newInstance = PlaybackStateManager()
INSTANCE = newInstance
return newInstance
}
class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager {
private val listeners = mutableListOf<PlaybackStateManager.Listener>()
@Volatile private var internalPlayer: InternalPlayer? = null
@Volatile private var pendingAction: InternalPlayer.Action? = null
@Volatile private var isInitialized = false
override val queue = EditableQueue()
@Volatile
override var parent: MusicParent? =
null // FIXME: Parent is interpreted wrong when nothing is playing.
private set
@Volatile
override var playerState = InternalPlayer.State.from(isPlaying = false, isAdvancing = false, 0)
private set
@Volatile
override var repeatMode = RepeatMode.NONE
set(value) {
field = value
notifyRepeatModeChanged()
}
override val currentAudioSessionId: Int?
get() = internalPlayer?.audioSessionId
@Synchronized
override fun addListener(listener: PlaybackStateManager.Listener) {
if (isInitialized) {
listener.onNewPlayback(queue, parent)
listener.onRepeatChanged(repeatMode)
listener.onStateChanged(playerState)
}
listeners.add(listener)
}
@Synchronized
override fun removeListener(listener: PlaybackStateManager.Listener) {
listeners.remove(listener)
}
@Synchronized
override fun registerInternalPlayer(internalPlayer: InternalPlayer) {
if (this.internalPlayer != null) {
logW("Internal player is already registered")
return
}
if (isInitialized) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
internalPlayer.seekTo(playerState.calculateElapsedPositionMs())
// See if there's any action that has been queued.
requestAction(internalPlayer)
// Once initialized, try to synchronize with the player state it has created.
synchronizeState(internalPlayer)
}
this.internalPlayer = internalPlayer
}
@Synchronized
override fun unregisterInternalPlayer(internalPlayer: InternalPlayer) {
if (this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
this.internalPlayer = null
}
// --- PLAYING FUNCTIONS ---
@Synchronized
override fun play(song: Song?, parent: MusicParent?, queue: List<Song>, shuffled: Boolean) {
val internalPlayer = internalPlayer ?: return
// Set up parent and queue
this.parent = parent
this.queue.start(song, queue, shuffled)
// Notify components of changes
notifyNewPlayback()
internalPlayer.loadSong(this.queue.currentSong, true)
// Played something, so we are initialized now
isInitialized = true
}
// --- QUEUE FUNCTIONS ---
@Synchronized
override fun next() {
val internalPlayer = internalPlayer ?: return
var play = true
if (!queue.goto(queue.index + 1)) {
queue.goto(0)
play = repeatMode == RepeatMode.ALL
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, play)
}
@Synchronized
override fun prev() {
val internalPlayer = internalPlayer ?: return
// If enabled, rewind before skipping back if the position is past 3 seconds [3000ms]
if (internalPlayer.shouldRewindWithPrev) {
rewind()
setPlaying(true)
} else {
if (!queue.goto(queue.index - 1)) {
queue.goto(0)
}
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
@Synchronized
override fun goto(index: Int) {
val internalPlayer = internalPlayer ?: return
if (queue.goto(index)) {
notifyIndexMoved()
internalPlayer.loadSong(queue.currentSong, true)
}
}
@Synchronized
override fun playNext(songs: List<Song>) {
val internalPlayer = internalPlayer ?: return
when (queue.playNext(songs)) {
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING)
Queue.ChangeResult.SONG -> {
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
}
}
@Synchronized
override fun addToQueue(songs: List<Song>) {
val internalPlayer = internalPlayer ?: return
when (queue.addToQueue(songs)) {
Queue.ChangeResult.MAPPING -> notifyQueueChanged(Queue.ChangeResult.MAPPING)
Queue.ChangeResult.SONG -> {
// Enqueueing actually started a new playback session from all songs.
parent = null
internalPlayer.loadSong(queue.currentSong, true)
notifyNewPlayback()
}
Queue.ChangeResult.INDEX -> error("Unreachable")
}
}
@Synchronized
override fun moveQueueItem(src: Int, dst: Int) {
logD("Moving item $src to position $dst")
notifyQueueChanged(queue.move(src, dst))
}
@Synchronized
override fun removeQueueItem(at: Int) {
val internalPlayer = internalPlayer ?: return
logD("Removing item at $at")
val change = queue.remove(at)
if (change == Queue.ChangeResult.SONG) {
internalPlayer.loadSong(queue.currentSong, playerState.isPlaying)
}
notifyQueueChanged(change)
}
@Synchronized
override fun reorder(shuffled: Boolean) {
queue.reorder(shuffled)
notifyQueueReordered()
}
// --- INTERNAL PLAYER FUNCTIONS ---
@Synchronized
override fun synchronizeState(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
val newState = internalPlayer.getState(queue.currentSong?.durationMs ?: 0)
if (newState != playerState) {
playerState = newState
notifyStateChanged()
}
}
@Synchronized
override fun startAction(action: InternalPlayer.Action) {
val internalPlayer = internalPlayer
if (internalPlayer == null || !internalPlayer.performAction(action)) {
logD("Internal player not present or did not consume action, waiting")
pendingAction = action
}
}
@Synchronized
override fun requestAction(internalPlayer: InternalPlayer) {
if (BuildConfig.DEBUG && this.internalPlayer !== internalPlayer) {
logW("Given internal player did not match current internal player")
return
}
if (pendingAction?.let(internalPlayer::performAction) == true) {
logD("Pending action consumed")
pendingAction = null
}
}
@Synchronized
override fun setPlaying(isPlaying: Boolean) {
internalPlayer?.setPlaying(isPlaying)
}
@Synchronized
override fun seekTo(positionMs: Long) {
internalPlayer?.seekTo(positionMs)
}
// --- PERSISTENCE FUNCTIONS ---
@Synchronized
override fun toSavedState() =
queue.toSavedState()?.let {
PlaybackStateManager.SavedState(
parent = parent,
queueState = it,
positionMs = playerState.calculateElapsedPositionMs(),
repeatMode = repeatMode)
}
@Synchronized
override fun applySavedState(
savedState: PlaybackStateManager.SavedState,
destructive: Boolean
) {
if (isInitialized && !destructive) {
return
}
val internalPlayer = internalPlayer ?: return
logD("Restoring state $savedState")
parent = savedState.parent
queue.applySavedState(savedState.queueState)
repeatMode = savedState.repeatMode
notifyNewPlayback()
// Continuing playback while also possibly doing drastic state updates is
// a bad idea, so pause.
internalPlayer.loadSong(queue.currentSong, false)
if (queue.currentSong != null) {
// Internal player may have reloaded the media item, re-seek to the previous position
seekTo(savedState.positionMs)
}
isInitialized = true
}
// --- CALLBACKS ---
private fun notifyIndexMoved() {
for (callback in listeners) {
callback.onIndexMoved(queue)
}
}
private fun notifyQueueChanged(change: Queue.ChangeResult) {
for (callback in listeners) {
callback.onQueueChanged(queue, change)
}
}
private fun notifyQueueReordered() {
for (callback in listeners) {
callback.onQueueReordered(queue)
}
}
private fun notifyNewPlayback() {
for (callback in listeners) {
callback.onNewPlayback(queue, parent)
}
}
private fun notifyStateChanged() {
for (callback in listeners) {
callback.onStateChanged(playerState)
}
}
private fun notifyRepeatModeChanged() {
for (callback in listeners) {
callback.onRepeatChanged(repeatMode)
}
}
}

View file

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

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

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