feat: promote linux native audio path
This commit is contained in:
@@ -171,7 +171,7 @@ List<PlatformCapability> _windowsCapabilities() => [
|
||||
List<PlatformCapability> _linuxCapabilities() => [
|
||||
const PlatformCapability(
|
||||
feature: 'Voice capture',
|
||||
description: 'PulseAudio/ALSA via cpal.',
|
||||
description: 'PipeWire voice I/O with PulseAudio fallback.',
|
||||
tier: CapabilityTier.supported,
|
||||
),
|
||||
const PlatformCapability(
|
||||
|
||||
@@ -226,6 +226,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
AndroidPermissionsService();
|
||||
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
||||
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
||||
Map<String, ClientPlaybackPreference> _clientPlaybackPrefs = const {};
|
||||
final Map<BigInt, double> _appliedClientPlaybackVolumes = {};
|
||||
String _clientPlaybackPrefsHost = '';
|
||||
late final BackIntentService _backIntentService;
|
||||
int _modalRouteDepth = 0;
|
||||
|
||||
@@ -314,6 +317,105 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _ensureClientPlaybackPreferencesLoadedForCurrentHost() async {
|
||||
final host = _hostCtl.text.trim().toLowerCase();
|
||||
if (host.isEmpty) {
|
||||
_clientPlaybackPrefsHost = '';
|
||||
_clientPlaybackPrefs = const {};
|
||||
_appliedClientPlaybackVolumes.clear();
|
||||
return;
|
||||
}
|
||||
if (_clientPlaybackPrefsHost == host) return;
|
||||
_appliedClientPlaybackVolumes.clear();
|
||||
try {
|
||||
_clientPlaybackPrefs = await _uiPreferences
|
||||
.loadClientPlaybackPreferencesForServer(host);
|
||||
_clientPlaybackPrefsHost = host;
|
||||
} catch (_) {
|
||||
_clientPlaybackPrefsHost = host;
|
||||
_clientPlaybackPrefs = const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyClientPlaybackPreferencesToSnapshot(
|
||||
rust.BridgeSnapshot snap,
|
||||
) async {
|
||||
await _ensureClientPlaybackPreferencesLoadedForCurrentHost();
|
||||
if (_clientPlaybackPrefs.isEmpty) return;
|
||||
for (final client in snap.clients) {
|
||||
if (client.id == snap.ownClientId ||
|
||||
client.isServerQuery ||
|
||||
client.uid.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final pref = _clientPlaybackPrefs[client.uid];
|
||||
if (pref == null) continue;
|
||||
if (_appliedClientPlaybackVolumes[client.id] == pref.appliedVolume) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await rust.setClientVolume(
|
||||
clientId: client.id,
|
||||
volume: pref.appliedVolume,
|
||||
);
|
||||
_appliedClientPlaybackVolumes[client.id] = pref.appliedVolume;
|
||||
} catch (_) {
|
||||
// Best-effort: queues may not exist yet for silent users.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setClientPlaybackPreference(
|
||||
rust.BridgeClient client,
|
||||
ClientPlaybackPreference preference,
|
||||
) async {
|
||||
if (client.id == _snapshot?.ownClientId ||
|
||||
client.isServerQuery ||
|
||||
client.uid.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final host = _hostCtl.text.trim().toLowerCase();
|
||||
if (host.isEmpty) return;
|
||||
|
||||
await _ensureClientPlaybackPreferencesLoadedForCurrentHost();
|
||||
final nextPrefs = Map<String, ClientPlaybackPreference>.from(
|
||||
_clientPlaybackPrefs,
|
||||
);
|
||||
if (preference.volume == 1.0 && !preference.muted) {
|
||||
nextPrefs.remove(client.uid);
|
||||
} else {
|
||||
nextPrefs[client.uid] = preference;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _clientPlaybackPrefs = nextPrefs);
|
||||
} else {
|
||||
_clientPlaybackPrefs = nextPrefs;
|
||||
}
|
||||
|
||||
try {
|
||||
await _uiPreferences.saveClientPlaybackPreference(
|
||||
serverHost: host,
|
||||
userUid: client.uid,
|
||||
volume: preference.volume,
|
||||
muted: preference.muted,
|
||||
);
|
||||
} catch (error) {
|
||||
_recordUiDiagnostic('save client playback preference', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await rust.setClientVolume(
|
||||
clientId: client.id,
|
||||
volume: preference.appliedVolume,
|
||||
);
|
||||
_appliedClientPlaybackVolumes[client.id] = preference.appliedVolume;
|
||||
} catch (error) {
|
||||
_recordUiDiagnostic('set client volume', error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestRecordAudioOnStartup() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -1482,6 +1584,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
void _applySnapshot(rust.BridgeSnapshot snap) {
|
||||
_snapshot = snap;
|
||||
unawaited(_applyClientPlaybackPreferencesToSnapshot(snap));
|
||||
final own = ownClientSnapshotState(snap);
|
||||
if (own == null) return;
|
||||
_inputMuted = own.inputMuted;
|
||||
@@ -2013,6 +2116,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
onJoinChannel: (ch) => _onJoinChannel(ch),
|
||||
onJoinChannelWithPassword: (ch) =>
|
||||
_onJoinChannel(ch, askForPassword: true),
|
||||
clientPlaybackPreferences: _clientPlaybackPrefs,
|
||||
onClientPlaybackPreferenceChanged:
|
||||
_setClientPlaybackPreference,
|
||||
onTs3ServerLink: _onTs3ServerLink,
|
||||
);
|
||||
final ownClientState = _ownClientState;
|
||||
|
||||
@@ -18,17 +18,19 @@ Future<void>? _storageInitFuture;
|
||||
Future<void>? _vadBootstrapFuture;
|
||||
StorageDirectoryProvider _storageDirectoryProvider =
|
||||
getApplicationSupportDirectory;
|
||||
StorageDirectoryProvider _vadModelDirectoryProvider =
|
||||
getApplicationSupportDirectory;
|
||||
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||
|
||||
Future<void> _defaultStorageInitializer(String dir) {
|
||||
return rust.initStorage(dir: dir);
|
||||
}
|
||||
|
||||
Future<File> _copyBundledAssetToDocuments({
|
||||
Future<File> _copyBundledAssetToManagedDirectory({
|
||||
required String assetPath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dir = await _vadModelDirectoryProvider();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
final data = await rootBundle.load(assetPath);
|
||||
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
@@ -59,7 +61,7 @@ Future<void> configureBundledVadModels() async {
|
||||
}
|
||||
|
||||
Future<void> _configureBundledVadModelsImpl() async {
|
||||
final silero = await _copyBundledAssetToDocuments(
|
||||
final silero = await _copyBundledAssetToManagedDirectory(
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
@@ -124,12 +126,15 @@ Future<void> _wireStorageImpl() async {
|
||||
@visibleForTesting
|
||||
void debugResetStorageBootstrap({
|
||||
StorageDirectoryProvider? storageDirectoryProvider,
|
||||
StorageDirectoryProvider? vadModelDirectoryProvider,
|
||||
StorageInitializer? storageInitializer,
|
||||
}) {
|
||||
_storageInitFuture = null;
|
||||
_vadBootstrapFuture = null;
|
||||
_storageDirectoryProvider =
|
||||
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||
_vadModelDirectoryProvider =
|
||||
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
|
||||
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||
}
|
||||
|
||||
|
||||
@@ -117,8 +117,20 @@ Future<StartupDependencyCheckResult> checkStartupDependencies() async {
|
||||
final executableDir = File(_resolvedExecutableProvider()).parent.path;
|
||||
final issues = <StartupDependencyIssue>[];
|
||||
|
||||
if (!_hasAnyLoadableLibrary(const ['libSDL2.so', 'libSDL2-2.0.so.0'])) {
|
||||
issues.add(_buildSdlIssue(distro));
|
||||
if (!_hasAnyLoadableLibrary(const [
|
||||
'libpipewire-0.3.so.0',
|
||||
'libpipewire-0.3.so',
|
||||
])) {
|
||||
issues.add(_buildPipeWireIssue(distro));
|
||||
}
|
||||
|
||||
if (!_hasAnyLoadableLibrary(const [
|
||||
'libpulse.so.0',
|
||||
'libpulse.so',
|
||||
'libpulse-simple.so.0',
|
||||
'libpulse-simple.so',
|
||||
])) {
|
||||
issues.add(_buildPulseAudioIssue(distro));
|
||||
}
|
||||
|
||||
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
|
||||
@@ -272,33 +284,76 @@ _LinuxArch _detectLinuxArch() {
|
||||
};
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildSdlIssue(_LinuxDistro distro) {
|
||||
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
|
||||
final List<StartupInstallHint> hints = switch (distro) {
|
||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Debian / Ubuntu',
|
||||
command: 'sudo apt install libsdl2-2.0-0',
|
||||
command: 'sudo apt install libpipewire-0.3-0',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||
StartupInstallHint(label: 'Fedora', command: 'sudo dnf install SDL2'),
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pipewire-libs',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||
StartupInstallHint(label: 'Arch Linux', command: 'sudo pacman -S sdl2'),
|
||||
StartupInstallHint(
|
||||
label: 'Arch Linux',
|
||||
command: 'sudo pacman -S pipewire',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||
};
|
||||
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-sdl2-runtime',
|
||||
title: 'SDL2 runtime is missing',
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary:
|
||||
'Chanora uses SDL2 for Linux audio playback. Without it, voice output will not work.',
|
||||
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
|
||||
details: const [
|
||||
'Install the SDL2 runtime package for your distribution.',
|
||||
'Install the PipeWire runtime package for your distribution.',
|
||||
'After installing it, restart Chanora and tap Recheck.',
|
||||
],
|
||||
severity: StartupDependencySeverity.required,
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: hints,
|
||||
);
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
|
||||
final List<StartupInstallHint> hints = switch (distro) {
|
||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Debian / Ubuntu',
|
||||
command: 'sudo apt install libpulse0',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pulseaudio-libs',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Arch Linux',
|
||||
command: 'sudo pacman -S libpulse',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||
};
|
||||
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-pulseaudio-runtime',
|
||||
title: 'PulseAudio runtime is missing',
|
||||
summary:
|
||||
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
|
||||
details: const [
|
||||
'Install the PulseAudio client library package for your distribution.',
|
||||
'After installing it, restart Chanora and tap Recheck.',
|
||||
],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: hints,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class UiSettings {
|
||||
@@ -7,10 +9,45 @@ class UiSettings {
|
||||
final String nickname;
|
||||
}
|
||||
|
||||
class ClientPlaybackPreference {
|
||||
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
|
||||
|
||||
final double volume;
|
||||
final bool muted;
|
||||
|
||||
double get appliedVolume => muted ? 0.0 : volume;
|
||||
|
||||
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
|
||||
return ClientPlaybackPreference(
|
||||
volume: _clampVolume(volume ?? this.volume),
|
||||
muted: muted ?? this.muted,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object> toJson() => {
|
||||
'volume': _clampVolume(volume),
|
||||
'muted': muted,
|
||||
};
|
||||
|
||||
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
|
||||
final volume = json['volume'];
|
||||
return ClientPlaybackPreference(
|
||||
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
|
||||
muted: json['muted'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
static double _clampVolume(double volume) {
|
||||
if (!volume.isFinite) return 1.0;
|
||||
return volume.clamp(0.0, 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
class UiPreferencesService {
|
||||
static const _hostKey = 'ui.host';
|
||||
static const _nicknameKey = 'ui.nickname';
|
||||
static const _permissionsExplainedKey = 'perms_explained';
|
||||
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
|
||||
|
||||
const UiPreferencesService();
|
||||
|
||||
@@ -37,4 +74,80 @@ class UiPreferencesService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_permissionsExplainedKey, true);
|
||||
}
|
||||
|
||||
Future<Map<String, ClientPlaybackPreference>>
|
||||
loadClientPlaybackPreferencesForServer(String serverHost) async {
|
||||
final normalizedHost = _normalizeServerHost(serverHost);
|
||||
if (normalizedHost.isEmpty) return const {};
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||
if (raw == null || raw.isEmpty) return const {};
|
||||
|
||||
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||
|
||||
final result = <String, ClientPlaybackPreference>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
final (host, uid) = _splitCompositeKey(key);
|
||||
if (host != normalizedHost ||
|
||||
uid.isEmpty ||
|
||||
value is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
result[uid] = ClientPlaybackPreference.fromJson(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> saveClientPlaybackPreference({
|
||||
required String serverHost,
|
||||
required String userUid,
|
||||
double? volume,
|
||||
bool? muted,
|
||||
}) async {
|
||||
final normalizedHost = _normalizeServerHost(serverHost);
|
||||
final normalizedUid = userUid.trim();
|
||||
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final current = await loadClientPlaybackPreferencesForServer(serverHost);
|
||||
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
|
||||
final updated = previous.copyWith(volume: volume, muted: muted);
|
||||
|
||||
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
|
||||
|
||||
if (updated.volume == 1.0 && !updated.muted) {
|
||||
decoded.remove(compositeKey);
|
||||
} else {
|
||||
decoded[compositeKey] = updated.toJson();
|
||||
}
|
||||
|
||||
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
|
||||
}
|
||||
|
||||
String _compositeKey(String normalizedHost, String normalizedUid) {
|
||||
return '$normalizedHost|$normalizedUid';
|
||||
}
|
||||
|
||||
(String, String) _splitCompositeKey(String key) {
|
||||
final index = key.indexOf('|');
|
||||
if (index == -1) return ('', '');
|
||||
return (key.substring(0, index), key.substring(index + 1));
|
||||
}
|
||||
|
||||
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
|
||||
|
||||
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return <String, dynamic>{};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
|
||||
} catch (_) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,6 +795,9 @@ class BridgeClient {
|
||||
/// Stable client id.
|
||||
final BigInt id;
|
||||
|
||||
/// Stable TeamSpeak unique identifier.
|
||||
final String uid;
|
||||
|
||||
/// Channel id the client is currently in.
|
||||
final BigInt channel;
|
||||
|
||||
@@ -821,6 +824,7 @@ class BridgeClient {
|
||||
|
||||
const BridgeClient({
|
||||
required this.id,
|
||||
required this.uid,
|
||||
required this.channel,
|
||||
required this.name,
|
||||
required this.inputMuted,
|
||||
@@ -834,6 +838,7 @@ class BridgeClient {
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
uid.hashCode ^
|
||||
channel.hashCode ^
|
||||
name.hashCode ^
|
||||
inputMuted.hashCode ^
|
||||
@@ -849,6 +854,7 @@ class BridgeClient {
|
||||
other is BridgeClient &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
uid == other.uid &&
|
||||
channel == other.channel &&
|
||||
name == other.name &&
|
||||
inputMuted == other.inputMuted &&
|
||||
|
||||
@@ -1751,18 +1751,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
BridgeClient dco_decode_bridge_client(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 9)
|
||||
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
|
||||
if (arr.length != 10)
|
||||
throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
|
||||
return BridgeClient(
|
||||
id: dco_decode_u_64(arr[0]),
|
||||
channel: dco_decode_u_64(arr[1]),
|
||||
name: dco_decode_String(arr[2]),
|
||||
inputMuted: dco_decode_bool(arr[3]),
|
||||
outputMuted: dco_decode_bool(arr[4]),
|
||||
isSpeaking: dco_decode_bool(arr[5]),
|
||||
isServerQuery: dco_decode_bool(arr[6]),
|
||||
talkPower: dco_decode_i_32(arr[7]),
|
||||
talkPowerGranted: dco_decode_bool(arr[8]),
|
||||
uid: dco_decode_String(arr[1]),
|
||||
channel: dco_decode_u_64(arr[2]),
|
||||
name: dco_decode_String(arr[3]),
|
||||
inputMuted: dco_decode_bool(arr[4]),
|
||||
outputMuted: dco_decode_bool(arr[5]),
|
||||
isSpeaking: dco_decode_bool(arr[6]),
|
||||
isServerQuery: dco_decode_bool(arr[7]),
|
||||
talkPower: dco_decode_i_32(arr[8]),
|
||||
talkPowerGranted: dco_decode_bool(arr[9]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2352,6 +2353,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var var_id = sse_decode_u_64(deserializer);
|
||||
var var_uid = sse_decode_String(deserializer);
|
||||
var var_channel = sse_decode_u_64(deserializer);
|
||||
var var_name = sse_decode_String(deserializer);
|
||||
var var_inputMuted = sse_decode_bool(deserializer);
|
||||
@@ -2362,6 +2364,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_talkPowerGranted = sse_decode_bool(deserializer);
|
||||
return BridgeClient(
|
||||
id: var_id,
|
||||
uid: var_uid,
|
||||
channel: var_channel,
|
||||
name: var_name,
|
||||
inputMuted: var_inputMuted,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -6,10 +8,17 @@ import '../services/channel_spacer.dart';
|
||||
import '../services/link_trust_service.dart';
|
||||
import '../services/snapshot_state_mapper.dart';
|
||||
import '../services/ts3_server_link.dart';
|
||||
import '../services/ui_preferences_service.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
import 'talk_power_warning.dart';
|
||||
|
||||
typedef ClientPlaybackPreferenceChanged =
|
||||
Future<void> Function(
|
||||
rust.BridgeClient client,
|
||||
ClientPlaybackPreference preference,
|
||||
);
|
||||
|
||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
||||
class SnapshotView extends StatefulWidget {
|
||||
/// Construct a snapshot view.
|
||||
@@ -25,6 +34,8 @@ class SnapshotView extends StatefulWidget {
|
||||
required this.canJoinVoiceChannel,
|
||||
required this.onJoinChannel,
|
||||
required this.onJoinChannelWithPassword,
|
||||
this.clientPlaybackPreferences = const {},
|
||||
this.onClientPlaybackPreferenceChanged,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
@@ -58,6 +69,12 @@ class SnapshotView extends StatefulWidget {
|
||||
/// Join a password-protected channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
||||
|
||||
/// Persisted per-client playback preferences keyed by TeamSpeak UID.
|
||||
final Map<String, ClientPlaybackPreference> clientPlaybackPreferences;
|
||||
|
||||
/// Apply an updated per-client playback preference.
|
||||
final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged;
|
||||
|
||||
/// Handle TeamSpeak server links embedded in server-provided text.
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
|
||||
@@ -293,21 +310,41 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
],
|
||||
)
|
||||
: null;
|
||||
final canAdjustPlayback = _canAdjustClientPlayback(client);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: channelIndent + _userRowStartIndent,
|
||||
right: 8,
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
decoration: decoration,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: status.icon,
|
||||
title: Text(client.name, style: nameStyle),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: canAdjustPlayback
|
||||
? () =>
|
||||
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
|
||||
: null,
|
||||
onSecondaryTap: canAdjustPlayback
|
||||
? () =>
|
||||
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
|
||||
: null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOut,
|
||||
decoration: decoration,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: status.icon,
|
||||
title: Text(client.name, style: nameStyle),
|
||||
trailing: canAdjustPlayback
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
tooltip: 'Playback options',
|
||||
onPressed: () =>
|
||||
unawaited(_showClientPlaybackSheet(client)),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -352,6 +389,100 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
});
|
||||
}
|
||||
|
||||
bool _canAdjustClientPlayback(rust.BridgeClient client) {
|
||||
return widget.onClientPlaybackPreferenceChanged != null &&
|
||||
client.id != widget.snapshot.ownClientId &&
|
||||
!client.isServerQuery &&
|
||||
client.uid.isNotEmpty;
|
||||
}
|
||||
|
||||
ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) {
|
||||
return widget.clientPlaybackPreferences[client.uid] ??
|
||||
const ClientPlaybackPreference();
|
||||
}
|
||||
|
||||
Future<void> _showClientPlaybackSheet(
|
||||
rust.BridgeClient client, {
|
||||
bool withHaptic = false,
|
||||
}) async {
|
||||
if (!_canAdjustClientPlayback(client)) return;
|
||||
|
||||
final onChanged = widget.onClientPlaybackPreferenceChanged;
|
||||
if (onChanged == null) return;
|
||||
|
||||
if (withHaptic) {
|
||||
unawaited(HapticFeedback.selectionClick().catchError((_) {}));
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
var preference = _clientPlaybackPreference(client);
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
void updatePreference(ClientPlaybackPreference next) {
|
||||
setModalState(() => preference = next);
|
||||
unawaited(onChanged(client, next));
|
||||
}
|
||||
|
||||
final volumePercent = (preference.volume * 100).round();
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
client.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Per-user playback',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile.adaptive(
|
||||
key: const Key('client-playback-mute-tile'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Mute playback'),
|
||||
subtitle: Text(
|
||||
preference.muted
|
||||
? 'Audio from this user is muted on this server.'
|
||||
: 'Audio from this user plays at $volumePercent%.',
|
||||
),
|
||||
value: preference.muted,
|
||||
onChanged: (muted) =>
|
||||
updatePreference(preference.copyWith(muted: muted)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Volume $volumePercent%',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
Slider(
|
||||
key: const Key('client-playback-volume-slider'),
|
||||
min: 0.0,
|
||||
max: 4.0,
|
||||
divisions: 40,
|
||||
label: '$volumePercent%',
|
||||
value: preference.volume,
|
||||
onChanged: (value) =>
|
||||
updatePreference(preference.copyWith(volume: value)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
||||
ThemeData theme,
|
||||
rust.BridgeClient client,
|
||||
|
||||
Reference in New Issue
Block a user