Skip to content
Closed
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
88 changes: 48 additions & 40 deletions CHANGELOG.md

Large diffs are not rendered by default.

42 changes: 41 additions & 1 deletion lib/src/bindings/bindings_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,12 @@ abstract class FlutterSoLoud {
/// [channels] mono, stereo, quad, 5.1, 7.1.
///
/// Returns [PlayerErrors.noError] if success.
///
/// The blocking native engine/device initialization runs off the UI thread so
/// it does not freeze the app (#481); the future completes once the engine is
/// initialized.
@mustBeOverridden
PlayerErrors initEngine(
Future<PlayerErrors> initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Expand All @@ -96,6 +100,37 @@ abstract class FlutterSoLoud {
@mustBeOverridden
void setAndroidAAudioAttributes(bool managed);

/// Android only: when [enable] is true, SoLoud stops the audio device once
/// the engine goes idle (no active voices), releasing the audioserver
/// AudioMix partial wakelock. Defaults to false, keeping the device running.
/// Can be called any time. No effect on non-Android backends or on web.
@mustBeOverridden
void setAndroidPauseDeviceWhenIdle(bool enable);

/// Stop the audio output device without deinitializing the engine. Only the
/// miniaudio device is stopped; loaded sounds, active voices and the
/// initialized state are preserved so playback can resume later with
/// [startAudioDevice]. Idempotent: a no-op if the device is already stopped.
///
/// The blocking native device call runs off the UI thread so it does not
/// freeze the app; the returned future completes once the device is stopped.
@mustBeOverridden
Future<PlayerErrors> stopAudioDevice();

/// Restart the audio output device previously stopped by [stopAudioDevice],
/// so existing voices and loaded sounds keep operating. Idempotent: a no-op
/// if the device is already started.
///
/// The blocking native device call runs off the UI thread so it does not
/// freeze the app; the returned future completes once the device is running.
@mustBeOverridden
Future<PlayerErrors> startAudioDevice();

/// Get the current state of the audio output device. Returns
/// [AudioDeviceState.uninitialized] if the engine is not initialized.
@mustBeOverridden
AudioDeviceState getAudioDeviceState();

/// Change the playback device.
///
/// [deviceId] the device ID. -1 for default OS output device.
Expand All @@ -109,6 +144,11 @@ abstract class FlutterSoLoud {
@mustBeOverridden
void deinit();

/// Like [deinit], but runs the blocking native teardown off the UI thread so
/// it does not freeze the app; the future completes once teardown is done.
@mustBeOverridden
Future<void> deinitAsync();

/// Gets the state of player
///
/// Return true if initilized
Expand Down
155 changes: 145 additions & 10 deletions lib/src/bindings/bindings_player_ffi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// ignore_for_file: omit_local_variable_types,public_member_api_docs

import 'dart:ffi' as ffi;
import 'dart:isolate';
import 'dart:typed_data';

import 'package:ffi/ffi.dart';
Expand All @@ -20,6 +21,64 @@ import 'package:flutter_soloud/src/sound_hash.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';

/// Rebuilds a `PlayerErrors Function()` device-lifecycle native function from
/// its raw pointer [address] and invokes it, returning the raw error code.
///
/// Top-level so it can run inside an [Isolate.run] worker: the blocking native
/// device start/stop then executes off the UI isolate instead of stalling it.
/// Only [address] (a sendable int) crosses the isolate boundary; the pointer is
/// reconstructed here and the same process-global device is operated on.
int _invokeDeviceLifecycle(int address) {
final fn = ffi.Pointer<ffi.NativeFunction<ffi.UnsignedInt Function()>>
.fromAddress(address)
.asFunction<int Function()>();
return fn();
}

/// Rebuilds the native `initEngine` function from its raw pointer [address] and
/// invokes it, returning the raw [PlayerErrors] code.
///
/// Top-level so it can run inside an [Isolate.run] worker: the blocking native
/// engine/device initialization (which can take seconds on Android/AAudio) then
/// executes off the UI isolate instead of stalling it (#481). Only sendable
/// ints cross the isolate boundary; the pointer is reconstructed here and the
/// same process-global engine is initialized.
int _invokeInitEngine(
int address,
int deviceId,
int sampleRate,
int bufferSize,
int channels,
int lowLatency,
) {
final fn =
ffi.Pointer<
ffi.NativeFunction<
ffi.Int32 Function(
ffi.Int,
ffi.UnsignedInt,
ffi.UnsignedInt,
ffi.UnsignedInt,
ffi.UnsignedInt,
)
>
>.fromAddress(address)
.asFunction<int Function(int, int, int, int, int)>();
return fn(deviceId, sampleRate, bufferSize, channels, lowLatency);
}

/// Rebuilds a `void Function()` native function from its raw pointer [address]
/// and invokes it.
///
/// Top-level so it can run inside an [Isolate.run] worker: the blocking native
/// teardown (device uninit) then executes off the UI isolate instead of
/// stalling it. Only [address] (a sendable int) crosses the isolate boundary.
void _invokeVoidNative(int address) {
ffi.Pointer<ffi.NativeFunction<ffi.Void Function()>>
.fromAddress(address)
.asFunction<void Function()>()();
}

typedef DartVoiceEndedCallbackT =
ffi.Pointer<ffi.NativeFunction<DartVoiceEndedCallbackTFunction>>;

Expand Down Expand Up @@ -260,19 +319,30 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();

@override
PlayerErrors initEngine(
Future<PlayerErrors> initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Channels channels,
bool lowLatency,
) {
final ret = _initEngine(
deviceId,
sampleRate,
bufferSize,
channels.count,
lowLatency ? 1 : 0,
) async {
// Run the blocking native engine/device initialization off the UI isolate
// so it does not freeze the app (it can take seconds on Android/AAudio,
// tripping the ANR watchdog — see #481). Only the raw function pointer
// address and the primitive arguments (all sendable ints) are captured; the
// pointer is rebuilt and called inside the worker.
final address = _initEnginePtr.address;
final channelCount = channels.count;
final lowLatencyInt = lowLatency ? 1 : 0;
final ret = await Isolate.run(
() => _invokeInitEngine(
address,
deviceId,
sampleRate,
bufferSize,
channelCount,
lowLatencyInt,
),
);
return PlayerErrors.values[ret];
}
Expand All @@ -289,8 +359,6 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
)
>
>('initEngine');
late final _initEngine = _initEnginePtr
.asFunction<int Function(int, int, int, int, int)>();

@override
void setAndroidAAudioAttributes(bool managed) {
Expand All @@ -304,6 +372,63 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
late final _setAndroidAAudioAttributes = _setAndroidAAudioAttributesPtr
.asFunction<void Function(int)>();

@override
void setAndroidPauseDeviceWhenIdle(bool enable) {
_setAndroidPauseDeviceWhenIdle(enable ? 1 : 0);
}

late final _setAndroidPauseDeviceWhenIdlePtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.UnsignedInt)>>(
'setAndroidPauseDeviceWhenIdle',
);
late final _setAndroidPauseDeviceWhenIdle = _setAndroidPauseDeviceWhenIdlePtr
.asFunction<void Function(int)>();

@override
Future<PlayerErrors> stopAudioDevice() async {
// Run the blocking native ma_device_stop() off the UI isolate. Only the
// raw function pointer address (a sendable int) is captured; the pointer
// is rebuilt and called inside the worker.
final address = _stopAudioDevicePtr.address;
final ret = await Isolate.run(() => _invokeDeviceLifecycle(address));
return PlayerErrors.values[ret];
}

late final _stopAudioDevicePtr =
_lookup<ffi.NativeFunction<ffi.UnsignedInt Function()>>(
'stopAudioDevice',
);

@override
Future<PlayerErrors> startAudioDevice() async {
// Run the blocking native ma_device_start() off the UI isolate so the app
// stays responsive (it can take tens of ms while the OS restarts the
// device). Only the raw function pointer address (a sendable int) is
// captured; the pointer is rebuilt and called inside the worker.
final address = _startAudioDevicePtr.address;
final ret = await Isolate.run(() => _invokeDeviceLifecycle(address));
return PlayerErrors.values[ret];
}

late final _startAudioDevicePtr =
_lookup<ffi.NativeFunction<ffi.UnsignedInt Function()>>(
'startAudioDevice',
);

@override
AudioDeviceState getAudioDeviceState() {
// Reading the device state is a cheap, non-blocking atomic load, so call
// it directly on the UI isolate.
return AudioDeviceState.fromValue(_getAudioDeviceState());
}

late final _getAudioDeviceStatePtr =
_lookup<ffi.NativeFunction<ffi.UnsignedInt Function()>>(
'getAudioDeviceState',
);
late final _getAudioDeviceState = _getAudioDeviceStatePtr
.asFunction<int Function()>();

@override
PlayerErrors changeDevice(int deviceId) {
final ret = _changeDevice(deviceId);
Expand Down Expand Up @@ -414,6 +539,16 @@ class FlutterSoLoudFfi extends FlutterSoLoud {
return _dispose();
}

@override
Future<void> deinitAsync() async {
// Run the blocking native teardown (device uninit) off the UI isolate so
// it does not freeze the app. Only the raw function pointer address (a
// sendable int) is captured; the pointer is rebuilt and called in the
// worker.
final address = _disposePtr.address;
await Isolate.run(() => _invokeVoidNative(address));
}

late final _disposePtr = _lookup<ffi.NativeFunction<ffi.Void Function()>>(
'dispose',
);
Expand Down
35 changes: 33 additions & 2 deletions lib/src/bindings/bindings_player_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,14 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
bool areXiphLibsAvailable() => wasmAreXiphLibsAvailable() == 1;

@override
PlayerErrors initEngine(
Future<PlayerErrors> initEngine(
int deviceId,
int sampleRate,
int bufferSize,
Channels channels,
bool lowLatency,
) {
) async {
// Web is single-threaded (no isolates), so call the wasm function directly.
// [lowLatency] only affects the native miniaudio backends (it selects the
// AAudio/CoreAudio performance profile); the Web Audio backend ignores it.
final ret = wasmInitEngine(
Expand All @@ -140,6 +141,32 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
// No-op on web: AAudio stream attributes are Android-only.
}

@override
void setAndroidPauseDeviceWhenIdle(bool enable) {
// No-op on web: no wakelock concept, device lifecycle differs.
}

@override
Future<PlayerErrors> stopAudioDevice() async {
// Web is single-threaded (no isolates) and the device change is instant,
// so call the wasm function directly.
final ret = wasmStopAudioDevice();
return PlayerErrors.values[ret];
}

@override
Future<PlayerErrors> startAudioDevice() async {
// Web is single-threaded (no isolates) and the device change is instant,
// so call the wasm function directly.
final ret = wasmStartAudioDevice();
return PlayerErrors.values[ret];
}

@override
AudioDeviceState getAudioDeviceState() {
return AudioDeviceState.fromValue(wasmGetAudioDeviceState());
}

@override
PlayerErrors changeDevice(int deviceId) {
final ret = wasmChangeDevice(deviceId);
Expand Down Expand Up @@ -186,6 +213,10 @@ class FlutterSoLoudWeb extends FlutterSoLoud {
@override
void deinit() => wasmDeinit();

@override
// Web is single-threaded (no isolates), so call the wasm function directly.
Future<void> deinitAsync() async => wasmDeinit();

@override
bool isInited() => wasmIsInited() == 1;

Expand Down
9 changes: 9 additions & 0 deletions lib/src/bindings/js_extension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ external int wasmInitEngine(
int lowLatency,
);

@JS('Module_soloud._stopAudioDevice')
external int wasmStopAudioDevice();

@JS('Module_soloud._startAudioDevice')
external int wasmStartAudioDevice();

@JS('Module_soloud._getAudioDeviceState')
external int wasmGetAudioDeviceState();

@JS('Module_soloud._changeDevice')
external int wasmChangeDevice(int deviceId);

Expand Down
39 changes: 39 additions & 0 deletions lib/src/enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,45 @@ enum PlayerStateNotification {
unlocked,
}

/// The state of the audio output device, as reported by
/// `SoLoud.getAudioDeviceState`.
///
/// The values mirror miniaudio's `ma_device_state`.
///
/// WARNING: Keep these in sync with `src/enums.h`.
enum AudioDeviceState {
/// The device is uninitialized. Also returned before the engine is
/// initialized or after it has been deinitialized.
uninitialized(0),

/// The device is stopped. This is the device's default state right after
/// initialization (for example after `SoLoud.stopAudioDevice`).
stopped(1),

/// The device is started and is requesting and/or delivering audio data.
started(2),

/// The device is transitioning from a stopped state to a started state.
starting(3),

/// The device is transitioning from a started state to a stopped state.
stopping(4);

const AudioDeviceState(this.value);

/// Returns the [AudioDeviceState] for the given native integer [value],
/// falling back to [uninitialized] for any unknown value.
factory AudioDeviceState.fromValue(int value) {
return AudioDeviceState.values.firstWhere(
(state) => state.value == value,
orElse: () => AudioDeviceState.uninitialized,
);
}

/// The native integer value of the state.
final int value;
}

/// The channels to be used while initializing the player.
enum Channels {
/// One channel.
Expand Down
Loading