Better audio management (#10)
* add audio_session package * `AudioManager` initial design * try to integrate audio manager * configure audio session * `DisposeAware` disposing if already published causes exception. * organize * guard flutter_webrtc calls * websocket async fix * use `ConnectionState` instead of `_isClosed` and `isReconnecting` * change create audio track defaults * update protos * protocol 3 speaker updates * fix exception * emit `RoomDisconnectedEvent` only once * fix exception * keep track of local / remote audio tracks * explicit types * manage track state * re-structure audio management * change defaults * unpublish all * use experimental build * change defaults * configuring is optional * call native `RTCAudioSession.setConfiguration` * defaults adjustment * iOS only for now * use lib 92.4515.07 * clean up * revert audio options for now * remove pod source * rename apple related audio * `createListener` method * organize native audio * `SpeakingChangedEvent` only on `Participant` * refactoring * fix ios compile * fix configure audio only for iOS logic * minor fix & clean up * change dispose logic * format protos * fix unpublishTrack * `createListener` into a mixin * simplify * use flutter_webrtc master * unpublish for example * update ios icon * android icon * web icon * favicon
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
import '../extensions.dart';
|
||||
import '../logger.dart';
|
||||
|
||||
typedef OnDisposeFunc = Future<void> Function();
|
||||
|
||||
mixin _Disposer {
|
||||
//
|
||||
final _disposeFuncs = <OnDisposeFunc>[];
|
||||
bool _isDisposed = false;
|
||||
bool get isDisposed => _isDisposed;
|
||||
|
||||
// last added func will be called first when disposing
|
||||
void onDispose(OnDisposeFunc func) => _disposeFuncs.add(func);
|
||||
|
||||
Future<bool> _dispose() async {
|
||||
if (!_isDisposed) {
|
||||
logger.fine('[${objectId}] dispose()');
|
||||
_isDisposed = true;
|
||||
if (_disposeFuncs.isNotEmpty) {
|
||||
logger.fine('[$objectId] running ${_disposeFuncs.length} dispose funcs...');
|
||||
// call dispose funcs in reverse order
|
||||
for (final _func in _disposeFuncs.reversed) {
|
||||
await _func();
|
||||
}
|
||||
_disposeFuncs.clear();
|
||||
logger.fine('[$objectId] dispose complete.');
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
logger.warning('[$objectId] unnecessary dispose() called.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Disposable with _Disposer {
|
||||
@mustCallSuper
|
||||
Future<bool> dispose() async {
|
||||
return await _dispose();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DisposableChangeNotifier extends ChangeNotifier with _Disposer {
|
||||
@override
|
||||
Future<bool> dispose() async {
|
||||
if (!isDisposed) super.dispose();
|
||||
return await super._dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get hasListeners {
|
||||
if (isDisposed) {
|
||||
logger.warning('called hasListeners on a disposed ChangeNotifier');
|
||||
return false;
|
||||
}
|
||||
return super.hasListeners;
|
||||
}
|
||||
|
||||
@override
|
||||
void addListener(VoidCallback listener) {
|
||||
if (isDisposed) {
|
||||
logger.warning('called addListener() on a disposed ChangeNotifier');
|
||||
return;
|
||||
}
|
||||
super.addListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void notifyListeners() {
|
||||
if (isDisposed) {
|
||||
logger.warning('called notifyListeners() on a disposed ChangeNotifier');
|
||||
return;
|
||||
}
|
||||
super.notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void removeListener(VoidCallback listener) {
|
||||
if (isDisposed) {
|
||||
logger.warning('called removeListener() on a disposed ChangeNotifier');
|
||||
return;
|
||||
}
|
||||
super.removeListener(listener);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// https://developer.apple.com/documentation/avfaudio/avaudiosession/category
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../logger.dart';
|
||||
|
||||
enum AppleAudioCategory {
|
||||
soloAmbient,
|
||||
playback,
|
||||
record,
|
||||
playAndRecord,
|
||||
multiRoute,
|
||||
}
|
||||
|
||||
// https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions
|
||||
enum AppleAudioCategoryOption {
|
||||
mixWithOthers, // Only playAndRecord, playback, or multiRoute.
|
||||
duckOthers, // Only playAndRecord, playback, or multiRoute.
|
||||
interruptSpokenAudioAndMixWithOthers,
|
||||
allowBluetooth, // Only playAndRecord or record.
|
||||
allowBluetoothA2DP,
|
||||
allowAirPlay,
|
||||
defaultToSpeaker,
|
||||
}
|
||||
|
||||
// https://developer.apple.com/documentation/avfaudio/avaudiosession/mode
|
||||
enum AppleAudioMode {
|
||||
default_,
|
||||
gameChat,
|
||||
measurement,
|
||||
moviePlayback,
|
||||
spokenAudio,
|
||||
videoChat,
|
||||
videoRecording,
|
||||
voiceChat,
|
||||
voicePrompt,
|
||||
}
|
||||
|
||||
extension AppleAudioCategoryExt on AppleAudioCategory {
|
||||
String toStringValue() => <AppleAudioCategory, String>{
|
||||
AppleAudioCategory.soloAmbient: 'soloAmbient',
|
||||
AppleAudioCategory.playback: 'playback',
|
||||
AppleAudioCategory.record: 'record',
|
||||
AppleAudioCategory.playAndRecord: 'playAndRecord',
|
||||
AppleAudioCategory.multiRoute: 'multiRoute',
|
||||
}[this]!;
|
||||
}
|
||||
|
||||
extension AppleAudioCategoryOptionExt on AppleAudioCategoryOption {
|
||||
String toStringValue() => <AppleAudioCategoryOption, String>{
|
||||
AppleAudioCategoryOption.mixWithOthers: 'mixWithOthers',
|
||||
AppleAudioCategoryOption.duckOthers: 'duckOthers',
|
||||
AppleAudioCategoryOption.interruptSpokenAudioAndMixWithOthers:
|
||||
'interruptSpokenAudioAndMixWithOthers',
|
||||
AppleAudioCategoryOption.allowBluetooth: 'allowBluetooth',
|
||||
AppleAudioCategoryOption.allowBluetoothA2DP: 'allowBluetoothA2DP',
|
||||
AppleAudioCategoryOption.allowAirPlay: 'allowAirPlay',
|
||||
AppleAudioCategoryOption.defaultToSpeaker: 'defaultToSpeaker',
|
||||
}[this]!;
|
||||
}
|
||||
|
||||
extension AppleAudioModeExt on AppleAudioMode {
|
||||
String toStringValue() => <AppleAudioMode, String>{
|
||||
AppleAudioMode.default_: 'default',
|
||||
AppleAudioMode.gameChat: 'gameChat',
|
||||
AppleAudioMode.measurement: 'measurement',
|
||||
AppleAudioMode.moviePlayback: 'moviePlayback',
|
||||
AppleAudioMode.spokenAudio: 'spokenAudio',
|
||||
AppleAudioMode.videoChat: 'videoChat',
|
||||
AppleAudioMode.videoRecording: 'videoRecording',
|
||||
AppleAudioMode.voiceChat: 'voiceChat',
|
||||
AppleAudioMode.voicePrompt: 'voicePrompt',
|
||||
}[this]!;
|
||||
}
|
||||
|
||||
class NativeAudioConfiguration {
|
||||
final AppleAudioCategory? appleAudioCategory;
|
||||
final Set<AppleAudioCategoryOption>? appleAudioCategoryOptions;
|
||||
final AppleAudioMode? appleAudioMode;
|
||||
|
||||
NativeAudioConfiguration({
|
||||
// for iOS / Mac
|
||||
this.appleAudioCategory,
|
||||
this.appleAudioCategoryOptions,
|
||||
this.appleAudioMode,
|
||||
// Android options
|
||||
// ...
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() => <String, dynamic>{
|
||||
if (appleAudioCategory != null) 'appleAudioCategory': appleAudioCategory!.toStringValue(),
|
||||
if (appleAudioCategoryOptions != null)
|
||||
'appleAudioCategoryOptions':
|
||||
appleAudioCategoryOptions!.map((e) => e.toStringValue()).toList(),
|
||||
if (appleAudioMode != null) 'appleAudioMode': appleAudioMode!.toStringValue(),
|
||||
};
|
||||
|
||||
NativeAudioConfiguration copyWith({
|
||||
AppleAudioCategory? appleAudioCategory,
|
||||
Set<AppleAudioCategoryOption>? appleAudioCategoryOptions,
|
||||
AppleAudioMode? appleAudioMode,
|
||||
}) =>
|
||||
NativeAudioConfiguration(
|
||||
appleAudioCategory: appleAudioCategory ?? this.appleAudioCategory,
|
||||
appleAudioCategoryOptions: appleAudioCategoryOptions ?? this.appleAudioCategoryOptions,
|
||||
appleAudioMode: appleAudioMode ?? this.appleAudioMode,
|
||||
);
|
||||
}
|
||||
|
||||
const _lkMethodChannel = MethodChannel('livekit_client');
|
||||
|
||||
Future<bool> configureNativeAudio(NativeAudioConfiguration configuration) async {
|
||||
try {
|
||||
final result = await _lkMethodChannel.invokeMethod<bool>(
|
||||
'configureNativeAudio',
|
||||
configuration.toMap(),
|
||||
);
|
||||
return result == true;
|
||||
} catch (_) {
|
||||
logger.warning('configureAudioSession did throw $_');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' as io;
|
||||
|
||||
import '../../logger.dart';
|
||||
import '../websocket.dart';
|
||||
import '../../extensions.dart';
|
||||
|
||||
Future<LiveKitWebSocketIO> lkWebSocketConnect(
|
||||
Uri uri, [
|
||||
WebSocketEventHandlers? options,
|
||||
]) =>
|
||||
LiveKitWebSocketIO.connect(uri, options);
|
||||
|
||||
class LiveKitWebSocketIO implements LiveKitWebSocket {
|
||||
final io.WebSocket _ws;
|
||||
final WebSocketEventHandlers? options;
|
||||
late final StreamSubscription _subscription;
|
||||
|
||||
LiveKitWebSocketIO._(
|
||||
this._ws, [
|
||||
this.options,
|
||||
]) {
|
||||
_subscription = _ws.listen(
|
||||
(dynamic data) => options?.onData?.call(data),
|
||||
onDone: () => dispose(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _subscription.cancel();
|
||||
await _ws.close();
|
||||
options?.onDispose?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
void send(List<int> data) {
|
||||
// 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED
|
||||
if (_ws.readyState != 1) {
|
||||
logger.fine('[$objectId] Tried to send data (readyState: ${_ws.readyState})');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_ws.add(data);
|
||||
} catch (_) {
|
||||
logger.fine('[$objectId] send did throw ${_}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<LiveKitWebSocketIO> connect(
|
||||
Uri uri, [
|
||||
WebSocketEventHandlers? options,
|
||||
]) async {
|
||||
logger.fine('[WebSocketIO] Connecting(uri: ${uri.toString()})...');
|
||||
try {
|
||||
final ws = await io.WebSocket.connect(uri.toString());
|
||||
logger.fine('[WebSocketIO] Connected');
|
||||
return LiveKitWebSocketIO._(ws, options);
|
||||
} catch (_) {
|
||||
logger.severe('[WebSocketIO] did throw ${_}');
|
||||
throw WebSocketException.connect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'dart:async';
|
||||
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../websocket.dart';
|
||||
|
||||
Future<LiveKitWebSocketWeb> lkWebSocketConnect(
|
||||
Uri uri, [
|
||||
WebSocketEventHandlers? options,
|
||||
]) =>
|
||||
LiveKitWebSocketWeb.connect(uri, options);
|
||||
|
||||
class LiveKitWebSocketWeb implements LiveKitWebSocket {
|
||||
final html.WebSocket _ws;
|
||||
final WebSocketEventHandlers? options;
|
||||
late final StreamSubscription _messageSubscription;
|
||||
late final StreamSubscription _closeSubscription;
|
||||
|
||||
LiveKitWebSocketWeb._(
|
||||
this._ws, [
|
||||
this.options,
|
||||
]) {
|
||||
_ws.binaryType = 'arraybuffer';
|
||||
_messageSubscription = _ws.onMessage.listen((_) {
|
||||
dynamic _data = _.data is ByteBuffer ? _.data.asUint8List() : _.data;
|
||||
options?.onData?.call(_data);
|
||||
});
|
||||
_closeSubscription = _ws.onClose.listen((_) => dispose());
|
||||
}
|
||||
|
||||
@override
|
||||
void send(List<int> data) => _ws.send(data);
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
options?.onDispose?.call();
|
||||
await _messageSubscription.cancel();
|
||||
await _closeSubscription.cancel();
|
||||
_ws.close();
|
||||
}
|
||||
|
||||
static Future<LiveKitWebSocketWeb> connect(
|
||||
Uri uri, [
|
||||
WebSocketEventHandlers? options,
|
||||
]) async {
|
||||
final completer = Completer<LiveKitWebSocketWeb>();
|
||||
final ws = html.WebSocket(uri.toString());
|
||||
ws.onOpen.listen((_) => completer.complete(LiveKitWebSocketWeb._(ws, options)));
|
||||
ws.onError.listen((_) => completer.completeError(WebSocketException.connect()));
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'platforms/io.dart' if (dart.library.html) 'platforms/web.dart';
|
||||
|
||||
class WebSocketException implements Exception {
|
||||
final int code;
|
||||
const WebSocketException._(this.code);
|
||||
|
||||
static WebSocketException unknown() => const WebSocketException._(0);
|
||||
static WebSocketException connect() => const WebSocketException._(1);
|
||||
|
||||
@override
|
||||
String toString() => {
|
||||
WebSocketException.unknown(): 'Unknown error',
|
||||
WebSocketException.connect(): 'Failed to connect',
|
||||
}[this]!;
|
||||
}
|
||||
|
||||
typedef WebSocketOnData = Function(dynamic data);
|
||||
typedef WebSocketOnError = Function(dynamic error);
|
||||
typedef WebSocketOnDispose = Function();
|
||||
|
||||
class WebSocketEventHandlers {
|
||||
final WebSocketOnData? onData;
|
||||
final WebSocketOnError? onError;
|
||||
final WebSocketOnDispose? onDispose;
|
||||
const WebSocketEventHandlers({
|
||||
this.onData,
|
||||
this.onError,
|
||||
this.onDispose,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class LiveKitWebSocket {
|
||||
void send(List<int> data);
|
||||
Future<void> dispose();
|
||||
|
||||
static Future<LiveKitWebSocket> connect(
|
||||
Uri uri, [
|
||||
WebSocketEventHandlers? options,
|
||||
]) =>
|
||||
lkWebSocketConnect(uri, options);
|
||||
}
|
||||
Reference in New Issue
Block a user