diff --git a/CHANGELOG.md b/CHANGELOG.md index f4925f619c1..b20c6e2627f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 d1d677af634..38a74c7fbd5 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 @@ -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, @@ -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), @@ -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, diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index 33cd1e741ff..62dfe90c563 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -1601,6 +1601,9 @@ 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. + Rewind after interruptions + Go back %1$d seconds when playback is interrupted by calls, alarms, navigation and other audio. + Don\'t go back when playback is interrupted by calls, alarms, navigation and other audio. Podcast Artwork Custom for this podcast Podcast episode grouping 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 406ce5700d1..17b8a534f5d 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 @@ -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" @@ -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 + + /** + * How many seconds to rewind when playback resumes after an audio interruption, 0 means off. + * Stored on device only, not synced. + */ + val interruptionRewindSeconds: UserSetting val autoAddUpNextLimit: UserSetting val autoAddUpNextLimitBehaviour: UserSetting fun getMaxUpNextEpisodes(): Int 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 f15788513ca..38632e9208a 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 @@ -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) { diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt index 7d86f7f52ed..dcd6198d3b7 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt @@ -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 @@ -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 -> @@ -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) } } } @@ -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 diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt index 885d897957c..ecb5af6766e 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt @@ -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 @@ -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() } } diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt index 3abfc92915b..f082aaf9f9d 100644 --- a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt @@ -356,6 +356,7 @@ class Media3SessionCallbackTest { verify(playbackManager).pauseSuspend( transientLoss = any(), sourceView = any(), + dueToInterruption = any(), ) } diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelperTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelperTest.kt new file mode 100644 index 00000000000..006e95b1002 --- /dev/null +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelperTest.kt @@ -0,0 +1,206 @@ +package au.com.shiftyjelly.pocketcasts.repositories.playback + +import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.preferences.UserSetting +import au.com.shiftyjelly.pocketcasts.sharedtest.InMemoryFeatureFlagRule +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.minutes +import java.util.Date +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class ResumptionHelperTest { + + @get:Rule + val featureFlagRule = InMemoryFeatureFlagRule() + + @Before + fun setUp() { + FeatureFlag.setEnabled(Feature.INTERRUPTION_REWIND, true) + } + + private fun episode(playedUpToMs: Int = PLAYED_UP_TO_MS, uuid: String = EPISODE_UUID) = PodcastEpisode( + uuid = uuid, + podcastUuid = "podcast-uuid", + publishedDate = Date(), + ).apply { + this.playedUpToMs = playedUpToMs + } + + private fun settingsMock( + lastPausedUuid: String? = EPISODE_UUID, + lastPausedAtMs: Int? = PLAYED_UP_TO_MS, + wasInterruption: Boolean = false, + lastPauseTime: Date? = Date(), + isIntelligentResumptionEnabled: Boolean = true, + rewindSeconds: Int = 5, + ): Settings { + val intelligentResumptionSetting = mock> { + on { value } doReturn isIntelligentResumptionEnabled + } + val interruptionRewindSetting = mock> { + on { value } doReturn rewindSeconds + } + return mock { + on { getLastPausedUUID() } doReturn lastPausedUuid + on { getLastPausedAt() } doReturn lastPausedAtMs + on { getLastPauseWasInterruption() } doReturn wasInterruption + on { getLastPauseTime() } doReturn lastPauseTime + on { intelligentPlaybackResumption } doReturn intelligentResumptionSetting + on { interruptionRewindSeconds } doReturn interruptionRewindSetting + } + } + + @Test + fun `interruption rewind applies when the last pause was an interruption`() { + val settings = settingsMock(wasInterruption = true, rewindSeconds = 30) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS - 30_000, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `interruption rewind does not apply to a regular pause`() { + val settings = settingsMock(wasInterruption = false, rewindSeconds = 30) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `interruption rewind does not apply when turned off`() { + val settings = settingsMock(wasInterruption = true, rewindSeconds = 0) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `interruption rewind does not apply when the feature flag is disabled`() { + FeatureFlag.setEnabled(Feature.INTERRUPTION_REWIND, false) + val settings = settingsMock(wasInterruption = true, rewindSeconds = 30) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `interruption rewind applies when intelligent playback resumption is off`() { + val settings = settingsMock( + wasInterruption = true, + rewindSeconds = 30, + isIntelligentResumptionEnabled = false, + ) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS - 30_000, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `interruption rewind does not rewind past the episode start`() { + val settings = settingsMock( + wasInterruption = true, + rewindSeconds = 30, + lastPausedAtMs = 3_000, + ) + val helper = ResumptionHelper(settings) + + assertEquals(0, helper.adjustedStartTimeMsFor(episode(playedUpToMs = 3_000))) + } + + @Test + fun `no adjustment for a different episode than the last paused one`() { + val settings = settingsMock(wasInterruption = true, rewindSeconds = 30) + val helper = ResumptionHelper(settings) + + val other = episode(uuid = "another-episode-uuid") + + assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(other)) + } + + @Test + fun `no adjustment when the playback position moved since the last pause`() { + val settings = settingsMock(wasInterruption = true, rewindSeconds = 30, lastPausedAtMs = 1_000) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `longer pause length rewind wins over a shorter interruption rewind`() { + val settings = settingsMock( + wasInterruption = true, + rewindSeconds = 5, + lastPauseTime = Date(Date().time - 25.hours()), + ) + val helper = ResumptionHelper(settings) + + // paused for more than 24 hours, the 30 second pause length rewind beats the 5 second interruption rewind + assertEquals(PLAYED_UP_TO_MS - 30_000, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `longer interruption rewind wins over a shorter pause length rewind`() { + val settings = settingsMock( + wasInterruption = true, + rewindSeconds = 60, + lastPauseTime = Date(Date().time - 6.minutes()), + ) + val helper = ResumptionHelper(settings) + + // paused for more than 5 minutes, the 60 second interruption rewind beats the 10 second pause length rewind + assertEquals(PLAYED_UP_TO_MS - 60_000, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `pause length rewind still applies to regular pauses`() { + val settings = settingsMock( + wasInterruption = false, + lastPauseTime = Date(Date().time - 6.minutes()), + ) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS - 10_000, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `no rewind for a short regular pause`() { + val settings = settingsMock(wasInterruption = false) + val helper = ResumptionHelper(settings) + + assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode())) + } + + @Test + fun `paused persists whether the pause came from an interruption`() { + val settings = settingsMock() + val helper = ResumptionHelper(settings) + + helper.paused(episode(), atPlayedUpToMs = PLAYED_UP_TO_MS, dueToInterruption = true) + + verify(settings).setLastPauseWasInterruption(true) + } + + @Test + fun `paused defaults to not being an interruption`() { + val settings = settingsMock() + val helper = ResumptionHelper(settings) + + helper.paused(episode(), atPlayedUpToMs = PLAYED_UP_TO_MS) + + verify(settings).setLastPauseWasInterruption(false) + } + + companion object { + private const val EPISODE_UUID = "episode-uuid" + private const val PLAYED_UP_TO_MS = 600_000 + } +} diff --git a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt index d0feda7e93b..1a9b4d4c2d1 100644 --- a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt +++ b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt @@ -369,6 +369,15 @@ enum class Feature( hasDevToggle = true, addedOn = LocalDate.parse("2026-07-02"), ), + INTERRUPTION_REWIND( + key = "interruption_rewind", + title = "Rewind after audio interruptions like calls and navigation", + defaultValue = isDebugOrPrototypeBuild, + tier = FeatureTier.Free, + hasFirebaseRemoteFlag = true, + hasDevToggle = true, + addedOn = LocalDate.parse("2026-07-19"), + ), } sealed class FeatureTier {