diff --git a/CHANGELOG.md b/CHANGELOG.md
index a15e39822a9..e8456944fc5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
8.12
-----
+* New Features
+ * Enable scrubbing in notification.
+ ([#5209](https://github.com/Automattic/pocket-casts-android/pull/5209))
* Bug Fixes
* Restore episode duration after download for feeds without an RSS length attribute
([#5258](https://github.com/Automattic/pocket-casts-android/pull/5258))
diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt
index 3579287fa41..a5c0f692011 100644
--- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt
+++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt
@@ -304,6 +304,15 @@ class PlaybackSettingsFragment : BaseFragment() {
)
}
+ SettingsItems.SETTINGS_ENABLE_LOCK_SCREEN_SCRUBBING -> {
+ EnableLockScreenScrubbing(
+ saved = settings.enableLockScreenScrubbing.flow.collectAsState().value,
+ onSave = { isLockScreenScrubbingEnabled ->
+ settings.enableLockScreenScrubbing.set(isLockScreenScrubbingEnabled, updateModifiedAt = true)
+ },
+ )
+ }
+
SettingsItems.SETTINGS_INTELLIGENT_PLAYBACK -> {
IntelligentPlaybackResumption(
saved = settings.intelligentPlaybackResumption.flow.collectAsState().value,
@@ -557,6 +566,14 @@ class PlaybackSettingsFragment : BaseFragment() {
indent = false,
)
+ @Composable
+ private fun EnableLockScreenScrubbing(saved: Boolean, onSave: (Boolean) -> Unit) = SettingRow(
+ primaryText = stringResource(id = LR.string.settings_enable_lock_screen_scrubbing),
+ toggle = SettingRowToggle.Switch(checked = saved),
+ modifier = Modifier.toggleable(value = saved, role = Role.Switch) { onSave(!saved) },
+ indent = false,
+ )
+
@Composable
private fun IntelligentPlaybackResumption(saved: Boolean, onSave: (Boolean) -> Unit) = SettingRow(
primaryText = stringResource(LR.string.settings_playback_resumption),
@@ -674,6 +691,7 @@ private enum class SettingsItems {
SETTINGS_KEEP_SCREEN_AWAKE,
SETTINGS_OPEN_PLAYER_AUTOMATICALLY,
SETTINGS_INTELLIGENT_PLAYBACK,
+ SETTINGS_ENABLE_LOCK_SCREEN_SCRUBBING,
SETTINGS_PLAY_UP_NEXT_EPISODE,
SETTINGS_ADJUST_REMAINING_TIME,
SETTINGS_GENERAL_AUTOPLAY,
diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml
index b259b1f358b..25c3ee06ae7 100644
--- a/modules/services/localization/src/main/res/values/strings.xml
+++ b/modules/services/localization/src/main/res/values/strings.xml
@@ -1564,6 +1564,7 @@
Never
Open player automatically
If on, the full-screen player will open when you start playing a podcast episode.
+ Enable lock screen scrubbing
Other media actions
Intelligent playback resumption
If on, Pocket Casts will go back a little in episodes you resume so you can catch up more comfortably.
diff --git a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt
index 0e194f05edb..08d1a31874c 100644
--- a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt
+++ b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt
@@ -382,6 +382,7 @@ interface Settings {
val streamingMode: UserSetting
val keepScreenAwake: UserSetting
val openPlayerAutomatically: UserSetting
+ val enableLockScreenScrubbing: UserSetting
val autoDownloadUnmeteredOnly: UserSetting
val autoDownloadOnlyWhenCharging: UserSetting
diff --git a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt
index 6e0ed19a76b..019a9e81d90 100644
--- a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt
+++ b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt
@@ -650,6 +650,12 @@ class SettingsImpl @Inject constructor(
sharedPrefs = sharedPreferences,
)
+ override val enableLockScreenScrubbing = UserSetting.BoolPref(
+ sharedPrefKey = "enableLockScreenScrubbing",
+ defaultValue = true,
+ sharedPrefs = sharedPreferences,
+ )
+
override val autoDownloadUnmeteredOnly = UserSetting.BoolPref(
sharedPrefKey = "autoDownloadOnlyDownloadOnWifi",
defaultValue = true,
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/CastStatePlayer.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/CastStatePlayer.kt
index 31394a6b933..b0580e83c33 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/CastStatePlayer.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/CastStatePlayer.kt
@@ -28,6 +28,7 @@ internal class CastStatePlayer(
private val onPause: () -> Unit,
private val onSeekTo: (Long) -> Unit,
private val onStop: () -> Unit,
+ private val canSeekProvider: () -> Boolean = { true },
) : SimpleBasePlayer(applicationLooper) {
private var castPlaying = false
@@ -74,6 +75,11 @@ internal class CastStatePlayer(
COMMAND_GET_CURRENT_MEDIA_ITEM,
COMMAND_GET_METADATA,
)
+ .apply {
+ if (canSeekProvider()) {
+ add(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
+ }
+ }
.build(),
)
.setPlaylist(listOf(placeholderItem))
@@ -102,6 +108,9 @@ internal class CastStatePlayer(
positionMs: Long,
seekCommand: @Player.Command Int,
): ListenableFuture<*> {
+ if (!canSeekProvider()) {
+ return Futures.immediateVoidFuture()
+ }
onSeekTo(positionMs)
return Futures.immediateVoidFuture()
}
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3LibrarySessionCallback.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3LibrarySessionCallback.kt
index 85654912d40..15bbd12d5bb 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3LibrarySessionCallback.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3LibrarySessionCallback.kt
@@ -48,6 +48,7 @@ internal class Media3LibrarySessionCallback(
private val packageValidator: PackageValidator?,
private val scopeProvider: () -> CoroutineScope,
private val contextProvider: () -> Context,
+ private val canSeekProvider: () -> Boolean = { true },
) : MediaLibraryService.MediaLibrarySession.Callback {
private val scope: CoroutineScope get() = scopeProvider()
@@ -65,7 +66,7 @@ internal class Media3LibrarySessionCallback(
LogBuffer.TAG_PLAYBACK,
"Unknown caller connected with transport-only access: ${controller.packageName} uid=${controller.uid}",
)
- return MediaSession.ConnectionResult.accept(SessionCommands.EMPTY, TRANSPORT_PLAYER_COMMANDS)
+ return MediaSession.ConnectionResult.accept(SessionCommands.EMPTY, transportPlayerCommands(canSeekProvider()))
}
if (!controller.packageName.contains("au.com.shiftyjelly.pocketcasts")) {
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Client: ${controller.packageName} connected to media session")
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt
index 791efa6341d..2cfe700ae7f 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt
@@ -62,6 +62,7 @@ internal class Media3SessionCallback(
private val scopeProvider: () -> CoroutineScope,
private val contextProvider: () -> Context,
private val source: SourceView = SourceView.MEDIA_BUTTON_BROADCAST_ACTION,
+ private val canSeekProvider: () -> Boolean = { true },
internal val commandMutex: Mutex = Mutex(),
) : MediaSession.Callback {
@@ -94,7 +95,7 @@ internal class Media3SessionCallback(
.add(SessionCommand(SessionCommand.COMMAND_CODE_SESSION_SET_RATING))
.build()
- return MediaSession.ConnectionResult.accept(sessionCommands, TRANSPORT_PLAYER_COMMANDS)
+ return MediaSession.ConnectionResult.accept(sessionCommands, transportPlayerCommands(canSeekProvider()))
}
override fun onCustomCommand(
@@ -373,16 +374,22 @@ internal class Media3SessionCallback(
*/
@OptIn(UnstableApi::class)
@Suppress("UnsafeOptInUsageError")
-internal val TRANSPORT_PLAYER_COMMANDS: Player.Commands = Player.Commands.Builder()
- .addAll(
- Player.COMMAND_PLAY_PAUSE,
- Player.COMMAND_SET_MEDIA_ITEM,
- Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
- Player.COMMAND_STOP,
- Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
- Player.COMMAND_GET_METADATA,
- )
- .build()
+internal fun transportPlayerCommands(canSeek: Boolean): Player.Commands {
+ return Player.Commands.Builder()
+ .addAll(
+ Player.COMMAND_PLAY_PAUSE,
+ Player.COMMAND_SET_MEDIA_ITEM,
+ Player.COMMAND_STOP,
+ Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
+ Player.COMMAND_GET_METADATA,
+ )
+ .apply {
+ if (canSeek) {
+ add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
+ }
+ }
+ .build()
+}
internal fun resolveArtworkUri(episode: BaseEpisode, podcast: Podcast?): Uri? {
return when (episode) {
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt
index aad3d54585f..a57a29e1279 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt
@@ -23,7 +23,6 @@ import androidx.media.utils.MediaConstants.PLAYBACK_STATE_EXTRAS_KEY_MEDIA_ID
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaLibraryService
-import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionError
import au.com.shiftyjelly.pocketcasts.analytics.SourceView
@@ -187,6 +186,10 @@ class MediaSessionManager(
mediaSession?.setPlaybackState(stateBuilder.build())
}
+ private fun canScrubPlayback(): Boolean {
+ return isAndroidAutoConnected || settings.enableLockScreenScrubbing.value
+ }
+
private var bookmarkHelper: BookmarkHelper
@OptIn(UnstableApi::class)
@@ -230,6 +233,8 @@ class MediaSessionManager(
extraBufferCapacity = 10,
)
+ private var isAndroidAutoConnected = false
+
init {
bookmarkHelper = BookmarkHelper(
playbackManager,
@@ -303,7 +308,11 @@ class MediaSessionManager(
if (scope.coroutineContext[Job]?.isActive != true) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
- val seedPlayer = SeedStatePlayer(Looper.getMainLooper())
+ val seedPlayer = SeedStatePlayer(
+ applicationLooper = Looper.getMainLooper(),
+ canSeekProvider = ::canScrubPlayback,
+ )
+
placeholderPlayer = seedPlayer
val placeholder = seedPlayer
@@ -339,6 +348,7 @@ class MediaSessionManager(
true
}
},
+ canSeekProvider = ::canScrubPlayback,
)
media3Callback = Media3SessionCallback(
@@ -350,6 +360,7 @@ class MediaSessionManager(
bookmarkHelper = bookmarkHelper,
scopeProvider = { scope },
contextProvider = { context },
+ canSeekProvider = ::canScrubPlayback,
commandMutex = commandMutex,
)
media3LibraryCallback = Media3LibrarySessionCallback(
@@ -366,6 +377,7 @@ class MediaSessionManager(
},
scopeProvider = { scope },
contextProvider = { context },
+ canSeekProvider = ::canScrubPlayback,
)
media3Session = MediaLibraryService.MediaLibrarySession.Builder(service, forwardingPlayer!!, media3LibraryCallback!!)
@@ -451,6 +463,7 @@ class MediaSessionManager(
}
},
onStop = { scope.launch { commandMutex.withLock { playbackManager.pauseSuspend(sourceView = source) } } },
+ canSeekProvider = ::canScrubPlayback,
)
castStatePlayer = player
installCastPlayerInternal(player)
@@ -478,6 +491,7 @@ class MediaSessionManager(
onSkipForward = { scope.launch { commandMutex.withLock { playbackManager.skipForwardSuspend() } } },
onSkipBack = { scope.launch { commandMutex.withLock { playbackManager.skipBackwardSuspend() } } },
playGuard = currentPlayer.playGuard,
+ canSeekProvider = ::canScrubPlayback,
).also {
it.currentMediaItem = currentPlayer.currentMediaItem
it.previousMediaId = currentPlayer.previousMediaId
@@ -542,6 +556,11 @@ class MediaSessionManager(
updateMedia3CustomLayout()
media3Service?.triggerNotificationUpdate()
}
+ Util.isAndroidAutoConnectedFlow(context).collect { autoConnected ->
+ isAndroidAutoConnected = autoConnected
+ val playbackStateCompat = getPlaybackStateCompat(playbackManager.playbackStateRelay.blockingFirst(), currentEpisode = playbackManager.getCurrentEpisode())
+ updatePlaybackState(playbackStateCompat)
+ }
}
} catch (e: Exception) {
Timber.e(e, "Failed to replay metadata after player install")
@@ -962,14 +981,20 @@ class MediaSessionManager(
PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH
+ val scrubbingAction = if (canScrubPlayback()) {
+ PlaybackStateCompat.ACTION_SEEK_TO
+ } else {
+ 0L
+ }
+
if (playbackState.isEmpty) {
return PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
- prepareActions
+ prepareActions or
+ scrubbingAction
} else {
val actions = PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
- PlaybackStateCompat.ACTION_SEEK_TO or
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH or
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
@@ -977,7 +1002,8 @@ class MediaSessionManager(
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND or
- prepareActions
+ prepareActions or
+ scrubbingAction
return if (useCustomSkipButtons()) {
actions
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PocketCastsForwardingPlayer.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PocketCastsForwardingPlayer.kt
index efc5abb3f41..6fc2456e541 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PocketCastsForwardingPlayer.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PocketCastsForwardingPlayer.kt
@@ -43,7 +43,8 @@ class PocketCastsForwardingPlayer(
private val onPause: (() -> Unit)? = null,
private val onSeekTo: ((Long) -> Unit)? = null,
internal val playGuard: (() -> Boolean) = { true },
-) : ForwardingPlayer(wrappedPlayer) {
+ private val canSeekProvider: () -> Boolean = { true },
+ ) : ForwardingPlayer(wrappedPlayer) {
internal var currentMediaItem: MediaItem = MediaItem.EMPTY
internal var previousMediaId: String? = null
@@ -68,7 +69,7 @@ class PocketCastsForwardingPlayer(
@MainThread
fun swapPlayer(newPlayer: Player): PocketCastsForwardingPlayer {
checkMainThread()
- return PocketCastsForwardingPlayer(newPlayer, onSkipForward, onSkipBack, onStop, onPlay, onPause, onSeekTo, playGuard).also {
+ return PocketCastsForwardingPlayer(newPlayer, onSkipForward, onSkipBack, onStop, onPlay, onPause, onSeekTo, playGuard, canSeekProvider).also {
it.currentMediaItem = this.currentMediaItem
it.previousMediaId = this.previousMediaId
it.isTransientLoss = this.isTransientLoss
@@ -163,20 +164,26 @@ class PocketCastsForwardingPlayer(
.addAll(
Player.COMMAND_PLAY_PAUSE,
Player.COMMAND_SET_MEDIA_ITEM,
- Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
Player.COMMAND_STOP,
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
Player.COMMAND_GET_METADATA,
)
+ .apply {
+ if (canSeekProvider()) {
+ add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
+ }
+ }
.build()
}
override fun seekTo(positionMs: Long) {
+ if (!canSeekProvider()) return
onSeekTo?.invoke(positionMs)
super.seekTo(positionMs)
}
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
+ if (!canSeekProvider()) return
onSeekTo?.invoke(positionMs)
super.seekTo(mediaItemIndex, positionMs)
}
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SeedStatePlayer.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SeedStatePlayer.kt
index 6cb920822dc..247c2c38b71 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SeedStatePlayer.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/SeedStatePlayer.kt
@@ -27,7 +27,8 @@ import com.google.common.util.concurrent.ListenableFuture
@OptIn(UnstableApi::class)
internal class SeedStatePlayer(
applicationLooper: Looper,
-) : SimpleBasePlayer(applicationLooper) {
+ private val canSeekProvider: () -> Boolean = { true },
+ ) : SimpleBasePlayer(applicationLooper) {
private var seeded = false
private var positionMs = 0L
@@ -58,11 +59,15 @@ internal class SeedStatePlayer(
Player.Commands.Builder()
.addAll(
COMMAND_PLAY_PAUSE,
- COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
COMMAND_STOP,
COMMAND_GET_CURRENT_MEDIA_ITEM,
COMMAND_GET_METADATA,
)
+ .apply {
+ if (canSeekProvider()) {
+ add(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
+ }
+ }
.build(),
)
.setPlaylist(listOf(placeholderItem))