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
Expand Up @@ -4,6 +4,8 @@
* Add option to switch to the HLS video stream when playing a downloaded episode
([#5620](https://github.com/Automattic/pocket-casts-android/pull/5620))
* Bug Fixes
* Fix Bluetooth play controls resuming slowly and playback repeatedly pausing on wireless Android Auto
([#5645](https://github.com/Automattic/pocket-casts-android/pull/5645))
* Allow copying description and show notes links with a long press
([#5628](https://github.com/Automattic/pocket-casts-android/pull/5628))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@ internal class Media3SessionCallback(

private val scope: CoroutineScope get() = scopeProvider()

private val mediaEventQueue = MediaEventQueue(scopeProvider = scopeProvider)
private val mediaButtonEventHandler = MediaButtonEventHandler(
scopeProvider = scopeProvider,
onImmediatePlay = { playbackManager.playIfNotPlaying(sourceView = source) },
onMediaEvent = { event ->
when (event) {
MediaEvent.SingleTap -> handleMediaButtonSingleTap()
MediaEvent.DoubleTap -> handleMediaButtonDoubleTap()
MediaEvent.TripleTap -> handleMediaButtonTripleTap()
}
},
)

override fun onConnect(
session: MediaSession,
Expand Down Expand Up @@ -204,9 +214,10 @@ internal class Media3SessionCallback(
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media3 media button event: keyCode=${keyEvent.keyCode}")

// Dedicated pause key has explicit semantics — handle it directly.
// KEYCODE_MEDIA_PLAY is routed through the multi-tap system because some
// Bluetooth headphones (e.g. Pixel Buds) send it spuriously after multi-tap
// sequences, and the MediaEventQueue suppresses those duplicates.
// KEYCODE_MEDIA_PLAY stays in the multi-tap system because some Bluetooth
// headphones send it spuriously after multi-tap sequences. Its play-only
// action runs as soon as the queue accepts the first tap, without waiting
// for the multi-tap window to expire.
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
scope.launch { playbackManager.pauseSuspend(sourceView = source) }
Expand Down Expand Up @@ -244,37 +255,7 @@ internal class Media3SessionCallback(
}
}

val inputEvent = when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_HEADSETHOOK,
-> MediaEvent.SingleTap

KeyEvent.KEYCODE_MEDIA_NEXT -> MediaEvent.DoubleTap

KeyEvent.KEYCODE_MEDIA_PREVIOUS -> MediaEvent.TripleTap

else -> null
}

if (inputEvent != null) {
scope.launch {
try {
val outputEvent = mediaEventQueue.consumeEvent(inputEvent)
when (outputEvent) {
MediaEvent.SingleTap -> handleMediaButtonSingleTap()
MediaEvent.DoubleTap -> handleMediaButtonDoubleTap()
MediaEvent.TripleTap -> handleMediaButtonTripleTap()
null -> Unit
}
} catch (e: Exception) {
Timber.e(e, "Media button event handling failed")
}
}
return true
}

return false
return mediaButtonEventHandler.handle(keyEvent)
}

private fun handleMediaButtonSingleTap() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ class MediaSessionManager(
LogBuffer.e(LogBuffer.TAG_PLAYBACK, "Failed to add command to queue: $tag")
}
},
scopeProvider = { scope },
),
)
}
Expand Down Expand Up @@ -1218,48 +1219,50 @@ class MediaSessionManager(
stateBuilder.addCustomAction(skipBackBuilder.build())
}

inner class MediaSessionCallback(
internal inner class MediaSessionCallback(
val playbackManager: PlaybackManager,
val episodeManager: EpisodeManager,
val enqueueCommand: (String, suspend () -> Unit) -> Unit,
scopeProvider: () -> CoroutineScope,
) : MediaSessionCompat.Callback() {

private var playFromSearchDisposable: Disposable? = null
private val mediaEventQueue = MediaEventQueue(scopeProvider = { this@MediaSessionManager.scope })
private val mediaButtonEventHandler = MediaButtonEventHandler(
scopeProvider = scopeProvider,
onImmediatePlay = {
playbackManager.playIfNotPlaying(sourceView = source)
},
onMediaEvent = { event ->
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button output event: $event")
when (event) {
MediaEvent.SingleTap -> handleMediaButtonSingleTap()
MediaEvent.DoubleTap -> handleMediaButtonDoubleTap()
MediaEvent.TripleTap -> handleMediaButtonTripleTap()
}
},
)

override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
if (Intent.ACTION_MEDIA_BUTTON == mediaButtonEvent.action) {
val keyEvent = IntentCompat.getParcelableExtra(mediaButtonEvent, Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) ?: return false
logEvent(keyEvent.toString())
if (keyEvent.action == KeyEvent.ACTION_DOWN) {
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button Android event: ${keyEvent.action}")
val inputEvent = when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> MediaEvent.SingleTap
KeyEvent.KEYCODE_MEDIA_NEXT -> MediaEvent.DoubleTap
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> MediaEvent.TripleTap
else -> null
}
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button input event: ${keyEvent.action}")

if (inputEvent != null) {
scope.launch {
val outputEvent = mediaEventQueue.consumeEvent(inputEvent)
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button output event: ${keyEvent.action}")
when (outputEvent) {
MediaEvent.SingleTap -> handleMediaButtonSingleTap()
MediaEvent.DoubleTap -> handleMediaButtonDoubleTap()
MediaEvent.TripleTap -> handleMediaButtonTripleTap()
null -> Unit
}
}
return true
}
}
} else {
if (Intent.ACTION_MEDIA_BUTTON != mediaButtonEvent.action) {
logEvent("onMediaButtonEvent(${mediaButtonEvent.action ?: "unknown action"})")
return super.onMediaButtonEvent(mediaButtonEvent)
}

return super.onMediaButtonEvent(mediaButtonEvent)
val keyEvent = IntentCompat.getParcelableExtra(
mediaButtonEvent,
Intent.EXTRA_KEY_EVENT,
KeyEvent::class.java,
) ?: return false
logEvent(keyEvent.toString())
if (keyEvent.action == KeyEvent.ACTION_DOWN) {
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button Android event: keyCode=${keyEvent.keyCode}")
}

return if (mediaButtonEventHandler.handle(keyEvent)) {
true
} else {
super.onMediaButtonEvent(mediaButtonEvent)
}
}

private fun onAddBookmark() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,25 @@ open class PlaybackManager @Inject constructor(
}
}

/**
* Plays the queue only if playback isn't already running, unlike the [playPause] toggle.
* Used for KEYCODE_MEDIA_PLAY, which has explicit play semantics: some head units
* (wireless Android Auto in particular) send it redundantly while playback is already
* running, and toggling would pause playback. Media-button callbacks invoke it immediately
* instead of waiting for multi-tap disambiguation.
*/
fun playIfNotPlaying(sourceView: SourceView = SourceView.UNKNOWN) {
if (isPlaying()) {
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Ignoring play request because playback is already playing")
} else {
LogBuffer.i(
LogBuffer.TAG_PLAYBACK,
"Explicit play request from source=$sourceView",
)
playQueue(sourceView)
}
}

fun playQueue(
sourceView: SourceView = SourceView.UNKNOWN,
showedStreamWarning: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionError
import androidx.media3.session.SessionResult
import au.com.shiftyjelly.pocketcasts.analytics.SourceView
import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode
import au.com.shiftyjelly.pocketcasts.preferences.Settings
Expand All @@ -19,8 +20,8 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager
import java.util.Date
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
Expand All @@ -31,6 +32,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.doCallRealMethod
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
Expand Down Expand Up @@ -65,7 +67,7 @@ class Media3SessionCallbackTest {
bookmarkHelper = mock()
mockSession = mock()
mockController = mock()
testScope = TestScope(UnconfinedTestDispatcher())
testScope = TestScope(StandardTestDispatcher())

callback = Media3SessionCallback(
playbackManager = playbackManager,
Expand Down Expand Up @@ -340,12 +342,74 @@ class Media3SessionCallbackTest {
// --- Headphone action handler tests ---

@Test
fun `KEYCODE_MEDIA_PLAY routes through multi-tap as single tap`() = runTest {
fun `KEYCODE_MEDIA_PLAY while paused starts playback before the multi-tap window expires`() = runTest {
doCallRealMethod().whenever(playbackManager).playIfNotPlaying(any())
whenever(playbackManager.isPlaying()).thenReturn(false)

sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY)

// A dedicated play key must not wait 600 ms for tap disambiguation.
verify(playbackManager).playQueue(
sourceView = eq(SourceView.MEDIA_BUTTON_BROADCAST_ACTION),
showedStreamWarning = any(),
)
verify(playbackManager, never()).playPause(sourceView = any())

testScope.advanceUntilIdle()

// Routed through MediaEventQueue — single tap resolves as play/pause
verify(playbackManager).playPause(sourceView = any())
// A lone dedicated play key has already been handled, so the delayed
// single-tap result must not toggle playback.
verify(playbackManager, never()).playPause(sourceView = any())
}

@Test
fun `KEYCODE_MEDIA_PLAY while paused resumes before the double-tap action`() = runTest {
mockHeadphoneNextAction(HeadphoneAction.ADD_BOOKMARK)
doCallRealMethod().whenever(playbackManager).playIfNotPlaying(any())
whenever(playbackManager.isPlaying()).thenReturn(false)

sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY)
verify(playbackManager).playQueue(
sourceView = eq(SourceView.MEDIA_BUTTON_BROADCAST_ACTION),
showedStreamWarning = any(),
)

sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
testScope.advanceUntilIdle()
shadowOf(android.os.Looper.getMainLooper()).idle()

verify(bookmarkHelper).handleAddBookmarkAction(any(), any())
verify(playbackManager, never()).playPause(sourceView = any())
}

@Test
fun `KEYCODE_MEDIA_PLAY stays suppressed after KEYCODE_MEDIA_NEXT`() = runTest {
mockHeadphoneNextAction(HeadphoneAction.SKIP_FORWARD)
mockSkipSettings()
whenever(playbackManager.isPlaying()).thenReturn(true)

sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_NEXT)
sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY)
testScope.advanceUntilIdle()

verify(playbackManager, never()).playIfNotPlaying(sourceView = any())
verify(playbackManager).skipForwardSuspend(
sourceView = any(),
jumpAmountSeconds = eq(30),
)
}

@Test
fun `KEYCODE_MEDIA_PLAY while already playing does not toggle`() = runTest {
doCallRealMethod().whenever(playbackManager).playIfNotPlaying(any())
whenever(playbackManager.isPlaying()).thenReturn(true)

sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY)
testScope.advanceUntilIdle()

verify(playbackManager, never()).playQueue(any(), any())
verify(playbackManager, never()).pause(any(), any())
verify(playbackManager, never()).playPause(any())
}

@Test
Expand Down Expand Up @@ -382,18 +446,6 @@ class Media3SessionCallbackTest {
)
}

@Test
fun `double tap with ADD_BOOKMARK setting calls bookmarkHelper`() = runTest {
mockHeadphoneNextAction(HeadphoneAction.ADD_BOOKMARK)

sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_NEXT)
testScope.advanceUntilIdle()
// The bookmark action dispatches on Dispatchers.Main, so idle the main looper
shadowOf(android.os.Looper.getMainLooper()).idle()

verify(bookmarkHelper).handleAddBookmarkAction(any(), any())
}

@Test
fun `triple tap with SKIP_BACK setting calls skipBackwardSuspend`() = runTest {
mockHeadphonePreviousAction(HeadphoneAction.SKIP_BACK)
Expand Down
Loading
Loading