From e0f5a9656464378e8e450d646e45b3cbe1103563 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:11:07 +0700 Subject: [PATCH] fix(player): distinguish AAudio access and decoder failures --- .../com/zarz/spotiflac/HiResPlaybackTest.kt | 22 +++++++++++++++++++ android/app/src/main/cpp/native_audio.cpp | 18 +++++++++++++-- .../com/zarz/spotiflac/HiResPlayback.kt | 10 +++++++-- lib/l10n/arb/app_en.arb | 2 +- lib/l10n/arb/app_id.arb | 2 +- lib/services/music_playback_deck.dart | 7 ++++++ 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/HiResPlaybackTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/HiResPlaybackTest.kt index 82967ab4..518626f6 100644 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/HiResPlaybackTest.kt +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/HiResPlaybackTest.kt @@ -96,12 +96,34 @@ class HiResPlaybackTest { } finally { playback.dispose(); file.delete() } } + @Test + fun invalidFileIsNotReportedAsExclusiveAccessFailure() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val file = File.createTempFile("invalid-pcm-", ".wav", context.cacheDir) + file.writeBytes(ByteArray(64)) + val playback = HiResPlayback(context) {} + try { + val done = CountDownLatch(1) + var reply: Any? = null + var error: String? = null + playback.command(MethodCall("prepare", mapOf("path" to file.path, "token" to 1))) { value, failure -> + reply = value; error = failure; done.countDown() + } + assertTrue(done.await(10, TimeUnit.SECONDS)) + assertNull(error) + assertEquals("format", (reply as Map<*, *>)["reason"]) + assertEquals(false, (reply as Map<*, *>)["fatal"]) + } finally { playback.dispose(); file.delete() } + } + @Test fun exclusiveOutputEitherVerifiesExactFormatOrRejectsSharedFallback() { assertThrows(IllegalStateException::class.java) { NativeAudio.openOboe(1, 2, 24, 0) } val handle = try { NativeAudio.openOboe(48000, 2, 16, 0) } catch (error: IllegalStateException) { assertTrue(error.message.orEmpty().contains("exclusive output unavailable")) + assertTrue(error.message.orEmpty().contains("requested=48000Hz/2ch/16bit")) + assertTrue(error.message.orEmpty().contains("format=")) return } try { diff --git a/android/app/src/main/cpp/native_audio.cpp b/android/app/src/main/cpp/native_audio.cpp index 2022982f..415d8226 100644 --- a/android/app/src/main/cpp/native_audio.cpp +++ b/android/app/src/main/cpp/native_audio.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +73,9 @@ public: if (precision <= 24) formats.push_back(oboe::AudioFormat::I24); formats.push_back(oboe::AudioFormat::I32); if (precision <= 24) formats.push_back(oboe::AudioFormat::Float); + std::ostringstream attempts; + attempts << "requested=" << rate << "Hz/" << channels << "ch/" << precision + << "bit, device=" << device; for (auto format : formats) { oboe::AudioStreamBuilder builder; builder.setDirection(oboe::Direction::Output) @@ -84,7 +88,12 @@ public: ->setDeviceId(device)->setDataCallback(this) ->setChannelConversionAllowed(false)->setFormatConversionAllowed(false) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None); - if (builder.openStream(stream) != oboe::Result::OK) continue; + const auto result = builder.openStream(stream); + attempts << "; format=" << oboe::convertToText(format); + if (result != oboe::Result::OK) { + attempts << " open=" << oboe::convertToText(result); + continue; + } // Exclusive is a request: Android can silently return Shared. Reject // that stream instead of displaying a misleading exclusive badge. if (stream->getAudioApi() == oboe::AudioApi::AAudio && @@ -97,10 +106,15 @@ public: ring.resize(static_cast(rate / 4) * frameBytes); return; } + attempts << " actual=" << oboe::convertToText(stream->getSharingMode()) + << "/" << stream->getSampleRate() << "Hz/" << stream->getChannelCount() + << "ch/" << oboe::convertToText(stream->getFormat()) + << ", api=" << oboe::convertToText(stream->getAudioApi()) + << ", device=" << stream->getDeviceId(); stream->close(); stream.reset(); } - throw std::runtime_error("Exact-rate AAudio exclusive output unavailable"); + throw std::runtime_error("Exact-rate AAudio exclusive output unavailable: " + attempts.str()); } ~ExclusiveOutput() override { if (stream) stream->close(); } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt index d8eda6ec..ba3ad43d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt @@ -70,6 +70,7 @@ internal class HiResPlayback(context: Context, private val emit: (Map("token") ?: 0 var isDsd = call.argument("requiresDsd") == true + var failureReason = "format" try { val path = requireNotNull(call.argument("path")) isDsd = isDsd || DsdSource.isDsd(path) @@ -78,9 +79,11 @@ internal class HiResPlayback(context: Context, private val emit: (Map(const UsbAudioStatus()); @@ -213,6 +216,10 @@ class MusicPlaybackDeck { usbAudioStatus.value = UsbAudioStatus( reason: response?['reason'] as String? ?? 'unsupported', ); + final detail = response?['detail'] as String?; + if (detail != null && detail.isNotEmpty) { + _log.w('Native output ${usbAudioStatus.value.reason}: $detail'); + } if (response?['fatal'] == true) { throw UsbDsdUnavailable(); }