feat: promote linux native audio path
This commit is contained in:
@@ -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