feat(ui): add per-user volume controls (#17)

This commit is contained in:
Edison Jwa
2026-06-03 18:08:52 +09:00
committed by GitHub
parent 5f7e2f7e97
commit 0381be6964
7 changed files with 489 additions and 49 deletions
+13
View File
@@ -172,6 +172,7 @@
"channelJoinFailedFamilyFull": "Channel family limit reached.",
"channelJoinFailedPrivate": "This channel is private.",
"channelJoinFailedTimeout": "Could not join channel: the server didn't respond in time.",
"channelJoinFailedFlooding": "Too many channel switches. Please wait a moment.",
"channelJoinFailedGeneric": "Could not join channel: {message}",
"@channelJoinFailedGeneric": {
"placeholders": {
@@ -284,5 +285,17 @@
"message": { "type": "String" }
}
},
"clientVolumeAction": "User Volume",
"clientVolumeTitle": "{name} — volume",
"@clientVolumeTitle": {
"placeholders": { "name": { "type": "String" } }
},
"clientVolumeLabel": "{percent}%",
"@clientVolumeLabel": {
"placeholders": { "percent": { "type": "int" } }
},
"clientVolumeMuteAction": "Mute user",
"clientVolumeUnmuteAction": "Unmute user",
"clientVolumeResetAction": "Reset to default",
"permissionDenied": "Permission Denied"
}
+7
View File
@@ -126,6 +126,7 @@
"channelJoinFailedFamilyFull": "频道家族人数已达上限。",
"channelJoinFailedPrivate": "此频道为私有频道。",
"channelJoinFailedTimeout": "无法加入频道:服务器响应超时。",
"channelJoinFailedFlooding": "切换频道过于频繁,请稍后再试。",
"channelJoinFailedGeneric": "无法加入频道:{message}",
"@channelJoinFailedGeneric": {
"placeholders": {
@@ -233,5 +234,11 @@
"message": { "type": "String" }
}
},
"clientVolumeAction": "用户音量",
"clientVolumeTitle": "{name} — 音量",
"clientVolumeLabel": "{percent}%",
"clientVolumeMuteAction": "静音该用户",
"clientVolumeUnmuteAction": "取消静音",
"clientVolumeResetAction": "恢复默认",
"permissionDenied": "权限被拒绝"
}
@@ -799,6 +799,12 @@ abstract class AppL10n {
/// **'Could not join channel: the server didn\'t respond in time.'**
String get channelJoinFailedTimeout;
/// No description provided for @channelJoinFailedFlooding.
///
/// In en, this message translates to:
/// **'Too many channel switches. Please wait a moment.'**
String get channelJoinFailedFlooding;
/// No description provided for @channelJoinFailedGeneric.
///
/// In en, this message translates to:
@@ -1193,6 +1199,42 @@ abstract class AppL10n {
String message,
);
/// No description provided for @clientVolumeAction.
///
/// In en, this message translates to:
/// **'User Volume'**
String get clientVolumeAction;
/// No description provided for @clientVolumeTitle.
///
/// In en, this message translates to:
/// **'{name} — volume'**
String clientVolumeTitle(String name);
/// No description provided for @clientVolumeLabel.
///
/// In en, this message translates to:
/// **'{percent}%'**
String clientVolumeLabel(int percent);
/// No description provided for @clientVolumeMuteAction.
///
/// In en, this message translates to:
/// **'Mute user'**
String get clientVolumeMuteAction;
/// No description provided for @clientVolumeUnmuteAction.
///
/// In en, this message translates to:
/// **'Unmute user'**
String get clientVolumeUnmuteAction;
/// No description provided for @clientVolumeResetAction.
///
/// In en, this message translates to:
/// **'Reset to default'**
String get clientVolumeResetAction;
/// No description provided for @permissionDenied.
///
/// In en, this message translates to:
@@ -398,6 +398,10 @@ class AppL10nEn extends AppL10n {
String get channelJoinFailedTimeout =>
'Could not join channel: the server didn\'t respond in time.';
@override
String get channelJoinFailedFlooding =>
'Too many channel switches. Please wait a moment.';
@override
String channelJoinFailedGeneric(String message) {
return 'Could not join channel: $message';
@@ -622,6 +626,28 @@ class AppL10nEn extends AppL10n {
return '<$time> \"$sender\" pokes you: $message';
}
@override
String get clientVolumeAction => 'User Volume';
@override
String clientVolumeTitle(String name) {
return '$name — volume';
}
@override
String clientVolumeLabel(int percent) {
return '$percent%';
}
@override
String get clientVolumeMuteAction => 'Mute user';
@override
String get clientVolumeUnmuteAction => 'Unmute user';
@override
String get clientVolumeResetAction => 'Reset to default';
@override
String get permissionDenied => 'Permission Denied';
}
@@ -389,6 +389,9 @@ class AppL10nZh extends AppL10n {
@override
String get channelJoinFailedTimeout => '无法加入频道:服务器响应超时。';
@override
String get channelJoinFailedFlooding => '切换频道过于频繁,请稍后再试。';
@override
String channelJoinFailedGeneric(String message) {
return '无法加入频道:$message';
@@ -611,6 +614,28 @@ class AppL10nZh extends AppL10n {
return '<$time> “$sender”戳了你一下:$message';
}
@override
String get clientVolumeAction => '用户音量';
@override
String clientVolumeTitle(String name) {
return '$name — 音量';
}
@override
String clientVolumeLabel(int percent) {
return '$percent%';
}
@override
String get clientVolumeMuteAction => '静音该用户';
@override
String get clientVolumeUnmuteAction => '取消静音';
@override
String get clientVolumeResetAction => '恢复默认';
@override
String get permissionDenied => '权限被拒绝';
}
@@ -90,6 +90,7 @@ class _SnapshotViewState extends State<SnapshotView> {
final _scrollController = ScrollController();
final Map<BigInt, bool> _channelExpandedById = {};
final _clientVolumePrefs = _ClientVolumePreferences.instance;
bool _welcomeExpanded = false;
double _welcomeHeight = 0;
final _welcomeKey = GlobalKey();
@@ -98,6 +99,7 @@ class _SnapshotViewState extends State<SnapshotView> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_clientVolumePrefs.addListener(_onClientVolumePrefsChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _welcomeKey.currentContext;
if (ctx != null) {
@@ -115,8 +117,13 @@ class _SnapshotViewState extends State<SnapshotView> {
}
}
void _onClientVolumePrefsChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_clientVolumePrefs.removeListener(_onClientVolumePrefsChanged);
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
@@ -292,27 +299,21 @@ class _SnapshotViewState extends State<SnapshotView> {
)
: null;
final isSelf = client.id == widget.snapshot.ownClientId;
final volumePref = isSelf
? const _ClientVolumePreference()
: _clientVolumePrefs.preferenceFor(client.id);
final canOpenPeerActions =
!isSelf &&
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
final canOpenClientMenu =
widget.onOpenClientInfo != null || canOpenPeerActions;
widget.onOpenClientInfo != null || canOpenPeerActions || !isSelf;
final decoration = status.isSpeaking
? BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.62,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.55),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.14),
blurRadius: 8,
spreadRadius: 1,
),
],
)
: null;
@@ -328,8 +329,17 @@ class _SnapshotViewState extends State<SnapshotView> {
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: status.icon,
leading: _ClientAvatarVoiceIndicator(
name: client.name,
speaking: status.isSpeaking,
badgeIcon: status.badgeIcon,
badgeColor: status.badgeColor,
badgeTooltip: status.badgeTooltip,
),
title: Text(client.name, style: nameStyle),
trailing: volumePref.isModified
? _ClientVolumeIndicator(preference: volumePref)
: null,
),
),
);
@@ -370,6 +380,15 @@ class _SnapshotViewState extends State<SnapshotView> {
title: Text(AppL10n.of(context).clientInfoAction),
),
),
if (!isSelf)
PopupMenuItem(
value: _ClientMenuAction.volume,
child: ListTile(
dense: true,
leading: const Icon(Icons.volume_up),
title: Text(AppL10n.of(context).clientVolumeAction),
),
),
if (!isSelf && widget.onOpenClientChat != null)
PopupMenuItem(
value: _ClientMenuAction.directMessage,
@@ -398,9 +417,20 @@ class _SnapshotViewState extends State<SnapshotView> {
widget.onOpenClientChat?.call(client);
case _ClientMenuAction.poke:
widget.onOpenClientPoke?.call(client);
case _ClientMenuAction.volume:
await _showVolumeSheet(client);
}
}
Future<void> _showVolumeSheet(rust.BridgeClient client) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (sheetContext) => _ClientVolumeSheet(client: client),
);
}
Widget _expandButton(
ThemeData theme, {
required bool hasVisibleChildren,
@@ -440,10 +470,13 @@ class _SnapshotViewState extends State<SnapshotView> {
});
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
ThemeData theme,
rust.BridgeClient client,
) {
({
IconData? badgeIcon,
Color? badgeColor,
String? badgeTooltip,
bool isSpeaking,
})
_clientVoiceStatusIcon(ThemeData theme, rust.BridgeClient client) {
final isSelf = client.id == widget.snapshot.ownClientId;
final inCurrentChannel = client.channel == widget.currentVoiceChannelId;
final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted;
@@ -469,47 +502,204 @@ class _SnapshotViewState extends State<SnapshotView> {
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
final speaking = rawSpeaking && transmitAllowed;
final IconData icon;
final Color color;
final String tooltip;
final IconData? badgeIcon;
final Color? badgeColor;
final String? badgeTooltip;
if (outputMuted) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip = 'Speaker muted';
badgeIcon = Icons.volume_off;
badgeColor = theme.colorScheme.error;
badgeTooltip = 'Speaker muted';
} else if (inputMuted) {
icon = Icons.mic_off;
color = theme.colorScheme.error;
tooltip = 'Microphone muted';
badgeIcon = Icons.mic_off;
badgeColor = theme.colorScheme.error;
badgeTooltip = 'Microphone muted';
} else if (talkPowerBlocked) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip =
badgeIcon = Icons.volume_off;
badgeColor = theme.colorScheme.error;
badgeTooltip =
'Insufficient talk power (${client.talkPower} < $neededTalkPower)';
} else if (speaking) {
icon = isSelf ? Icons.mic : Icons.volume_up;
color = theme.colorScheme.primary;
tooltip = 'Speaking';
} else if (inCurrentChannel) {
icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined;
color = theme.colorScheme.onSurfaceVariant;
tooltip = 'Not speaking';
} else {
icon = Icons.person_outline;
color = theme.colorScheme.onSurfaceVariant;
tooltip = 'Outside current channel';
badgeIcon = null;
badgeColor = null;
badgeTooltip = null;
}
return (
icon: Tooltip(
message: tooltip,
child: Icon(icon, color: color),
),
badgeIcon: badgeIcon,
badgeColor: badgeColor,
badgeTooltip: badgeTooltip,
isSpeaking: speaking,
);
}
}
enum _ClientMenuAction { info, directMessage, poke }
enum _ClientMenuAction { info, directMessage, poke, volume }
class _ClientVolumePreference {
const _ClientVolumePreference({this.volume = 1.0, this.muted = false});
final double volume;
final bool muted;
bool get isModified => muted || (volume - 1.0).abs() > 0.001;
bool get isEffectivelyMuted => muted || volume <= 0.001;
}
class _ClientVolumePreferences extends ChangeNotifier {
_ClientVolumePreferences._();
static final instance = _ClientVolumePreferences._();
final Map<BigInt, _ClientVolumePreference> _byClientId = {};
_ClientVolumePreference preferenceFor(BigInt clientId) {
return _byClientId[clientId] ?? const _ClientVolumePreference();
}
void setPreference(BigInt clientId, _ClientVolumePreference preference) {
if (preference.isModified) {
_byClientId[clientId] = preference;
} else {
_byClientId.remove(clientId);
}
notifyListeners();
}
}
class _ClientAvatarVoiceIndicator extends StatelessWidget {
const _ClientAvatarVoiceIndicator({
required this.name,
required this.speaking,
required this.badgeIcon,
required this.badgeColor,
required this.badgeTooltip,
});
final String name;
final bool speaking;
final IconData? badgeIcon;
final Color? badgeColor;
final String? badgeTooltip;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final initial = name.trim().isEmpty ? '?' : name.trim()[0].toUpperCase();
return SizedBox(
width: 40,
height: 40,
child: Stack(
clipBehavior: Clip.none,
children: [
Align(
alignment: Alignment.center,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
width: 34,
height: 34,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: speaking
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerHighest,
border: speaking
? Border.all(color: theme.colorScheme.primary, width: 2)
: null,
boxShadow: speaking
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(
alpha: 0.20,
),
blurRadius: 8,
spreadRadius: 1,
),
]
: null,
),
child: Center(
child: Text(
initial,
style: theme.textTheme.labelLarge?.copyWith(
color: speaking
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
),
),
),
if (badgeIcon != null && badgeColor != null && badgeTooltip != null)
Positioned(
right: 0,
bottom: 0,
child: Tooltip(
message: badgeTooltip!,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.surface,
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Icon(badgeIcon, size: 12, color: badgeColor),
),
),
),
],
),
);
}
}
class _ClientVolumeIndicator extends StatelessWidget {
const _ClientVolumeIndicator({required this.preference});
final _ClientVolumePreference preference;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final percent = (preference.volume * 100).round();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (preference.isEffectivelyMuted)
Tooltip(
message: 'Locally muted',
child: Icon(
Icons.volume_off,
size: 18,
color: theme.colorScheme.error,
),
),
if ((preference.volume - 1.0).abs() > 0.001) ...[
if (preference.isEffectivelyMuted) const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'$percent%',
style: theme.textTheme.labelSmall?.copyWith(
color: preference.isEffectivelyMuted
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
],
],
);
}
}
class _SpacerChannelContent extends StatelessWidget {
const _SpacerChannelContent({required this.spacer});
@@ -755,3 +945,140 @@ class _WelcomeMessageTile extends StatelessWidget {
);
}
}
class _ClientVolumeSheet extends StatefulWidget {
const _ClientVolumeSheet({required this.client});
final rust.BridgeClient client;
@override
State<_ClientVolumeSheet> createState() => _ClientVolumeSheetState();
}
class _ClientVolumeSheetState extends State<_ClientVolumeSheet> {
static const _maxVolume = 1.5;
final _prefs = _ClientVolumePreferences.instance;
double _volume = 1.0;
bool _muted = false;
double _volumeBeforeMute = 1.0;
@override
void initState() {
super.initState();
final preference = _prefs.preferenceFor(widget.client.id);
_volume = preference.volume.clamp(0.0, _maxVolume);
_muted = preference.muted;
_volumeBeforeMute = _volume <= 0.001 ? 1.0 : _volume;
}
void _applyVolume() {
_prefs.setPreference(
widget.client.id,
_ClientVolumePreference(volume: _volume, muted: _muted),
);
rust.setClientVolume(
clientId: widget.client.id,
volume: _muted ? 0.0 : _volume,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final percent = (_volume * 100).round();
final isModified = _volume != 1.0 || _muted;
return Padding(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.clientVolumeTitle(widget.client.name),
style: theme.textTheme.titleMedium,
),
),
IconButton(
icon: Icon(
_muted ? Icons.volume_off : Icons.volume_up,
color: _muted ? theme.colorScheme.error : null,
),
tooltip: _muted
? l10n.clientVolumeUnmuteAction
: l10n.clientVolumeMuteAction,
onPressed: () {
setState(() {
if (!_muted) {
_volumeBeforeMute = _volume;
_muted = true;
} else {
_muted = false;
_volume = _volumeBeforeMute;
}
});
_applyVolume();
},
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Slider(
value: _volume,
min: 0.0,
max: _maxVolume,
divisions: 30,
label: l10n.clientVolumeLabel(percent),
onChanged: _muted
? null
: (v) {
setState(() => _volume = v);
_applyVolume();
},
),
),
SizedBox(
width: 52,
child: Text(
l10n.clientVolumeLabel(percent),
style: theme.textTheme.bodyMedium?.copyWith(
color: _muted ? theme.colorScheme.error : null,
),
textAlign: TextAlign.end,
),
),
],
),
if (isModified)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
icon: const Icon(Icons.restart_alt, size: 18),
label: Text(l10n.clientVolumeResetAction),
onPressed: () {
setState(() {
_volume = 1.0;
_muted = false;
_volumeBeforeMute = 1.0;
});
_applyVolume();
},
),
),
const SizedBox(height: 8),
],
),
);
}
}
@@ -194,9 +194,9 @@ void main() {
expect(find.text('Channels'), findsNothing);
expect(find.byIcon(Icons.tag), findsNWidgets(3));
expect(parentX, inInclusiveRange(58, 66));
expect(parentUserX - parentX, inInclusiveRange(22, 28));
expect(parentUserX - parentX, inInclusiveRange(38, 44));
expect(childX - parentX, inInclusiveRange(10, 14));
expect(childUserX - childX, inInclusiveRange(22, 28));
expect(childUserX - childX, inInclusiveRange(38, 44));
});
testWidgets('server welcome starts collapsed above channel tree', (
@@ -253,7 +253,7 @@ void main() {
expect(find.text('Alice'), findsOneWidget);
expect(find.byIcon(Icons.tag), findsOneWidget);
expect(find.byIcon(Icons.mic_none), findsOneWidget);
expect(find.byIcon(Icons.mic_none), findsNothing);
expect(find.byType(ListTile), findsOneWidget);
final userHighlight = tester.widget<AnimatedContainer>(