feat: promote linux native audio path
This commit is contained in:
@@ -352,7 +352,22 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
|
||||
).firstOrNull { File(it).resolve("cargo").exists() }
|
||||
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
|
||||
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
|
||||
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
|
||||
val hostArch = System.getProperty("os.arch").lowercase()
|
||||
val defaultRustupToolchain = when {
|
||||
org.gradle.internal.os.OperatingSystem.current().isMacOsX && hostArch.contains("aarch64") ->
|
||||
"stable-aarch64-apple-darwin"
|
||||
org.gradle.internal.os.OperatingSystem.current().isMacOsX ->
|
||||
"stable-x86_64-apple-darwin"
|
||||
org.gradle.internal.os.OperatingSystem.current().isWindows && hostArch.contains("aarch64") ->
|
||||
"stable-aarch64-pc-windows-msvc"
|
||||
org.gradle.internal.os.OperatingSystem.current().isWindows ->
|
||||
"stable-x86_64-pc-windows-msvc"
|
||||
hostArch.contains("aarch64") || hostArch.contains("arm64") ->
|
||||
"stable-aarch64-unknown-linux-gnu"
|
||||
else ->
|
||||
"stable-x86_64-unknown-linux-gnu"
|
||||
}
|
||||
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: defaultRustupToolchain
|
||||
|
||||
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
|
||||
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,21 @@ set(BINARY_NAME "chanora_flutter")
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "app.chanora.chanora_flutter")
|
||||
|
||||
# Repository root for Rust bridge builds / bundle staging.
|
||||
set(CHANORA_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../..")
|
||||
|
||||
# Optional path to a Linux ONNX Runtime shared library for Silero VAD.
|
||||
# When set, the bundle carries a local `lib/libonnxruntime.so` sidecar and the
|
||||
# runner exports `ORT_DYLIB_PATH` to that file before Flutter starts.
|
||||
set(CHANORA_ONNXRUNTIME_SHARED_LIB "$ENV{CHANORA_ONNXRUNTIME_SHARED_LIB}" CACHE FILEPATH
|
||||
"Absolute path to libonnxruntime.so for Linux bundle packaging")
|
||||
|
||||
# Optional path to a prebuilt Rust bridge shared library. When unset, the Linux
|
||||
# bundle build runs `cargo build -p chanora_bridge` for the host profile and
|
||||
# stages the resulting `libchanora_bridge.so` automatically.
|
||||
set(CHANORA_BRIDGE_SHARED_LIB "$ENV{CHANORA_BRIDGE_SHARED_LIB}" CACHE FILEPATH
|
||||
"Absolute path to libchanora_bridge.so for Linux bundle packaging")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
@@ -53,6 +68,7 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
find_program(CARGO_EXECUTABLE cargo REQUIRED)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
@@ -60,6 +76,27 @@ add_subdirectory("runner")
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
if(CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
set(CHANORA_BRIDGE_PROFILE_DIR "debug")
|
||||
set(CHANORA_BRIDGE_CARGO_ARGS build --package chanora_bridge)
|
||||
else()
|
||||
set(CHANORA_BRIDGE_PROFILE_DIR "release")
|
||||
set(CHANORA_BRIDGE_CARGO_ARGS build --release --package chanora_bridge)
|
||||
endif()
|
||||
|
||||
if(CHANORA_BRIDGE_SHARED_LIB)
|
||||
set(CHANORA_BRIDGE_STAGED_LIB "${CHANORA_BRIDGE_SHARED_LIB}")
|
||||
else()
|
||||
set(CHANORA_BRIDGE_STAGED_LIB
|
||||
"${CHANORA_REPO_ROOT}/target/${CHANORA_BRIDGE_PROFILE_DIR}/libchanora_bridge.so")
|
||||
add_custom_target(chanora_bridge_bundle
|
||||
COMMAND "${CARGO_EXECUTABLE}" ${CHANORA_BRIDGE_CARGO_ARGS}
|
||||
WORKING_DIRECTORY "${CHANORA_REPO_ROOT}"
|
||||
COMMENT "Building chanora_bridge for Linux bundle"
|
||||
VERBATIM)
|
||||
add_dependencies(${BINARY_NAME} chanora_bridge_bundle)
|
||||
endif()
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
@@ -100,6 +137,29 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
if(EXISTS "${CHANORA_BRIDGE_STAGED_LIB}")
|
||||
install(FILES "${CHANORA_BRIDGE_STAGED_LIB}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
else()
|
||||
message(WARNING
|
||||
"Chanora bridge shared library was not found for Linux bundling: "
|
||||
"${CHANORA_BRIDGE_STAGED_LIB}")
|
||||
endif()
|
||||
|
||||
if(CHANORA_ONNXRUNTIME_SHARED_LIB)
|
||||
if(EXISTS "${CHANORA_ONNXRUNTIME_SHARED_LIB}")
|
||||
install(FILES "${CHANORA_ONNXRUNTIME_SHARED_LIB}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
RENAME "libonnxruntime.so"
|
||||
COMPONENT Runtime)
|
||||
else()
|
||||
message(WARNING
|
||||
"CHANORA_ONNXRUNTIME_SHARED_LIB was set but the file was not found: "
|
||||
"${CHANORA_ONNXRUNTIME_SHARED_LIB}")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
|
||||
@@ -14,6 +14,27 @@ struct _MyApplication {
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
static void configure_onnxruntime_dylib_path() {
|
||||
const gchar* existing = g_getenv("ORT_DYLIB_PATH");
|
||||
if (existing != nullptr && *existing != '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
g_autofree gchar* exe_path = g_file_read_link("/proc/self/exe", nullptr);
|
||||
if (exe_path == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_autofree gchar* exe_dir = g_path_get_dirname(exe_path);
|
||||
g_autofree gchar* ort_path =
|
||||
g_build_filename(exe_dir, "lib", "libonnxruntime.so", nullptr);
|
||||
if (!g_file_test(ort_path, G_FILE_TEST_IS_REGULAR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_setenv("ORT_DYLIB_PATH", ort_path, TRUE);
|
||||
}
|
||||
|
||||
// Called when first Flutter frame received.
|
||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
@@ -110,6 +131,7 @@ static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
configure_onnxruntime_dylib_path();
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ void main() {
|
||||
rust.BridgeClient client({
|
||||
BigInt? id,
|
||||
BigInt? channelId,
|
||||
String uid = '',
|
||||
int talkPower = 0,
|
||||
bool talkPowerGranted = false,
|
||||
bool inputMuted = false,
|
||||
@@ -25,6 +26,7 @@ void main() {
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: id ?? BigInt.from(7),
|
||||
uid: uid,
|
||||
channel: channelId ?? BigInt.one,
|
||||
name: 'Me',
|
||||
inputMuted: inputMuted,
|
||||
|
||||
@@ -18,7 +18,7 @@ void main() {
|
||||
expect(result.issues, isEmpty);
|
||||
});
|
||||
|
||||
test('missing SDL2 is reported as required on Debian-like systems', () async {
|
||||
test('missing Linux audio runtimes are reported as recommended', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => false,
|
||||
@@ -31,11 +31,19 @@ void main() {
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.hasIssues, isTrue);
|
||||
final sdl = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-sdl2-runtime',
|
||||
final pipewire = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||
);
|
||||
expect(sdl.isRequired, isTrue);
|
||||
expect(sdl.installHints.single.command, 'sudo apt install libsdl2-2.0-0');
|
||||
final pulse = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-pulseaudio-runtime',
|
||||
);
|
||||
expect(pipewire.isRequired, isFalse);
|
||||
expect(pulse.isRequired, isFalse);
|
||||
expect(
|
||||
pipewire.installHints.single.command,
|
||||
'sudo apt install libpipewire-0.3-0',
|
||||
);
|
||||
expect(pulse.installHints.single.command, 'sudo apt install libpulse0');
|
||||
});
|
||||
|
||||
test('distro detection parses quoted ID_LIKE lists', () async {
|
||||
@@ -51,18 +59,22 @@ void main() {
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.platformLabel, 'Fedora');
|
||||
final sdl = result.issues.singleWhere(
|
||||
(issue) => issue.id == 'linux-sdl2-runtime',
|
||||
final pipewire = result.issues.singleWhere(
|
||||
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||
);
|
||||
expect(
|
||||
pipewire.installHints.single.command,
|
||||
'sudo dnf install pipewire-libs',
|
||||
);
|
||||
expect(sdl.installHints.single.command, 'sudo dnf install SDL2');
|
||||
});
|
||||
|
||||
test(
|
||||
'missing ONNX runtime is reported as recommended when SDL2 is present',
|
||||
'missing ONNX runtime is reported as recommended when Linux audio runtimes are present',
|
||||
() async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => candidate.contains('SDL2'),
|
||||
libraryProbe: (candidate) =>
|
||||
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||
fileExists: (_) async => false,
|
||||
osReleaseProvider: () async => 'ID=fedora\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
@@ -106,7 +118,8 @@ void main() {
|
||||
test('bundle-local ONNX runtime clears the recommendation', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => candidate.contains('SDL2'),
|
||||
libraryProbe: (candidate) =>
|
||||
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
@@ -130,11 +143,11 @@ void main() {
|
||||
platformLabel: 'Fedora',
|
||||
issues: [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-sdl2-runtime',
|
||||
title: 'SDL2 runtime is missing',
|
||||
summary: 'Audio playback needs SDL2.',
|
||||
details: ['Install SDL2 and recheck.'],
|
||||
severity: StartupDependencySeverity.required,
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary: 'Primary Linux audio needs PipeWire.',
|
||||
details: ['Install PipeWire and recheck.'],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -146,7 +159,9 @@ void main() {
|
||||
expect(text, contains('platform="Fedora"'));
|
||||
expect(
|
||||
text,
|
||||
contains('issues=[linux-sdl2-runtime:required:SDL2 runtime is missing]'),
|
||||
contains(
|
||||
'issues=[linux-pipewire-runtime:recommended:PipeWire runtime is missing]',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,4 +39,79 @@ void main() {
|
||||
|
||||
expect(await service.hasExplainedPermissions(), isTrue);
|
||||
});
|
||||
|
||||
test('stores per-user playback preferences per server host', () async {
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'Example.COM',
|
||||
userUid: 'user-a',
|
||||
volume: 0.5,
|
||||
muted: true,
|
||||
);
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'other.example',
|
||||
userUid: 'user-a',
|
||||
volume: 2.0,
|
||||
muted: false,
|
||||
);
|
||||
|
||||
final examplePrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
final otherPrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'other.example',
|
||||
);
|
||||
|
||||
expect(examplePrefs['user-a']?.volume, 0.5);
|
||||
expect(examplePrefs['user-a']?.muted, isTrue);
|
||||
expect(otherPrefs['user-a']?.volume, 2.0);
|
||||
expect(otherPrefs['user-a']?.muted, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'drops default playback preferences instead of persisting them',
|
||||
() async {
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'example.com',
|
||||
userUid: 'user-a',
|
||||
volume: 0.2,
|
||||
muted: true,
|
||||
);
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'example.com',
|
||||
userUid: 'user-a',
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
);
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
expect(prefs, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('ignores corrupt playback preference JSON', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'audio.client_playback_prefs': '{not-json',
|
||||
});
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
|
||||
expect(prefs, isEmpty);
|
||||
});
|
||||
|
||||
test('clamps invalid playback volumes to unity', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'audio.client_playback_prefs':
|
||||
'{"example.com|user-a":{"volume":"invalid","muted":false}}',
|
||||
});
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
|
||||
expect(prefs['user-a']?.volume, 1.0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,10 +36,12 @@ void main() {
|
||||
required BigInt id,
|
||||
required String name,
|
||||
required BigInt channelId,
|
||||
String uid = '',
|
||||
bool isServerQuery = false,
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: id,
|
||||
uid: uid,
|
||||
channel: channelId,
|
||||
name: name,
|
||||
inputMuted: false,
|
||||
@@ -496,7 +498,15 @@ void main() {
|
||||
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
|
||||
final serverTileSize = tester.getSize(
|
||||
find
|
||||
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
|
||||
.ancestor(
|
||||
of: find.byIcon(Icons.dns_outlined),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is SizedBox &&
|
||||
widget.width == 92 &&
|
||||
widget.height == 92,
|
||||
),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
|
||||
@@ -506,6 +516,95 @@ void main() {
|
||||
expect(serverTileSize.height, 92);
|
||||
});
|
||||
|
||||
testWidgets('chat sidebar uses MD3-style selected container colors', (
|
||||
tester,
|
||||
) async {
|
||||
final theme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: ChatPage(
|
||||
messages: const [],
|
||||
snapshot: snapshot(channels: const [], clients: const []),
|
||||
initialTarget: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final serverIndicator = find.ancestor(
|
||||
of: find.byIcon(Icons.dns_outlined),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final channelIndicator = find.ancestor(
|
||||
of: find.byIcon(Icons.tag),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
|
||||
final serverDecoration =
|
||||
tester.widget<AnimatedContainer>(serverIndicator).decoration
|
||||
as BoxDecoration;
|
||||
final channelDecoration =
|
||||
tester.widget<AnimatedContainer>(channelIndicator).decoration
|
||||
as BoxDecoration;
|
||||
final serverLabel = tester.widget<Text>(find.text('Server').last);
|
||||
final channelLabel = tester.widget<Text>(find.text('Channel'));
|
||||
|
||||
expect(tester.getSize(serverIndicator), const Size(76, 76));
|
||||
expect(tester.getSize(channelIndicator), const Size(76, 76));
|
||||
expect(serverDecoration.color, theme.colorScheme.primaryContainer);
|
||||
expect(channelDecoration.color, Colors.transparent);
|
||||
expect(serverLabel.style?.color, theme.colorScheme.onPrimaryContainer);
|
||||
expect(channelLabel.style?.color, theme.colorScheme.onSurfaceVariant);
|
||||
});
|
||||
|
||||
testWidgets('long private chat labels stay within fixed rail indicator', (
|
||||
tester,
|
||||
) async {
|
||||
const longName = 'Very Long Private Chat Name That Should Not Stretch';
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
senderId: BigInt.from(2),
|
||||
senderName: longName,
|
||||
message: 'Private',
|
||||
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||
),
|
||||
],
|
||||
snapshot: snapshot(
|
||||
channels: const [],
|
||||
clients: [
|
||||
client(
|
||||
id: BigInt.from(2),
|
||||
name: longName,
|
||||
channelId: BigInt.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||
initialClientName: longName,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final selectedIndicator = find.ancestor(
|
||||
of: find.text(longName).first,
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final privateLabel = tester.widget<Text>(find.text(longName).first);
|
||||
|
||||
expect(tester.getSize(selectedIndicator), const Size(76, 76));
|
||||
expect(privateLabel.maxLines, 2);
|
||||
expect(privateLabel.overflow, TextOverflow.ellipsis);
|
||||
expect(privateLabel.textAlign, TextAlign.center);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'plus opens user picker and close removes selected private chat',
|
||||
(tester) async {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/services/ui_preferences_service.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/widgets/snapshot_view.dart';
|
||||
|
||||
@@ -28,12 +30,14 @@ void main() {
|
||||
required int id,
|
||||
required int channelId,
|
||||
required String name,
|
||||
String uid = '',
|
||||
bool speaking = false,
|
||||
int talkPower = 0,
|
||||
bool talkPowerGranted = false,
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: BigInt.from(id),
|
||||
uid: uid,
|
||||
channel: BigInt.from(channelId),
|
||||
name: name,
|
||||
inputMuted: false,
|
||||
@@ -51,6 +55,8 @@ void main() {
|
||||
BigInt? ownClientId,
|
||||
BigInt? currentVoiceChannelId,
|
||||
rust.BridgeAudioStats? audioStats,
|
||||
Map<String, ClientPlaybackPreference> clientPlaybackPreferences = const {},
|
||||
ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
@@ -75,6 +81,8 @@ void main() {
|
||||
canJoinVoiceChannel: true,
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
clientPlaybackPreferences: clientPlaybackPreferences,
|
||||
onClientPlaybackPreferenceChanged: onClientPlaybackPreferenceChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -262,6 +270,122 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('remote client playback options button opens playback sheet', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
clientPlaybackPreferences: const {
|
||||
'user-b': ClientPlaybackPreference(volume: 0.5, muted: false),
|
||||
},
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.more_horiz));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Per-user playback'), findsOneWidget);
|
||||
expect(find.text('Mute playback'), findsOneWidget);
|
||||
expect(find.text('Volume 50%'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('client-playback-volume-slider')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('remote client long press opens playback sheet', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Per-user playback'), findsOneWidget);
|
||||
expect(find.text('Mute playback'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('remote client secondary click opens playback sheet', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithText(ListTile, 'Bob'),
|
||||
buttons: kSecondaryButton,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Per-user playback'), findsOneWidget);
|
||||
expect(find.text('Mute playback'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('client playback sheet forwards mute and volume changes', (
|
||||
tester,
|
||||
) async {
|
||||
final changes = <ClientPlaybackPreference>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, preference) async {
|
||||
changes.add(preference);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.more_horiz));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Mute playback'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changes, isNotEmpty);
|
||||
expect(changes.last.muted, isTrue);
|
||||
|
||||
await tester.drag(
|
||||
find.byKey(const Key('client-playback-volume-slider')),
|
||||
const Offset(160, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changes.last.volume, greaterThan(1.0));
|
||||
});
|
||||
|
||||
testWidgets('spacer channels render as layout rows and keep channel taps', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -17,11 +17,11 @@ void main() {
|
||||
platformLabel: 'Fedora',
|
||||
issues: const [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-sdl2-runtime',
|
||||
title: 'SDL2 runtime is missing',
|
||||
summary: 'Audio playback needs SDL2.',
|
||||
details: ['Install SDL2 and recheck.'],
|
||||
severity: StartupDependencySeverity.required,
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary: 'Primary Linux audio needs PipeWire.',
|
||||
details: ['Install PipeWire and recheck.'],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: [
|
||||
StartupInstallHint(
|
||||
label: 'Release downloads',
|
||||
@@ -29,7 +29,7 @@ void main() {
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install SDL2',
|
||||
command: 'sudo dnf install pipewire-libs',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -52,14 +52,14 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Finish Linux setup'), findsOneWidget);
|
||||
expect(find.text('SDL2 runtime is missing'), findsOneWidget);
|
||||
expect(find.text('Continue with limited mode'), findsOneWidget);
|
||||
expect(find.text('PipeWire runtime is missing'), findsOneWidget);
|
||||
expect(find.text('Continue anyway'), findsOneWidget);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
expect(find.text('Copy'), findsOneWidget);
|
||||
expect(loggedResults, [result]);
|
||||
|
||||
await tester.ensureVisible(find.text('Continue with limited mode'));
|
||||
await tester.tap(find.text('Continue with limited mode'));
|
||||
await tester.ensureVisible(find.text('Continue anyway'));
|
||||
await tester.tap(find.text('Continue anyway'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
|
||||
Reference in New Issue
Block a user