feat(ui): add per-user volume controls (#17)
This commit is contained in:
@@ -172,6 +172,7 @@
|
|||||||
"channelJoinFailedFamilyFull": "Channel family limit reached.",
|
"channelJoinFailedFamilyFull": "Channel family limit reached.",
|
||||||
"channelJoinFailedPrivate": "This channel is private.",
|
"channelJoinFailedPrivate": "This channel is private.",
|
||||||
"channelJoinFailedTimeout": "Could not join channel: the server didn't respond in time.",
|
"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": "Could not join channel: {message}",
|
||||||
"@channelJoinFailedGeneric": {
|
"@channelJoinFailedGeneric": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -284,5 +285,17 @@
|
|||||||
"message": { "type": "String" }
|
"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"
|
"permissionDenied": "Permission Denied"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@
|
|||||||
"channelJoinFailedFamilyFull": "频道家族人数已达上限。",
|
"channelJoinFailedFamilyFull": "频道家族人数已达上限。",
|
||||||
"channelJoinFailedPrivate": "此频道为私有频道。",
|
"channelJoinFailedPrivate": "此频道为私有频道。",
|
||||||
"channelJoinFailedTimeout": "无法加入频道:服务器响应超时。",
|
"channelJoinFailedTimeout": "无法加入频道:服务器响应超时。",
|
||||||
|
"channelJoinFailedFlooding": "切换频道过于频繁,请稍后再试。",
|
||||||
"channelJoinFailedGeneric": "无法加入频道:{message}",
|
"channelJoinFailedGeneric": "无法加入频道:{message}",
|
||||||
"@channelJoinFailedGeneric": {
|
"@channelJoinFailedGeneric": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
@@ -233,5 +234,11 @@
|
|||||||
"message": { "type": "String" }
|
"message": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"clientVolumeAction": "用户音量",
|
||||||
|
"clientVolumeTitle": "{name} — 音量",
|
||||||
|
"clientVolumeLabel": "{percent}%",
|
||||||
|
"clientVolumeMuteAction": "静音该用户",
|
||||||
|
"clientVolumeUnmuteAction": "取消静音",
|
||||||
|
"clientVolumeResetAction": "恢复默认",
|
||||||
"permissionDenied": "权限被拒绝"
|
"permissionDenied": "权限被拒绝"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -799,6 +799,12 @@ abstract class AppL10n {
|
|||||||
/// **'Could not join channel: the server didn\'t respond in time.'**
|
/// **'Could not join channel: the server didn\'t respond in time.'**
|
||||||
String get channelJoinFailedTimeout;
|
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.
|
/// No description provided for @channelJoinFailedGeneric.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1193,6 +1199,42 @@ abstract class AppL10n {
|
|||||||
String message,
|
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.
|
/// No description provided for @permissionDenied.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -398,6 +398,10 @@ class AppL10nEn extends AppL10n {
|
|||||||
String get channelJoinFailedTimeout =>
|
String get channelJoinFailedTimeout =>
|
||||||
'Could not join channel: the server didn\'t respond in time.';
|
'Could not join channel: the server didn\'t respond in time.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFlooding =>
|
||||||
|
'Too many channel switches. Please wait a moment.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String channelJoinFailedGeneric(String message) {
|
String channelJoinFailedGeneric(String message) {
|
||||||
return 'Could not join channel: $message';
|
return 'Could not join channel: $message';
|
||||||
@@ -622,6 +626,28 @@ class AppL10nEn extends AppL10n {
|
|||||||
return '<$time> \"$sender\" pokes you: $message';
|
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
|
@override
|
||||||
String get permissionDenied => 'Permission Denied';
|
String get permissionDenied => 'Permission Denied';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -389,6 +389,9 @@ class AppL10nZh extends AppL10n {
|
|||||||
@override
|
@override
|
||||||
String get channelJoinFailedTimeout => '无法加入频道:服务器响应超时。';
|
String get channelJoinFailedTimeout => '无法加入频道:服务器响应超时。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channelJoinFailedFlooding => '切换频道过于频繁,请稍后再试。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String channelJoinFailedGeneric(String message) {
|
String channelJoinFailedGeneric(String message) {
|
||||||
return '无法加入频道:$message';
|
return '无法加入频道:$message';
|
||||||
@@ -611,6 +614,28 @@ class AppL10nZh extends AppL10n {
|
|||||||
return '<$time> “$sender”戳了你一下:$message';
|
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
|
@override
|
||||||
String get permissionDenied => '权限被拒绝';
|
String get permissionDenied => '权限被拒绝';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
|
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
final Map<BigInt, bool> _channelExpandedById = {};
|
final Map<BigInt, bool> _channelExpandedById = {};
|
||||||
|
final _clientVolumePrefs = _ClientVolumePreferences.instance;
|
||||||
bool _welcomeExpanded = false;
|
bool _welcomeExpanded = false;
|
||||||
double _welcomeHeight = 0;
|
double _welcomeHeight = 0;
|
||||||
final _welcomeKey = GlobalKey();
|
final _welcomeKey = GlobalKey();
|
||||||
@@ -98,6 +99,7 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
|
_clientVolumePrefs.addListener(_onClientVolumePrefsChanged);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final ctx = _welcomeKey.currentContext;
|
final ctx = _welcomeKey.currentContext;
|
||||||
if (ctx != null) {
|
if (ctx != null) {
|
||||||
@@ -115,8 +117,13 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onClientVolumePrefsChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_clientVolumePrefs.removeListener(_onClientVolumePrefsChanged);
|
||||||
_scrollController.removeListener(_onScroll);
|
_scrollController.removeListener(_onScroll);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -292,27 +299,21 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
final isSelf = client.id == widget.snapshot.ownClientId;
|
final isSelf = client.id == widget.snapshot.ownClientId;
|
||||||
|
final volumePref = isSelf
|
||||||
|
? const _ClientVolumePreference()
|
||||||
|
: _clientVolumePrefs.preferenceFor(client.id);
|
||||||
final canOpenPeerActions =
|
final canOpenPeerActions =
|
||||||
!isSelf &&
|
!isSelf &&
|
||||||
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
|
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
|
||||||
final canOpenClientMenu =
|
final canOpenClientMenu =
|
||||||
widget.onOpenClientInfo != null || canOpenPeerActions;
|
widget.onOpenClientInfo != null || canOpenPeerActions || !isSelf;
|
||||||
|
|
||||||
final decoration = status.isSpeaking
|
final decoration = status.isSpeaking
|
||||||
? BoxDecoration(
|
? BoxDecoration(
|
||||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||||
borderRadius: BorderRadius.circular(8),
|
alpha: 0.62,
|
||||||
border: Border.all(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.55),
|
|
||||||
width: 1.2,
|
|
||||||
),
|
),
|
||||||
boxShadow: [
|
borderRadius: BorderRadius.circular(8),
|
||||||
BoxShadow(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.14),
|
|
||||||
blurRadius: 8,
|
|
||||||
spreadRadius: 1,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -328,8 +329,17 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.compact,
|
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),
|
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),
|
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)
|
if (!isSelf && widget.onOpenClientChat != null)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _ClientMenuAction.directMessage,
|
value: _ClientMenuAction.directMessage,
|
||||||
@@ -398,9 +417,20 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
widget.onOpenClientChat?.call(client);
|
widget.onOpenClientChat?.call(client);
|
||||||
case _ClientMenuAction.poke:
|
case _ClientMenuAction.poke:
|
||||||
widget.onOpenClientPoke?.call(client);
|
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(
|
Widget _expandButton(
|
||||||
ThemeData theme, {
|
ThemeData theme, {
|
||||||
required bool hasVisibleChildren,
|
required bool hasVisibleChildren,
|
||||||
@@ -440,10 +470,13 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
({
|
||||||
ThemeData theme,
|
IconData? badgeIcon,
|
||||||
rust.BridgeClient client,
|
Color? badgeColor,
|
||||||
) {
|
String? badgeTooltip,
|
||||||
|
bool isSpeaking,
|
||||||
|
})
|
||||||
|
_clientVoiceStatusIcon(ThemeData theme, rust.BridgeClient client) {
|
||||||
final isSelf = client.id == widget.snapshot.ownClientId;
|
final isSelf = client.id == widget.snapshot.ownClientId;
|
||||||
final inCurrentChannel = client.channel == widget.currentVoiceChannelId;
|
final inCurrentChannel = client.channel == widget.currentVoiceChannelId;
|
||||||
final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted;
|
final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted;
|
||||||
@@ -469,47 +502,204 @@ class _SnapshotViewState extends State<SnapshotView> {
|
|||||||
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
|
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
|
||||||
final speaking = rawSpeaking && transmitAllowed;
|
final speaking = rawSpeaking && transmitAllowed;
|
||||||
|
|
||||||
final IconData icon;
|
final IconData? badgeIcon;
|
||||||
final Color color;
|
final Color? badgeColor;
|
||||||
final String tooltip;
|
final String? badgeTooltip;
|
||||||
if (outputMuted) {
|
if (outputMuted) {
|
||||||
icon = Icons.volume_off;
|
badgeIcon = Icons.volume_off;
|
||||||
color = theme.colorScheme.error;
|
badgeColor = theme.colorScheme.error;
|
||||||
tooltip = 'Speaker muted';
|
badgeTooltip = 'Speaker muted';
|
||||||
} else if (inputMuted) {
|
} else if (inputMuted) {
|
||||||
icon = Icons.mic_off;
|
badgeIcon = Icons.mic_off;
|
||||||
color = theme.colorScheme.error;
|
badgeColor = theme.colorScheme.error;
|
||||||
tooltip = 'Microphone muted';
|
badgeTooltip = 'Microphone muted';
|
||||||
} else if (talkPowerBlocked) {
|
} else if (talkPowerBlocked) {
|
||||||
icon = Icons.volume_off;
|
badgeIcon = Icons.volume_off;
|
||||||
color = theme.colorScheme.error;
|
badgeColor = theme.colorScheme.error;
|
||||||
tooltip =
|
badgeTooltip =
|
||||||
'Insufficient talk power (${client.talkPower} < $neededTalkPower)';
|
'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 {
|
} else {
|
||||||
icon = Icons.person_outline;
|
badgeIcon = null;
|
||||||
color = theme.colorScheme.onSurfaceVariant;
|
badgeColor = null;
|
||||||
tooltip = 'Outside current channel';
|
badgeTooltip = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
icon: Tooltip(
|
badgeIcon: badgeIcon,
|
||||||
message: tooltip,
|
badgeColor: badgeColor,
|
||||||
child: Icon(icon, color: color),
|
badgeTooltip: badgeTooltip,
|
||||||
),
|
|
||||||
isSpeaking: speaking,
|
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 {
|
class _SpacerChannelContent extends StatelessWidget {
|
||||||
const _SpacerChannelContent({required this.spacer});
|
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.text('Channels'), findsNothing);
|
||||||
expect(find.byIcon(Icons.tag), findsNWidgets(3));
|
expect(find.byIcon(Icons.tag), findsNWidgets(3));
|
||||||
expect(parentX, inInclusiveRange(58, 66));
|
expect(parentX, inInclusiveRange(58, 66));
|
||||||
expect(parentUserX - parentX, inInclusiveRange(22, 28));
|
expect(parentUserX - parentX, inInclusiveRange(38, 44));
|
||||||
expect(childX - parentX, inInclusiveRange(10, 14));
|
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', (
|
testWidgets('server welcome starts collapsed above channel tree', (
|
||||||
@@ -253,7 +253,7 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Alice'), findsOneWidget);
|
expect(find.text('Alice'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.tag), 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);
|
expect(find.byType(ListTile), findsOneWidget);
|
||||||
|
|
||||||
final userHighlight = tester.widget<AnimatedContainer>(
|
final userHighlight = tester.widget<AnimatedContainer>(
|
||||||
|
|||||||
Reference in New Issue
Block a user