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() + } }