From 90ceb6d2e0dec9d0588a820a27ae43341f97485f Mon Sep 17 00:00:00 2001 From: Sam Mingo Date: Sun, 4 Oct 2026 17:56:22 +0000 Subject: [PATCH] feat(bookmark): auto-save bookmark on pause for cross-client resume --- .../ultrasonic/service/PlaybackService.kt | 46 +++++++++++++++++++ .../org/moire/ultrasonic/util/Settings.kt | 3 ++ .../src/main/res/values/setting_keys.xml | 1 + ultrasonic/src/main/res/values/strings.xml | 2 + ultrasonic/src/main/res/xml/settings.xml | 6 +++ 5 files changed, 58 insertions(+) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt index 1f5180a3..857e45dd 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/PlaybackService.kt @@ -42,6 +42,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.Credentials @@ -75,6 +77,8 @@ import org.moire.ultrasonic.util.toMediaItem import org.moire.ultrasonic.util.toTrack import timber.log.Timber +private const val BOOKMARK_INTERVAL_MS = 30_000L + @SuppressLint("UnsafeOptInUsageError") @Suppress("TooManyFunctions") // Service lifecycle and Player.Listener callbacks class PlaybackService : @@ -100,6 +104,7 @@ class PlaybackService : private var backendSwitchGeneration = 0L private var accessStateSubscription: Job? = null + private var bookmarkJob: Job? = null // Recovery state for failed playback items (see recoverFromPlaybackError). private val mainHandler = Handler(Looper.getMainLooper()) @@ -152,6 +157,7 @@ class PlaybackService : private fun releasePlayerAndSession() { Timber.i("Releasing player and session") + stopPeriodicBookmarkSave() cancel() cancelPendingRecovery() backendSwitchGeneration++ @@ -732,6 +738,9 @@ class PlaybackService : } } previousMediaItem = mediaItem + if (Settings.shouldSaveBookmarkOnPause && player.isPlaying) { + startPeriodicBookmarkSave() + } // Since we cannot update the metadata of the media item after creation, // we cannot set change the rating on it @@ -750,6 +759,14 @@ class PlaybackService : updateWidgetPlayerState(isPlaying) cacheNextSongs() scrobblePlayerState() + if (Settings.shouldSaveBookmarkOnPause) { + if (isPlaying) { + startPeriodicBookmarkSave() + } else if (player.playbackState == Player.STATE_READY) { + stopPeriodicBookmarkSave() + saveBookmark() + } + } } override fun onRepeatModeChanged(repeatMode: Int) { @@ -767,6 +784,35 @@ class PlaybackService : } } + private fun startPeriodicBookmarkSave() { + bookmarkJob?.cancel() + bookmarkJob = launch { + delay(BOOKMARK_INTERVAL_MS) + while (isActive) { + saveBookmark() + delay(BOOKMARK_INTERVAL_MS) + } + } + } + + private fun stopPeriodicBookmarkSave() { + bookmarkJob?.cancel() + bookmarkJob = null + } + + private fun saveBookmark() { + val track = player.currentMediaItem?.toTrack() ?: return + val positionMs = player.currentPosition + launch { + runCatching { + withContext(Dispatchers.IO) { + musicServiceFactory.getMusicService() + .createBookmark(track.id, positionMs.toInt()) + } + }.onFailure { Timber.w(it, "Failed to save bookmark") } + } + } + private fun scrobblePlayerState() { val track = player.currentMediaItem?.toTrack() ?: return when { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt index f8c5d130..37be58e5 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Settings.kt @@ -236,6 +236,9 @@ object Settings { var shouldClearBookmark by BooleanSetting(getKey(R.string.setting_key_clear_bookmark), false) + var shouldSaveBookmarkOnPause + by BooleanSetting(getKey(R.string.setting_key_save_bookmark_on_pause), false) + var shouldAskForShareDetails by BooleanSetting(getKey(R.string.setting_key_ask_for_share_details), true) diff --git a/ultrasonic/src/main/res/values/setting_keys.xml b/ultrasonic/src/main/res/values/setting_keys.xml index d1f2f07e..170011aa 100644 --- a/ultrasonic/src/main/res/values/setting_keys.xml +++ b/ultrasonic/src/main/res/values/setting_keys.xml @@ -41,6 +41,7 @@ chatRefreshInterval directoryCacheTime clearBookmark + saveBookmarkOnPause discAndTrackSort sharingAlwaysAskForDetails sharingDefaultDescription diff --git a/ultrasonic/src/main/res/values/strings.xml b/ultrasonic/src/main/res/values/strings.xml index 5085274b..245b9b31 100644 --- a/ultrasonic/src/main/res/values/strings.xml +++ b/ultrasonic/src/main/res/values/strings.xml @@ -210,6 +210,8 @@ Chat Refresh Interval Clear Bookmark Clear bookmark upon completion of playback of a song + Save Bookmark on Pause + Automatically save playback position when pausing, enabling resume from another client Clear Search History Clear Image Cache Delete %1$s of cached images diff --git a/ultrasonic/src/main/res/xml/settings.xml b/ultrasonic/src/main/res/xml/settings.xml index b39c6b17..b93a3e77 100644 --- a/ultrasonic/src/main/res/xml/settings.xml +++ b/ultrasonic/src/main/res/xml/settings.xml @@ -84,6 +84,12 @@ a:summary="@string/settings.clear_bookmark_summary" a:title="@string/settings.clear_bookmark" app:iconSpaceReserved="false"/> +