From 73e10c95199a1b0772291ac8dbd1daec7410bab1 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sat, 2 Jul 2022 17:15:21 -0600 Subject: [PATCH] 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. --- .../java/org/oxycblt/auxio/music/Indexer.kt | 4 + .../org/oxycblt/auxio/music/IndexerService.kt | 26 +++- .../org/oxycblt/auxio/music/MusicStore.kt | 1 + .../auxio/music/dirs/MusicDirsDialog.kt | 12 +- .../playback/state/PlaybackStateManager.kt | 114 ++++++++++-------- .../auxio/playback/system/PlaybackService.kt | 5 - .../auxio/settings/SettingsListFragment.kt | 6 +- .../org/oxycblt/auxio/util/ContextUtil.kt | 11 -- 8 files changed, 104 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt index b0d56b4fa..5c8cac7f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt @@ -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) { diff --git a/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt index 8193371ff..610eaf58d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt @@ -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 { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index 8cbc0c439..4122372af 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -83,6 +83,7 @@ class MusicStore private constructor() { } fun sanitize(song: Song) = songs.find { it.id == song.id } + fun sanitize(songs: List) = 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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt index e0156ac1a..f80a3b8a7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt @@ -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(), 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 { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 8f65c6fe8..accc292bf 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -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 --- diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index d573ae82d..26f39c84e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt index a40d679fd..8809a4104 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt @@ -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) } diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt index 950ec46ee..51bbe08d0 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt @@ -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) -}