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.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.*
|
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.AudioInfo
|
||||||
import org.oxycblt.auxio.music.metadata.Disc
|
import org.oxycblt.auxio.music.metadata.Disc
|
||||||
import org.oxycblt.auxio.music.metadata.ReleaseType
|
import org.oxycblt.auxio.music.metadata.ReleaseType
|
||||||
|
@ -57,7 +56,7 @@ constructor(
|
||||||
private val audioInfoProvider: AudioInfo.Provider,
|
private val audioInfoProvider: AudioInfo.Provider,
|
||||||
private val musicSettings: MusicSettings,
|
private val musicSettings: MusicSettings,
|
||||||
private val playbackSettings: PlaybackSettings
|
private val playbackSettings: PlaybackSettings
|
||||||
) : ViewModel(), MusicRepository.Listener {
|
) : ViewModel(), MusicRepository.UpdateListener {
|
||||||
private var currentSongJob: Job? = null
|
private var currentSongJob: Job? = null
|
||||||
|
|
||||||
// --- SONG ---
|
// --- SONG ---
|
||||||
|
@ -152,18 +151,16 @@ constructor(
|
||||||
get() = playbackSettings.inParentPlaybackMode
|
get() = playbackSettings.inParentPlaybackMode
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
musicRepository.removeListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (library == null) {
|
if (!changes.library) return
|
||||||
// Nothing to do.
|
val library = musicRepository.library ?: return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we are showing any item right now, we will need to refresh it (and any information
|
// 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
|
// 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.SelectionFragment
|
||||||
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
import org.oxycblt.auxio.list.selection.SelectionViewModel
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.library.Library
|
|
||||||
import org.oxycblt.auxio.music.system.Indexer
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.ui.MainNavigationAction
|
import org.oxycblt.auxio.ui.MainNavigationAction
|
||||||
import org.oxycblt.auxio.ui.NavigationViewModel
|
import org.oxycblt.auxio.ui.NavigationViewModel
|
||||||
|
@ -158,7 +156,7 @@ class HomeFragment :
|
||||||
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
collect(homeModel.recreateTabs.flow, ::handleRecreate)
|
||||||
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
collectImmediately(homeModel.currentTabMode, ::updateCurrentTab)
|
||||||
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
|
collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab)
|
||||||
collectImmediately(musicModel.indexerState, ::updateIndexerState)
|
collectImmediately(musicModel.indexingState, ::updateIndexerState)
|
||||||
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
|
collect(navModel.exploreNavigationItem.flow, ::handleNavigation)
|
||||||
collectImmediately(selectionModel.selected, ::updateSelection)
|
collectImmediately(selectionModel.selected, ::updateSelection)
|
||||||
}
|
}
|
||||||
|
@ -340,14 +338,14 @@ class HomeFragment :
|
||||||
homeModel.recreateTabs.consume()
|
homeModel.recreateTabs.consume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateIndexerState(state: Indexer.State?) {
|
private fun updateIndexerState(state: IndexingState?) {
|
||||||
// TODO: Make music loading experience a bit more pleasant
|
// TODO: Make music loading experience a bit more pleasant
|
||||||
// 1. Loading placeholder for item lists
|
// 1. Loading placeholder for item lists
|
||||||
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
|
||||||
val binding = requireBinding()
|
val binding = requireBinding()
|
||||||
when (state) {
|
when (state) {
|
||||||
is Indexer.State.Complete -> setupCompleteState(binding, state.result)
|
is IndexingState.Completed -> setupCompleteState(binding, state.error)
|
||||||
is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing)
|
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
|
||||||
null -> {
|
null -> {
|
||||||
logD("Indexer is in indeterminate state")
|
logD("Indexer is in indeterminate state")
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||||
|
@ -355,77 +353,77 @@ class HomeFragment :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupCompleteState(binding: FragmentHomeBinding, result: Result<Library>) {
|
private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) {
|
||||||
if (result.isSuccess) {
|
if (error == null) {
|
||||||
logD("Received ok response")
|
logD("Received ok response")
|
||||||
binding.homeFab.show()
|
binding.homeFab.show()
|
||||||
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.visibility = View.INVISIBLE
|
||||||
} else {
|
return
|
||||||
logD("Received non-ok response")
|
}
|
||||||
val context = requireContext()
|
|
||||||
val throwable = unlikelyToBeNull(result.exceptionOrNull())
|
logD("Received non-ok response")
|
||||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
val context = requireContext()
|
||||||
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||||
when (throwable) {
|
binding.homeIndexingProgress.visibility = View.INVISIBLE
|
||||||
is Indexer.NoPermissionException -> {
|
when (error) {
|
||||||
logD("Updating UI to permission request state")
|
is NoAudioPermissionException -> {
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
logD("Updating UI to permission request state")
|
||||||
// Configure the action to act as a permission launcher.
|
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
|
||||||
binding.homeIndexingAction.apply {
|
// Configure the action to act as a permission launcher.
|
||||||
visibility = View.VISIBLE
|
binding.homeIndexingAction.apply {
|
||||||
text = context.getString(R.string.lbl_grant)
|
visibility = View.VISIBLE
|
||||||
setOnClickListener {
|
text = context.getString(R.string.lbl_grant)
|
||||||
requireNotNull(storagePermissionLauncher) {
|
setOnClickListener {
|
||||||
"Permission launcher was not available"
|
requireNotNull(storagePermissionLauncher) {
|
||||||
}
|
"Permission launcher was not available"
|
||||||
.launch(Indexer.PERMISSION_READ_AUDIO)
|
}
|
||||||
}
|
.launch(PERMISSION_READ_AUDIO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is Indexer.NoMusicException -> {
|
}
|
||||||
logD("Updating UI to no music state")
|
is NoMusicException -> {
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
logD("Updating UI to no music state")
|
||||||
// Configure the action to act as a reload trigger.
|
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
|
||||||
binding.homeIndexingAction.apply {
|
// Configure the action to act as a reload trigger.
|
||||||
visibility = View.VISIBLE
|
binding.homeIndexingAction.apply {
|
||||||
text = context.getString(R.string.lbl_retry)
|
visibility = View.VISIBLE
|
||||||
setOnClickListener { musicModel.refresh() }
|
text = context.getString(R.string.lbl_retry)
|
||||||
}
|
setOnClickListener { musicModel.refresh() }
|
||||||
}
|
}
|
||||||
else -> {
|
}
|
||||||
logD("Updating UI to error state")
|
else -> {
|
||||||
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
logD("Updating UI to error state")
|
||||||
// Configure the action to act as a reload trigger.
|
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
|
||||||
binding.homeIndexingAction.apply {
|
// Configure the action to act as a reload trigger.
|
||||||
visibility = View.VISIBLE
|
binding.homeIndexingAction.apply {
|
||||||
text = context.getString(R.string.lbl_retry)
|
visibility = View.VISIBLE
|
||||||
setOnClickListener { musicModel.rescan() }
|
text = context.getString(R.string.lbl_retry)
|
||||||
}
|
setOnClickListener { musicModel.rescan() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) {
|
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
|
||||||
// Remove all content except for the progress indicator.
|
// Remove all content except for the progress indicator.
|
||||||
binding.homeIndexingContainer.visibility = View.VISIBLE
|
binding.homeIndexingContainer.visibility = View.VISIBLE
|
||||||
binding.homeIndexingProgress.visibility = View.VISIBLE
|
binding.homeIndexingProgress.visibility = View.VISIBLE
|
||||||
binding.homeIndexingAction.visibility = View.INVISIBLE
|
binding.homeIndexingAction.visibility = View.INVISIBLE
|
||||||
|
|
||||||
when (indexing) {
|
when (progress) {
|
||||||
is Indexer.Indexing.Indeterminate -> {
|
is IndexingProgress.Indeterminate -> {
|
||||||
// In a query/initialization state, show a generic loading status.
|
// In a query/initialization state, show a generic loading status.
|
||||||
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
|
||||||
binding.homeIndexingProgress.isIndeterminate = true
|
binding.homeIndexingProgress.isIndeterminate = true
|
||||||
}
|
}
|
||||||
is Indexer.Indexing.Songs -> {
|
is IndexingProgress.Songs -> {
|
||||||
// Actively loading songs, show the current progress.
|
// Actively loading songs, show the current progress.
|
||||||
binding.homeIndexingStatus.text =
|
binding.homeIndexingStatus.text =
|
||||||
getString(R.string.fmt_indexing, indexing.current, indexing.total)
|
getString(R.string.fmt_indexing, progress.current, progress.total)
|
||||||
binding.homeIndexingProgress.apply {
|
binding.homeIndexingProgress.apply {
|
||||||
isIndeterminate = false
|
isIndeterminate = false
|
||||||
max = indexing.total
|
max = progress.total
|
||||||
progress = indexing.current
|
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.Sort
|
||||||
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
import org.oxycblt.auxio.list.adapter.UpdateInstructions
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.library.Library
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.util.Event
|
import org.oxycblt.auxio.util.Event
|
||||||
import org.oxycblt.auxio.util.MutableEvent
|
import org.oxycblt.auxio.util.MutableEvent
|
||||||
|
@ -46,7 +45,7 @@ constructor(
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
private val musicRepository: MusicRepository,
|
private val musicRepository: MusicRepository,
|
||||||
private val musicSettings: MusicSettings
|
private val musicSettings: MusicSettings
|
||||||
) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener {
|
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
|
||||||
|
|
||||||
private val _songsList = MutableStateFlow(listOf<Song>())
|
private val _songsList = MutableStateFlow(listOf<Song>())
|
||||||
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
|
||||||
|
@ -117,37 +116,37 @@ constructor(
|
||||||
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
homeSettings.registerListener(this)
|
homeSettings.registerListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicRepository.removeListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
homeSettings.unregisterListener(this)
|
homeSettings.unregisterListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (library != null) {
|
if (!changes.library) return
|
||||||
logD("Library changed, refreshing library")
|
val library = musicRepository.library ?: return
|
||||||
// Get the each list of items in the library to use as our list data.
|
logD("Library changed, refreshing library")
|
||||||
// Applying the preferred sorting to them.
|
// Get the each list of items in the library to use as our list data.
|
||||||
_songsInstructions.put(UpdateInstructions.Diff)
|
// Applying the preferred sorting to them.
|
||||||
_songsList.value = musicSettings.songSort.songs(library.songs)
|
_songsInstructions.put(UpdateInstructions.Diff)
|
||||||
_albumsInstructions.put(UpdateInstructions.Diff)
|
_songsList.value = musicSettings.songSort.songs(library.songs)
|
||||||
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
|
_albumsInstructions.put(UpdateInstructions.Diff)
|
||||||
_artistsInstructions.put(UpdateInstructions.Diff)
|
_albumsLists.value = musicSettings.albumSort.albums(library.albums)
|
||||||
_artistsList.value =
|
_artistsInstructions.put(UpdateInstructions.Diff)
|
||||||
musicSettings.artistSort.artists(
|
_artistsList.value =
|
||||||
if (homeSettings.shouldHideCollaborators) {
|
musicSettings.artistSort.artists(
|
||||||
// Hide Collaborators is enabled, filter out collaborators.
|
if (homeSettings.shouldHideCollaborators) {
|
||||||
library.artists.filter { !it.isCollaborator }
|
// Hide Collaborators is enabled, filter out collaborators.
|
||||||
} else {
|
library.artists.filter { !it.isCollaborator }
|
||||||
library.artists
|
} else {
|
||||||
})
|
library.artists
|
||||||
_genresInstructions.put(UpdateInstructions.Diff)
|
})
|
||||||
_genresList.value = musicSettings.genreSort.genres(library.genres)
|
_genresInstructions.put(UpdateInstructions.Diff)
|
||||||
}
|
_genresList.value = musicSettings.genreSort.genres(library.genres)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTabsChanged() {
|
override fun onTabsChanged() {
|
||||||
|
@ -159,7 +158,7 @@ constructor(
|
||||||
override fun onHideCollaboratorsChanged() {
|
override fun onHideCollaboratorsChanged() {
|
||||||
// Changes in the hide collaborator setting will change the artist contents
|
// Changes in the hide collaborator setting will change the artist contents
|
||||||
// of the library, consider it a library update.
|
// of the library, consider it a library update.
|
||||||
onLibraryChanged(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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.library.Library
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] that manages the current selection.
|
* A [ViewModel] that manages the current selection.
|
||||||
|
@ -33,21 +32,19 @@ import org.oxycblt.auxio.music.library.Library
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||||
ViewModel(), MusicRepository.Listener {
|
ViewModel(), MusicRepository.UpdateListener {
|
||||||
private val _selected = MutableStateFlow(listOf<Music>())
|
private val _selected = MutableStateFlow(listOf<Music>())
|
||||||
/** the currently selected items. These are ordered in earliest selected and latest selected. */
|
/** the currently selected items. These are ordered in earliest selected and latest selected. */
|
||||||
val selected: StateFlow<List<Music>>
|
val selected: StateFlow<List<Music>>
|
||||||
get() = _selected
|
get() = _selected
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (library == null) {
|
if (!changes.library) return
|
||||||
return
|
val library = musicRepository.library ?: return
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize the selection to remove items that no longer exist and thus
|
// Sanitize the selection to remove items that no longer exist and thus
|
||||||
// won't appear in any list.
|
// won't appear in any list.
|
||||||
_selected.value =
|
_selected.value =
|
||||||
|
@ -64,7 +61,7 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.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.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.oxycblt.auxio.music.system.Indexer
|
|
||||||
import org.oxycblt.auxio.music.system.IndexerImpl
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface MusicModule {
|
interface MusicModule {
|
||||||
@Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
|
@Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository
|
||||||
@Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer
|
|
||||||
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
|
@Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,75 +18,327 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music
|
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 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.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
|
* Music information is loaded in-memory by this repository using an [IndexingWorker].
|
||||||
* generally recommended to use this over Indexer to keep track of the library state, as the
|
* Changes in music (loading) can be reacted to with [UpdateListener] and [IndexingListener].
|
||||||
* interface will be less volatile.
|
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
interface MusicRepository {
|
interface MusicRepository {
|
||||||
/**
|
/** The current immutable music library loaded from the file-system. */
|
||||||
* The current [Library]. May be null if a [Library] has not been successfully loaded yet. This
|
val library: Library?
|
||||||
* can change, so it's highly recommended to not access this directly and instead rely on
|
/** The current mutable user-defined playlists loaded from the file-system. */
|
||||||
* [Listener].
|
val playlists: List<Playlist>?
|
||||||
*/
|
/** The current state of music loading. Null if no load has occurred yet. */
|
||||||
var library: Library?
|
val indexingState: IndexingState?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a [Listener] to this instance. This can be used to receive changes in the music library.
|
* Add an [UpdateListener] to receive updates from this instance.
|
||||||
* Will invoke all [Listener] methods to initialize the instance with the current state.
|
* @param listener The [UpdateListener] to add.
|
||||||
*
|
|
||||||
* @param listener The [Listener] to add.
|
|
||||||
* @see Listener
|
|
||||||
*/
|
*/
|
||||||
fun addListener(listener: Listener)
|
fun addUpdateListener(listener: UpdateListener)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a [Listener] from this instance, preventing it from receiving any further updates.
|
* Remove an [UpdateListener] such that it does not receive any further updates from this
|
||||||
*
|
* instance.
|
||||||
* @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in
|
* @param listener The [UpdateListener] to remove.
|
||||||
* the first place.
|
|
||||||
* @see Listener
|
|
||||||
*/
|
*/
|
||||||
fun removeListener(listener: Listener)
|
fun removeUpdateListener(listener: UpdateListener)
|
||||||
|
|
||||||
/** A listener for changes in [MusicRepository] */
|
/**
|
||||||
interface Listener {
|
* Add an [IndexingListener] to receive updates from this instance.
|
||||||
|
* @param listener The [UpdateListener] to add.
|
||||||
|
*/
|
||||||
|
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 the current [Library] has changed.
|
* Called when a change to the stored music information occurs.
|
||||||
*
|
* @param changes The [Changes] that have occured.
|
||||||
* @param library The new [Library], or null if no [Library] has been loaded yet.
|
|
||||||
*/
|
*/
|
||||||
fun onLibraryChanged(library: Library?)
|
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 {
|
class MusicRepositoryImpl
|
||||||
private val listeners = mutableListOf<MusicRepository.Listener>()
|
@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
|
override var library: Library? = null
|
||||||
set(value) {
|
override var playlists: List<Playlist>? = null
|
||||||
field = value
|
private var previousCompletedState: IndexingState.Completed? = null
|
||||||
for (callback in listeners) {
|
private var currentIndexingState: IndexingState? = null
|
||||||
callback.onLibraryChanged(library)
|
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 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) {
|
||||||
override fun addListener(listener: MusicRepository.Listener) {
|
if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) ==
|
||||||
listener.onLibraryChanged(library)
|
PackageManager.PERMISSION_DENIED) {
|
||||||
listeners.add(listener)
|
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
|
@Synchronized
|
||||||
override fun removeListener(listener: MusicRepository.Listener) {
|
private fun emitData(library: Library, playlists: List<Playlist>) {
|
||||||
listeners.remove(listener)
|
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 javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.system.Indexer
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [ViewModel] providing data specific to the music loading process.
|
* 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)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class MusicViewModel @Inject constructor(private val indexer: Indexer) :
|
class MusicViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||||
ViewModel(), Indexer.Listener {
|
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. */
|
/** 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)
|
private val _statistics = MutableStateFlow<Statistics?>(null)
|
||||||
/** [Statistics] about the last completed music load. */
|
/** [Statistics] about the last completed music load. */
|
||||||
|
@ -44,36 +43,39 @@ class MusicViewModel @Inject constructor(private val indexer: Indexer) :
|
||||||
get() = _statistics
|
get() = _statistics
|
||||||
|
|
||||||
init {
|
init {
|
||||||
indexer.registerListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
|
musicRepository.addIndexingListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
indexer.unregisterListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
|
musicRepository.removeIndexingListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onIndexerStateChanged(state: Indexer.State?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
_indexerState.value = state
|
if (!changes.library) return
|
||||||
if (state is Indexer.State.Complete) {
|
val library = musicRepository.library ?: return
|
||||||
// New state is a completed library, update the statistics values.
|
_statistics.value =
|
||||||
val library = state.result.getOrNull() ?: return
|
Statistics(
|
||||||
_statistics.value =
|
library.songs.size,
|
||||||
Statistics(
|
library.albums.size,
|
||||||
library.songs.size,
|
library.artists.size,
|
||||||
library.albums.size,
|
library.genres.size,
|
||||||
library.artists.size,
|
library.songs.sumOf { it.durationMs })
|
||||||
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. */
|
/** Requests that the music library should be re-loaded while leveraging the cache. */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
indexer.requestReindex(true)
|
musicRepository.requestIndex(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Requests that the music library be re-loaded without the cache. */
|
/** Requests that the music library be re-loaded without the cache. */
|
||||||
fun rescan() {
|
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.BuildConfig
|
||||||
import org.oxycblt.auxio.IntegerTable
|
import org.oxycblt.auxio.IntegerTable
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
|
import org.oxycblt.auxio.music.IndexingProgress
|
||||||
import org.oxycblt.auxio.service.ForegroundServiceNotification
|
import org.oxycblt.auxio.service.ForegroundServiceNotification
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.newMainPendingIntent
|
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.
|
* 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
|
* @return true if the notification updated, false otherwise
|
||||||
*/
|
*/
|
||||||
fun updateIndexingState(indexing: Indexer.Indexing): Boolean {
|
fun updateIndexingState(progress: IndexingProgress): Boolean {
|
||||||
when (indexing) {
|
when (progress) {
|
||||||
is Indexer.Indexing.Indeterminate -> {
|
is IndexingProgress.Indeterminate -> {
|
||||||
// Indeterminate state, use a vaguer description and in-determinate progress.
|
// Indeterminate state, use a vaguer description and in-determinate progress.
|
||||||
// These events are not very frequent, and thus we don't need to safeguard
|
// These events are not very frequent, and thus we don't need to safeguard
|
||||||
// against rate limiting.
|
// against rate limiting.
|
||||||
logD("Updating state to $indexing")
|
logD("Updating state to $progress")
|
||||||
lastUpdateTime = -1
|
lastUpdateTime = -1
|
||||||
setContentText(context.getString(R.string.lng_indexing))
|
setContentText(context.getString(R.string.lng_indexing))
|
||||||
setProgress(0, 0, true)
|
setProgress(0, 0, true)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
is Indexer.Indexing.Songs -> {
|
is IndexingProgress.Songs -> {
|
||||||
// Determinate state, show an active progress meter. Since these updates arrive
|
// Determinate state, show an active progress meter. Since these updates arrive
|
||||||
// highly rapidly, only update every 1.5 seconds to prevent notification rate
|
// highly rapidly, only update every 1.5 seconds to prevent notification rate
|
||||||
// limiting.
|
// limiting.
|
||||||
|
@ -80,10 +81,10 @@ class IndexingNotification(private val context: Context) :
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
lastUpdateTime = SystemClock.elapsedRealtime()
|
lastUpdateTime = SystemClock.elapsedRealtime()
|
||||||
logD("Updating state to $indexing")
|
logD("Updating state to $progress")
|
||||||
setContentText(
|
setContentText(
|
||||||
context.getString(R.string.fmt_indexing, indexing.current, indexing.total))
|
context.getString(R.string.fmt_indexing, progress.current, progress.total))
|
||||||
setProgress(indexing.total, indexing.current, false)
|
setProgress(progress.total, progress.current, false)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,13 +28,12 @@ import android.os.PowerManager
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import java.lang.Runnable
|
||||||
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
|
||||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||||
import org.oxycblt.auxio.service.ForegroundManager
|
import org.oxycblt.auxio.service.ForegroundManager
|
||||||
|
@ -56,12 +55,17 @@ import org.oxycblt.auxio.util.logD
|
||||||
* TODO: Unify with PlaybackService as part of the service independence project
|
* TODO: Unify with PlaybackService as part of the service independence project
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@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 imageLoader: ImageLoader
|
||||||
@Inject lateinit var musicRepository: MusicRepository
|
@Inject lateinit var musicRepository: MusicRepository
|
||||||
@Inject lateinit var indexer: Indexer
|
|
||||||
@Inject lateinit var musicSettings: MusicSettings
|
@Inject lateinit var musicSettings: MusicSettings
|
||||||
@Inject lateinit var playbackManager: PlaybackStateManager
|
@Inject lateinit var playbackManager: PlaybackStateManager
|
||||||
|
|
||||||
private val serviceJob = Job()
|
private val serviceJob = Job()
|
||||||
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
|
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||||
private var currentIndexJob: Job? = null
|
private var currentIndexJob: Job? = null
|
||||||
|
@ -85,13 +89,9 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
||||||
// condition to cause us to load music before we were fully initialize.
|
// condition to cause us to load music before we were fully initialize.
|
||||||
indexerContentObserver = SystemContentObserver()
|
indexerContentObserver = SystemContentObserver()
|
||||||
musicSettings.registerListener(this)
|
musicSettings.registerListener(this)
|
||||||
indexer.registerController(this)
|
musicRepository.addUpdateListener(this)
|
||||||
// An indeterminate indexer and a missing library implies we are extremely early
|
musicRepository.addIndexingListener(this)
|
||||||
// in app initialization so start loading music.
|
musicRepository.registerWorker(this)
|
||||||
if (musicRepository.library == null && indexer.isIndeterminate) {
|
|
||||||
logD("No library present and no previous response, indexing music now")
|
|
||||||
onStartIndexing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
logD("Service created.")
|
logD("Service created.")
|
||||||
}
|
}
|
||||||
|
@ -109,83 +109,66 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
||||||
// events will not occur.
|
// events will not occur.
|
||||||
indexerContentObserver.release()
|
indexerContentObserver.release()
|
||||||
musicSettings.unregisterListener(this)
|
musicSettings.unregisterListener(this)
|
||||||
indexer.unregisterController(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
|
musicRepository.removeIndexingListener(this)
|
||||||
|
musicRepository.unregisterWorker(this)
|
||||||
// Then cancel any remaining music loading jobs.
|
// Then cancel any remaining music loading jobs.
|
||||||
serviceJob.cancel()
|
serviceJob.cancel()
|
||||||
indexer.reset()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CONTROLLER CALLBACKS ---
|
// --- CONTROLLER CALLBACKS ---
|
||||||
|
|
||||||
override fun onStartIndexing(withCache: Boolean) {
|
override fun requestIndex(withCache: Boolean) {
|
||||||
if (indexer.isIndexing) {
|
// Cancel the previous music loading job.
|
||||||
// Cancel the previous music loading job.
|
currentIndexJob?.cancel()
|
||||||
currentIndexJob?.cancel()
|
|
||||||
indexer.reset()
|
|
||||||
}
|
|
||||||
// Start a new music loading job on a co-routine.
|
// 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?) {
|
override val context = this
|
||||||
when (state) {
|
|
||||||
is Indexer.State.Indexing -> updateActiveSession(state.indexing)
|
override val scope = indexScope
|
||||||
is Indexer.State.Complete -> {
|
|
||||||
val newLibrary = state.result.getOrNull()
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (newLibrary != null && newLibrary != musicRepository.library) {
|
if (!changes.library) return
|
||||||
logD("Applying new library")
|
val library = musicRepository.library ?: return
|
||||||
// We only care if the newly-loaded library is going to replace a previously
|
// Wipe possibly-invalidated outdated covers
|
||||||
// loaded library.
|
imageLoader.memoryCache?.clear()
|
||||||
if (musicRepository.library != null) {
|
// Clear invalid models from PlaybackStateManager. This is not connected
|
||||||
// Wipe possibly-invalidated outdated covers
|
// to a listener as it is bad practice for a shared object to attach to
|
||||||
imageLoader.memoryCache?.clear()
|
// the listener system of another.
|
||||||
// Clear invalid models from PlaybackStateManager. This is not connected
|
playbackManager.toSavedState()?.let { savedState ->
|
||||||
// to a listener as it is bad practice for a shared object to attach to
|
playbackManager.applySavedState(
|
||||||
// the listener system of another.
|
PlaybackStateManager.SavedState(
|
||||||
playbackManager.toSavedState()?.let { savedState ->
|
parent = savedState.parent?.let(library::sanitize),
|
||||||
playbackManager.applySavedState(
|
queueState =
|
||||||
PlaybackStateManager.SavedState(
|
savedState.queueState.remap { song ->
|
||||||
parent = savedState.parent?.let(newLibrary::sanitize),
|
library.sanitize(requireNotNull(song))
|
||||||
queueState =
|
},
|
||||||
savedState.queueState.remap { song ->
|
positionMs = savedState.positionMs,
|
||||||
newLibrary.sanitize(requireNotNull(song))
|
repeatMode = savedState.repeatMode),
|
||||||
},
|
true)
|
||||||
positionMs = savedState.positionMs,
|
}
|
||||||
repeatMode = savedState.repeatMode),
|
}
|
||||||
true)
|
|
||||||
}
|
override fun onIndexingStateChanged() {
|
||||||
}
|
val state = musicRepository.indexingState
|
||||||
// Forward the new library to MusicStore to continue the update process.
|
if (state is IndexingState.Indexing) {
|
||||||
musicRepository.library = newLibrary
|
updateActiveSession(state.progress)
|
||||||
}
|
} else {
|
||||||
// On errors, while we would want to show a notification that displays the
|
updateIdleSession()
|
||||||
// error, that requires the Android 13 notification permission, which is not
|
|
||||||
// handled right now.
|
|
||||||
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 ---
|
// --- INTERNAL ---
|
||||||
|
|
||||||
/**
|
private fun updateActiveSession(progress: IndexingProgress) {
|
||||||
* 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) {
|
|
||||||
// When loading, we want to enter the foreground state so that android does
|
// 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
|
// not shut off the loading process. Note that while we will always post the
|
||||||
// notification when initially starting, we will not update the notification
|
// notification when initially starting, we will not update the notification
|
||||||
// unless it indicates that it has changed.
|
// unless it indicates that it has changed.
|
||||||
val changed = indexingNotification.updateIndexingState(state)
|
val changed = indexingNotification.updateIndexingState(progress)
|
||||||
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
|
if (!foregroundManager.tryStartForeground(indexingNotification) && changed) {
|
||||||
logD("Notification changed, re-posting notification")
|
logD("Notification changed, re-posting notification")
|
||||||
indexingNotification.post()
|
indexingNotification.post()
|
||||||
|
@ -194,10 +177,6 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
||||||
wakeLock.acquireSafe()
|
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() {
|
private fun updateIdleSession() {
|
||||||
if (musicSettings.shouldBeObserving) {
|
if (musicSettings.shouldBeObserving) {
|
||||||
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
// There are a few reasons why we stay in the foreground with automatic rescanning:
|
||||||
|
@ -244,7 +223,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
||||||
|
|
||||||
override fun onIndexingSettingChanged() {
|
override fun onIndexingSettingChanged() {
|
||||||
// Music loading configuration changed, need to reload music.
|
// Music loading configuration changed, need to reload music.
|
||||||
onStartIndexing(true)
|
requestIndex(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onObservingChanged() {
|
override fun onObservingChanged() {
|
||||||
|
@ -252,7 +231,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
|
||||||
// notification if we were actively loading when the automatic rescanning
|
// notification if we were actively loading when the automatic rescanning
|
||||||
// setting changed. In such a case, the state will still be updated when
|
// setting changed. In such a case, the state will still be updated when
|
||||||
// the music loading process ends.
|
// the music loading process ends.
|
||||||
if (!indexer.isIndexing) {
|
if (currentIndexJob == null) {
|
||||||
updateIdleSession()
|
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
|
// Check here if we should even start a reindex. This is much less bug-prone than
|
||||||
// registering and de-registering this component as this setting changes.
|
// registering and de-registering this component as this setting changes.
|
||||||
if (musicSettings.shouldBeObserving) {
|
if (musicSettings.shouldBeObserving) {
|
||||||
onStartIndexing(true)
|
requestIndex(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,6 @@ import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.oxycblt.auxio.music.*
|
import org.oxycblt.auxio.music.*
|
||||||
import org.oxycblt.auxio.music.library.Library
|
|
||||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -35,7 +34,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) :
|
||||||
ViewModel(), MusicRepository.Listener {
|
ViewModel(), MusicRepository.UpdateListener {
|
||||||
|
|
||||||
private val _currentItem = MutableStateFlow<Music?>(null)
|
private val _currentItem = MutableStateFlow<Music?>(null)
|
||||||
/** The current item whose artists should be shown in the picker. Null if there is no item. */
|
/** The current item whose artists should be shown in the picker. Null if there is no item. */
|
||||||
|
@ -52,12 +51,16 @@ class PickerViewModel @Inject constructor(private val musicRepository: MusicRepo
|
||||||
val genreChoices: StateFlow<List<Genre>>
|
val genreChoices: StateFlow<List<Genre>>
|
||||||
get() = _genreChoices
|
get() = _genreChoices
|
||||||
|
|
||||||
override fun onCleared() {
|
init {
|
||||||
musicRepository.removeListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onCleared() {
|
||||||
if (library != null) {
|
musicRepository.removeUpdateListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
|
if (changes.library && musicRepository.library != null) {
|
||||||
refreshChoices()
|
refreshChoices()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,7 +48,6 @@ import org.oxycblt.auxio.BuildConfig
|
||||||
import org.oxycblt.auxio.music.MusicRepository
|
import org.oxycblt.auxio.music.MusicRepository
|
||||||
import org.oxycblt.auxio.music.MusicSettings
|
import org.oxycblt.auxio.music.MusicSettings
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.library.Library
|
|
||||||
import org.oxycblt.auxio.playback.PlaybackSettings
|
import org.oxycblt.auxio.playback.PlaybackSettings
|
||||||
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
import org.oxycblt.auxio.playback.persist.PersistenceRepository
|
||||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||||
|
@ -82,7 +81,7 @@ class PlaybackService :
|
||||||
Player.Listener,
|
Player.Listener,
|
||||||
InternalPlayer,
|
InternalPlayer,
|
||||||
MediaSessionComponent.Listener,
|
MediaSessionComponent.Listener,
|
||||||
MusicRepository.Listener {
|
MusicRepository.UpdateListener {
|
||||||
// Player components
|
// Player components
|
||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
@Inject lateinit var mediaSourceFactory: MediaSource.Factory
|
@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
|
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||||
// condition to cause us to load music before we were fully initialize.
|
// condition to cause us to load music before we were fully initialize.
|
||||||
playbackManager.registerInternalPlayer(this)
|
playbackManager.registerInternalPlayer(this)
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
mediaSessionComponent.registerListener(this)
|
mediaSessionComponent.registerListener(this)
|
||||||
registerReceiver(
|
registerReceiver(
|
||||||
systemReceiver,
|
systemReceiver,
|
||||||
|
@ -187,7 +186,7 @@ class PlaybackService :
|
||||||
// Pause just in case this destruction was unexpected.
|
// Pause just in case this destruction was unexpected.
|
||||||
playbackManager.setPlaying(false)
|
playbackManager.setPlaying(false)
|
||||||
playbackManager.unregisterInternalPlayer(this)
|
playbackManager.unregisterInternalPlayer(this)
|
||||||
musicRepository.removeListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
|
|
||||||
unregisterReceiver(systemReceiver)
|
unregisterReceiver(systemReceiver)
|
||||||
serviceJob.cancel()
|
serviceJob.cancel()
|
||||||
|
@ -299,10 +298,8 @@ class PlaybackService :
|
||||||
playbackManager.next()
|
playbackManager.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MUSICSTORE OVERRIDES ---
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
|
if (changes.library && musicRepository.library != null) {
|
||||||
override fun onLibraryChanged(library: Library?) {
|
|
||||||
if (library != null) {
|
|
||||||
// We now have a library, see if we have anything we need to do.
|
// We now have a library, see if we have anything we need to do.
|
||||||
playbackManager.requestAction(this)
|
playbackManager.requestAction(this)
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,7 @@ constructor(
|
||||||
private val searchEngine: SearchEngine,
|
private val searchEngine: SearchEngine,
|
||||||
private val searchSettings: SearchSettings,
|
private val searchSettings: SearchSettings,
|
||||||
private val playbackSettings: PlaybackSettings,
|
private val playbackSettings: PlaybackSettings,
|
||||||
) : ViewModel(), MusicRepository.Listener {
|
) : ViewModel(), MusicRepository.UpdateListener {
|
||||||
private var lastQuery: String? = null
|
private var lastQuery: String? = null
|
||||||
private var currentSearchJob: Job? = null
|
private var currentSearchJob: Job? = null
|
||||||
|
|
||||||
|
@ -64,17 +64,16 @@ constructor(
|
||||||
get() = playbackSettings.inListPlaybackMode
|
get() = playbackSettings.inListPlaybackMode
|
||||||
|
|
||||||
init {
|
init {
|
||||||
musicRepository.addListener(this)
|
musicRepository.addUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
musicRepository.removeListener(this)
|
musicRepository.removeUpdateListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLibraryChanged(library: Library?) {
|
override fun onMusicChanges(changes: MusicRepository.Changes) {
|
||||||
if (library != null) {
|
if (changes.library && musicRepository.library != null) {
|
||||||
// Make sure our query is up to date with the music library.
|
|
||||||
search(lastQuery)
|
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.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.oxycblt.auxio.music.library.FakeLibrary
|
import org.oxycblt.auxio.music.library.FakeLibrary
|
||||||
import org.oxycblt.auxio.music.system.FakeIndexer
|
import org.oxycblt.auxio.music.library.Library
|
||||||
import org.oxycblt.auxio.music.system.Indexer
|
|
||||||
import org.oxycblt.auxio.util.forceClear
|
import org.oxycblt.auxio.util.forceClear
|
||||||
|
|
||||||
class MusicViewModelTest {
|
class MusicViewModelTest {
|
||||||
@Test
|
@Test
|
||||||
fun indexerState() {
|
fun indexerState() {
|
||||||
val indexer =
|
val indexer =
|
||||||
TestIndexer().apply { state = Indexer.State.Indexing(Indexer.Indexing.Indeterminate) }
|
TestMusicRepository().apply {
|
||||||
|
indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate)
|
||||||
|
}
|
||||||
val musicViewModel = MusicViewModel(indexer)
|
val musicViewModel = MusicViewModel(indexer)
|
||||||
assertTrue(indexer.listener is MusicViewModel)
|
assertTrue(indexer.updateListener is MusicViewModel)
|
||||||
|
assertTrue(indexer.indexingListener is MusicViewModel)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
Indexer.Indexing.Indeterminate,
|
IndexingProgress.Indeterminate,
|
||||||
(musicViewModel.indexerState.value as Indexer.State.Indexing).indexing)
|
(musicViewModel.indexingState.value as IndexingState.Indexing).progress)
|
||||||
indexer.state = null
|
indexer.indexingState = null
|
||||||
assertEquals(null, musicViewModel.indexerState.value)
|
assertEquals(null, musicViewModel.indexingState.value)
|
||||||
musicViewModel.forceClear()
|
musicViewModel.forceClear()
|
||||||
assertTrue(indexer.listener == null)
|
assertTrue(indexer.indexingListener == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun statistics() {
|
fun statistics() {
|
||||||
val indexer =
|
val musicRepository = TestMusicRepository()
|
||||||
TestIndexer().apply { state = Indexer.State.Complete(Result.success(TestLibrary())) }
|
val musicViewModel = MusicViewModel(musicRepository)
|
||||||
val musicViewModel = MusicViewModel(indexer)
|
assertEquals(null, musicViewModel.statistics.value)
|
||||||
|
musicRepository.library = TestLibrary()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
MusicViewModel.Statistics(
|
MusicViewModel.Statistics(
|
||||||
2,
|
2,
|
||||||
|
@ -60,33 +63,49 @@ class MusicViewModelTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun requests() {
|
fun requests() {
|
||||||
val indexer = TestIndexer()
|
val indexer = TestMusicRepository()
|
||||||
val musicViewModel = MusicViewModel(indexer)
|
val musicViewModel = MusicViewModel(indexer)
|
||||||
musicViewModel.refresh()
|
musicViewModel.refresh()
|
||||||
musicViewModel.rescan()
|
musicViewModel.rescan()
|
||||||
assertEquals(listOf(true, false), indexer.requests)
|
assertEquals(listOf(true, false), indexer.requests)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class TestIndexer : FakeIndexer() {
|
private class TestMusicRepository : FakeMusicRepository() {
|
||||||
var listener: Indexer.Listener? = null
|
override var library: Library? = null
|
||||||
var state: Indexer.State? = null
|
|
||||||
set(value) {
|
set(value) {
|
||||||
field = 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>()
|
val requests = mutableListOf<Boolean>()
|
||||||
|
|
||||||
override fun registerListener(listener: Indexer.Listener) {
|
override fun addUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||||
this.listener = listener
|
listener.onMusicChanges(MusicRepository.Changes(library = true, playlists = false))
|
||||||
listener.onIndexerStateChanged(state)
|
this.updateListener = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unregisterListener(listener: Indexer.Listener) {
|
override fun removeUpdateListener(listener: MusicRepository.UpdateListener) {
|
||||||
this.listener = null
|
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)
|
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