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/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/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/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/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 9db1e9c5b..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 @@ -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,8 +132,11 @@ fun SharedHomeScreen( } }, bottomBar = { - if (!fullscreenMap.value && !keyboardOpen.value) { - SharedHomeBottomAppBar(bottomButtonFunctions) + if (!fullscreenMap.value && !hideChromeForKeyboard) { + SharedHomeBottomAppBar( + bottomButtonFunctions = bottomButtonFunctions, + activeCallout = state.activeCallout, + ) } }, floatingActionButton = { @@ -170,6 +188,7 @@ fun SharedHomeScreen( hint = stringResource(Res.string.search_bar_hint), userLocation = state.location, isSearching = state.searchInProgress, + onExpandedChange = { searchExpanded = it }, ) }, onMapLongClick = onMapLongClick, 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 dbe9ac1bd..1a654c7c6 100644 --- a/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt +++ b/shared/src/iosMain/kotlin/org/scottishtecharmy/soundscape/IosSoundscapeService.kt @@ -3,16 +3,22 @@ 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 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.audio.TourButton import org.scottishtecharmy.soundscape.database.local.MarkersAndRoutesDatabaseProvider import org.scottishtecharmy.soundscape.database.local.dao.RouteDao import org.scottishtecharmy.soundscape.geoengine.GeoEngine @@ -83,6 +89,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 @@ -142,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() @@ -553,24 +568,105 @@ 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 [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(source: TourButton, body: suspend CoroutineScope.() -> Unit) { + val previousJob = calloutJob + calloutJob = scope.launch(audioEngine.audioDispatcher) { + val wasActive = previousJob?.isActive == true + if (wasActive) previousJob.cancel() + + audioEngine.clearTextToSpeechQueue() + + 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 + } + + _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() { - val callout = geoEngine.myLocation() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + 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. + audioEngine.createEarcon(EARCON_MODE_ENTER, AudioType.STANDARD) + val callout = withContext(Dispatchers.Default) { geoEngine.myLocation() } + ensureActive() + var handle = 0L + if (callout != null) { + handle = speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + } + audioEngine.createEarcon(EARCON_MODE_EXIT, AudioType.STANDARD) + awaitHandle(handle) + } } override fun whatsAroundMe() { - val callout = geoEngine.whatsAroundMe() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + startCallout(TourButton.AROUND_ME) { + val callout = withContext(Dispatchers.Default) { geoEngine.whatsAroundMe() } + ensureActive() + var handle = 0L + if (callout.positionedStrings.isNotEmpty()) { + handle = speakCalloutCommon(callout, true, audioEngine, lastGeometry, ruler) + } + awaitHandle(handle) + } } override fun aheadOfMe() { - val callout = geoEngine.aheadOfMe() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + startCallout(TourButton.AHEAD_OF_ME) { + val callout = withContext(Dispatchers.Default) { geoEngine.aheadOfMe() } + ensureActive() + var handle = 0L + if (callout != null) { + handle = speakCalloutCommon(callout, true, audioEngine, lastGeometry, ruler) + } + awaitHandle(handle) + } } override fun nearbyMarkers() { - val callout = geoEngine.nearbyMarkers() - speakCalloutCommon(callout, false, audioEngine, lastGeometry, ruler) + startCallout(TourButton.NEARBY_MARKERS) { + val callout = withContext(Dispatchers.Default) { geoEngine.nearbyMarkers() } + ensureActive() + val handle = speakCalloutCommon(callout, true, audioEngine, lastGeometry, ruler) + awaitHandle(handle) + } } // --- Beacon Control --- 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 } 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 af0820eab..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 @@ -88,7 +108,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() } @@ -118,7 +138,7 @@ class IosAudioEngine : AudioEngine { if (engineStarted) return // Configure and activate audio session - configureAndActivateSession() + configureAudioSession() // Register for audio session notifications registerAudioSessionObservers() @@ -141,7 +161,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 +192,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)") } @@ -332,17 +358,28 @@ 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) } }) - 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()) { + 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 + // 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 @@ -413,16 +450,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() } } @@ -432,7 +484,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 ---