music: unify indexer and repository
Unify Indexer and MusicRepository into a single class. This is meant to create a single dependency on PlaylistDatabase and reduce the amount of orchestration.
This commit is contained in:
parent
4033a791a7
commit
9a282e2be9
18 changed files with 689 additions and 852 deletions
|
@ -36,7 +36,6 @@ import org.oxycblt.auxio.list.Item
|
|||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.music.metadata.AudioInfo
|
||||
import org.oxycblt.auxio.music.metadata.Disc
|
||||
import org.oxycblt.auxio.music.metadata.ReleaseType
|
||||
|
@ -57,7 +56,7 @@ constructor(
|
|||
private val audioInfoProvider: AudioInfo.Provider,
|
||||
private val musicSettings: MusicSettings,
|
||||
private val playbackSettings: PlaybackSettings
|
||||
) : ViewModel(), MusicRepository.Listener {
|
||||
) : ViewModel(), MusicRepository.UpdateListener {
|
||||
private var currentSongJob: Job? = null
|
||||
|
||||
// --- SONG ---
|
||||
|
@ -152,18 +151,16 @@ constructor(
|
|||
get() = playbackSettings.inParentPlaybackMode
|
||||
|
||||
init {
|
||||
musicRepository.addListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
musicRepository.removeListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library == null) {
|
||||
// Nothing to do.
|
||||
return
|
||||
}
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.library) return
|
||||
val library = musicRepository.library ?: return
|
||||
|
||||
// If we are showing any item right now, we will need to refresh it (and any information
|
||||
// related to it) with the new library in order to prevent stale items from showing up
|
||||
|
|
|
@ -55,8 +55,6 @@ 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.system.Indexer
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||
|
@ -158,7 +156,7 @@ class HomeFragment :
|
|||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
||||
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
|
||||
collectImmediately(musicModel.indexerState, ::updateIndexerState)
|
||||
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
|
||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||
}
|
||||
|
@ -340,14 +338,14 @@ class HomeFragment :
|
|||
homeModel.recreateTabs.consume()
|
||||
}
|
||||
|
||||
private fun updateIndexerState(state: Indexer.State?) {
|
||||
private fun updateIndexerState(state: IndexingState?) {
|
||||
// TODO: Make music loading experience a bit more pleasant
|
||||
// 1. Loading placeholder for item lists
|
||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
||||
val binding = requireBinding()
|
||||
when (state) {
|
||||
is Indexer.State.Complete -> setupCompleteState(binding, state.result)
|
||||
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
|
||||
is IndexingState.Completed -> setupCompleteState(binding, state.error)
|
||||
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
|
||||
null -> {
|
||||
logD("Indexer is in indeterminate state")
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
|
@ -355,19 +353,20 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
|
||||
if (result.isSuccess) {
|
||||
private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) {
|
||||
if (error == null) {
|
||||
logD("Received ok response")
|
||||
binding.homeFab.show()
|
||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
logD("Received non-ok response")
|
||||
val context = requireContext()
|
||||
val throwable = unlikelyToBeNull(result.exceptionOrNull())
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||
when (throwable) {
|
||||
is Indexer.NoPermissionException -> {
|
||||
when (error) {
|
||||
is NoAudioPermissionException -> {
|
||||
logD("Updating UI to permission request state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||
// Configure the action to act as a permission launcher.
|
||||
|
@ -378,11 +377,11 @@ class HomeFragment :
|
|||
requireNotNull(storagePermissionLauncher) {
|
||||
"Permission launcher was not available"
|
||||
}
|
||||
.launch(Indexer.PERMISSION_READ_AUDIO)
|
||||
.launch(PERMISSION_READ_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Indexer.NoMusicException -> {
|
||||
is NoMusicException -> {
|
||||
logD("Updating UI to no music state")
|
||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||
// Configure the action to act as a reload trigger.
|
||||
|
@ -404,28 +403,27 @@ class HomeFragment :
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
|
||||
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
|
||||
// Remove all content except for the progress indicator.
|
||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
||||
binding.homeIndexingAction.visibility = View.INVISIBLE
|
||||
|
||||
when (indexing) {
|
||||
is Indexer.Indexing.Indeterminate -> {
|
||||
when (progress) {
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
// In a query/initialization state, show a generic loading status.
|
||||
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
||||
binding.homeIndexingProgress.isIndeterminate = true
|
||||
}
|
||||
is Indexer.Indexing.Songs -> {
|
||||
is IndexingProgress.Songs -> {
|
||||
// Actively loading songs, show the current progress.
|
||||
binding.homeIndexingStatus.text =
|
||||
getString(R.string.fmt_indexing, indexing.current, indexing.total)
|
||||
getString(R.string.fmt_indexing, progress.current, progress.total)
|
||||
binding.homeIndexingProgress.apply {
|
||||
isIndeterminate = false
|
||||
max = indexing.total
|
||||
progress = indexing.current
|
||||
max = progress.total
|
||||
this.progress = progress.current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,7 +27,6 @@ import org.oxycblt.auxio.home.tabs.Tab
|
|||
import org.oxycblt.auxio.list.Sort
|
||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.util.Event
|
||||
import org.oxycblt.auxio.util.MutableEvent
|
||||
|
@ -46,7 +45,7 @@ constructor(
|
|||
private val playbackSettings: PlaybackSettings,
|
||||
private val musicRepository: MusicRepository,
|
||||
private val musicSettings: MusicSettings
|
||||
) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener {
|
||||
) : ViewModel(), MusicRepository.UpdateListener, 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. */
|
||||
|
@ -117,18 +116,19 @@ constructor(
|
|||
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||
|
||||
init {
|
||||
musicRepository.addListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
homeSettings.registerListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
homeSettings.unregisterListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library != null) {
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.library) return
|
||||
val library = musicRepository.library ?: return
|
||||
logD("Library changed, refreshing library")
|
||||
// Get the each list of items in the library to use as our list data.
|
||||
// Applying the preferred sorting to them.
|
||||
|
@ -148,7 +148,6 @@ constructor(
|
|||
_genresInstructions.put(UpdateInstructions.Diff)
|
||||
_genresList.value = musicSettings.genreSort.genres(library.genres)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTabsChanged() {
|
||||
// Tabs changed, update the current tabs and set up a re-create event.
|
||||
|
@ -159,7 +158,7 @@ constructor(
|
|||
override fun onHideCollaboratorsChanged() {
|
||||
// Changes in the hide collaborator setting will change the artist contents
|
||||
// of the library, consider it a library update.
|
||||
onLibraryChanged(musicRepository.library)
|
||||
onMusicChanges(MusicRepository.Changes(library = true, playlists = false))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,7 +24,6 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
|
||||
/**
|
||||
* A [ViewModel] that manages the current selection.
|
||||
|
@ -33,21 +32,19 @@ import org.oxycblt.auxio.music.library.Library
|
|||
*/
|
||||
@HiltViewModel
|
||||
class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||
ViewModel(), MusicRepository.Listener {
|
||||
ViewModel(), MusicRepository.UpdateListener {
|
||||
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 {
|
||||
musicRepository.addListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library == null) {
|
||||
return
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.library) return
|
||||
val library = musicRepository.library ?: return
|
||||
// Sanitize the selection to remove items that no longer exist and thus
|
||||
// won't appear in any list.
|
||||
_selected.value =
|
||||
|
@ -64,7 +61,7 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR
|
|||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
82
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
82
app/src/main/java/org/oxycblt/auxio/music/Indexing.kt
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* Indexing.kt is part of Auxio.
|
||||
*
|
||||
* 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 android.os.Build
|
||||
|
||||
/**
|
||||
* Version-aware permission identifier for reading audio files.
|
||||
*/
|
||||
val PERMISSION_READ_AUDIO =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
android.Manifest.permission.READ_MEDIA_AUDIO
|
||||
} else {
|
||||
android.Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current state of the music loader.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface IndexingState {
|
||||
/**
|
||||
* Music loading is on-going.
|
||||
* @param progress The current progress of the music loading.
|
||||
*/
|
||||
data class Indexing(val progress: IndexingProgress) : IndexingState
|
||||
|
||||
/**
|
||||
* Music loading has completed.
|
||||
* @param error If music loading has failed, the error that occurred will be here. Otherwise,
|
||||
* it will be null.
|
||||
*/
|
||||
data class Completed(val error: Throwable?) : IndexingState
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current progress of music loading.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
sealed interface IndexingProgress {
|
||||
/** Other work is being done that does not have a defined progress. */
|
||||
object Indeterminate : IndexingProgress
|
||||
|
||||
/**
|
||||
* Songs are currently being loaded.
|
||||
* @param current The current amount of songs loaded.
|
||||
* @param total The projected total amount of songs.
|
||||
*/
|
||||
data class Songs(val current: Int, val total: Int) : IndexingProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class NoAudioPermissionException : Exception() {
|
||||
override val message = "Storage permissions are required to load music"
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when no music was found.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class NoMusicException : Exception() {
|
||||
override val message = "No music was found on the device"
|
||||
}
|
|
@ -23,13 +23,10 @@ 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
|
||||
}
|
||||
|
|
|
@ -18,75 +18,327 @@
|
|||
|
||||
package org.oxycblt.auxio.music
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import org.oxycblt.auxio.music.cache.CacheRepository
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.music.library.RawSong
|
||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
||||
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
|
||||
|
||||
/**
|
||||
* A repository granting access to the music library.
|
||||
* Primary manager of music information and loading.
|
||||
*
|
||||
* This can be used to obtain certain music items, or await changes to the music library. It is
|
||||
* generally recommended to use this over Indexer to keep track of the library state, as the
|
||||
* interface will be less volatile.
|
||||
* Music information is loaded in-memory by this repository using an [IndexingWorker].
|
||||
* Changes in music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
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].
|
||||
*/
|
||||
var library: Library?
|
||||
/** The current immutable music library loaded from the file-system. */
|
||||
val library: Library?
|
||||
/** The current mutable user-defined playlists loaded from the file-system. */
|
||||
val playlists: List<Playlist>?
|
||||
/** The current state of music loading. Null if no load has occurred yet. */
|
||||
val indexingState: IndexingState?
|
||||
|
||||
/**
|
||||
* Add a [Listener] to this instance. This can be used to receive changes in the music library.
|
||||
* Will invoke all [Listener] methods to initialize the instance with the current state.
|
||||
*
|
||||
* @param listener The [Listener] to add.
|
||||
* @see Listener
|
||||
* Add an [UpdateListener] to receive updates from this instance.
|
||||
* @param listener The [UpdateListener] to add.
|
||||
*/
|
||||
fun addListener(listener: Listener)
|
||||
fun addUpdateListener(listener: UpdateListener)
|
||||
|
||||
/**
|
||||
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
|
||||
*
|
||||
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
||||
* the first place.
|
||||
* @see Listener
|
||||
* Remove an [UpdateListener] such that it does not receive any further updates from this
|
||||
* instance.
|
||||
* @param listener The [UpdateListener] to remove.
|
||||
*/
|
||||
fun removeListener(listener: Listener)
|
||||
fun removeUpdateListener(listener: UpdateListener)
|
||||
|
||||
/** A listener for changes in [MusicRepository] */
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the current [Library] has changed.
|
||||
*
|
||||
* @param library The new [Library], or null if no [Library] has been loaded yet.
|
||||
* Add an [IndexingListener] to receive updates from this instance.
|
||||
* @param listener The [UpdateListener] to add.
|
||||
*/
|
||||
fun onLibraryChanged(library: Library?)
|
||||
fun addIndexingListener(listener: IndexingListener)
|
||||
|
||||
/**
|
||||
* Remove an [IndexingListener] such that it does not receive any further updates from this
|
||||
* instance.
|
||||
* @param listener The [IndexingListener] to remove.
|
||||
*/
|
||||
fun removeIndexingListener(listener: IndexingListener)
|
||||
|
||||
/**
|
||||
* Register an [IndexingWorker] to handle loading operations. Will do nothing if one is already
|
||||
* registered.
|
||||
* @param worker The [IndexingWorker] to register.
|
||||
*/
|
||||
fun registerWorker(worker: IndexingWorker)
|
||||
|
||||
/**
|
||||
* Unregister an [IndexingWorker] and drop any work currently being done by it. Does nothing
|
||||
* if given [IndexingWorker] is not the currently registered instance.
|
||||
* @param worker The [IndexingWorker] to unregister.
|
||||
*/
|
||||
fun unregisterWorker(worker: IndexingWorker)
|
||||
|
||||
/**
|
||||
* Request that a music loading operation is started by the current [IndexingWorker]. Does
|
||||
* nothing if one is not available.
|
||||
* @param withCache Whether to load with the music cache or not.
|
||||
*/
|
||||
fun requestIndex(withCache: Boolean)
|
||||
|
||||
/**
|
||||
* Load the music library. Any prior loads will be canceled.
|
||||
* @param worker The [IndexingWorker] to perform the work with.
|
||||
* @param withCache Whether to load with the music cache or not.
|
||||
* @return The top-level music loading [Job] started.
|
||||
*/
|
||||
fun index(worker: IndexingWorker, withCache: Boolean): Job
|
||||
|
||||
/**
|
||||
* A listener for changes to the stored music information.
|
||||
*/
|
||||
interface UpdateListener {
|
||||
/**
|
||||
* Called when a change to the stored music information occurs.
|
||||
* @param changes The [Changes] that have occured.
|
||||
*/
|
||||
fun onMusicChanges(changes: Changes)
|
||||
}
|
||||
/**
|
||||
* Flags indicating which kinds of music information changed.
|
||||
* @param library Whether the current [Library] has changed.
|
||||
* @param playlists Whether the current [Playlist]s have changed.
|
||||
*/
|
||||
data class Changes(val library: Boolean, val playlists: Boolean)
|
||||
|
||||
/**
|
||||
* A listener for events in the music loading process.
|
||||
*/
|
||||
interface IndexingListener {
|
||||
/**
|
||||
* Called when the music loading state changed.
|
||||
*/
|
||||
fun onIndexingStateChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* A persistent worker that can load music in the background.
|
||||
*/
|
||||
interface IndexingWorker {
|
||||
/**
|
||||
* A [Context] required to read device storage
|
||||
*/
|
||||
val context: Context
|
||||
|
||||
/**
|
||||
* The [CoroutineScope] to perform coroutine music loading work on.
|
||||
*/
|
||||
val scope: CoroutineScope
|
||||
|
||||
/**
|
||||
* Request that the music loading process ([index]) should be started. Any prior
|
||||
* loads should be canceled.
|
||||
* @param withCache Whether to use the music cache when loading.
|
||||
*/
|
||||
fun requestIndex(withCache: Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
class MusicRepositoryImpl @Inject constructor() : MusicRepository {
|
||||
private val listeners = mutableListOf<MusicRepository.Listener>()
|
||||
class MusicRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val musicSettings: MusicSettings,
|
||||
private val cacheRepository: CacheRepository,
|
||||
private val mediaStoreExtractor: MediaStoreExtractor,
|
||||
private val tagExtractor: TagExtractor
|
||||
) : MusicRepository {
|
||||
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
|
||||
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
|
||||
private var indexingWorker: MusicRepository.IndexingWorker? = null
|
||||
|
||||
@Volatile
|
||||
override var library: Library? = null
|
||||
set(value) {
|
||||
field = value
|
||||
for (callback in listeners) {
|
||||
callback.onLibraryChanged(library)
|
||||
override var playlists: List<Playlist>? = null
|
||||
private var previousCompletedState: IndexingState.Completed? = null
|
||||
private var currentIndexingState: IndexingState? = null
|
||||
override val indexingState: IndexingState?
|
||||
get() = currentIndexingState ?: previousCompletedState
|
||||
|
||||
@Synchronized
|
||||
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
updateListeners.add(listener)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
updateListeners.remove(listener)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
indexingListeners.add(listener)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
indexingListeners.remove(listener)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
|
||||
if (indexingWorker != null) {
|
||||
logW("Worker is already registered")
|
||||
return
|
||||
}
|
||||
indexingWorker = worker
|
||||
if (indexingState == null) {
|
||||
worker.requestIndex(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun addListener(listener: MusicRepository.Listener) {
|
||||
listener.onLibraryChanged(library)
|
||||
listeners.add(listener)
|
||||
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
|
||||
if (indexingWorker !== worker) {
|
||||
logW("Given worker did not match current worker")
|
||||
return
|
||||
}
|
||||
indexingWorker = null
|
||||
currentIndexingState = null
|
||||
}
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
indexingWorker?.requestIndex(withCache)
|
||||
}
|
||||
|
||||
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) =
|
||||
worker.scope.launch {
|
||||
try {
|
||||
val start = System.currentTimeMillis()
|
||||
indexImpl(worker, withCache)
|
||||
logD(
|
||||
"Music indexing completed successfully in " +
|
||||
"${System.currentTimeMillis() - start}ms")
|
||||
} 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())
|
||||
emitComplete(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
|
||||
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED) {
|
||||
logE("Permission check failed")
|
||||
// No permissions, signal that we can't do anything.
|
||||
throw NoAudioPermissionException()
|
||||
}
|
||||
|
||||
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
|
||||
// how long a media database query will take.
|
||||
emitLoading(IndexingProgress.Indeterminate)
|
||||
|
||||
// Do the initial query of the cache and media databases in parallel.
|
||||
logD("Starting queries")
|
||||
val mediaStoreQueryJob = worker.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 =
|
||||
worker.scope.async {
|
||||
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
||||
}
|
||||
val metadataJob =
|
||||
worker.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)
|
||||
emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
|
||||
}
|
||||
// These should be no-ops
|
||||
mediaStoreJob.await()
|
||||
metadataJob.await()
|
||||
|
||||
if (rawSongs.isEmpty()) {
|
||||
logE("Music library was empty")
|
||||
throw NoMusicException()
|
||||
}
|
||||
|
||||
// Successfully loaded the library, now save the cache and create the library in
|
||||
// parallel.
|
||||
logD("Discovered ${rawSongs.size} songs, starting finalization")
|
||||
emitLoading(IndexingProgress.Indeterminate)
|
||||
val libraryJob =
|
||||
worker.scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) }
|
||||
if (cache == null || cache.invalidated) {
|
||||
cacheRepository.writeCache(rawSongs)
|
||||
}
|
||||
val newLibrary = libraryJob.await()
|
||||
withContext(Dispatchers.Main) {
|
||||
emitComplete(null)
|
||||
emitData(newLibrary, listOf())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitLoading(progress: IndexingProgress) {
|
||||
yield()
|
||||
synchronized(this) {
|
||||
currentIndexingState = IndexingState.Indexing(progress)
|
||||
for (listener in indexingListeners) {
|
||||
listener.onIndexingStateChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitComplete(error: Exception?) {
|
||||
yield()
|
||||
synchronized(this) {
|
||||
previousCompletedState = IndexingState.Completed(error)
|
||||
currentIndexingState = null
|
||||
for (listener in indexingListeners) {
|
||||
listener.onIndexingStateChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeListener(listener: MusicRepository.Listener) {
|
||||
listeners.remove(listener)
|
||||
private fun emitData(library: Library, playlists: List<Playlist>) {
|
||||
val libraryChanged = this.library != library
|
||||
val playlistsChanged = this.playlists != playlists
|
||||
if (!libraryChanged && !playlistsChanged) return
|
||||
|
||||
this.library = library
|
||||
this.playlists = playlists
|
||||
val changes = MusicRepository.Changes(libraryChanged, playlistsChanged)
|
||||
for (listener in updateListeners) {
|
||||
listener.onMusicChanges(changes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,6 @@ 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
|
||||
|
||||
/**
|
||||
* A [ViewModel] providing data specific to the music loading process.
|
||||
|
@ -31,12 +30,12 @@ import org.oxycblt.auxio.music.system.Indexer
|
|||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MusicViewModel @Inject constructor(private val indexer: Indexer) :
|
||||
ViewModel(), Indexer.Listener {
|
||||
class MusicViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||
ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
|
||||
|
||||
private val _indexerState = MutableStateFlow<Indexer.State?>(null)
|
||||
private val _indexingState = MutableStateFlow<IndexingState?>(null)
|
||||
/** The current music loading state, or null if no loading is going on. */
|
||||
val indexerState: StateFlow<Indexer.State?> = _indexerState
|
||||
val indexingState: StateFlow<IndexingState?> = _indexingState
|
||||
|
||||
private val _statistics = MutableStateFlow<Statistics?>(null)
|
||||
/** [Statistics] about the last completed music load. */
|
||||
|
@ -44,18 +43,18 @@ class MusicViewModel @Inject constructor(private val indexer: Indexer) :
|
|||
get() = _statistics
|
||||
|
||||
init {
|
||||
indexer.registerListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
indexer.unregisterListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
musicRepository.removeIndexingListener(this)
|
||||
}
|
||||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
_indexerState.value = state
|
||||
if (state is Indexer.State.Complete) {
|
||||
// New state is a completed library, update the statistics values.
|
||||
val library = state.result.getOrNull() ?: return
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.library) return
|
||||
val library = musicRepository.library ?: return
|
||||
_statistics.value =
|
||||
Statistics(
|
||||
library.songs.size,
|
||||
|
@ -64,16 +63,19 @@ class MusicViewModel @Inject constructor(private val indexer: Indexer) :
|
|||
library.genres.size,
|
||||
library.songs.sumOf { it.durationMs })
|
||||
}
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
_indexingState.value = musicRepository.indexingState
|
||||
}
|
||||
|
||||
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
||||
fun refresh() {
|
||||
indexer.requestReindex(true)
|
||||
musicRepository.requestIndex(true)
|
||||
}
|
||||
|
||||
/** Requests that the music library be re-loaded without the cache. */
|
||||
fun rescan() {
|
||||
indexer.requestReindex(false)
|
||||
musicRepository.requestIndex(false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,446 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 Auxio Project
|
||||
* Indexer.kt is part of Auxio.
|
||||
*
|
||||
* 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.system
|
||||
|
||||
import android.Manifest
|
||||
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.cache.CacheRepository
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.music.library.RawSong
|
||||
import org.oxycblt.auxio.music.metadata.TagExtractor
|
||||
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
|
||||
|
||||
/**
|
||||
* Core music loading state class.
|
||||
*
|
||||
* 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 [MusicRepository] instead if you do not need to work with the exact
|
||||
* music loading state.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
interface Indexer {
|
||||
/** Whether music loading is occurring or not. */
|
||||
val isIndexing: Boolean
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Register a [Controller] for this instance. This instance will handle any commands to start
|
||||
* the music loading process. There can be only one [Controller] at a time. Will invoke all
|
||||
* [Listener] methods to initialize the instance with the current state.
|
||||
*
|
||||
* @param controller The [Controller] to register. Will do nothing if already registered.
|
||||
*/
|
||||
fun registerController(controller: Controller)
|
||||
|
||||
/**
|
||||
* Unregister the [Controller] from this instance, prevent it from recieving any further
|
||||
* commands.
|
||||
*
|
||||
* @param controller The [Controller] to unregister. Must be the current [Controller]. Does
|
||||
* nothing if invoked by another [Controller] implementation.
|
||||
*/
|
||||
fun unregisterController(controller: Controller)
|
||||
|
||||
/**
|
||||
* Register the [Listener] for this instance. This can be used to receive rapid-fire updates to
|
||||
* the current music loading state. There can be only one [Listener] at a time. Will invoke all
|
||||
* [Listener] methods to initialize the instance with the current state.
|
||||
*
|
||||
* @param listener The [Listener] to add.
|
||||
*/
|
||||
fun registerListener(listener: Listener)
|
||||
|
||||
/**
|
||||
* Unregister a [Listener] from this instance, preventing it from recieving any further updates.
|
||||
*
|
||||
* @param listener The [Listener] to unregister. Must be the current [Listener]. Does nothing if
|
||||
* invoked by another [Listener] implementation.
|
||||
* @see Listener
|
||||
*/
|
||||
fun unregisterListener(listener: Listener)
|
||||
|
||||
/**
|
||||
* Start the indexing process. This should be done from in the background from [Controller]'s
|
||||
* context after a command has been received to start the 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.
|
||||
* @param scope The [CoroutineScope] to run the indexing job in.
|
||||
* @return The [Job] stacking the indexing status.
|
||||
*/
|
||||
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 [Indexer.Controller] should call
|
||||
* [index] eventually.
|
||||
*
|
||||
* @param withCache Whether to use the cache when loading music. Does nothing if there is no
|
||||
* [Indexer.Controller].
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
fun reset()
|
||||
|
||||
/** Represents the current state of [Indexer]. */
|
||||
sealed class State {
|
||||
/**
|
||||
* Music loading is ongoing.
|
||||
*
|
||||
* @param indexing The current music loading progress..
|
||||
* @see Indexer.Indexing
|
||||
*/
|
||||
data class Indexing(val indexing: Indexer.Indexing) : State()
|
||||
|
||||
/**
|
||||
* Music loading has completed.
|
||||
*
|
||||
* @param result The outcome of the music loading process.
|
||||
*/
|
||||
data class Complete(val result: Result<Library>) : State()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the current progress of the music loader. Usually encapsulated in a [State].
|
||||
*
|
||||
* @see State.Indexing
|
||||
*/
|
||||
sealed class Indexing {
|
||||
/**
|
||||
* Music loading is occurring, but no definite estimate can be put on the current progress.
|
||||
*/
|
||||
object Indeterminate : Indexing()
|
||||
|
||||
/**
|
||||
* Music loading has a definite progress.
|
||||
*
|
||||
* @param current The current amount of songs that have been loaded.
|
||||
* @param total The projected total amount of songs that will be loaded.
|
||||
*/
|
||||
class Songs(val current: Int, val total: Int) : Indexing()
|
||||
}
|
||||
|
||||
/** Thrown when the required permissions to load the music library have not been granted yet. */
|
||||
class NoPermissionException : Exception() {
|
||||
override val message: String
|
||||
get() = "Not granted permissions to load music library"
|
||||
}
|
||||
|
||||
/** Thrown when no music was found on the device. */
|
||||
class NoMusicException : Exception() {
|
||||
override val message: String
|
||||
get() = "Unable to find any music"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, [MusicRepository.Listener] is highly recommended due to it's updates only
|
||||
* consisting of the [Library].
|
||||
*/
|
||||
interface Listener {
|
||||
/**
|
||||
* Called when the current state of the Indexer changed.
|
||||
*
|
||||
* Notes:
|
||||
* - Null means that no loading is going on, but no load has completed either.
|
||||
* - [State.Complete] may represent a previous load, if the current loading process was
|
||||
* canceled for one reason or another.
|
||||
*/
|
||||
fun onIndexerStateChanged(state: State?)
|
||||
}
|
||||
|
||||
/**
|
||||
* Context that runs the music loading process. Implementations should be capable of running the
|
||||
* background for long periods of time without android killing the process.
|
||||
*/
|
||||
interface Controller : Listener {
|
||||
/**
|
||||
* Called when a new music loading process was requested. Implementations should forward
|
||||
* this to [index].
|
||||
*
|
||||
* @param withCache Whether to use the cache or not when loading. If false, the cache should
|
||||
* still be written, but no cache entries will be loaded into the new library.
|
||||
* @see index
|
||||
*/
|
||||
fun onStartIndexing(withCache: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* A version-compatible identifier for the read external storage permission required by the
|
||||
* system to load audio.
|
||||
*/
|
||||
val PERMISSION_READ_AUDIO =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13
|
||||
Manifest.permission.READ_MEDIA_AUDIO
|
||||
} 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 response = indexImpl(context, withCache, this)
|
||||
logD(
|
||||
"Music indexing completed successfully in " +
|
||||
"${System.currentTimeMillis() - start}ms")
|
||||
Result.success(response)
|
||||
} catch (e: CancellationException) {
|
||||
// Got cancelled, propagate upwards to top-level co-routine.
|
||||
logD("Loading routine was cancelled")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// Music loading process failed due to something we have not handled.
|
||||
logE("Music indexing failed")
|
||||
logE(e.stackTraceToString())
|
||||
Result.failure(e)
|
||||
}
|
||||
emitCompletion(result)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun requestReindex(withCache: Boolean) {
|
||||
logD("Requesting reindex")
|
||||
controller?.onStartIndexing(withCache)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun reset() {
|
||||
logD("Cancelling last job")
|
||||
emitIndexing(null)
|
||||
}
|
||||
|
||||
private suspend fun indexImpl(
|
||||
context: Context,
|
||||
withCache: Boolean,
|
||||
scope: CoroutineScope
|
||||
): Library {
|
||||
if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) ==
|
||||
PackageManager.PERMISSION_DENIED) {
|
||||
logE("Permission check failed")
|
||||
// No permissions, signal that we can't do anything.
|
||||
throw Indexer.NoPermissionException()
|
||||
}
|
||||
|
||||
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
|
||||
// how long a media database query will take.
|
||||
emitIndexing(Indexer.Indexing.Indeterminate)
|
||||
|
||||
// Do the initial query of the cache and media databases in parallel.
|
||||
logD("Starting queries")
|
||||
val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() }
|
||||
val cache =
|
||||
if (withCache) {
|
||||
cacheRepository.readCache()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val query = mediaStoreQueryJob.await()
|
||||
|
||||
// Now start processing the queried song information in parallel. Songs that can't be
|
||||
// received from the cache are consisted incomplete and pushed to a separate channel
|
||||
// that will eventually be processed into completed raw songs.
|
||||
logD("Starting song discovery")
|
||||
val completeSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED)
|
||||
val mediaStoreJob =
|
||||
scope.async {
|
||||
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
|
||||
}
|
||||
val metadataJob = scope.async { tagExtractor.consume(incompleteSongs, completeSongs) }
|
||||
|
||||
// Await completed raw songs as they are processed.
|
||||
val rawSongs = LinkedList<RawSong>()
|
||||
for (rawSong in completeSongs) {
|
||||
rawSongs.add(rawSong)
|
||||
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal))
|
||||
}
|
||||
// These should be no-ops
|
||||
mediaStoreJob.await()
|
||||
metadataJob.await()
|
||||
|
||||
if (rawSongs.isEmpty()) {
|
||||
logE("Music library was empty")
|
||||
throw Indexer.NoMusicException()
|
||||
}
|
||||
|
||||
// Successfully loaded the library, now save the cache and create the library in
|
||||
// parallel.
|
||||
logD("Discovered ${rawSongs.size} songs, starting finalization")
|
||||
emitIndexing(Indexer.Indexing.Indeterminate)
|
||||
val libraryJob = scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) }
|
||||
if (cache == null || cache.invalidated) {
|
||||
cacheRepository.writeCache(rawSongs)
|
||||
}
|
||||
return libraryJob.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a new [Indexer.State.Indexing] state. This can be used to signal the current state of
|
||||
* the music loading process to external code. Assumes that the callee has already checked if
|
||||
* they have not been canceled and thus have the ability to emit a new state.
|
||||
*
|
||||
* @param indexing The new [Indexer.Indexing] state to emit, or null if no loading process is
|
||||
* occurring.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun emitIndexing(indexing: Indexer.Indexing?) {
|
||||
indexingState = indexing
|
||||
// If we have canceled the loading process, we want to revert to a previous completion
|
||||
// whenever possible to prevent state inconsistency.
|
||||
val state =
|
||||
indexingState?.let { Indexer.State.Indexing(it) }
|
||||
?: lastResponse?.let { Indexer.State.Complete(it) }
|
||||
controller?.onIndexerStateChanged(state)
|
||||
listener?.onIndexerStateChanged(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a new [Indexer.State.Complete] state. This can be used to signal the completion of the
|
||||
* music loading process to external code. Will check if the callee has not been canceled and
|
||||
* thus has the ability to emit a new state
|
||||
*
|
||||
* @param result The new [Result] to emit, representing the outcome of the music loading
|
||||
* process.
|
||||
*/
|
||||
private suspend fun emitCompletion(result: Result<Library>) {
|
||||
yield()
|
||||
// Swap to the Main thread so that downstream callbacks don't crash from being on
|
||||
// a background thread. Does not occur in emitIndexing due to efficiency reasons.
|
||||
withContext(Dispatchers.Main) {
|
||||
synchronized(this) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ import androidx.core.app.NotificationCompat
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.IntegerTable
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.IndexingProgress
|
||||
import org.oxycblt.auxio.service.ForegroundServiceNotification
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
||||
|
@ -56,22 +57,22 @@ class IndexingNotification(private val context: Context) :
|
|||
/**
|
||||
* Update this notification with the new music loading state.
|
||||
*
|
||||
* @param indexing The new music loading state to display in the notification.
|
||||
* @param progress The new music loading state to display in the notification.
|
||||
* @return true if the notification updated, false otherwise
|
||||
*/
|
||||
fun updateIndexingState(indexing: Indexer.Indexing): Boolean {
|
||||
when (indexing) {
|
||||
is Indexer.Indexing.Indeterminate -> {
|
||||
fun updateIndexingState(progress: IndexingProgress): Boolean {
|
||||
when (progress) {
|
||||
is IndexingProgress.Indeterminate -> {
|
||||
// Indeterminate state, use a vaguer description and in-determinate progress.
|
||||
// These events are not very frequent, and thus we don't need to safeguard
|
||||
// against rate limiting.
|
||||
logD("Updating state to $indexing")
|
||||
logD("Updating state to $progress")
|
||||
lastUpdateTime = -1
|
||||
setContentText(context.getString(R.string.lng_indexing))
|
||||
setProgress(0, 0, true)
|
||||
return true
|
||||
}
|
||||
is Indexer.Indexing.Songs -> {
|
||||
is IndexingProgress.Songs -> {
|
||||
// Determinate state, show an active progress meter. Since these updates arrive
|
||||
// highly rapidly, only update every 1.5 seconds to prevent notification rate
|
||||
// limiting.
|
||||
|
@ -80,10 +81,10 @@ class IndexingNotification(private val context: Context) :
|
|||
return false
|
||||
}
|
||||
lastUpdateTime = SystemClock.elapsedRealtime()
|
||||
logD("Updating state to $indexing")
|
||||
logD("Updating state to $progress")
|
||||
setContentText(
|
||||
context.getString(R.string.fmt_indexing, indexing.current, indexing.total))
|
||||
setProgress(indexing.total, indexing.current, false)
|
||||
context.getString(R.string.fmt_indexing, progress.current, progress.total))
|
||||
setProgress(progress.total, progress.current, false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,13 +28,12 @@ import android.os.PowerManager
|
|||
import android.provider.MediaStore
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.lang.Runnable
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.*
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.service.ForegroundManager
|
||||
|
@ -56,12 +55,17 @@ import org.oxycblt.auxio.util.logD
|
|||
* TODO: Unify with PlaybackService as part of the service independence project
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
||||
class IndexerService :
|
||||
Service(),
|
||||
MusicRepository.IndexingWorker,
|
||||
MusicRepository.IndexingListener,
|
||||
MusicRepository.UpdateListener,
|
||||
MusicSettings.Listener {
|
||||
@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
|
||||
|
@ -85,13 +89,9 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
|||
// condition to cause us to load music before we were fully initialize.
|
||||
indexerContentObserver = SystemContentObserver()
|
||||
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 (musicRepository.library == null && indexer.isIndeterminate) {
|
||||
logD("No library present and no previous response, indexing music now")
|
||||
onStartIndexing(true)
|
||||
}
|
||||
musicRepository.addUpdateListener(this)
|
||||
musicRepository.addIndexingListener(this)
|
||||
musicRepository.registerWorker(this)
|
||||
|
||||
logD("Service created.")
|
||||
}
|
||||
|
@ -109,34 +109,30 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
|||
// events will not occur.
|
||||
indexerContentObserver.release()
|
||||
musicSettings.unregisterListener(this)
|
||||
indexer.unregisterController(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
musicRepository.removeIndexingListener(this)
|
||||
musicRepository.unregisterWorker(this)
|
||||
// Then cancel any remaining music loading jobs.
|
||||
serviceJob.cancel()
|
||||
indexer.reset()
|
||||
}
|
||||
|
||||
// --- CONTROLLER CALLBACKS ---
|
||||
|
||||
override fun onStartIndexing(withCache: Boolean) {
|
||||
if (indexer.isIndexing) {
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
// Cancel the previous music loading job.
|
||||
currentIndexJob?.cancel()
|
||||
indexer.reset()
|
||||
}
|
||||
// Start a new music loading job on a co-routine.
|
||||
currentIndexJob = indexer.index(this@IndexerService, withCache, indexScope)
|
||||
currentIndexJob =
|
||||
indexScope.launch { musicRepository.index(this@IndexerService, withCache) }
|
||||
}
|
||||
|
||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
||||
when (state) {
|
||||
is Indexer.State.Indexing -> updateActiveSession(state.indexing)
|
||||
is Indexer.State.Complete -> {
|
||||
val newLibrary = state.result.getOrNull()
|
||||
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 (musicRepository.library != null) {
|
||||
override val context = this
|
||||
|
||||
override val scope = indexScope
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (!changes.library) return
|
||||
val library = musicRepository.library ?: return
|
||||
// Wipe possibly-invalidated outdated covers
|
||||
imageLoader.memoryCache?.clear()
|
||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||
|
@ -145,47 +141,34 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
|||
playbackManager.toSavedState()?.let { savedState ->
|
||||
playbackManager.applySavedState(
|
||||
PlaybackStateManager.SavedState(
|
||||
parent = savedState.parent?.let(newLibrary::sanitize),
|
||||
parent = savedState.parent?.let(library::sanitize),
|
||||
queueState =
|
||||
savedState.queueState.remap { song ->
|
||||
newLibrary.sanitize(requireNotNull(song))
|
||||
library.sanitize(requireNotNull(song))
|
||||
},
|
||||
positionMs = savedState.positionMs,
|
||||
repeatMode = savedState.repeatMode),
|
||||
true)
|
||||
}
|
||||
}
|
||||
// Forward the new library to MusicStore to continue the update process.
|
||||
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
|
||||
// handled right now.
|
||||
|
||||
override fun onIndexingStateChanged() {
|
||||
val state = musicRepository.indexingState
|
||||
if (state is IndexingState.Indexing) {
|
||||
updateActiveSession(state.progress)
|
||||
} else {
|
||||
updateIdleSession()
|
||||
}
|
||||
null -> {
|
||||
// Null is the indeterminate state that occurs on app startup or after
|
||||
// the cancellation of a load, so in that case we want to stop foreground
|
||||
// since (technically) nothing is loading.
|
||||
updateIdleSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- INTERNAL ---
|
||||
|
||||
/**
|
||||
* Update the current state to "Active", in which the service signals that music loading is
|
||||
* on-going.
|
||||
*
|
||||
* @param state The current music loading state.
|
||||
*/
|
||||
private fun updateActiveSession(state: Indexer.Indexing) {
|
||||
private fun updateActiveSession(progress: IndexingProgress) {
|
||||
// When loading, we want to enter the foreground state so that android does
|
||||
// not shut off the loading process. Note that while we will always post the
|
||||
// notification when initially starting, we will not update the notification
|
||||
// unless it indicates that it has changed.
|
||||
val changed = indexingNotification.updateIndexingState(state)
|
||||
val changed = indexingNotification.updateIndexingState(progress)
|
||||
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
|
||||
logD("Notification changed, re-posting notification")
|
||||
indexingNotification.post()
|
||||
|
@ -194,10 +177,6 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
|||
wakeLock.acquireSafe()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current state to "Idle", in which it either does nothing or signals that it's
|
||||
* currently monitoring the music library for changes.
|
||||
*/
|
||||
private fun updateIdleSession() {
|
||||
if (musicSettings.shouldBeObserving) {
|
||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||
|
@ -244,7 +223,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
|||
|
||||
override fun onIndexingSettingChanged() {
|
||||
// Music loading configuration changed, need to reload music.
|
||||
onStartIndexing(true)
|
||||
requestIndex(true)
|
||||
}
|
||||
|
||||
override fun onObservingChanged() {
|
||||
|
@ -252,7 +231,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
|||
// notification if we were actively loading when the automatic rescanning
|
||||
// setting changed. In such a case, the state will still be updated when
|
||||
// the music loading process ends.
|
||||
if (!indexer.isIndexing) {
|
||||
if (currentIndexJob == null) {
|
||||
updateIdleSession()
|
||||
}
|
||||
}
|
||||
|
@ -290,7 +269,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
|||
// 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 (musicSettings.shouldBeObserving) {
|
||||
onStartIndexing(true)
|
||||
requestIndex(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.oxycblt.auxio.music.*
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
|
@ -35,7 +34,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
|||
*/
|
||||
@HiltViewModel
|
||||
class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||
ViewModel(), MusicRepository.Listener {
|
||||
ViewModel(), MusicRepository.UpdateListener {
|
||||
|
||||
private val _currentItem = MutableStateFlow<Music?>(null)
|
||||
/** The current item whose artists should be shown in the picker. Null if there is no item. */
|
||||
|
@ -52,12 +51,16 @@ class PickerViewModel @Inject constructor(private val musicRepository: MusicRepo
|
|||
val genreChoices: StateFlow<List<Genre>>
|
||||
get() = _genreChoices
|
||||
|
||||
override fun onCleared() {
|
||||
musicRepository.removeListener(this)
|
||||
init {
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library != null) {
|
||||
override fun onCleared() {
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.library && musicRepository.library != null) {
|
||||
refreshChoices()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,7 +48,6 @@ import org.oxycblt.auxio.BuildConfig
|
|||
import org.oxycblt.auxio.music.MusicRepository
|
||||
import org.oxycblt.auxio.music.MusicSettings
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
|
@ -82,7 +81,7 @@ class PlaybackService :
|
|||
Player.Listener,
|
||||
InternalPlayer,
|
||||
MediaSessionComponent.Listener,
|
||||
MusicRepository.Listener {
|
||||
MusicRepository.UpdateListener {
|
||||
// Player components
|
||||
private lateinit var player: ExoPlayer
|
||||
@Inject lateinit var mediaSourceFactory: MediaSource.Factory
|
||||
|
@ -148,7 +147,7 @@ class PlaybackService :
|
|||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
playbackManager.registerInternalPlayer(this)
|
||||
musicRepository.addListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
mediaSessionComponent.registerListener(this)
|
||||
registerReceiver(
|
||||
systemReceiver,
|
||||
|
@ -187,7 +186,7 @@ class PlaybackService :
|
|||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.setPlaying(false)
|
||||
playbackManager.unregisterInternalPlayer(this)
|
||||
musicRepository.removeListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
|
||||
unregisterReceiver(systemReceiver)
|
||||
serviceJob.cancel()
|
||||
|
@ -299,10 +298,8 @@ class PlaybackService :
|
|||
playbackManager.next()
|
||||
}
|
||||
|
||||
// --- MUSICSTORE OVERRIDES ---
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library != null) {
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.library && musicRepository.library != null) {
|
||||
// We now have a library, see if we have anything we need to do.
|
||||
playbackManager.requestAction(this)
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ constructor(
|
|||
private val searchEngine: SearchEngine,
|
||||
private val searchSettings: SearchSettings,
|
||||
private val playbackSettings: PlaybackSettings,
|
||||
) : ViewModel(), MusicRepository.Listener {
|
||||
) : ViewModel(), MusicRepository.UpdateListener {
|
||||
private var lastQuery: String? = null
|
||||
private var currentSearchJob: Job? = null
|
||||
|
||||
|
@ -64,17 +64,16 @@ constructor(
|
|||
get() = playbackSettings.inListPlaybackMode
|
||||
|
||||
init {
|
||||
musicRepository.addListener(this)
|
||||
musicRepository.addUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
musicRepository.removeListener(this)
|
||||
musicRepository.removeUpdateListener(this)
|
||||
}
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
if (library != null) {
|
||||
// Make sure our query is up to date with the music library.
|
||||
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||
if (changes.library && musicRepository.library != null) {
|
||||
search(lastQuery)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* FakeMusicRepository.kt is part of Auxio.
|
||||
*
|
||||
* 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 kotlinx.coroutines.Job
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
|
||||
open class FakeMusicRepository : MusicRepository {
|
||||
override var indexingState: IndexingState?
|
||||
get() = throw NotImplementedError()
|
||||
set(_) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
override var library: Library?
|
||||
get() = throw NotImplementedError()
|
||||
set(_) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
override var playlists: List<Playlist>?
|
||||
get() = throw NotImplementedError()
|
||||
set(_) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun registerWorker(worker: MusicRepository.IndexingWorker) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean): Job {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* MusicRepositoryTest.kt is part of Auxio.
|
||||
*
|
||||
* 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 org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.oxycblt.auxio.music.library.FakeLibrary
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
|
||||
class MusicRepositoryTest {
|
||||
@Test
|
||||
fun listeners() {
|
||||
val listener = TestListener()
|
||||
val impl =
|
||||
MusicRepositoryImpl().apply {
|
||||
library = null
|
||||
addListener(listener)
|
||||
}
|
||||
impl.library = TestLibrary(0)
|
||||
assertEquals(listOf(null, TestLibrary(0)), listener.updates)
|
||||
|
||||
val listener2 = TestListener()
|
||||
impl.addListener(listener2)
|
||||
impl.library = TestLibrary(1)
|
||||
assertEquals(listOf(TestLibrary(0), TestLibrary(1)), listener2.updates)
|
||||
}
|
||||
|
||||
private class TestListener : MusicRepository.Listener {
|
||||
val updates = mutableListOf<Library?>()
|
||||
|
||||
override fun onLibraryChanged(library: Library?) {
|
||||
updates.add(library)
|
||||
}
|
||||
}
|
||||
|
||||
private data class TestLibrary(private val id: Int) : FakeLibrary()
|
||||
}
|
|
@ -22,31 +22,34 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.oxycblt.auxio.music.library.FakeLibrary
|
||||
import org.oxycblt.auxio.music.system.FakeIndexer
|
||||
import org.oxycblt.auxio.music.system.Indexer
|
||||
import org.oxycblt.auxio.music.library.Library
|
||||
import org.oxycblt.auxio.util.forceClear
|
||||
|
||||
class MusicViewModelTest {
|
||||
@Test
|
||||
fun indexerState() {
|
||||
val indexer =
|
||||
TestIndexer().apply { state = Indexer.State.Indexing(Indexer.Indexing.Indeterminate) }
|
||||
TestMusicRepository().apply {
|
||||
indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate)
|
||||
}
|
||||
val musicViewModel = MusicViewModel(indexer)
|
||||
assertTrue(indexer.listener is MusicViewModel)
|
||||
assertTrue(indexer.updateListener is MusicViewModel)
|
||||
assertTrue(indexer.indexingListener is MusicViewModel)
|
||||
assertEquals(
|
||||
Indexer.Indexing.Indeterminate,
|
||||
(musicViewModel.indexerState.value as Indexer.State.Indexing).indexing)
|
||||
indexer.state = null
|
||||
assertEquals(null, musicViewModel.indexerState.value)
|
||||
IndexingProgress.Indeterminate,
|
||||
(musicViewModel.indexingState.value as IndexingState.Indexing).progress)
|
||||
indexer.indexingState = null
|
||||
assertEquals(null, musicViewModel.indexingState.value)
|
||||
musicViewModel.forceClear()
|
||||
assertTrue(indexer.listener == null)
|
||||
assertTrue(indexer.indexingListener == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statistics() {
|
||||
val indexer =
|
||||
TestIndexer().apply { state = Indexer.State.Complete(Result.success(TestLibrary())) }
|
||||
val musicViewModel = MusicViewModel(indexer)
|
||||
val musicRepository = TestMusicRepository()
|
||||
val musicViewModel = MusicViewModel(musicRepository)
|
||||
assertEquals(null, musicViewModel.statistics.value)
|
||||
musicRepository.library = TestLibrary()
|
||||
assertEquals(
|
||||
MusicViewModel.Statistics(
|
||||
2,
|
||||
|
@ -60,33 +63,49 @@ class MusicViewModelTest {
|
|||
|
||||
@Test
|
||||
fun requests() {
|
||||
val indexer = TestIndexer()
|
||||
val indexer = TestMusicRepository()
|
||||
val musicViewModel = MusicViewModel(indexer)
|
||||
musicViewModel.refresh()
|
||||
musicViewModel.rescan()
|
||||
assertEquals(listOf(true, false), indexer.requests)
|
||||
}
|
||||
|
||||
private class TestIndexer : FakeIndexer() {
|
||||
var listener: Indexer.Listener? = null
|
||||
var state: Indexer.State? = null
|
||||
private class TestMusicRepository : FakeMusicRepository() {
|
||||
override var library: Library? = null
|
||||
set(value) {
|
||||
field = value
|
||||
listener?.onIndexerStateChanged(value)
|
||||
updateListener?.onMusicChanges(
|
||||
MusicRepository.Changes(library = true, playlists = false))
|
||||
}
|
||||
override var indexingState: IndexingState? = null
|
||||
set(value) {
|
||||
field = value
|
||||
indexingListener?.onIndexingStateChanged()
|
||||
}
|
||||
|
||||
var updateListener: MusicRepository.UpdateListener? = null
|
||||
var indexingListener: MusicRepository.IndexingListener? = null
|
||||
val requests = mutableListOf<Boolean>()
|
||||
|
||||
override fun registerListener(listener: Indexer.Listener) {
|
||||
this.listener = listener
|
||||
listener.onIndexerStateChanged(state)
|
||||
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
listener.onMusicChanges(MusicRepository.Changes(library = true, playlists = false))
|
||||
this.updateListener = listener
|
||||
}
|
||||
|
||||
override fun unregisterListener(listener: Indexer.Listener) {
|
||||
this.listener = null
|
||||
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||
this.updateListener = null
|
||||
}
|
||||
|
||||
override fun requestReindex(withCache: Boolean) {
|
||||
override fun addIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
listener.onIndexingStateChanged()
|
||||
this.indexingListener = listener
|
||||
}
|
||||
|
||||
override fun removeIndexingListener(listener: MusicRepository.IndexingListener) {
|
||||
this.indexingListener = null
|
||||
}
|
||||
|
||||
override fun requestIndex(withCache: Boolean) {
|
||||
requests.add(withCache)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* FakeIndexer.kt is part of Auxio.
|
||||
*
|
||||
* 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.system
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
open class FakeIndexer : Indexer {
|
||||
override val isIndeterminate: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
override val isIndexing: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun registerController(controller: Indexer.Controller) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun unregisterController(controller: Indexer.Controller) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun registerListener(listener: Indexer.Listener) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun unregisterListener(listener: Indexer.Listener) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun requestReindex(withCache: Boolean) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue