feat: promote linux native audio path

This commit is contained in:
Edison Jwa
2026-05-25 17:42:06 +09:00
parent c19de3a370
commit a2d686d9d0
73 changed files with 26156 additions and 467 deletions
@@ -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(
+106
View File
@@ -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,
+60
View File
@@ -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);