diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index bc65ecf..a1ceb0c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -589,6 +589,8 @@ class GearslipRunner( // The driver picked the radio or another source on the car: pause the phone's music // as Android Auto does, instead of letting it play on to no one. onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") }, + onPausedForNow = { CarServices.pausePhoneMediaForNow() }, + onGivenBack = { CarServices.resumePhoneMedia() }, ) if (CarSettings.pipeAudio.value) { openChannel(media.serviceId) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt index 24ff01a..e86a0fb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt @@ -36,6 +36,9 @@ class AudioLink( private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) }, /** The car took the sound back after giving it, as when the driver switches to the radio. */ private val onTakenBack: () -> Unit = {}, + /** The car wants the speakers for a moment (a call, its own voice), and then [onGivenBack]. */ + private val onPausedForNow: () -> Unit = {}, + private val onGivenBack: () -> Unit = {}, ) : CarAudio.Link { private var configIndex = 0 @@ -43,6 +46,8 @@ class AudioLink( @Volatile private var started = false @Volatile private var focusGranted = false @Volatile private var focusRequested = false + /** The car took the speakers for a moment, and we paused the phone's music until it's done. */ + @Volatile private var pausedForNow = false @Volatile private var inFlight = 0 // The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise. @Volatile private var maxUnacked = 4 @@ -222,9 +227,32 @@ class AudioLink( val had = focusGranted focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT || state == FOCUS_STATE_GAIN_MEDIA_ONLY - if (focusGranted) startStream() else stop() - // A ducking request leaves the music playing; a loss means another source has the speakers. - if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack() + when { + focusGranted -> { + startStream() + if (pausedForNow) { + pausedForNow = false + onGivenBack() + } + } + // A call or the car's own voice. Releasing focus here meant the car never handed it + // back, so the music stayed paused after the call: keep the claim and wait for GAIN. + state == FOCUS_STATE_LOSS_TRANSIENT -> { + synchronized(startLock) { stopLocked(release = false) } + if (had) { + pausedForNow = true + onPausedForNow() + } + } + else -> { + stop() + // A ducking request leaves the music playing; a loss means another source has the speakers. + if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) { + pausedForNow = false + onTakenBack() + } + } + } } /** @@ -299,19 +327,20 @@ class AudioLink( synchronized(startLock) { stopLocked() } } - private fun stopLocked() { + /** [release] false keeps our claim on the speakers, so the car hands them back on its own. */ + private fun stopLocked(release: Boolean = true) { if (!started) return started = false synchronized(ackLock) { ackLock.notifyAll() } runCatching { sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong())) - if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) + if (release && focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) } - focusRequested = false + if (release) focusRequested = false focusGranted = false aac?.release() aac = null - GearslipLog.i("$tag: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes") + GearslipLog.i("$tag: -> Stop${if (release) ", AudioFocusRequest(RELEASE)" else ""} after $chunks chunks / $bytes bytes") chunks = 0 bytes = 0 // A new stream starts with the car's buffer empty again: earn the lead back if it's needed. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index 0b33326..502fdae 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car import android.content.ComponentName import android.content.Context +import android.media.session.MediaController import android.media.session.MediaSessionManager import android.media.session.PlaybackState import app.seb3thehacker.gearslip.audio.CarAudio @@ -254,7 +255,41 @@ object CarServices { * just the one Gearslip opened: anything the car was playing was going through the car. */ fun pausePhoneMedia(why: String = "the car left") { - val ctx = context ?: return + pausedForNow = null + pausedOwnForNow = false + pause(why) + } + + /** What [pausePhoneMediaForNow] paused, for [resumePhoneMedia] to play again. */ + @Volatile private var pausedForNow: List? = null + /** Without notification access, whether it paused the app Gearslip drives itself. */ + @Volatile private var pausedOwnForNow = false + + /** The car wants the speakers for a moment: pause, and remember what to play after. */ + fun pausePhoneMediaForNow() { + pausedOwnForNow = false + pausedForNow = pause("the car needs the speakers for a moment") + } + + /** The car handed the speakers back: play what it paused, unless the driver has since. */ + fun resumePhoneMedia() { + val paused = pausedForNow ?: return + pausedForNow = null + if (pausedOwnForNow) { + pausedOwnForNow = false + mediaOrNull()?.let { if (!it.now.value.playing) it.togglePlay() } + return + } + val waiting = paused.filter { it.playbackState?.state == PlaybackState.STATE_PAUSED } + waiting.forEach { runCatching { it.transportControls.play() } } + if (waiting.isNotEmpty()) { + app.seb3thehacker.gearslip.GearslipLog.i("media: playing ${waiting.joinToString { it.packageName }} again: the car gave the speakers back") + } + } + + /** What it paused. Without notification access it can only reach the app Gearslip drives itself. */ + private fun pause(why: String): List { + val ctx = context ?: return emptyList() val manager = ctx.getSystemService(MediaSessionManager::class.java) val listener = ComponentName(ctx, GearslipNotificationListener::class.java) val playing = try { @@ -264,13 +299,19 @@ object CarServices { } if (playing == null) { // No notification access: the app Gearslip itself is driving is all it can reach. - mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() } - return + mediaOrNull()?.let { + if (it.now.value.playing) { + it.togglePlay() + pausedOwnForNow = true + } + } + return emptyList() } playing.forEach { runCatching { it.transportControls.pause() } } if (playing.isNotEmpty()) { app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why") } + return playing } /** The car went away: nothing should keep running for a screen that no longer exists. */