music: enable runtime rescanning [#72]

Finally enable runtime rescanning, opening the door for a ton of new
features and automatic rescanning later on.

More work needs to be done on making the shared mutable state in-app
safer to use.
This commit is contained in:
OxygenCobalt 2022-07-02 17:15:21 -06:00
parent bbf3b1778b
commit 73e10c9519
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
8 changed files with 104 additions and 75 deletions

View file

@ -68,6 +68,10 @@ class Indexer {
val isIndeterminate: Boolean val isIndeterminate: Boolean
get() = lastResponse == null && indexingState == null get() = lastResponse == null && indexingState == null
/** Whether this instance is actively indexing or not. */
val isIndexing: Boolean
get() = indexingState != null
/** Register a [Controller] with this instance. */ /** Register a [Controller] with this instance. */
fun registerController(controller: Controller) { fun registerController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller != null) { if (BuildConfig.DEBUG && this.controller != null) {

View file

@ -21,12 +21,16 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import coil.imageLoader
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.playback.state.PlaybackStateDatabase
import org.oxycblt.auxio.playback.state.PlaybackStateManager
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
@ -46,7 +50,9 @@ class IndexerService : Service(), Indexer.Controller {
private val serviceJob = Job() private val serviceJob = Job()
private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO)
private val updateScope = CoroutineScope(serviceJob + Dispatchers.IO) private val updateScope = CoroutineScope(serviceJob + Dispatchers.Main)
private val playbackManager = PlaybackStateManager.getInstance()
private var isForeground = false private var isForeground = false
private lateinit var notification: IndexerNotification private lateinit var notification: IndexerNotification
@ -80,6 +86,11 @@ class IndexerService : Service(), Indexer.Controller {
} }
override fun onStartIndexing() { override fun onStartIndexing() {
if (indexer.isIndexing) {
indexer.cancelLast()
indexScope.cancel()
}
indexScope.launch { indexer.index(this@IndexerService) } indexScope.launch { indexer.index(this@IndexerService) }
} }
@ -90,16 +101,23 @@ class IndexerService : Service(), Indexer.Controller {
state.response.library != musicStore.library) { state.response.library != musicStore.library) {
logD("Applying new library") logD("Applying new library")
val newLibrary = state.response.library
// Load was completed successfully, so apply the new library if we // Load was completed successfully, so apply the new library if we
// have not already. Only when we are done updating the library will // have not already. Only when we are done updating the library will
// the service stop it's foreground state. // the service stop it's foreground state.
updateScope.launch { updateScope.launch {
// TODO: Update PlaybackStateManager here imageLoader.memoryCache?.clear()
withContext(Dispatchers.Main) { if (musicStore.library != null) {
musicStore.updateLibrary(state.response.library) // This is a new library, so we need to make sure the playback state
// is coherent.
playbackManager.sanitize(
PlaybackStateDatabase.getInstance(this@IndexerService), newLibrary)
} }
withContext(Dispatchers.Main) { musicStore.updateLibrary(newLibrary) }
stopForegroundSession() stopForegroundSession()
} }
} else { } else {

View file

@ -83,6 +83,7 @@ class MusicStore private constructor() {
} }
fun sanitize(song: Song) = songs.find { it.id == song.id } fun sanitize(song: Song) = songs.find { it.id == song.id }
fun sanitize(songs: List<Song>) = songs.mapNotNull { sanitize(it) }
fun sanitize(album: Album) = albums.find { it.id == album.id } fun sanitize(album: Album) = albums.find { it.id == album.id }
fun sanitize(artist: Artist) = artists.find { it.id == artist.id } fun sanitize(artist: Artist) = artists.find { it.id == artist.id }
fun sanitize(genre: Genre) = genres.find { it.id == genre.id } fun sanitize(genre: Genre) = genres.find { it.id == genre.id }

View file

@ -25,17 +25,18 @@ import android.view.LayoutInflater
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.Directory import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.music.IndexerViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment import org.oxycblt.auxio.ui.fragment.ViewBindingDialogFragment
import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
@ -46,6 +47,7 @@ import org.oxycblt.auxio.util.showToast
class MusicDirsDialog : class MusicDirsDialog :
ViewBindingDialogFragment<DialogMusicDirsBinding>(), MusicDirAdapter.Listener { ViewBindingDialogFragment<DialogMusicDirsBinding>(), MusicDirAdapter.Listener {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val indexerModel: IndexerViewModel by activityViewModels()
private val dirAdapter = MusicDirAdapter(this) private val dirAdapter = MusicDirAdapter(this)
private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) } private val settings: Settings by lifecycleObject { binding -> Settings(binding.context) }
@ -85,7 +87,7 @@ class MusicDirsDialog :
if (dirs.dirs != dirAdapter.data.currentList || if (dirs.dirs != dirAdapter.data.currentList ||
dirs.shouldInclude != isInclude(requireBinding())) { dirs.shouldInclude != isInclude(requireBinding())) {
logD("Committing changes") logD("Committing changes")
saveAndRestart() saveAndDismiss()
} else { } else {
logD("Dropping changes") logD("Dropping changes")
dismiss() dismiss()
@ -186,10 +188,10 @@ class MusicDirsDialog :
private fun isInclude(binding: DialogMusicDirsBinding) = private fun isInclude(binding: DialogMusicDirsBinding) =
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
private fun saveAndRestart() { private fun saveAndDismiss() {
settings.setMusicDirs(MusicDirs(dirAdapter.data.currentList, isInclude(requireBinding()))) settings.setMusicDirs(MusicDirs(dirAdapter.data.currentList, isInclude(requireBinding())))
indexerModel.reindex()
playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() } dismiss()
} }
private fun requireStorageManager(): StorageManager { private fun requireStorageManager(): StorageManager {

View file

@ -45,6 +45,8 @@ import org.oxycblt.auxio.util.logW
* *
* All access should be done with [PlaybackStateManager.getInstance]. * All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt * @author OxygenCobalt
*
* TODO: Replace synchronized calls with annotation
*/ */
class PlaybackStateManager private constructor() { class PlaybackStateManager private constructor() {
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
@ -361,12 +363,11 @@ class PlaybackStateManager private constructor() {
* **Seek** to a [positionMs]. * **Seek** to a [positionMs].
* @param positionMs The position to seek to in millis. * @param positionMs The position to seek to in millis.
*/ */
@Synchronized
fun seekTo(positionMs: Long) { fun seekTo(positionMs: Long) {
synchronized(this) { this.positionMs = positionMs
this.positionMs = positionMs controller?.seekTo(positionMs)
controller?.seekTo(positionMs) notifyPositionChanged()
notifyPositionChanged()
}
} }
/** Rewind to the beginning of a song. */ /** Rewind to the beginning of a song. */
@ -377,34 +378,8 @@ class PlaybackStateManager private constructor() {
/** Restore the state from the [database] */ /** Restore the state from the [database] */
suspend fun restoreState(database: PlaybackStateDatabase) { suspend fun restoreState(database: PlaybackStateDatabase) {
val library = musicStore.library ?: return val library = musicStore.library ?: return
val start: Long withContext(Dispatchers.IO) { readImpl(database, library) }?.let(::restoreImpl)
val state: PlaybackStateDatabase.SavedState? isInitialized = true
logD("Getting state from DB")
withContext(Dispatchers.IO) {
start = System.currentTimeMillis()
state = database.read(library)
}
logD("State read completed successfully in ${System.currentTimeMillis() - start}ms")
synchronized(this) {
if (state != null) {
index = state.index
parent = state.parent
_queue = state.queue.toMutableList()
repeatMode = state.repeatMode
isShuffled = state.isShuffled
notifyNewPlayback()
seekTo(state.positionMs)
notifyRepeatModeChanged()
notifyShuffledChanged()
}
isInitialized = true
}
} }
/** Save the current state to the [database]. */ /** Save the current state to the [database]. */
@ -412,23 +387,66 @@ class PlaybackStateManager private constructor() {
logD("Saving state to DB") logD("Saving state to DB")
// Pack the entire state and save it to the database. // Pack the entire state and save it to the database.
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { saveImpl(database) }
val start = System.currentTimeMillis() }
database.write( suspend fun sanitize(database: PlaybackStateDatabase, newLibrary: MusicStore.Library) {
synchronized(this) { // Since we need to sanitize the state and re-save it for consistency, take the
PlaybackStateDatabase.SavedState( // easy way out and just write a new state and restore from it. Don't really care.
index = index, logD("Sanitizing state")
parent = parent, isPlaying = false
queue = _queue, val state =
positionMs = positionMs, withContext(Dispatchers.IO) {
isShuffled = isShuffled, saveImpl(database)
repeatMode = repeatMode) readImpl(database, newLibrary)
}) }
this@PlaybackStateManager.logD( state?.let(::restoreImpl)
"State save completed successfully in ${System.currentTimeMillis() - start}ms") }
}
private fun readImpl(
database: PlaybackStateDatabase,
library: MusicStore.Library
): PlaybackStateDatabase.SavedState? {
logD("Getting state from DB")
val start: Long = System.currentTimeMillis()
val state: PlaybackStateDatabase.SavedState? = database.read(library)
logD("State read completed successfully in ${System.currentTimeMillis() - start}ms")
return state
}
@Synchronized
private fun restoreImpl(state: PlaybackStateDatabase.SavedState) {
index = state.index
parent = state.parent
_queue = state.queue.toMutableList()
repeatMode = state.repeatMode
isShuffled = state.isShuffled
notifyNewPlayback()
seekTo(state.positionMs)
notifyRepeatModeChanged()
notifyShuffledChanged()
}
@Synchronized
private fun saveImpl(database: PlaybackStateDatabase) {
val start = System.currentTimeMillis()
database.write(
PlaybackStateDatabase.SavedState(
index = index,
parent = parent,
queue = _queue,
positionMs = positionMs,
isShuffled = isShuffled,
repeatMode = repeatMode))
this@PlaybackStateManager.logD(
"State save completed successfully in ${System.currentTimeMillis() - start}ms")
} }
// --- CALLBACKS --- // --- CALLBACKS ---

View file

@ -24,7 +24,6 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.media.AudioManager import android.media.AudioManager
import android.os.IBinder import android.os.IBinder
import android.support.v4.media.MediaMetadataCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ExoPlayer
@ -273,10 +272,6 @@ class PlaybackService :
override fun onPostNotification(notification: NotificationComponent) { override fun onPostNotification(notification: NotificationComponent) {
if (hasPlayed && playbackManager.song != null) { if (hasPlayed && playbackManager.song != null) {
logD(
mediaSessionComponent.mediaSession.controller.metadata.getText(
MediaMetadataCompat.METADATA_KEY_TITLE))
if (!isForeground) { if (!isForeground) {
startForeground(IntegerTable.PLAYBACK_NOTIFICATION_CODE, notification.build()) startForeground(IntegerTable.PLAYBACK_NOTIFICATION_CODE, notification.build())
isForeground = true isForeground = true

View file

@ -22,6 +22,7 @@ import android.view.View
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceCategory import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
@ -30,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView
import coil.Coil import coil.Coil
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.TabCustomizeDialog import org.oxycblt.auxio.home.tabs.TabCustomizeDialog
import org.oxycblt.auxio.music.IndexerViewModel
import org.oxycblt.auxio.music.dirs.MusicDirsDialog import org.oxycblt.auxio.music.dirs.MusicDirsDialog
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.replaygain.PreAmpCustomizeDialog import org.oxycblt.auxio.playback.replaygain.PreAmpCustomizeDialog
@ -38,7 +40,6 @@ import org.oxycblt.auxio.settings.ui.IntListPreferenceDialog
import org.oxycblt.auxio.settings.ui.WrappedDialogPreference import org.oxycblt.auxio.settings.ui.WrappedDialogPreference
import org.oxycblt.auxio.ui.accent.AccentCustomizeDialog import org.oxycblt.auxio.ui.accent.AccentCustomizeDialog
import org.oxycblt.auxio.util.androidActivityViewModels import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.isNight import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logEOrThrow import org.oxycblt.auxio.util.logEOrThrow
@ -56,6 +57,7 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
@Suppress("UNUSED") @Suppress("UNUSED")
class SettingsListFragment : PreferenceFragmentCompat() { class SettingsListFragment : PreferenceFragmentCompat() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels() private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val indexerModel: IndexerViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -123,7 +125,7 @@ class SettingsListFragment : PreferenceFragmentCompat() {
} }
} }
getString(R.string.set_key_reindex) -> { getString(R.string.set_key_reindex) -> {
playbackModel.savePlaybackState(requireContext()) { context?.hardRestart() } indexerModel.reindex()
} }
else -> return super.onPreferenceTreeClick(preference) else -> return super.onPreferenceTreeClick(preference)
} }

View file

@ -237,14 +237,3 @@ fun Context.newBroadcastPendingIntent(what: String): PendingIntent =
Intent(what).setFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY), Intent(what).setFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
/** Hard-restarts the app. Useful for forcing the app to reload music. */
fun Context.hardRestart() {
// Instead of having to do a ton of cleanup and horrible code changes
// to restart this application non-destructively, I just restart the UI task [There is only
// one, after all] and then kill the application using exitProcess. Works well enough.
val intent =
Intent(applicationContext, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
exitProcess(0)
}