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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
8.17
-----
* New Features
* Add an optional "Rewind after interruptions" setting that jumps back a little when playback resumes after calls, alarms, navigation and other audio
([#5611](https://github.com/Automattic/pocket-casts-android/pull/5611))
* Add support for Flightcast transcripts
([#5544](https://github.com/Automattic/pocket-casts-android/pull/5544))
* Stats Heatmap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,20 @@ class PlaybackSettingsFragment : BaseFragment() {
)
}

SettingsItems.SETTINGS_INTERRUPTION_REWIND -> {
if (FeatureFlag.isEnabled(Feature.INTERRUPTION_REWIND)) {
InterruptionRewind(
saved = settings.interruptionRewindSeconds.flow.collectAsState().value,
onSave = { seconds ->
// TODO: track a SettingsGeneralInterruptionRewindChangedEvent once it is
// available in the Event Horizon library, it can't be added from this repo
// updateModifiedAt is false because this setting is device-local, not synced
settings.interruptionRewindSeconds.set(seconds, updateModifiedAt = false)
},
)
}
}

SettingsItems.SETTINGS_PLAY_UP_NEXT_EPISODE -> {
PlayUpNextOnTap(
saved = settings.tapOnUpNextShouldPlay.flow.collectAsState().value,
Expand Down Expand Up @@ -595,6 +609,30 @@ class PlaybackSettingsFragment : BaseFragment() {
indent = false,
)

@Composable
private fun InterruptionRewind(
saved: Int,
onSave: (Int) -> Unit,
) = SettingRadioDialogRow(
primaryText = stringResource(LR.string.settings_interruption_rewind),
secondaryText = if (saved > 0) {
stringResource(LR.string.settings_interruption_rewind_summary, saved)
} else {
stringResource(LR.string.settings_interruption_rewind_summary_off)
},
options = listOf(0, 5, 10, 15, 30, 60),
savedOption = saved,
optionToLocalisedString = { seconds ->
if (seconds > 0) {
getString(LR.string.seconds_plural, seconds)
} else {
getString(LR.string.off)
}
},
onSave = onSave,
indent = false,
)

@Composable
private fun PlayUpNextOnTap(saved: Boolean, onSave: (Boolean) -> Unit) = SettingRow(
primaryText = stringResource(LR.string.settings_up_next_tap),
Expand Down Expand Up @@ -704,6 +742,7 @@ private enum class SettingsItems {
SETTINGS_OPEN_PLAYER_AUTOMATICALLY,
SETTINGS_GENERATED_CHAPTERS,
SETTINGS_INTELLIGENT_PLAYBACK,
SETTINGS_INTERRUPTION_REWIND,
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 @@ -1601,6 +1601,9 @@
<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>
<string name="settings_interruption_rewind">Rewind after interruptions</string>
<string name="settings_interruption_rewind_summary">Go back %1$d seconds when playback is interrupted by calls, alarms, navigation and other audio.</string>
<string name="settings_interruption_rewind_summary_off">Don\'t go back when playback is interrupted by calls, alarms, navigation and other audio.</string>
<string name="settings_podcast_artwork">Podcast Artwork</string>
<string name="settings_podcast_custom">Custom for this podcast</string>
<string name="settings_podcast_episode_grouping">Podcast episode grouping</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ interface Settings {
const val PREFERENCE_CHAPTERS_EXPANDED = "chaptersExpanded"
const val PREFERENCE_UPNEXT_EXPANDED = "upnextExpanded"
const val INTELLIGENT_PLAYBACK_RESUMPTION = "intelligentPlaybackResumption"
const val INTERRUPTION_REWIND_SECONDS = "interruptionRewindSeconds"
const val DEFAULT_INTERRUPTION_REWIND_SECONDS = 5
const val UP_NEXT_BADGE_MAX_COUNT = 999

const val STORAGE_ON_CUSTOM_FOLDER = "custom_folder"
Expand Down Expand Up @@ -510,7 +512,15 @@ interface Settings {
fun getLastPausedUUID(): String?
fun setLastPausedAt(pausedAt: Int)
fun getLastPausedAt(): Int?
fun setLastPauseWasInterruption(wasInterruption: Boolean)
fun getLastPauseWasInterruption(): Boolean
val intelligentPlaybackResumption: UserSetting<Boolean>

/**
* How many seconds to rewind when playback resumes after an audio interruption, 0 means off.
* Stored on device only, not synced.
*/
val interruptionRewindSeconds: UserSetting<Int>
val autoAddUpNextLimit: UserSetting<Int>
val autoAddUpNextLimitBehaviour: UserSetting<AutoAddUpNextLimitBehaviour>
fun getMaxUpNextEpisodes(): Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -949,12 +949,26 @@ class SettingsImpl @Inject constructor(
return if (lastPausedAt != 0) lastPausedAt else null
}

override fun setLastPauseWasInterruption(wasInterruption: Boolean) {
setBoolean("last_pause_was_interruption", wasInterruption)
}

override fun getLastPauseWasInterruption(): Boolean {
return getBoolean("last_pause_was_interruption", defaultValue = false)
}

override val intelligentPlaybackResumption = UserSetting.BoolPref(
sharedPrefKey = Settings.INTELLIGENT_PLAYBACK_RESUMPTION,
defaultValue = true,
sharedPrefs = sharedPreferences,
)

override val interruptionRewindSeconds = UserSetting.IntPref(
sharedPrefKey = Settings.INTERRUPTION_REWIND_SECONDS,
defaultValue = Settings.DEFAULT_INTERRUPTION_REWIND_SECONDS,
sharedPrefs = sharedPreferences,
)

private fun setDate(preference: String, date: Date?) {
val editor = sharedPreferences.edit()
if (date == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ open class PlaybackManager @Inject constructor(
private var resettingPlayer = false
private var episodeLastBufferStatus: EpisodeBufferStatus? = null
private var focusWasPlaying: Date? = null
private var pauseDueToInterruption = false
private var forcePlayerSwitch = false
private var updateTimerDisposable: Disposable? = null
private var bufferUpdateTimerDisposable: Disposable? = null
Expand Down Expand Up @@ -892,13 +893,25 @@ open class PlaybackManager @Inject constructor(
}
}

fun pause(transientLoss: Boolean = false, sourceView: SourceView = SourceView.UNKNOWN) {
fun pause(
transientLoss: Boolean = false,
sourceView: SourceView = SourceView.UNKNOWN,
dueToInterruption: Boolean = false,
) {
launch {
pauseSuspend(transientLoss, sourceView)
pauseSuspend(transientLoss, sourceView, dueToInterruption)
}
}

suspend fun pauseSuspend(transientLoss: Boolean = false, sourceView: SourceView = SourceView.UNKNOWN) {
suspend fun pauseSuspend(
transientLoss: Boolean = false,
sourceView: SourceView = SourceView.UNKNOWN,
dueToInterruption: Boolean = false,
) {
// remembered until the player reports the pause so onPlayerPaused can tell the
// resumption helper whether this pause came from an audio interruption
pauseDueToInterruption = dueToInterruption

if (!transientLoss) {
focusManager.giveUpAudioFocus()
playbackStateRelay.blockingFirst().let { playbackState ->
Expand Down Expand Up @@ -1525,13 +1538,18 @@ open class PlaybackManager @Inject constructor(

val episode = getCurrentEpisode()

// consume the interruption marker so pauses that reach this callback without going
// through pauseSuspend (sleep timer, cast receiver, error paths) can't reuse a stale value
val dueToInterruption = pauseDueToInterruption
pauseDueToInterruption = false

player?.let {
val positionMs = it.getCurrentPositionMs()
if (positionMs > 0) {
updateCurrentPositionInDatabase()

episode?.let { episode ->
resumptionHelper.paused(episode, positionMs)
resumptionHelper.paused(episode, positionMs, dueToInterruption = dueToInterruption)
}
}
}
Expand Down Expand Up @@ -1868,7 +1886,7 @@ open class PlaybackManager @Inject constructor(
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Focus lost while playing")
focusWasPlaying = Date()

pause(transientLoss = transientLoss, sourceView = SourceView.AUTO_PAUSE)
pause(transientLoss = transientLoss, sourceView = SourceView.AUTO_PAUSE, dueToInterruption = true)
} else {
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Focus lost not playing")
focusWasPlaying = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package au.com.shiftyjelly.pocketcasts.repositories.playback

import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
import au.com.shiftyjelly.pocketcasts.preferences.Settings
import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature
import au.com.shiftyjelly.pocketcasts.utils.featureflag.FeatureFlag
import au.com.shiftyjelly.pocketcasts.utils.hours
import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer
import au.com.shiftyjelly.pocketcasts.utils.minutes
Expand All @@ -13,44 +15,71 @@ class ResumptionHelper(val settings: Settings) {
private var lastPauseTime: Date? = settings.getLastPauseTime()

fun adjustedStartTimeMsFor(episode: BaseEpisode): Int {
if (!settings.intelligentPlaybackResumption.value ||
settings.getLastPausedUUID() != episode.uuid ||
if (settings.getLastPausedUUID() != episode.uuid ||
(settings.getLastPausedAt() ?: 0) != episode.playedUpToMs
) {
return episode.playedUpToMs
}

val lastPauseTime = this.lastPauseTime ?: return episode.playedUpToMs
// the pause length rewind and the interruption rewind can both apply to the same resume,
// take the larger of the two rather than stacking them
val rewindMs = maxOf(pauseLengthRewindMs(), interruptionRewindMs())
if (rewindMs <= 0) {
return episode.playedUpToMs
}

return (episode.playedUpToMs - rewindMs).toInt().coerceAtLeast(0)
}

fun paused(episode: BaseEpisode, atPlayedUpToMs: Int, dueToInterruption: Boolean = false) {
lastPauseTime = Date()
settings.setLastPauseTime(Date())
settings.setLastPausedUUID(episode.uuid)
settings.setLastPausedAt(atPlayedUpToMs)
settings.setLastPauseWasInterruption(dueToInterruption)
}

private fun pauseLengthRewindMs(): Long {
if (!settings.intelligentPlaybackResumption.value) {
return 0
}

val lastPauseTime = this.lastPauseTime ?: return 0

val adjustedTime = when {
return when {
lastPauseTime.timeIntervalSinceNow() > 24.hours() -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "More than 24 hours since this episode was paused, jumping back 30 seconds")
episode.playedUpToMs - 30.seconds()
30.seconds()
}

lastPauseTime.timeIntervalSinceNow() > 1.hours() -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "More than 1 hour since this episode was paused, jumping back 15 seconds")
episode.playedUpToMs - 15.seconds()
15.seconds()
}

lastPauseTime.timeIntervalSinceNow() > 5.minutes() -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "More than 5 minutes since this episode was paused, jumping back 10 seconds")
episode.playedUpToMs - 10.seconds()
10.seconds()
}

else -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "Not enough time passed since this episode was last paused, no time adjustment required")
episode.playedUpToMs.toLong()
0
}
}

return adjustedTime.toInt().coerceAtLeast(0)
}

fun paused(episode: BaseEpisode, atPlayedUpToMs: Int) {
lastPauseTime = Date()
settings.setLastPauseTime(Date())
settings.setLastPausedUUID(episode.uuid)
settings.setLastPausedAt(atPlayedUpToMs)
private fun interruptionRewindMs(): Long {
if (!FeatureFlag.isEnabled(Feature.INTERRUPTION_REWIND) || !settings.getLastPauseWasInterruption()) {
return 0
}

val rewindSeconds = settings.interruptionRewindSeconds.value
if (rewindSeconds <= 0) {
return 0
}

LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Playback was interrupted, jumping back $rewindSeconds seconds")
return rewindSeconds.seconds()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ class Media3SessionCallbackTest {
verify(playbackManager).pauseSuspend(
transientLoss = any(),
sourceView = any(),
dueToInterruption = any(),
)
}

Expand Down
Loading