fix: improve voice and chat controls

This commit is contained in:
Edison Jwa
2026-05-25 18:26:56 +09:00
parent d03ea937e6
commit 487aff4e7c
13 changed files with 351 additions and 147 deletions
@@ -88,6 +88,7 @@ class _SnapshotViewState extends State<SnapshotView> {
static const _channelIconColumnWidth = 24.0;
static const _channelTextGap = 8.0;
static const _userRowStartIndent = 32.0;
static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0];
final _scrollController = ScrollController();
final Map<BigInt, bool> _channelExpandedById = {};
@@ -311,6 +312,7 @@ class _SnapshotViewState extends State<SnapshotView> {
)
: null;
final canAdjustPlayback = _canAdjustClientPlayback(client);
Offset? secondaryTapPosition;
return Padding(
padding: EdgeInsets.only(
@@ -320,12 +322,15 @@ class _SnapshotViewState extends State<SnapshotView> {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: canAdjustPlayback
? () =>
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
: null,
onSecondaryTapDown: canAdjustPlayback
? (details) => secondaryTapPosition = details.globalPosition
: null,
onSecondaryTap: canAdjustPlayback
? () =>
unawaited(_showClientPlaybackSheet(client, withHaptic: true))
? () => unawaited(
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
)
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
@@ -336,14 +341,6 @@ class _SnapshotViewState extends State<SnapshotView> {
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,
),
),
),
@@ -401,8 +398,9 @@ class _SnapshotViewState extends State<SnapshotView> {
const ClientPlaybackPreference();
}
Future<void> _showClientPlaybackSheet(
Future<void> _showClientPlaybackMenu(
rust.BridgeClient client, {
Offset? anchor,
bool withHaptic = false,
}) async {
if (!_canAdjustClientPlayback(client)) return;
@@ -415,72 +413,52 @@ class _SnapshotViewState extends State<SnapshotView> {
}
if (!mounted) return;
var preference = _clientPlaybackPreference(client);
await showModalBottomSheet<void>(
final preference = _clientPlaybackPreference(client);
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final position = anchor ?? overlay.size.center(Offset.zero);
final selected = await showMenu<_ClientPlaybackMenuAction>(
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)),
),
],
),
),
);
},
);
},
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
overlay.size.width - position.dx,
overlay.size.height - position.dy,
),
items: [
PopupMenuItem<_ClientPlaybackMenuAction>(
value: const _ToggleMuteClientPlaybackMenuAction(),
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
preference.muted ? Icons.volume_off : Icons.volume_up,
),
title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'),
subtitle: Text(client.name),
),
),
const PopupMenuDivider(),
..._clientPlaybackVolumePresets.map(
(preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>(
value: _VolumeClientPlaybackMenuAction(preset),
checked:
!preference.muted && (preference.volume - preset).abs() < 0.001,
child: Text('Volume ${(preset * 100).round()}%'),
),
),
],
);
if (selected == null || !mounted) return;
final next = switch (selected) {
_ToggleMuteClientPlaybackMenuAction() => preference.copyWith(
muted: !preference.muted,
),
_VolumeClientPlaybackMenuAction(:final volume) => preference.copyWith(
volume: volume,
muted: false,
),
};
await onChanged(client, next);
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
@@ -695,6 +673,20 @@ class _ChannelTreeNode {
final List<_ChannelTreeNode> children = [];
}
sealed class _ClientPlaybackMenuAction {
const _ClientPlaybackMenuAction();
}
class _ToggleMuteClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _ToggleMuteClientPlaybackMenuAction();
}
class _VolumeClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _VolumeClientPlaybackMenuAction(this.volume);
final double volume;
}
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
final byParent = <BigInt, List<rust.BridgeChannel>>{};
final knownIds = {for (final channel in channels) channel.id};