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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#### 4.0.13 (20 Jul 2026)
- Android: the plugin now does no native work at app startup. Plugin registration and `onAttachedToEngine` are pure Java bookkeeping; the native library is loaded lazily, only when an engine-lifecycle hook actually has to call into it. Previously a static initializer pulled the whole library onto the main thread during app launch even for apps that never played a sound, and a load failure there crashed plugin registration
- fix: Waveform audio sources do not match engine sample rate #501. Thanks to @Colton127
- Android now stops the audio device when idle (no active voices) like every other platform, releasing the audioserver `AudioMix` partial wakelock #250; use `setAudioDeviceIdleTimeout()` to keep it running
- add `stopAudioDevice()` / `startAudioDevice()` to control the audio output device without deinitializing the engine (loaded sounds and voice state are preserved); `stopAudioDevice()` is an idle-only no-op while an unpaused voice is active unless `force: true` is passed, while `startAudioDevice()` temporarily starts or prewarms the output and remains subject to the configured idle timeout; blocking native device operations run off the UI thread
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,59 @@
package flutter.soloud.flutter_soloud;

import android.util.Log;
import androidx.annotation.NonNull;
import io.flutter.embedding.engine.plugins.FlutterPlugin;

public final class FlutterSoloudPlugin implements FlutterPlugin {
static {
System.loadLibrary("flutter_soloud_plugin");
}
private static final String TAG = "FlutterSoloudPlugin";

/**
* Guarded by the class monitor.
*
* <p>The native library is loaded lazily, at the first point one of the
* hooks below actually needs to call into it -- never from a static
* initializer and never from {@link #onAttachedToEngine}. Both of those run
* at engine startup (GeneratedPluginRegistrant instantiates and attaches
* every plugin), which would drag the whole multi-megabyte library onto the
* main thread during app launch even for an app that never plays a sound.
*
* <p>By the time a hook fires, an app that uses SoLoud has already loaded
* the same library from Dart via {@code DynamicLibrary.open}, so
* {@code System.loadLibrary} is a refcount bump rather than a real load.
* Successful loads are cached; failed loads can be retried by a later hook.
*/
private static boolean nativeLibraryLoaded = false;

private static native boolean
nativeClearDartCallbackRegistrationsForEngine(long engineId);

private Long engineId;

private static synchronized boolean ensureNativeLibraryLoaded() {
if (nativeLibraryLoaded) {
return true;
}

try {
System.loadLibrary("flutter_soloud_plugin");
nativeLibraryLoaded = true;
return true;
} catch (UnsatisfiedLinkError error) {
// Never fail engine teardown over this. The FFI layer opens the
// same library from Dart and surfaces the error there, where it is
// catchable and actionable.
return false;
}
}

@SuppressWarnings("deprecation")
@Override
public void onAttachedToEngine(
@NonNull FlutterPluginBinding binding
) {
// Deliberately does no native work. This runs during app launch for
// every app that depends on the plugin, whether or not it ever uses
// SoLoud, so it must stay pure Java bookkeeping.
engineId = binding.getFlutterEngine().getEngineId();
}

Expand All @@ -28,9 +64,17 @@ public void onDetachedFromEngine(
final Long detachedEngineId = engineId;
engineId = null;

if (detachedEngineId != null) {
nativeClearDartCallbackRegistrationsForEngine(
detachedEngineId
if (detachedEngineId == null || !ensureNativeLibraryLoaded()) {
return;
}

try {
nativeClearDartCallbackRegistrationsForEngine(detachedEngineId);
} catch (UnsatisfiedLinkError error) {
Log.w(
TAG,
"Unable to clear Dart callback registrations during engine teardown",
error
);
}
}
Expand Down
42 changes: 39 additions & 3 deletions example/tests/tests.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,25 @@ class MyHomePage extends StatefulWidget {
class _MyHomePageState extends State<MyHomePage> {
final output = StringBuffer();
final textEditingController = TextEditingController();
final outputScrollController = ScrollController();
late final List<_Test> tests;
TestEntry? selectedTest;
bool isRunningAll = false;
bool shouldAutoScrollOutput = true;
int lastDebugOutputLength = 0;

@override
void initState() {
super.initState();
tests = allTests.map((e) => _Test(entry: e)).toList();
selectedTest = tests.first.entry;
outputScrollController.addListener(_handleOutputScroll);
}

@override
void dispose() {
outputScrollController.removeListener(_handleOutputScroll);
outputScrollController.dispose();
textEditingController.dispose();
super.dispose();
}
Expand All @@ -86,6 +92,7 @@ class _MyHomePageState extends State<MyHomePage> {
children: [
TextField(
controller: textEditingController,
scrollController: outputScrollController,
style: const TextStyle(color: Colors.black, fontSize: 12),
expands: true,
maxLines: null,
Expand All @@ -107,6 +114,7 @@ class _MyHomePageState extends State<MyHomePage> {
onPressed: () {
textEditingController.clear();
output.clear();
lastDebugOutputLength = 0;
},
),
),
Expand Down Expand Up @@ -231,6 +239,7 @@ class _MyHomePageState extends State<MyHomePage> {
setState(() {
isRunningAll = true;
output.clear();
lastDebugOutputLength = 0;
for (final test in tests) {
test.status = TestStatus.none;
}
Expand Down Expand Up @@ -299,9 +308,36 @@ class _MyHomePageState extends State<MyHomePage> {
}

void _updateOutput() {
textEditingController.text = output.toString();
debugPrint(output.toString());
if (mounted) setState(() {});
final shouldAutoScroll = shouldAutoScrollOutput;
final outputText = output.toString();
textEditingController.text = outputText;

if (outputText.length < lastDebugOutputLength) {
lastDebugOutputLength = 0;
}
if (outputText.length > lastDebugOutputLength) {
debugPrint(outputText.substring(lastDebugOutputLength));
lastDebugOutputLength = outputText.length;
}

if (mounted) {
setState(() {});
if (shouldAutoScroll) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !outputScrollController.hasClients) return;
outputScrollController.jumpTo(
outputScrollController.position.maxScrollExtent,
);
});
}
}
}

void _handleOutputScroll() {
if (!outputScrollController.hasClients) return;
final position = outputScrollController.position;
final isAtBottom = (position.maxScrollExtent - position.pixels).abs() <= 24;
shouldAutoScrollOutput = isAtBottom;
}
}

Expand Down
2 changes: 1 addition & 1 deletion example/tests/tests/audio_device_lifecycle_races.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ Future<StringBuffer> testAudioDeviceLifecycleRaces() async {

await stoppedEvent;
await restartedEvent;
final rapidState = SoLoud.instance.getAudioDeviceState();
final rapidState = await _waitForDeviceState(AudioDeviceState.started);
assert(
rapidState == AudioDeviceState.started,
'Rapid interruption cycle $i lost the recovery start: $rapidState',
Expand Down
Loading