mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Fix auto-resume after CAP pause
This commit is contained in:
@@ -14,17 +14,22 @@ import javax.inject.Singleton
|
||||
class MediaControl @Inject constructor(
|
||||
private val audioManager: AudioManager,
|
||||
) {
|
||||
private var capPauseExpiryElapsedRealtime: Long = 0L
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = audioManager.isMusicActive
|
||||
|
||||
val wasRecentlyPausedByCap: Boolean
|
||||
get() = capPauseExpiryElapsedRealtime > SystemClock.elapsedRealtime()
|
||||
|
||||
suspend fun sendPlay() {
|
||||
log(TAG, INFO) { "sendPlay()" }
|
||||
if (audioManager.isMusicActive) {
|
||||
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
|
||||
log(TAG, INFO) { "Music is already playing, not sending play" }
|
||||
return
|
||||
}
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
|
||||
clearRecentCapPause()
|
||||
}
|
||||
|
||||
suspend fun sendPause() {
|
||||
@@ -34,10 +39,15 @@ class MediaControl @Inject constructor(
|
||||
return
|
||||
}
|
||||
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
|
||||
markRecentCapPause()
|
||||
}
|
||||
|
||||
suspend fun sendPlayPause() {
|
||||
log(TAG) { "sendPlayPause()" }
|
||||
if (wasRecentlyPausedByCap) {
|
||||
sendPlay()
|
||||
return
|
||||
}
|
||||
if (audioManager.isMusicActive) {
|
||||
sendPause()
|
||||
} else {
|
||||
@@ -71,7 +81,16 @@ class MediaControl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun markRecentCapPause() {
|
||||
capPauseExpiryElapsedRealtime = SystemClock.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS
|
||||
}
|
||||
|
||||
private fun clearRecentCapPause() {
|
||||
capPauseExpiryElapsedRealtime = 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("MediaControl")
|
||||
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,15 @@ class PlayPause @Inject constructor(
|
||||
previous = prevState,
|
||||
current = currState,
|
||||
onePodMode = reactions.onePodMode,
|
||||
isCurrentlyPlaying = mediaControl.isPlaying
|
||||
isCurrentlyPlaying = mediaControl.isPlaying,
|
||||
wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap,
|
||||
)
|
||||
|
||||
if (decision.usedRecentCapPauseOverride) {
|
||||
log(TAG, VERBOSE) {
|
||||
"Resume override: recent CAP pause window is active, allowing play despite playing=true"
|
||||
}
|
||||
}
|
||||
log(TAG, VERBOSE) { "Decision: ${decision.reason}" }
|
||||
|
||||
// Execute the decision
|
||||
@@ -131,17 +137,19 @@ class PlayPause @Inject constructor(
|
||||
previous: EarDetectionState,
|
||||
current: EarDetectionState,
|
||||
onePodMode: Boolean,
|
||||
isCurrentlyPlaying: Boolean
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean = false,
|
||||
): PlayPauseDecision = if (onePodMode) {
|
||||
evaluateOnePodMode(previous, current, isCurrentlyPlaying)
|
||||
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
||||
} else {
|
||||
evaluateNormalMode(previous, current, isCurrentlyPlaying)
|
||||
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
|
||||
}
|
||||
|
||||
private fun evaluateOnePodMode(
|
||||
previous: EarDetectionState,
|
||||
current: EarDetectionState,
|
||||
isCurrentlyPlaying: Boolean
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByUs: Boolean,
|
||||
): PlayPauseDecision {
|
||||
val netChange = current.podCount - previous.podCount
|
||||
|
||||
@@ -154,10 +162,11 @@ class PlayPause @Inject constructor(
|
||||
)
|
||||
|
||||
// Net increase: pod(s) inserted → play
|
||||
netChange > 0 && !isCurrentlyPlaying -> PlayPauseDecision(
|
||||
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
|
||||
shouldPlay = true,
|
||||
shouldPause = false,
|
||||
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)"
|
||||
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
|
||||
usedRecentCapPauseOverride = isCurrentlyPlaying && wasRecentlyPausedByUs,
|
||||
)
|
||||
|
||||
// No net change, or action not appropriate for current playing state
|
||||
@@ -172,17 +181,19 @@ class PlayPause @Inject constructor(
|
||||
private fun evaluateNormalMode(
|
||||
previous: EarDetectionState,
|
||||
current: EarDetectionState,
|
||||
isCurrentlyPlaying: Boolean
|
||||
isCurrentlyPlaying: Boolean,
|
||||
wasRecentlyPausedByCap: Boolean,
|
||||
): PlayPauseDecision {
|
||||
val wasWorn = previous.bothInEar
|
||||
val isWorn = current.bothInEar
|
||||
|
||||
return when {
|
||||
// Transition: not worn → worn, and not playing → play
|
||||
!wasWorn && isWorn && !isCurrentlyPlaying -> PlayPauseDecision(
|
||||
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
|
||||
shouldPlay = true,
|
||||
shouldPause = false,
|
||||
reason = "Normal mode: both pods in ear"
|
||||
reason = "Normal mode: both pods in ear",
|
||||
usedRecentCapPauseOverride = isCurrentlyPlaying && wasRecentlyPausedByCap,
|
||||
)
|
||||
|
||||
// Transition: worn → not worn, and playing → pause
|
||||
@@ -235,7 +246,8 @@ class PlayPause @Inject constructor(
|
||||
data class PlayPauseDecision(
|
||||
val shouldPlay: Boolean,
|
||||
val shouldPause: Boolean,
|
||||
val reason: String
|
||||
val reason: String,
|
||||
val usedRecentCapPauseOverride: Boolean = false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.os.SystemClock
|
||||
import io.mockk.Runs
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class MediaControlTest : BaseTest() {
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
private lateinit var mediaControl: MediaControl
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(SystemClock::class)
|
||||
every { SystemClock.elapsedRealtime() } returns 1_000L
|
||||
every { SystemClock.uptimeMillis() } returns 1_000L
|
||||
audioManager = mockk(relaxed = true)
|
||||
every { audioManager.dispatchMediaKeyEvent(any()) } just Runs
|
||||
mediaControl = MediaControl(audioManager)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
unmockkStatic(SystemClock::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendPlay ignores stale active state after cap pause`() = runTest {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
|
||||
mediaControl.sendPause()
|
||||
assertTrue(mediaControl.wasRecentlyPausedByCap)
|
||||
clearMocks(audioManager, answers = false, recordedCalls = true)
|
||||
|
||||
mediaControl.sendPlay()
|
||||
assertFalse(mediaControl.wasRecentlyPausedByCap)
|
||||
|
||||
verify(exactly = 2) { audioManager.dispatchMediaKeyEvent(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendPlayPause resumes after cap pause even when audio manager still reports active`() = runTest {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
|
||||
mediaControl.sendPause()
|
||||
assertTrue(mediaControl.wasRecentlyPausedByCap)
|
||||
clearMocks(audioManager, answers = false, recordedCalls = true)
|
||||
|
||||
mediaControl.sendPlayPause()
|
||||
assertFalse(mediaControl.wasRecentlyPausedByCap)
|
||||
|
||||
verify(exactly = 2) { audioManager.dispatchMediaKeyEvent(any()) }
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import io.mockk.MockKAnnotations
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import testhelpers.BaseTest
|
||||
@@ -62,6 +64,11 @@ abstract class BaseBlePodsTest : BaseTest() {
|
||||
every { SystemClockWrap.elapsedRealtimeNanos } returns 1000L
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
unmockkObject(SystemClockWrap)
|
||||
}
|
||||
|
||||
internal suspend inline fun <reified T : BlePodSnapshot?> create(
|
||||
hex: String,
|
||||
address: String = "77:49:4C:D8:25:0C",
|
||||
@@ -79,4 +86,4 @@ abstract class BaseBlePodsTest : BaseTest() {
|
||||
|
||||
block.invoke(factory.create(result) as T)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,24 @@ class PlayPauseLogicTest : BaseTest() {
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one in to both in - should play if recently paused by cap`() {
|
||||
val previous = EarDetectionState.fromDualPod(left = true, right = false)
|
||||
val current = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
val decision = playPause.evaluatePlayPauseAction(
|
||||
previous = previous,
|
||||
current = current,
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = true,
|
||||
wasRecentlyPausedByUs = true,
|
||||
)
|
||||
|
||||
decision.shouldPlay shouldBe true
|
||||
decision.shouldPause shouldBe false
|
||||
decision.usedRecentCapPauseOverride shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `none in to one in - no action (need both)`() {
|
||||
val previous = EarDetectionState.fromDualPod(left = false, right = false)
|
||||
@@ -288,6 +306,24 @@ class PlayPauseLogicTest : BaseTest() {
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one in to both in - should play if recently paused by cap`() {
|
||||
val previous = EarDetectionState.fromDualPod(left = true, right = false)
|
||||
val current = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
val decision = playPause.evaluatePlayPauseAction(
|
||||
previous = previous,
|
||||
current = current,
|
||||
onePodMode = true,
|
||||
isCurrentlyPlaying = true,
|
||||
wasRecentlyPausedByUs = true,
|
||||
)
|
||||
|
||||
decision.shouldPlay shouldBe true
|
||||
decision.shouldPause shouldBe false
|
||||
decision.usedRecentCapPauseOverride shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `both in to one in - should pause if playing`() {
|
||||
val previous = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
Reference in New Issue
Block a user