Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}