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
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. */
fun registerController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller != null) {

View file

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

View file

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

View file

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

View file

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

View file

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