Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,7 @@
<string name="settings_notification_vibrate_never">Never</string>
<string name="settings_open_player_automatically">Open player automatically</string>
<string name="settings_open_player_automatically_summary">If on, the full-screen player will open when you start playing a podcast episode.</string>
<string name="settings_enable_lock_screen_scrubbing">Enable lock screen scrubbing</string>
<string name="settings_other_media_actions">Other media actions</string>
<string name="settings_playback_resumption">Intelligent playback resumption</string>
<string name="settings_playback_resumption_summary">If on, Pocket Casts will go back a little in episodes you resume so you can catch up more comfortably.</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ interface Settings {
val streamingMode: UserSetting<Boolean>
val keepScreenAwake: UserSetting<Boolean>
val openPlayerAutomatically: UserSetting<Boolean>
val enableLockScreenScrubbing: UserSetting<Boolean>

val autoDownloadUnmeteredOnly: UserSetting<Boolean>
val autoDownloadOnlyWhenCharging: UserSetting<Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -102,6 +108,9 @@ internal class CastStatePlayer(
positionMs: Long,
seekCommand: @Player.Command Int,
): ListenableFuture<*> {
if (!canSeekProvider()) {
return Futures.immediateVoidFuture()
}
onSeekTo(positionMs)
return Futures.immediateVoidFuture()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -230,6 +233,8 @@ class MediaSessionManager(
extraBufferCapacity = 10,
)

private var isAndroidAutoConnected = false

init {
bookmarkHelper = BookmarkHelper(
playbackManager,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -339,6 +348,7 @@ class MediaSessionManager(
true
}
},
canSeekProvider = ::canScrubPlayback,
)

media3Callback = Media3SessionCallback(
Expand All @@ -350,6 +360,7 @@ class MediaSessionManager(
bookmarkHelper = bookmarkHelper,
scopeProvider = { scope },
contextProvider = { context },
canSeekProvider = ::canScrubPlayback,
commandMutex = commandMutex,
)
media3LibraryCallback = Media3LibrarySessionCallback(
Expand All @@ -366,6 +377,7 @@ class MediaSessionManager(
},
scopeProvider = { scope },
contextProvider = { context },
canSeekProvider = ::canScrubPlayback,
)

media3Session = MediaLibraryService.MediaLibrarySession.Builder(service, forwardingPlayer!!, media3LibraryCallback!!)
Expand Down Expand Up @@ -451,6 +463,7 @@ class MediaSessionManager(
}
},
onStop = { scope.launch { commandMutex.withLock { playbackManager.pauseSuspend(sourceView = source) } } },
canSeekProvider = ::canScrubPlayback,
)
castStatePlayer = player
installCastPlayerInternal(player)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -542,6 +556,11 @@ class MediaSessionManager(
updateMedia3CustomLayout()
media3Service?.triggerNotificationUpdate()
}
Util.isAndroidAutoConnectedFlow(context).collect { autoConnected ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling collect() here leak memory. Collecting is never cancelled unless the scope is cancelled. This means that if I play 3 episodes I will have 3 active subscriptions to isAndroidAutoConnectedFlow().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it be ok to use Util.isAndroidAutoConnectedFlow(context).first()?
The terminal operator that returns the first element emitted by the flow and then cancels flow's collection. Throws NoSuchElementException if the flow was empty.

Will need to handle the exception, if the flow is empty and we can wrap it in try/catch. Does this makes sense?

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")
Expand Down Expand Up @@ -962,22 +981,29 @@ 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
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM or
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND or
prepareActions
prepareActions or
scrubbingAction

return if (useCustomSkipButtons()) {
actions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down