From 0381be69644d7dd89c97b04d367f01db094dc66a Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 3 Jun 2026 18:08:52 +0900 Subject: [PATCH] feat(ui): add per-user volume controls (#17) --- apps/chanora_flutter/lib/l10n/app_en.arb | 13 + apps/chanora_flutter/lib/l10n/app_zh.arb | 7 + .../lib/l10n/generated/app_localizations.dart | 42 ++ .../l10n/generated/app_localizations_en.dart | 26 ++ .../l10n/generated/app_localizations_zh.dart | 25 ++ .../lib/widgets/snapshot_view.dart | 419 ++++++++++++++++-- .../test/widgets/snapshot_view_test.dart | 6 +- 7 files changed, 489 insertions(+), 49 deletions(-) diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 4d40a52..d12ea5f 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -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" } diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 01a871a..a3af60c 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -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": "权限被拒绝" } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index c9c5a0b..a32831a 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -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: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 9d210c0..cdc2b6b 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -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'; } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 17c4b07..6586b08 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -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 => '权限被拒绝'; } diff --git a/apps/chanora_flutter/lib/widgets/snapshot_view.dart b/apps/chanora_flutter/lib/widgets/snapshot_view.dart index d0836cf..9eaedbd 100644 --- a/apps/chanora_flutter/lib/widgets/snapshot_view.dart +++ b/apps/chanora_flutter/lib/widgets/snapshot_view.dart @@ -90,6 +90,7 @@ class _SnapshotViewState extends State { final _scrollController = ScrollController(); final Map _channelExpandedById = {}; + final _clientVolumePrefs = _ClientVolumePreferences.instance; bool _welcomeExpanded = false; double _welcomeHeight = 0; final _welcomeKey = GlobalKey(); @@ -98,6 +99,7 @@ class _SnapshotViewState extends State { 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 { } } + 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 { ) : 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), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.55), - width: 1.2, + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.62, ), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.primary.withValues(alpha: 0.14), - blurRadius: 8, - spreadRadius: 1, - ), - ], + borderRadius: BorderRadius.circular(8), ) : null; @@ -328,8 +329,17 @@ class _SnapshotViewState extends State { 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 { 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 { widget.onOpenClientChat?.call(client); case _ClientMenuAction.poke: widget.onOpenClientPoke?.call(client); + case _ClientMenuAction.volume: + await _showVolumeSheet(client); } } + Future _showVolumeSheet(rust.BridgeClient client) async { + await showModalBottomSheet( + 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 { }); } - ({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 { (!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 _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), + ], + ), + ); + } +} diff --git a/apps/chanora_flutter/test/widgets/snapshot_view_test.dart b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart index 0e151ca..f39c280 100644 --- a/apps/chanora_flutter/test/widgets/snapshot_view_test.dart +++ b/apps/chanora_flutter/test/widgets/snapshot_view_test.dart @@ -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(