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:
parent
bbf3b1778b
commit
73e10c9519
8 changed files with 104 additions and 75 deletions
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 ---
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue