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
@@ -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,