Skip to content

Commit 3f6468c

Browse files
authored
Fix media session controls (#3493)
1 parent 569d15b commit 3f6468c

File tree

4 files changed

+263
-74
lines changed

4 files changed

+263
-74
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Bug Fixes
44
* Fix status bar theming during onboarding.
55
([#3460](https://github.com/Automattic/pocket-casts-android/pull/3460))
6+
* Fix issue with playback stopping when using Pixel Buds actions.
7+
([#3493](https://github.com/Automattic/pocket-casts-android/pull/3493))
68
* Updates
79
* Remove audio and video clip sharing
810
([#3481](https://github.com/Automattic/pocket-casts-android/pull/3481))
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package au.com.shiftyjelly.pocketcasts.repositories.playback
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.Job
5+
import kotlinx.coroutines.delay
6+
import kotlinx.coroutines.launch
7+
8+
internal class MediaEventQueue(
9+
private val scope: CoroutineScope,
10+
) {
11+
private var singleTapJob: SingleTapJob? = null
12+
private var multiTapJob: Job? = null
13+
14+
suspend fun consumeEvent(event: MediaEvent) = when (event) {
15+
MediaEvent.SingleTap -> handleSingleTapEvent()
16+
MediaEvent.DoubleTap, MediaEvent.TripleTap -> handleMultiTapEvent(event)
17+
}
18+
19+
private suspend fun handleSingleTapEvent(): MediaEvent? {
20+
val currentSingleTapJob = singleTapJob
21+
return when {
22+
// Pixel Buds (and possibly other headphones) trigger KEYCODE_MEDIA_PLAY
23+
// after KEYCODE_MEDIA_NEXT or KEYCODE_MEDIA_PREVIOUS.
24+
// We need to ignore it so the single tap action isn't triggered in such cases.
25+
multiTapJob?.isActive == true -> {
26+
null
27+
}
28+
29+
currentSingleTapJob?.isActive == true -> {
30+
currentSingleTapJob.incrementTaps()
31+
null
32+
}
33+
34+
else -> {
35+
val newSingleTapJob = SingleTapJob(scope)
36+
singleTapJob = newSingleTapJob
37+
newSingleTapJob.await()
38+
newSingleTapJob.event()
39+
}
40+
}
41+
}
42+
43+
private fun handleMultiTapEvent(event: MediaEvent): MediaEvent {
44+
val currentJob = multiTapJob
45+
multiTapJob = scope.launch { delay(250) }
46+
currentJob?.cancel()
47+
return event
48+
}
49+
50+
private class SingleTapJob(
51+
private val scope: CoroutineScope,
52+
) {
53+
private var counter: Int = 1
54+
55+
private val job = scope.launch { delay(600) }
56+
57+
val isActive get() = job.isActive
58+
59+
suspend fun await() = job.join()
60+
61+
fun incrementTaps() {
62+
counter++
63+
}
64+
65+
fun event() = when (counter) {
66+
1 -> MediaEvent.SingleTap
67+
2 -> MediaEvent.DoubleTap
68+
else -> MediaEvent.TripleTap
69+
}
70+
}
71+
}
72+
73+
internal enum class MediaEvent {
74+
SingleTap,
75+
DoubleTap,
76+
TripleTap,
77+
}

modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt

Lines changed: 32 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ import io.reactivex.rxkotlin.Observables
4949
import io.reactivex.rxkotlin.addTo
5050
import io.reactivex.rxkotlin.subscribeBy
5151
import io.reactivex.schedulers.Schedulers
52-
import java.util.Timer
53-
import java.util.TimerTask
5452
import kotlin.coroutines.CoroutineContext
5553
import kotlinx.coroutines.CoroutineScope
5654
import kotlinx.coroutines.Dispatchers
@@ -524,43 +522,37 @@ class MediaSessionManager(
524522
val enqueueCommand: (String, suspend () -> Unit) -> Unit,
525523
) : MediaSessionCompat.Callback() {
526524

527-
private var playPauseTimer: Timer? = null
528525
private var playFromSearchDisposable: Disposable? = null
529-
private var buttonPressSuccessions: Int = 0
526+
private val mediaEventQueue = MediaEventQueue(scope = this@MediaSessionManager)
530527

531528
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
532529
if (Intent.ACTION_MEDIA_BUTTON == mediaButtonEvent.action) {
533-
val keyEvent = IntentCompat.getParcelableExtra(mediaButtonEvent, Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
534-
?: return false
530+
val keyEvent = IntentCompat.getParcelableExtra(mediaButtonEvent, Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) ?: return false
535531
logEvent(keyEvent.toString())
536532
if (keyEvent.action == KeyEvent.ACTION_DOWN) {
537-
when (keyEvent.keyCode) {
538-
// When the phone is in sleep mode, and the audio player doesn't have focus, KEYCODE_MEDIA_PLAY is
539-
// called instead of KEYCODE_MEDIA_PLAY_PAUSE or KEYCODE_HEADSETHOOK
540-
KeyEvent.KEYCODE_MEDIA_PLAY -> {
541-
handleMediaButtonSingleTap()
542-
return true
543-
}
544-
// Some wired headphones, such as the Apple USB-C headphones do not invoke KeyEvent.KEYCODE_HEADSETHOOK.
545-
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
546-
handleMediaButtonSingleTap()
547-
return true
548-
}
549-
// This should be called on most wired headsets.
550-
KeyEvent.KEYCODE_HEADSETHOOK -> {
551-
handleMediaButtonSingleTap()
552-
return true
553-
}
554-
KeyEvent.KEYCODE_MEDIA_NEXT -> {
555-
// Not sent on some devices. Use KEYCODE_MEDIA_PLAY_PAUSE or KEYCODE_HEADSETHOOK timeout workarounds
556-
handleMediaButtonDoubleTap()
557-
return true
558-
}
559-
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
560-
// Not sent on some devices. Use KEYCODE_MEDIA_PLAY_PAUSE or KEYCODE_HEADSETHOOK timeout workarounds
561-
handleMediaButtonTripleTap()
562-
return true
533+
val inputEvent = when (keyEvent.keyCode) {
534+
/**
535+
* KEYCODE_MEDIA_PLAY_PAUSE - called when the player audio has focus
536+
* KEYCODE_MEDIA_PLAY - can be called when the player doesn't have focus such when sleep mode
537+
* KEYCODE_HEADSETHOOK - called on most wired headsets
538+
*/
539+
KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> MediaEvent.SingleTap
540+
KeyEvent.KEYCODE_MEDIA_NEXT -> MediaEvent.DoubleTap
541+
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> MediaEvent.TripleTap
542+
else -> null
543+
}
544+
545+
if (inputEvent != null) {
546+
launch {
547+
val outputEvent = mediaEventQueue.consumeEvent(inputEvent)
548+
when (outputEvent) {
549+
MediaEvent.SingleTap -> handleMediaButtonSingleTap()
550+
MediaEvent.DoubleTap -> handleMediaButtonDoubleTap()
551+
MediaEvent.TripleTap -> handleMediaButtonTripleTap()
552+
null -> Unit
553+
}
563554
}
555+
return true
564556
}
565557
}
566558
} else {
@@ -582,49 +574,15 @@ class MediaSessionManager(
582574
}
583575
}
584576

585-
private fun getCurrentControllerInfo(): String {
586-
val info = mediaSession.currentControllerInfo
587-
return "Controller: ${info.packageName} pid: ${info.pid} uid: ${info.uid}"
577+
private fun logEvent(action: String) {
578+
val userInfo = runCatching {
579+
val info = mediaSession.currentControllerInfo
580+
"Controller: ${info.packageName} pid: ${info.pid} uid: ${info.uid}"
581+
}.getOrNull()
582+
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Event from Media Session to $action. ${userInfo.orEmpty()}")
588583
}
589-
590-
// The parameter inSessionCallback can only be set to true if being called from the MediaSession.Callback thread. The method getCurrentControllerInfo() can only be called from this thread.
591-
private fun logEvent(action: String, inSessionCallback: Boolean = true) {
592-
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Event from Media Session to $action. ${if (inSessionCallback) getCurrentControllerInfo() else ""}")
593-
}
594-
595584
private fun handleMediaButtonSingleTap() {
596-
// this code allows the user to double tap or triple tap their play pause button to skip ahead.
597-
// Basically it allows them 600ms to press it again (or a third time) to cause a skip forward/backward instead of a play/pause
598-
buttonPressSuccessions++
599-
600-
if (buttonPressSuccessions == 1) {
601-
playPauseTimer = Timer().apply {
602-
schedule(
603-
object : TimerTask() {
604-
override fun run() {
605-
logEvent("play from headset hook", inSessionCallback = false)
606-
607-
when {
608-
buttonPressSuccessions == 2 && playbackManager.isPlaying() ->
609-
playbackManager.skipForward(sourceView = source)
610-
611-
buttonPressSuccessions == 3 && playbackManager.isPlaying() ->
612-
playbackManager.skipBackward(sourceView = source)
613-
614-
else ->
615-
playbackManager.playPause(sourceView = source)
616-
}
617-
618-
// Invalidate timer and reset the number of button press quick successions
619-
playPauseTimer?.cancel()
620-
playPauseTimer = null
621-
buttonPressSuccessions = 0
622-
}
623-
},
624-
600,
625-
)
626-
}
627-
}
585+
playbackManager.playPause(sourceView = source)
628586
}
629587

630588
private fun handleMediaButtonDoubleTap() {
@@ -722,7 +680,7 @@ class MediaSessionManager(
722680
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
723681
mediaId ?: return
724682
launch {
725-
logEvent("play from media id", inSessionCallback = false)
683+
logEvent("play from media id")
726684

727685
val autoMediaId = AutoMediaId.fromMediaId(mediaId)
728686
val episodeId = autoMediaId.episodeId
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package au.com.shiftyjelly.pocketcasts.repositories.playback
2+
3+
import kotlinx.coroutines.async
4+
import kotlinx.coroutines.delay
5+
import kotlinx.coroutines.test.runTest
6+
import kotlinx.coroutines.yield
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Assert.assertNull
9+
import org.junit.Test
10+
11+
class MediaEventQueueTest {
12+
@Test
13+
fun `single tap event`() = runTest {
14+
val handler = MediaEventQueue(this)
15+
16+
val event = handler.consumeEvent(MediaEvent.SingleTap)
17+
18+
assertEquals(MediaEvent.SingleTap, event)
19+
}
20+
21+
@Test
22+
fun `double tap event`() = runTest {
23+
val handler = MediaEventQueue(this)
24+
25+
val event = handler.consumeEvent(MediaEvent.DoubleTap)
26+
27+
assertEquals(MediaEvent.DoubleTap, event)
28+
}
29+
30+
@Test
31+
fun `triple tap event`() = runTest {
32+
val handler = MediaEventQueue(this)
33+
34+
val event = handler.consumeEvent(MediaEvent.TripleTap)
35+
36+
assertEquals(MediaEvent.TripleTap, event)
37+
}
38+
39+
@Test
40+
fun `map single tap events to double tap event`() = runTest {
41+
val handler = MediaEventQueue(this)
42+
43+
val firstEvent = async { handler.consumeEvent(MediaEvent.SingleTap) }
44+
45+
yield()
46+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
47+
48+
assertEquals(MediaEvent.DoubleTap, firstEvent.await())
49+
}
50+
51+
@Test
52+
fun `map single tap events to triple tap event`() = runTest {
53+
val handler = MediaEventQueue(this)
54+
55+
val firstEvent = async { handler.consumeEvent(MediaEvent.SingleTap) }
56+
57+
yield()
58+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
59+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
60+
61+
assertEquals(MediaEvent.TripleTap, firstEvent.await())
62+
}
63+
64+
@Test
65+
fun `map single tap events to triple tap event when event count is higher`() = runTest {
66+
val handler = MediaEventQueue(this)
67+
68+
val firstEvent = async { handler.consumeEvent(MediaEvent.SingleTap) }
69+
70+
yield()
71+
repeat(100) {
72+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
73+
}
74+
75+
assertEquals(MediaEvent.TripleTap, firstEvent.await())
76+
}
77+
78+
@Test
79+
fun `map single tap events to multi tap event in time window`() = runTest {
80+
val handler = MediaEventQueue(this)
81+
82+
val firstEvent = async { handler.consumeEvent(MediaEvent.SingleTap) }
83+
84+
delay(600)
85+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
86+
87+
assertEquals(MediaEvent.DoubleTap, firstEvent.await())
88+
}
89+
90+
@Test
91+
fun `map single tap events to single tap events outside of time window`() = runTest {
92+
val handler = MediaEventQueue(this)
93+
94+
val firstEvent = async { handler.consumeEvent(MediaEvent.SingleTap) }
95+
96+
delay(601)
97+
val secondEvent = handler.consumeEvent(MediaEvent.SingleTap)
98+
assertEquals(MediaEvent.SingleTap, secondEvent)
99+
100+
assertEquals(MediaEvent.SingleTap, firstEvent.await())
101+
}
102+
103+
@Test
104+
fun `do not reset single tap time window with each new event`() = runTest {
105+
val handler = MediaEventQueue(this)
106+
107+
val firstEvent = async { handler.consumeEvent(MediaEvent.SingleTap) }
108+
109+
delay(250)
110+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
111+
112+
delay(250)
113+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
114+
115+
delay(250)
116+
val secondEvent = async { handler.consumeEvent(MediaEvent.SingleTap) }
117+
118+
yield()
119+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
120+
121+
assertEquals(MediaEvent.TripleTap, firstEvent.await())
122+
assertEquals(MediaEvent.DoubleTap, secondEvent.await())
123+
}
124+
125+
@Test
126+
fun `ignore single tap events while double tap window is active`() = runTest {
127+
val handler = MediaEventQueue(this)
128+
129+
handler.consumeEvent(MediaEvent.DoubleTap)
130+
131+
delay(250)
132+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
133+
134+
delay(1)
135+
val event = handler.consumeEvent(MediaEvent.SingleTap)
136+
assertEquals(MediaEvent.SingleTap, event)
137+
}
138+
139+
@Test
140+
fun `ignore single tap events while triple tap window is active`() = runTest {
141+
val handler = MediaEventQueue(this)
142+
143+
handler.consumeEvent(MediaEvent.TripleTap)
144+
145+
delay(250)
146+
assertNull(handler.consumeEvent(MediaEvent.SingleTap))
147+
148+
delay(1)
149+
val event = handler.consumeEvent(MediaEvent.SingleTap)
150+
assertEquals(MediaEvent.SingleTap, event)
151+
}
152+
}

0 commit comments

Comments
 (0)