From d7e205cd9d67719e47af269d112a12c32ac44bea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:22:33 +0200 Subject: [PATCH] fix(audio): bypass toggle no longer greys out after being turned off The "internal direct output" toggle was enabled only while the current output was reported available. Turning it off closes the direct stream and re-evaluates the route from cached results (a format Android mixed, or a probe taken while our own stream held the output), so the toggle often came back greyed out and could only be re-enabled after an app restart or a route change. - Lock the toggle only for permanent impossibilities (Android too old, native library missing); route/format-specific results stay shown in the subtitle but no longer block re-enabling. - Re-enabling clears the mixed-format list, the open cache and the cached probe, so the output is actually tested again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LR3s8LM4rGRXJiyve854EE --- .../app/playback/InternalDirectOutput.kt | 20 +++++++++++++++++++ .../app/ui/settings/SettingsScreen.kt | 8 ++++---- .../playback/InternalDirectSignalPathTest.kt | 18 +++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/lastwave/app/playback/InternalDirectOutput.kt b/app/src/main/java/com/lastwave/app/playback/InternalDirectOutput.kt index e8105c2dc..32ccbe2d6 100644 --- a/app/src/main/java/com/lastwave/app/playback/InternalDirectOutput.kt +++ b/app/src/main/java/com/lastwave/app/playback/InternalDirectOutput.kt @@ -65,6 +65,17 @@ data class InternalDirectAvailability( ) { val available: Boolean get() = reason == InternalDirectReason.AVAILABLE || reason == InternalDirectReason.ACTIVE + + /** + * Whether the settings toggle may be switched on. Only a permanent + * impossibility (Android too old, native library missing) locks it. + * Route- or format-specific results (Bluetooth codec, one format mixed + * by Android, exclusive refused while something else held the output) + * can change on the next track or route, and the user must be able to + * turn the feature back on without restarting the app. + */ + val canEnable: Boolean + get() = reason != InternalDirectReason.ANDROID_TOO_OLD && reason != InternalDirectReason.NATIVE_MISSING } /** The direct stream actually playing, for the signal-path page and the badge. */ @@ -169,6 +180,15 @@ class InternalDirectOutput @Inject constructor( if (!enabled) { synchronized(openLock) { closeLocked() } lastFailure = null + } else { + // Re-enabling is an explicit retry: forget what earlier attempts + // concluded (formats mixed by Android, a probe taken while our own + // stream held the output) so this output is tested again. + synchronized(openLock) { + openCache.clear() + mixedRoutes.clear() + cachedProbe = null + } } refreshAvailability() } diff --git a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt index 64edbedcd..cdcc5acf1 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt @@ -1013,10 +1013,10 @@ fun SettingsScreen( ) }, checked = internalDirectEnabled, - // Greyed out when this output cannot be bypassed, but an - // enabled toggle always stays reachable so it can be turned off. - enabled = internalDirectEnabled || internalDirectAvailability.available || - internalDirectAvailability.reason == com.lastwave.app.playback.InternalDirectReason.CHECKING, + // Greyed out only when this device can never bypass the mixer. + // A route- or format-specific refusal must not lock the toggle: + // turning it off used to grey it out until the app restarted. + enabled = internalDirectEnabled || internalDirectAvailability.canEnable, onCheckedChange = viewModel::setInternalDirectEnabled, position = position, isHighlighted = (highlightedSettingId == "audio.internal_direct"), diff --git a/app/src/test/java/com/lastwave/app/playback/InternalDirectSignalPathTest.kt b/app/src/test/java/com/lastwave/app/playback/InternalDirectSignalPathTest.kt index 5b0fe1f7d..c0ce515d1 100644 --- a/app/src/test/java/com/lastwave/app/playback/InternalDirectSignalPathTest.kt +++ b/app/src/test/java/com/lastwave/app/playback/InternalDirectSignalPathTest.kt @@ -254,4 +254,22 @@ class InternalDirectSignalPathTest { // A garbage source never reaches the loop. assertThat(InternalDirectOutput.fallbackRates(0, 44_100)).isEmpty() } + @Test + fun toggleStaysReachableUnlessTheDeviceCanNeverBypass() { + fun availability(reason: InternalDirectReason) = + InternalDirectAvailability(InternalRoute.SPEAKER, "Speaker", reason) + // Route- or format-specific results must not lock the toggle once it is turned off. + listOf( + InternalDirectReason.AVAILABLE, + InternalDirectReason.ACTIVE, + InternalDirectReason.CHECKING, + InternalDirectReason.BLUETOOTH, + InternalDirectReason.USB, + InternalDirectReason.EXCLUSIVE_REFUSED, + InternalDirectReason.NO_AAUDIO, + InternalDirectReason.MIXED_BY_ANDROID, + ).forEach { assertThat(availability(it).canEnable).isTrue() } + assertThat(availability(InternalDirectReason.ANDROID_TOO_OLD).canEnable).isFalse() + assertThat(availability(InternalDirectReason.NATIVE_MISSING).canEnable).isFalse() + } }