From e1d12afe4309b8e519843c7f62c0efbd114a12bb Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 25 Aug 2026 10:06:39 +0100 Subject: [PATCH 1/7] Fix iOS splash hang by unifying AVAudioSession setup Two callers were reaching for the same AVAudioSession at launch: SplashCoordinator.playSplashAudio() hard-coded .playback + .mixWithOthers and setActive(true), while IosAudioEngine.ensureEngineStarted() ran its own setCategory/setActive with options driven by the user's MIX_AUDIO preference (default false). Whichever landed second, the loser's AVAudioPlayer could stop delivering audioPlayerDidFinishPlaying. Because SplashCoordinator only dismisses when both didFinishAudio and the 1.5s timer have fired, a missed callback wedged the splash indefinitely; iOS watchdog-killed the app ~20% of launches and reported it as a crash on the next launch. Make IosAudioEngine the single owner of the session: expose configureAudioSession() and have the Swift splash call it before playing, then stop deactivating the session on dismiss (the engine is about to use it). Both paths now use identical category/options and there is one setCategory/setActive call site. Co-Authored-By: Claude Opus 4.7 (1M context) --- iosApp/iosApp/SplashView.swift | 17 ++++++----------- .../soundscape/audio/IosAudioEngine.kt | 12 +++++++++--- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/iosApp/iosApp/SplashView.swift b/iosApp/iosApp/SplashView.swift index b61cecf31..b6f5e698a 100644 --- a/iosApp/iosApp/SplashView.swift +++ b/iosApp/iosApp/SplashView.swift @@ -1,4 +1,5 @@ import AVFoundation +import Shared import SwiftUI // Mirrors Android's MainActivity splash flow (app/src/main/.../MainActivity.kt): @@ -54,15 +55,12 @@ final class SplashCoordinator: ObservableObject { dismissIfReady() return } + // Delegate AVAudioSession setup to IosAudioEngine so the splash player + // and the shared engine don't race each other on setCategory/setActive. + // The engine owns the session lifecycle from here on — we never call + // setActive(false) below, since the engine is about to start using it. + IosSoundscapeService.companion.getInstance().audioEngine.configureAudioSession() do { - // .playback so the splash audio is heard regardless of the silent - // switch (MediaPlayer on Android plays through the media stream). - // .mixWithOthers so we don't hijack any music the user is already - // playing. AVAudioSession is iOS-only, hence the os guard. - #if os(iOS) - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers]) - try AVAudioSession.sharedInstance().setActive(true) - #endif let p = try AVAudioPlayer(contentsOf: url) p.volume = 0.7 let delegate = SplashAudioDelegate { [weak self] in @@ -98,9 +96,6 @@ final class SplashCoordinator: ObservableObject { } player = nil audioDelegate = nil - #if os(iOS) - try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) - #endif } private func currentMinorVersion() -> String { diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt index af0820eab..1dcf67e2b 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt @@ -118,7 +118,7 @@ class IosAudioEngine : AudioEngine { if (engineStarted) return // Configure and activate audio session - configureAndActivateSession() + configureAudioSession() // Register for audio session notifications registerAudioSessionObservers() @@ -141,7 +141,13 @@ class IosAudioEngine : AudioEngine { } } - private fun configureAndActivateSession() { + /** + * Idempotent AVAudioSession setup. Called internally when the audio engine + * first needs to start playing, and from Swift (SplashCoordinator) so the + * splash's AVAudioPlayer shares the same session category/options rather + * than racing us for `setCategory`/`setActive` on the shared session. + */ + fun configureAudioSession() { val session = AVAudioSession.sharedInstance() val options = if (mixWithOthers) AVAudioSessionCategoryOptionMixWithOthers else 0u try { @@ -166,7 +172,7 @@ class IosAudioEngine : AudioEngine { * Reconfigure the audio session when the mixWithOthers setting changes at runtime. */ private fun reconfigureAudioSession() { - configureAndActivateSession() + configureAudioSession() println("IosAudioEngine: Audio session reconfigured (mixWithOthers=$mixWithOthers)") } From 4425d338e6dec57cb83a9e85abc4ce78dd50b8c8 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 25 Aug 2026 10:08:51 +0100 Subject: [PATCH 2/7] Install Kotlin unhandled-exception hook before any shared code runs installUnhandledExceptionLogger() was called from inside the Compose entry point (MainViewController), which meant Kotlin/Native had no setUnhandledExceptionHook installed during LegacyMigrator, FirebaseBootstrap, and IosSoundscapeService construction. Any coroutine failure in that window terminated the process with nothing written to stderr. Make the installer public and move the call to iOSApp.init() as the first line, so the hook is active before any Swift-invoked Kotlin code runs. Co-Authored-By: Claude Opus 4.7 (1M context) --- iosApp/iosApp/iOSApp.swift | 6 ++++++ .../scottishtecharmy/soundscape/MainViewController.kt | 1 - .../soundscape/UnhandledExceptionLogger.kt | 9 ++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 7a13172f4..a6642659e 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -6,6 +6,12 @@ struct iOSApp: App { @StateObject private var splashCoordinator = SplashCoordinator() init() { + // Must be first: installs Kotlin/Native's setUnhandledExceptionHook so + // any uncaught exception thrown during LegacyMigrator, FirebaseBootstrap, + // or IosSoundscapeService construction (or any coroutine they spawn) is + // written to stderr before the process dies, instead of vanishing. + UnhandledExceptionLoggerKt.installUnhandledExceptionLogger() + // Run the legacy → multiplatform data migration before the Compose // UI mounts. Synchronous so the new app's preferences and Room // database are populated before MainViewController reads them. diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt index 156c13648..4909bdb94 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/MainViewController.kt @@ -46,7 +46,6 @@ import platform.UIKit.UIViewController import platform.UIKit.UIWindow fun MainViewController() = ComposeUIViewController { - installUnhandledExceptionLogger() val service = remember { IosSoundscapeService.getInstance() } val mgr = service.offlineMapManager val prefs = service.preferencesProvider diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt index 8fb1a0da8..3cf51a419 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/UnhandledExceptionLogger.kt @@ -19,6 +19,13 @@ private val install: Boolean by lazy { true } -internal fun installUnhandledExceptionLogger() { +/** + * Public so Swift can invoke it from `iOSApp.init()` before any other Kotlin + * code runs. Installing later (e.g. from the Compose entry point) leaves a + * window during LegacyMigrator + FirebaseBootstrap + IosSoundscapeService + * construction where a coroutine failure would terminate the process with no + * log written. + */ +fun installUnhandledExceptionLogger() { install } From 21914d534b549aa4c5f342d00232cfc17ccb286f Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 25 Aug 2026 10:36:28 +0100 Subject: [PATCH 3/7] Stop status-bar leak when the search bar activates Opening the search popup raises the IME, which used to hide the top and bottom bars on the home screen. With no top bar, the collapsed search bar shifted up under the transparent iOS status bar and stayed visible there (the popup does not paint over the status-bar strip). Dismissing the popup produced the reverse glitch: `expanded` cleared immediately, but the IME took its own animation to close, briefly hiding the bars again. Hold a `searchOwnsIme` flag true from the moment the popup opens the IME until the IME has fully closed. Keep the top/bottom bars in place for the whole search session, so the underlying layout never moves and nothing bleeds behind the status bar. Once the top bar stays visible on Android too, the search Popup would open below it (Popup defaults to its anchor position). Pin the Popup with a PopupPositionProvider that always returns (0, 0) so it covers the whole window on both platforms. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soundscape/components/MainSearchBar.kt | 22 ++++++++++++++++++- .../screens/home/home/SharedHomeScreen.kt | 20 +++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/MainSearchBar.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/MainSearchBar.kt index 12045cd55..036406cf4 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/MainSearchBar.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/components/MainSearchBar.kt @@ -52,7 +52,12 @@ import androidx.compose.ui.semantics.CollectionItemInfo import androidx.compose.ui.semantics.collectionItemInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import org.jetbrains.compose.resources.stringResource import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt @@ -77,6 +82,7 @@ fun MainSearchBar( onItemClick: (LocationDescription) -> Unit, userLocation: LngLatAlt?, isSearching: Boolean = false, + onExpandedChange: (Boolean) -> Unit = {}, ) { val shape = RoundedCornerShape(spacing.small) val colors = MaterialTheme.colorScheme @@ -86,6 +92,8 @@ fun MainSearchBar( val focusRequester = remember { FocusRequester() } val searchLocation = remember { mutableStateOf(userLocation) } + LaunchedEffect(expanded) { onExpandedChange(expanded) } + // Collapsed search bar Surface( modifier = modifier @@ -119,9 +127,21 @@ fun MainSearchBar( } } - // Fullscreen search overlay + // Fullscreen search overlay. Pin to (0, 0) in window coordinates so it covers + // the top bar on Android instead of docking at the collapsed search bar's anchor. + val fullscreenPositionProvider = remember { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset = IntOffset.Zero + } + } if (expanded) { Popup( + popupPositionProvider = fullscreenPositionProvider, onDismissRequest = { expanded = false }, properties = PopupProperties(focusable = true) ) { diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt index 9db1e9c5b..3774ab2aa 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -78,6 +79,20 @@ fun SharedHomeScreen( var drawerOpen by remember { mutableStateOf(false) } val fullscreenMap = remember { mutableStateOf(false) } val keyboardOpen = keyboardAsState() + var searchExpanded by remember { mutableStateOf(false) } + // Stays true from the moment the search popup opens the IME until the IME has + // fully animated closed after dismissal. Prevents the top/bottom bars from + // briefly hiding during that close animation, which would otherwise slide the + // collapsed search bar up under the iOS status bar. + var searchOwnsIme by remember { mutableStateOf(false) } + LaunchedEffect(searchExpanded, keyboardOpen.value) { + if (searchExpanded && keyboardOpen.value) { + searchOwnsIme = true + } else if (!keyboardOpen.value) { + searchOwnsIme = false + } + } + val hideChromeForKeyboard = keyboardOpen.value && !searchExpanded && !searchOwnsIme val routePlaying = (state.currentRouteData.routeData != null) val newReleaseDialog = remember { @@ -107,7 +122,7 @@ fun SharedHomeScreen( // room for the search. This is important when the font size is very large, but it's // also good for allowing the user to view more search results. topBar = { - if (!keyboardOpen.value) { + if (!hideChromeForKeyboard) { SharedHomeTopAppBar( onMenuClick = { drawerOpen = true }, streetPreviewState = state.streetPreviewState.enabled != StreetPreviewEnabled.OFF, @@ -117,7 +132,7 @@ fun SharedHomeScreen( } }, bottomBar = { - if (!fullscreenMap.value && !keyboardOpen.value) { + if (!fullscreenMap.value && !hideChromeForKeyboard) { SharedHomeBottomAppBar(bottomButtonFunctions) } }, @@ -170,6 +185,7 @@ fun SharedHomeScreen( hint = stringResource(Res.string.search_bar_hint), userLocation = state.location, isSearching = state.searchInProgress, + onExpandedChange = { searchExpanded = it }, ) }, onMapLongClick = onMapLongClick, From f57cb82f0660b6478c9fda48e8d29e02445c5c30 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 25 Aug 2026 11:34:45 +0100 Subject: [PATCH 4/7] Make iOS callout buttons interrupt audio like Android The home-screen callout buttons (my location, what's around me, ahead of me, nearby markers) queued audio on iOS instead of cancelling in-flight callouts the way Android does. Port Android's startCallout pattern - a coroutine job that cancels its predecessor and clears the audio queue before speaking - and match Android's ClearQueue semantics in IosAudioEngine so earcons queued behind an interrupted callout are dropped instead of playing out. Run the callout coroutine on Dispatchers.Main so its AVAudioEngine mutations don't race with the TTS render callback, and guard the render callback so a late-arriving render for a cancelled sound doesn't attach a zombie node. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soundscape/IosSoundscapeService.kt | 69 ++++++++++++++++--- .../soundscape/audio/IosAudioEngine.kt | 55 +++++++++++---- 2 files changed, 103 insertions(+), 21 deletions(-) diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt index dbe9ac1bd..293b61516 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt @@ -3,10 +3,13 @@ package org.scottishtecharmy.soundscape import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.getString import org.scottishtecharmy.soundscape.audio.AudioTour import org.scottishtecharmy.soundscape.audio.AudioTourHost @@ -83,6 +86,7 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic private val scope = CoroutineScope(Dispatchers.Default + Job()) private var suppressionJob: Job? = null + private var calloutJob: Job? = null // Providers — iosLocationProvider is the device GPS. locationProvider is the // currently active provider, which is swapped to a StaticLocationProvider while @@ -553,24 +557,73 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic // --- GeoEngine Queries --- + private suspend fun awaitHandle(handle: Long) { + while (handle != 0L && audioEngine.isHandleActive(handle)) { + delay(100) + } + } + + /** + * Mirrors SoundscapeService.startCallout on Android. Cancels any previous + * user-initiated callout and clears the TTS queue so a button press + * interrupts (rather than queues behind) existing audio. If a callout was + * still in progress, the press just cancels it — pressing the same button + * twice silences the app. + * + * Runs on Dispatchers.Main so clearTextToSpeechQueue and the enqueue calls + * in [body] are serialized with the TTS render callback (which itself + * dispatches to the main queue). Off-main AVAudioEngine mutations racing + * with the render callback tripped `[_nodes containsObject: node]` on + * disconnect. + */ + private fun startCallout(body: suspend CoroutineScope.() -> Unit) { + val previousJob = calloutJob + calloutJob = scope.launch(Dispatchers.Main) { + val wasActive = previousJob?.isActive == true + if (wasActive) previousJob.cancel() + + audioEngine.clearTextToSpeechQueue() + + if (wasActive) return@launch + + body() + } + } + override fun myLocation() { - val callout = geoEngine.myLocation() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + startCallout { + val callout = withContext(Dispatchers.Default) { geoEngine.myLocation() } + ensureActive() + val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + awaitHandle(handle) + } } override fun whatsAroundMe() { - val callout = geoEngine.whatsAroundMe() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + startCallout { + val callout = withContext(Dispatchers.Default) { geoEngine.whatsAroundMe() } + ensureActive() + val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + awaitHandle(handle) + } } override fun aheadOfMe() { - val callout = geoEngine.aheadOfMe() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + startCallout { + val callout = withContext(Dispatchers.Default) { geoEngine.aheadOfMe() } + ensureActive() + val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + awaitHandle(handle) + } } override fun nearbyMarkers() { - val callout = geoEngine.nearbyMarkers() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + startCallout { + val callout = withContext(Dispatchers.Default) { geoEngine.nearbyMarkers() } + ensureActive() + val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + awaitHandle(handle) + } } // --- Beacon Control --- diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt index 1dcf67e2b..929f48f3a 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt @@ -88,7 +88,7 @@ class IosAudioEngine : AudioEngine { private var mediaResetObserver: Any? = null private sealed class PlayerEntry { - class Discrete(val player: DiscretePlayer) : PlayerEntry() + class Discrete(val player: DiscretePlayer, val isTts: Boolean) : PlayerEntry() class Beacon(val player: BeaconPlayer) : PlayerEntry() } @@ -343,12 +343,23 @@ class IosAudioEngine : AudioEngine { } }) - withActivePlayersLock { activePlayers[sound.handle] = PlayerEntry.Discrete(player) } + withActivePlayersLock { + activePlayers[sound.handle] = PlayerEntry.Discrete(player, sound.isTts) + } if (sound.isTts) { // Render TTS to PCM buffers, then connect and play through the audio graph ttsRenderer.render(sound.text) { buffers -> platform.darwin.dispatch_async(platform.darwin.dispatch_get_main_queue()) { + // If the sound was cancelled (e.g. clearTextToSpeechQueue) while we were + // rendering, it is no longer in activePlayers. Bail without touching the + // engine — otherwise we would attach a node that no one will disconnect + // and the next teardown trips AVAudioEngine's `_nodes containsObject` + // assertion. + val stillActive = + withActivePlayersLock { activePlayers.containsKey(sound.handle) } + if (!stillActive) return@dispatch_async + if (buffers.isNotEmpty()) { // Attach the layer player.layer.format = buffers.first().format @@ -419,16 +430,31 @@ class IosAudioEngine : AudioEngine { // Cancel any in-progress TTS rendering ttsRenderer.cancel() - // Remove TTS entries from queue - discreteQueue.removeAll { it.isTts } - - // Stop current if it's TTS - val currentHandle = currentDiscreteHandle - if (currentHandle != null) { - val entry = withActivePlayersLock { activePlayers[currentHandle] } - if (entry is PlayerEntry.Discrete) { - entry.player.stop() - } + // Match Android's ClearQueue: nuke *everything* queued behind the current + // sound (TTS and earcons alike). Filtering just isTts here left earcons + // piled up so callouts kept playing beeps long after the user cancelled. + discreteQueue.clear() + + // Stop the current sound only if it's TTS — matches Android where + // ttsEngine.stop() cancels in-flight TTS but leaves currently-playing + // earcons/beacons alone. Removing from activePlayers *before* stop() so + // that a TTS render callback already dispatched to the main queue for + // this handle sees the sound as cancelled and bails on its own + // containsKey check; otherwise it would attach a zombie node behind our + // back. Clearing currentDiscreteHandle here also lets the next enqueued + // sound play immediately instead of stacking behind the async + // onDiscreteComplete. + val currentHandle = currentDiscreteHandle ?: return + val stopped = withActivePlayersLock { + val entry = activePlayers[currentHandle] + if (entry is PlayerEntry.Discrete && entry.isTts) { + activePlayers.remove(currentHandle) + entry + } else null + } + if (stopped != null) { + currentDiscreteHandle = null + stopped.player.stop() } } @@ -438,7 +464,10 @@ class IosAudioEngine : AudioEngine { } override fun isHandleActive(handle: Long): Boolean { - return withActivePlayersLock { activePlayers.containsKey(handle) } + if (withActivePlayersLock { activePlayers.containsKey(handle) }) return true + // A handle enqueued behind the currently-playing sound isn't in activePlayers + // yet — treat it as active so awaitHandle waits for its whole turn. + return discreteQueue.any { it.handle == handle } } // --- AudioEngine Interface: Beacons --- From 9b55e39525fd1b41cd3c054af28b81aa1fe961f5 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 25 Aug 2026 11:37:06 +0100 Subject: [PATCH 5/7] Match Android's mode-earcon behavior in iOS callouts The four home-screen callout methods were passing addModeEarcon=false to speakCalloutCommon, so iOS never played the enter/exit beeps that frame each callout on Android. myLocation now plays the enter earcon immediately (feedback while the reverse geocoder runs) and the exit earcon after the callout; whatsAroundMe/aheadOfMe/nearbyMarkers now pass addModeEarcon=true. Callouts with no positioned strings or a null result skip the earcons so a nothing-to-say press stays silent instead of just beeping. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soundscape/IosSoundscapeService.kt | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt index 293b61516..c633134cd 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt @@ -15,6 +15,8 @@ import org.scottishtecharmy.soundscape.audio.AudioTour import org.scottishtecharmy.soundscape.audio.AudioTourHost import org.scottishtecharmy.soundscape.audio.BeaconPreviewController import org.scottishtecharmy.soundscape.audio.AudioType +import org.scottishtecharmy.soundscape.audio.EARCON_MODE_ENTER +import org.scottishtecharmy.soundscape.audio.EARCON_MODE_EXIT import org.scottishtecharmy.soundscape.audio.IosAudioEngine import org.scottishtecharmy.soundscape.database.local.MarkersAndRoutesDatabaseProvider import org.scottishtecharmy.soundscape.database.local.dao.RouteDao @@ -592,9 +594,17 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic override fun myLocation() { startCallout { + // myLocation can take a second or so if it does network reverse + // geocoding — play the enter earcon immediately so the user hears + // the action registered, mirroring Android. + audioEngine.createEarcon(EARCON_MODE_ENTER, AudioType.STANDARD) val callout = withContext(Dispatchers.Default) { geoEngine.myLocation() } ensureActive() - val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + var handle = 0L + if (callout != null) { + handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + } + audioEngine.createEarcon(EARCON_MODE_EXIT, AudioType.STANDARD) awaitHandle(handle) } } @@ -603,7 +613,10 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic startCallout { val callout = withContext(Dispatchers.Default) { geoEngine.whatsAroundMe() } ensureActive() - val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + var handle = 0L + if (callout.positionedStrings.isNotEmpty()) { + handle = speakCalloutCommon(callout, true, audioEngine, lastGeometry, ruler) + } awaitHandle(handle) } } @@ -612,7 +625,10 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic startCallout { val callout = withContext(Dispatchers.Default) { geoEngine.aheadOfMe() } ensureActive() - val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + var handle = 0L + if (callout != null) { + handle = speakCalloutCommon(callout, true, audioEngine, lastGeometry, ruler) + } awaitHandle(handle) } } @@ -621,7 +637,7 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic startCallout { val callout = withContext(Dispatchers.Default) { geoEngine.nearbyMarkers() } ensureActive() - val handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + val handle = speakCalloutCommon(callout, true, audioEngine, lastGeometry, ruler) awaitHandle(handle) } } From dc5b83a2c5120182743fdc5c5937bdace972daec Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 25 Aug 2026 11:56:18 +0100 Subject: [PATCH 6/7] Run iOS callout audio pipeline on a dedicated GCD queue Tapping the search bar for the first time while a callout was speaking caused TTS to crackle and gap out, because iOS's first soft-keyboard instantiation stalls the main thread for hundreds of milliseconds and the audio pipeline (TTS render completion, DiscretePlayer completion, startCallout coroutine) was hopping through the main queue between utterances. Introduce a serial GCD queue owned by IosAudioEngine and expose it as a CoroutineDispatcher, then route the two render-callback dispatches and startCallout onto that queue instead of Dispatchers.Main. AVAudioEngine mutations stay serialized (so the `[_nodes containsObject: node]` crash the previous commits fixed doesn't return), and callout audio is now decoupled from any main-thread stall. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soundscape/IosSoundscapeService.kt | 15 ++++++---- .../audio/DispatchQueueDispatcher.kt | 23 ++++++++++++++ .../soundscape/audio/IosAudioEngine.kt | 30 +++++++++++++++---- 3 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/DispatchQueueDispatcher.kt diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt index c633134cd..540ebb081 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt @@ -572,15 +572,18 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic * still in progress, the press just cancels it — pressing the same button * twice silences the app. * - * Runs on Dispatchers.Main so clearTextToSpeechQueue and the enqueue calls - * in [body] are serialized with the TTS render callback (which itself - * dispatches to the main queue). Off-main AVAudioEngine mutations racing - * with the render callback tripped `[_nodes containsObject: node]` on - * disconnect. + * Runs on [IosAudioEngine.audioDispatcher] — the serial GCD queue that + * also owns the TTS render callback and DiscretePlayer completion in + * IosAudioEngine. Keeping the whole pipeline on that queue serializes the + * AVAudioEngine mutations (avoiding the `[_nodes containsObject: node]` + * crash we hit previously) *and* keeps callout audio decoupled from + * main-thread stalls — e.g. the ~100–500 ms hiccup when iOS instantiates + * the soft keyboard for the first time after the user taps the search + * bar mid-callout, which used to gap out the audio between utterances. */ private fun startCallout(body: suspend CoroutineScope.() -> Unit) { val previousJob = calloutJob - calloutJob = scope.launch(Dispatchers.Main) { + calloutJob = scope.launch(audioEngine.audioDispatcher) { val wasActive = previousJob?.isActive == true if (wasActive) previousJob.cancel() diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/DispatchQueueDispatcher.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/DispatchQueueDispatcher.kt new file mode 100644 index 000000000..68948cc7e --- /dev/null +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/DispatchQueueDispatcher.kt @@ -0,0 +1,23 @@ +package org.scottishtecharmy.soundscape.audio + +import kotlin.coroutines.CoroutineContext +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Runnable +import platform.darwin.dispatch_async +import platform.darwin.dispatch_queue_t + +/** + * [CoroutineDispatcher] backed by a Grand Central Dispatch queue. Coroutine + * work is submitted via `dispatch_async`, so a serial queue gives sequential + * execution and a concurrent queue gives parallel execution — the dispatcher + * inherits whichever the passed queue was created with. + */ +@OptIn(ExperimentalForeignApi::class) +internal class DispatchQueueDispatcher( + private val queue: dispatch_queue_t, +) : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + dispatch_async(queue) { block.run() } + } +} diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt index 929f48f3a..a212b3d0c 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/audio/IosAudioEngine.kt @@ -2,6 +2,7 @@ package org.scottishtecharmy.soundscape.audio import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.cValue +import kotlinx.coroutines.CoroutineDispatcher import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt import org.scottishtecharmy.soundscape.services.mediacontrol.MediaControlTarget import platform.AVFAudio.AVAudio3DAngularOrientation @@ -17,6 +18,9 @@ import platform.AVFAudio.AVAudioSessionInterruptionTypeEnded import platform.AVFAudio.AVAudioSessionInterruptionTypeKey import platform.AVFAudio.setActive import platform.CoreAudioTypes.kAudioChannelLayoutTag_Stereo +import platform.darwin.dispatch_async +import platform.darwin.dispatch_queue_create +import platform.darwin.dispatch_queue_t import platform.Foundation.NSLock import platform.Foundation.NSNotification import platform.Foundation.NSNotificationCenter @@ -47,11 +51,27 @@ class IosAudioEngine : AudioEngine { private val activePlayers = mutableMapOf() // activePlayers is touched from multiple threads: from coroutine dispatchers - // (updateGeometry, createBeacon, speakCallout, ...) and from the main queue - // via dispatch_async completion callbacks. mutableMapOf is not thread-safe, - // so guard every access with this lock. + // (updateGeometry, createBeacon, speakCallout, ...) and from [audioQueue] + // completion callbacks. mutableMapOf is not thread-safe, so guard every + // access with this lock. private val activePlayersLock = NSLock() + // Serial queue that owns the discrete-sound pipeline (TTS render callback, + // DiscretePlayer completion, playQueued → attach/connect/play). Kept off + // the main queue so first-tap keyboard init (or any other main-thread stall) + // can't gap out callout audio between utterances. + private val audioQueue: dispatch_queue_t = + dispatch_queue_create("org.scottishtecharmy.soundscape.audio", null)!! + + /** + * Serial coroutine dispatcher backed by [audioQueue]. Publish this so + * [org.scottishtecharmy.soundscape.IosSoundscapeService.startCallout] can + * launch on the same queue: its `clearTextToSpeechQueue` + `createTextToSpeech` + * calls then serialize naturally with the render/completion callbacks in + * [playQueued] / [onDiscreteComplete], without ever hopping to main. + */ + val audioDispatcher: CoroutineDispatcher = DispatchQueueDispatcher(audioQueue) + // Discrete sound queue private val discreteQueue = ArrayDeque() private var currentDiscreteHandle: Long? = null @@ -338,7 +358,7 @@ class IosAudioEngine : AudioEngine { val is3D = sound.audioType != AudioType.STANDARD val player = DiscretePlayer(onComplete = { - platform.darwin.dispatch_async(platform.darwin.dispatch_get_main_queue()) { + dispatch_async(audioQueue) { onDiscreteComplete(sound.handle) } }) @@ -350,7 +370,7 @@ class IosAudioEngine : AudioEngine { if (sound.isTts) { // Render TTS to PCM buffers, then connect and play through the audio graph ttsRenderer.render(sound.text) { buffers -> - platform.darwin.dispatch_async(platform.darwin.dispatch_get_main_queue()) { + dispatch_async(audioQueue) { // If the sound was cancelled (e.g. clearTextToSpeechQueue) while we were // rendering, it is no longer in activePlayers. Bail without touching the // engine — otherwise we would attach a node that no one will disconnect From a97d6b14bbb1f94b8c1b4d627920a8232852aa98 Mon Sep 17 00:00:00 2001 From: Dave Craig Date: Tue, 25 Aug 2026 12:28:12 +0100 Subject: [PATCH 7/7] Highlight the active callout button while its audio plays Legacy iOS animated whichever "hear my surroundings" button was speaking and stopped animating when the callout finished or was cancelled. Port that: MediaControllableService gains an activeCalloutFlow, each service's startCallout publishes/clears the source around its body (with a compareAndSet in finally so a superseding callout keeps its value), HomeViewModel folds the flow into HomeState, and the home bottom bar flips the active button to the theme primary colour with a pulsing icon scale until the flow returns to null. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../soundscape/services/SoundscapeService.kt | 33 +++++++++--- .../soundscape/screens/home/HomeState.kt | 5 ++ .../soundscape/screens/home/HomeViewModel.kt | 5 ++ .../home/home/SharedHomeBottomAppBar.kt | 54 ++++++++++++++++++- .../screens/home/home/SharedHomeScreen.kt | 5 +- .../mediacontrol/MediaControllableService.kt | 11 ++++ .../soundscape/IosSoundscapeService.kt | 38 ++++++++++--- 7 files changed, 134 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt index 7f6dd9894..e6fd865c3 100644 --- a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt +++ b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch @@ -55,6 +56,7 @@ import org.scottishtecharmy.soundscape.audio.BeaconPreviewController import org.scottishtecharmy.soundscape.audio.EARCON_MODE_ENTER import org.scottishtecharmy.soundscape.audio.EARCON_MODE_EXIT import org.scottishtecharmy.soundscape.audio.NativeAudioEngine +import org.scottishtecharmy.soundscape.audio.TourButton import org.scottishtecharmy.soundscape.bluetooth.AudioHeadsetBatteryMonitor import org.scottishtecharmy.soundscape.database.local.MarkersAndRoutesDatabaseProvider import org.scottishtecharmy.soundscape.database.local.model.MarkerEntity @@ -178,6 +180,13 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl // Guard to prevent duplicate user-triggered callouts private var calloutJob: Job? = null + // Which "hear my surroundings" button is currently animating. Set by + // startCallout on launch and cleared when the callout body finishes or is + // superseded (via compareAndSet so a fresh callout doesn't clobber its + // own value in the previous coroutine's finally block). + private val _activeCalloutFlow = MutableStateFlow(null) + override val activeCalloutFlow: StateFlow = _activeCalloutFlow.asStateFlow() + // Wake lock — keeps CPU running while screen is off so audio callbacks continue private var wakeLock: PowerManager.WakeLock? = null @@ -897,7 +906,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl * and [body] is skipped. Otherwise the TTS queue is cleared and then [body] runs, preserving the * clear-before-speak ordering that callouts rely on. */ - private fun startCallout(body: suspend CoroutineScope.() -> Unit) { + private fun startCallout(source: TourButton, body: suspend CoroutineScope.() -> Unit) { val previousJob = calloutJob calloutJob = coroutineScope.launch { val wasActive = previousJob?.isActive == true @@ -908,14 +917,24 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl audioEngine.clearTextToSpeechQueue() // If a callout was already in progress, the user action just cancels it. - if (wasActive) return@launch + if (wasActive) { + _activeCalloutFlow.value = null + return@launch + } - body() + _activeCalloutFlow.value = source + try { + body() + } finally { + // Only clear if it's still us — a fresh callout that cancelled + // this one already set the flow to its own source. + _activeCalloutFlow.compareAndSet(source, null) + } } } override fun myLocation() { - startCallout { + startCallout(TourButton.MY_LOCATION) { if (requestAudioFocus()) { // The call to myLocation can take a second or so as it might be doing network // based reverse geocoding. Ensure that the user has feedback that the action is @@ -936,7 +955,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl } override fun whatsAroundMe() { - startCallout { + startCallout(TourButton.AROUND_ME) { val results = geoEngine.whatsAroundMe() ensureActive() var lastHandle = 0L @@ -948,7 +967,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl } override fun aheadOfMe() { - startCallout { + startCallout(TourButton.AHEAD_OF_ME) { val results = geoEngine.aheadOfMe() ensureActive() var lastHandle = 0L @@ -960,7 +979,7 @@ class SoundscapeService : MediaSessionService(), GeoEngineListener, MediaControl } override fun nearbyMarkers() { - startCallout { + startCallout(TourButton.NEARBY_MARKERS) { val results = geoEngine.nearbyMarkers() ensureActive() val lastHandle = speakCallout(results, true) diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeState.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeState.kt index 9d86adf2f..dd9398781 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeState.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeState.kt @@ -1,5 +1,6 @@ package org.scottishtecharmy.soundscape.screens.home +import org.scottishtecharmy.soundscape.audio.TourButton import org.scottishtecharmy.soundscape.geoengine.StreetPreviewState import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription @@ -18,4 +19,8 @@ data class HomeState( val routesTabSelected: Boolean = true, val permissionsRequired: Boolean = false, val voiceCommandListening: Boolean = false, + /** Which "hear my surroundings" button has an in-flight callout, or null + * when no callout is playing. Drives the pulse animation on the active + * button in the home-screen bottom bar. */ + val activeCallout: TourButton? = null, ) diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeViewModel.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeViewModel.kt index f59719619..01d9fde5a 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeViewModel.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/HomeViewModel.kt @@ -106,6 +106,11 @@ open class HomeViewModel( } } } + scope.launch { + service.activeCalloutFlow.collectLatest { active -> + _state.update { it.copy(activeCallout = active) } + } + } } private fun stopMonitoringLocation() { diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeBottomAppBar.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeBottomAppBar.kt index 7c2409840..4fc35c907 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeBottomAppBar.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeBottomAppBar.kt @@ -1,5 +1,11 @@ package org.scottishtecharmy.soundscape.screens.home.home +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -14,15 +20,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.heading @@ -31,6 +40,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.scottishtecharmy.soundscape.audio.TourButton import org.scottishtecharmy.soundscape.resources.Res import org.scottishtecharmy.soundscape.resources.ahead_of_me_24px import org.scottishtecharmy.soundscape.resources.around_me_24px @@ -75,6 +85,7 @@ data class StreetPreviewFunctions( fun SharedHomeBottomAppBar( bottomButtonFunctions: BottomButtonFunctions, modifier: Modifier = Modifier, + activeCallout: TourButton? = null, ) { val myLocationHint = stringResource(Res.string.ui_action_button_my_location_acc_hint) val nearbyMarkersHint = stringResource(Res.string.ui_action_button_nearby_markers_acc_hint) @@ -118,6 +129,7 @@ fun SharedHomeBottomAppBar( icon = painterResource(Res.drawable.my_location_24px), text = stringResource(Res.string.ui_action_button_my_location), onClick = { bottomButtonFunctions.myLocation() }, + isActive = activeCallout == TourButton.MY_LOCATION, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -129,6 +141,7 @@ fun SharedHomeBottomAppBar( icon = painterResource(Res.drawable.around_me_24px), text = stringResource(Res.string.ui_action_button_around_me), onClick = { bottomButtonFunctions.aroundMe() }, + isActive = activeCallout == TourButton.AROUND_ME, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -140,6 +153,7 @@ fun SharedHomeBottomAppBar( icon = painterResource(Res.drawable.ahead_of_me_24px), text = stringResource(Res.string.ui_action_button_ahead_of_me), onClick = { bottomButtonFunctions.aheadOfMe() }, + isActive = activeCallout == TourButton.AHEAD_OF_ME, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -151,6 +165,7 @@ fun SharedHomeBottomAppBar( icon = painterResource(Res.drawable.nearby_markers_24px), text = stringResource(Res.string.ui_action_button_nearby_markers), onClick = { bottomButtonFunctions.nearbyMarkers() }, + isActive = activeCallout == TourButton.NEARBY_MARKERS, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -168,13 +183,43 @@ private fun HomeBottomAppBarButton( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, + isActive: Boolean = false, ) { + // Legacy iOS pulsed a `LineScaleParty` NVActivityIndicator inside the + // button while the callout audio played. Equivalent here: the button + // flips to a solid high-contrast highlight (theme primary) for the + // duration of the callout, and the icon pulses in scale for a motion + // cue. Stops the moment the callout finishes or the user cancels. + val infiniteTransition = rememberInfiniteTransition(label = "calloutPulse") + val iconScale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.5f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "iconScale", + ) + + val colorScheme = MaterialTheme.colorScheme + val restingColors = currentAppButtonColors + val activeColors = if (isActive) { + ButtonDefaults.buttonColors( + containerColor = colorScheme.primary, + contentColor = colorScheme.onPrimary, + disabledContainerColor = restingColors.disabledContainerColor, + disabledContentColor = restingColors.disabledContentColor, + ) + } else { + restingColors + } + Button( onClick = onClick, shape = RectangleShape, modifier = modifier, contentPadding = PaddingValues(spacing.extraSmall), - colors = currentAppButtonColors, + colors = activeColors, ) { Column( verticalArrangement = Arrangement.Top, @@ -186,7 +231,12 @@ private fun HomeBottomAppBarButton( contentDescription = null, modifier = Modifier .size(spacing.icon) - .align(Alignment.CenterHorizontally), + .align(Alignment.CenterHorizontally) + .graphicsLayer { + val s = if (isActive) iconScale else 1f + scaleX = s + scaleY = s + }, ) Spacer(modifier = Modifier.height(spacing.small)) Text( diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt index 3774ab2aa..67ad15354 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/screens/home/home/SharedHomeScreen.kt @@ -133,7 +133,10 @@ fun SharedHomeScreen( }, bottomBar = { if (!fullscreenMap.value && !hideChromeForKeyboard) { - SharedHomeBottomAppBar(bottomButtonFunctions) + SharedHomeBottomAppBar( + bottomButtonFunctions = bottomButtonFunctions, + activeCallout = state.activeCallout, + ) } }, floatingActionButton = { diff --git a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/services/mediacontrol/MediaControllableService.kt b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/services/mediacontrol/MediaControllableService.kt index b21485b8e..34077f64c 100644 --- a/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/services/mediacontrol/MediaControllableService.kt +++ b/shared/src/commonMain/kotlin/org/scottishtecharmy/soundscape/services/mediacontrol/MediaControllableService.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.scottishtecharmy.soundscape.audio.AudioType +import org.scottishtecharmy.soundscape.audio.TourButton import org.scottishtecharmy.soundscape.geoengine.GridState import org.scottishtecharmy.soundscape.geoengine.StreetPreviewEnabled import org.scottishtecharmy.soundscape.geoengine.StreetPreviewState @@ -28,6 +29,9 @@ private val DEFAULT_HEAD_HEADING_FLOW: StateFlow = private val DEFAULT_HEADSET_BATTERY_FLOW: StateFlow = MutableStateFlow(null).asStateFlow() +private val DEFAULT_ACTIVE_CALLOUT_FLOW: StateFlow = + MutableStateFlow(null).asStateFlow() + interface MediaControllableService { // Media control target methods fun routeMute(): Boolean @@ -85,6 +89,13 @@ interface MediaControllableService { val voiceCommandStateFlow: StateFlow get() = DEFAULT_VOICE_COMMAND_FLOW + /** Which "hear my surroundings" button, if any, has an in-flight callout. + * Emits null when nothing is playing. The home-screen bottom bar uses + * this to animate the active button and stop when the audio completes + * or the user cancels by tapping again. */ + val activeCalloutFlow: StateFlow + get() = DEFAULT_ACTIVE_CALLOUT_FLOW + fun routeStartReverse(routeId: Long) fun setStreetPreviewMode(on: Boolean, location: LngLatAlt? = null) {} fun streetPreviewGo() {} diff --git a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt index 540ebb081..1a654c7c6 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt @@ -18,6 +18,7 @@ import org.scottishtecharmy.soundscape.audio.AudioType import org.scottishtecharmy.soundscape.audio.EARCON_MODE_ENTER import org.scottishtecharmy.soundscape.audio.EARCON_MODE_EXIT import org.scottishtecharmy.soundscape.audio.IosAudioEngine +import org.scottishtecharmy.soundscape.audio.TourButton import org.scottishtecharmy.soundscape.database.local.MarkersAndRoutesDatabaseProvider import org.scottishtecharmy.soundscape.database.local.dao.RouteDao import org.scottishtecharmy.soundscape.geoengine.GeoEngine @@ -148,6 +149,14 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic private val _streetPreviewFlow = MutableStateFlow(StreetPreviewState(StreetPreviewEnabled.OFF)) override val streetPreviewFlow: StateFlow = _streetPreviewFlow.asStateFlow() + // Which "hear my surroundings" button is currently animating. Set by + // startCallout on launch and cleared when the callout body finishes or + // is superseded by another button press (via compareAndSet, so a fresh + // callout doesn't clobber its own value in the previous coroutine's + // finally block). + private val _activeCalloutFlow = MutableStateFlow(null) + override val activeCalloutFlow: StateFlow = _activeCalloutFlow.asStateFlow() + // Pending intent flow — populated by Swift IntentBridge from onOpenURL etc. private val _pendingIntent = MutableStateFlow(null) val pendingIntent: StateFlow = _pendingIntent.asStateFlow() @@ -581,7 +590,7 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic * the soft keyboard for the first time after the user taps the search * bar mid-callout, which used to gap out the audio between utterances. */ - private fun startCallout(body: suspend CoroutineScope.() -> Unit) { + private fun startCallout(source: TourButton, body: suspend CoroutineScope.() -> Unit) { val previousJob = calloutJob calloutJob = scope.launch(audioEngine.audioDispatcher) { val wasActive = previousJob?.isActive == true @@ -589,14 +598,29 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic audioEngine.clearTextToSpeechQueue() - if (wasActive) return@launch + if (wasActive) { + // Toggle-off: previous callout was in flight, this press just + // cancels it. Clear the animation state directly — the previous + // job's finally block will also fire, but compareAndSet(source, null) + // is idempotent so double-clear is a no-op. + _activeCalloutFlow.value = null + return@launch + } - body() + _activeCalloutFlow.value = source + try { + body() + } finally { + // Only clear if the flow is still us — if a newer callout + // started while we were cancelled, its coroutine already set + // the flow to the new source and we mustn't clobber it. + _activeCalloutFlow.compareAndSet(source, null) + } } } override fun myLocation() { - startCallout { + startCallout(TourButton.MY_LOCATION) { // myLocation can take a second or so if it does network reverse // geocoding — play the enter earcon immediately so the user hears // the action registered, mirroring Android. @@ -613,7 +637,7 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic } override fun whatsAroundMe() { - startCallout { + startCallout(TourButton.AROUND_ME) { val callout = withContext(Dispatchers.Default) { geoEngine.whatsAroundMe() } ensureActive() var handle = 0L @@ -625,7 +649,7 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic } override fun aheadOfMe() { - startCallout { + startCallout(TourButton.AHEAD_OF_ME) { val callout = withContext(Dispatchers.Default) { geoEngine.aheadOfMe() } ensureActive() var handle = 0L @@ -637,7 +661,7 @@ class IosSoundscapeService : GeoEngineListener, MediaControllableService, Servic } override fun nearbyMarkers() { - startCallout { + startCallout(TourButton.NEARBY_MARKERS) { val callout = withContext(Dispatchers.Default) { geoEngine.nearbyMarkers() } ensureActive() val handle = speakCalloutCommon(callout, true, audioEngine, lastGeometry, ruler)