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))