Files
chanora/apps/chanora_flutter/lib/widgets/snapshot_view.dart
T

789 lines
23 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
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.
const SnapshotView({
super.key,
required this.snapshot,
required this.audioStats,
required this.currentVoiceChannelId,
required this.pendingVoiceChannelId,
required this.localInputMuted,
required this.localOutputMuted,
required this.hasJoinPending,
required this.canJoinVoiceChannel,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.clientPlaybackPreferences = const {},
this.onClientPlaybackPreferenceChanged,
this.onTs3ServerLink,
});
/// Current bridge snapshot.
final rust.BridgeSnapshot snapshot;
/// Latest audio stats, used for local speaking state.
final rust.BridgeAudioStats? audioStats;
/// Current voice channel id.
final BigInt? currentVoiceChannelId;
/// Pending join target, if any.
final BigInt? pendingVoiceChannelId;
/// Local input mute state.
final bool localInputMuted;
/// Local output mute state.
final bool localOutputMuted;
/// True while a channel join is in flight.
final bool hasJoinPending;
/// True when the local client may join voice channels.
final bool canJoinVoiceChannel;
/// Join an unlocked channel.
final ValueChanged<rust.BridgeChannel> onJoinChannel;
/// 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;
@override
State<SnapshotView> createState() => _SnapshotViewState();
}
class _SnapshotViewState extends State<SnapshotView> {
static const _indentPerLevel = 12.0;
static const _expandColumnWidth = 28.0;
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 = {};
bool _welcomeExpanded = true;
double _welcomeHeight = 0;
final _welcomeKey = GlobalKey();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _welcomeKey.currentContext;
if (ctx != null) {
final box = ctx.findRenderObject() as RenderBox?;
if (box != null && mounted) {
setState(() => _welcomeHeight = box.size.height);
}
}
});
}
void _onScroll() {
if (_welcomeExpanded && _scrollController.offset > _welcomeHeight) {
setState(() => _welcomeExpanded = false);
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final tree = _buildChannelTree(widget.snapshot.channels);
final clientsByChannel = <BigInt, List<rust.BridgeClient>>{};
for (final c in widget.snapshot.clients) {
if (!c.isServerQuery) {
clientsByChannel.putIfAbsent(c.channel, () => []).add(c);
}
}
return ListView(
controller: _scrollController,
children: [
Text(
l10n.countChannelsAndClients(
widget.snapshot.channels.length,
widget.snapshot.clients.length,
),
style: theme.textTheme.bodyMedium,
),
if (widget.snapshot.welcomeMessage.isNotEmpty) ...[
const SizedBox(height: 8),
_WelcomeMessageTile(
key: _welcomeKey,
welcomeMessage: widget.snapshot.welcomeMessage,
expanded: _welcomeExpanded,
onToggle: () =>
setState(() => _welcomeExpanded = !_welcomeExpanded),
onTs3ServerLink: widget.onTs3ServerLink,
),
],
const Divider(height: 24),
for (final node in tree.roots)
..._channelTreeRows(theme, node, clientsByChannel, 0),
],
);
}
List<Widget> _channelTreeRows(
ThemeData theme,
_ChannelTreeNode node,
Map<BigInt, List<rust.BridgeClient>> clientsByChannel,
int depth,
) {
final channel = node.channel;
final clients = clientsByChannel[channel.id] ?? const <rust.BridgeClient>[];
final hasVisibleChildren = clients.isNotEmpty || node.children.isNotEmpty;
final expanded = _isChannelExpanded(channel.id);
final channelIndent = (depth.clamp(0, 8)) * _indentPerLevel;
return [
_channelTile(
theme,
channel,
channelIndent: channelIndent,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onToggleExpanded: hasVisibleChildren
? () => _toggleChannelExpanded(channel.id)
: null,
),
if (expanded) ...[
for (final client in clients) _clientTile(theme, client, channelIndent),
for (final child in node.children)
..._channelTreeRows(theme, child, clientsByChannel, depth + 1),
],
];
}
Widget _channelTile(
ThemeData theme,
rust.BridgeChannel channel, {
required double channelIndent,
required bool hasVisibleChildren,
required bool expanded,
required VoidCallback? onToggleExpanded,
}) {
final spacer = parseSpacerChannelName(channel.name);
final onTap =
widget.hasJoinPending ||
!widget.canJoinVoiceChannel ||
channel.id == widget.currentVoiceChannelId
? null
: () => channel.hasPassword
? widget.onJoinChannelWithPassword(channel)
: widget.onJoinChannel(channel);
if (spacer.isSpacer) {
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
const SizedBox(width: _channelTextGap),
Expanded(child: _SpacerChannelContent(spacer: spacer)),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
);
}
Widget _clientTile(
ThemeData theme,
rust.BridgeClient client,
double channelIndent,
) {
final status = _clientVoiceStatusIcon(theme, client);
final nameStyle = client.isServerQuery
? TextStyle(color: theme.colorScheme.onSurfaceVariant)
: status.isSpeaking
? TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
)
: null;
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,
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.14),
blurRadius: 8,
spreadRadius: 1,
),
],
)
: null;
final canAdjustPlayback = _canAdjustClientPlayback(client);
Offset? secondaryTapPosition;
return Padding(
padding: EdgeInsets.only(
left: channelIndent + _userRowStartIndent,
right: 8,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: canAdjustPlayback
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
: null,
onSecondaryTapDown: canAdjustPlayback
? (details) => secondaryTapPosition = details.globalPosition
: null,
onSecondaryTap: canAdjustPlayback
? () => unawaited(
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
)
: 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),
),
),
),
);
}
Widget _expandButton(
ThemeData theme, {
required bool hasVisibleChildren,
required bool expanded,
required VoidCallback? onPressed,
}) {
if (!hasVisibleChildren) {
return const SizedBox(
width: _expandColumnWidth,
height: _expandColumnWidth,
);
}
return Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(
width: _expandColumnWidth,
height: _expandColumnWidth,
child: Icon(
expanded ? Icons.expand_more : Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
bool _isChannelExpanded(BigInt channelId) {
return _channelExpandedById[channelId] ?? true;
}
void _toggleChannelExpanded(BigInt channelId) {
setState(() {
_channelExpandedById[channelId] = !_isChannelExpanded(channelId);
});
}
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> _showClientPlaybackMenu(
rust.BridgeClient client, {
Offset? anchor,
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;
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,
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(
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;
final inputMuted = isSelf ? widget.localInputMuted : client.inputMuted;
final neededTalkPower = snapshotNeededTalkPower(
widget.snapshot,
client.channel,
);
final talkPowerBlocked =
isSelf &&
inCurrentChannel &&
isTalkPowerBlocked(
talkPower: client.talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: client.talkPowerGranted,
);
final rawSpeaking = isSelf
? (widget.audioStats?.pttActive ?? false)
: client.isSpeaking;
final transmitAllowed =
!outputMuted &&
!inputMuted &&
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
final speaking = rawSpeaking && transmitAllowed;
final IconData icon;
final Color color;
final String tooltip;
if (outputMuted) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip = 'Speaker muted';
} else if (inputMuted) {
icon = Icons.mic_off;
color = theme.colorScheme.error;
tooltip = 'Microphone muted';
} else if (talkPowerBlocked) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip =
'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';
}
return (
icon: Tooltip(
message: tooltip,
child: Icon(icon, color: color),
),
isSpeaking: speaking,
);
}
}
class _SpacerChannelContent extends StatelessWidget {
const _SpacerChannelContent({required this.spacer});
final SpacerChannelNameParseResult spacer;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
if (spacer.isBlankSpacer) {
return const SizedBox(height: 20);
}
if (spacer.specialType != null) {
return SizedBox(
height: 22,
child: CustomPaint(
painter: _SpacerLinePainter(
color: color.withValues(alpha: 0.72),
type: spacer.specialType!,
),
),
);
}
if (spacer.isRepeating) {
return LayoutBuilder(
builder: (context, constraints) {
final pattern = spacer.text.isEmpty ? ' ' : spacer.text;
final estimatedColumns = (constraints.maxWidth / 8).ceil().clamp(
1,
256,
);
return Text(
channelSpacerLabel(
formatSpacerChannelName(
SpacerChannelNameFormatOptions(
alignment: spacer.alignment,
isRepeating: true,
uniqueSuffix: spacer.uniqueSuffix,
text: pattern,
),
),
repeatColumns: estimatedColumns,
),
maxLines: 1,
overflow: TextOverflow.clip,
softWrap: false,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
);
},
);
}
return Text(
spacer.text,
textAlign: switch (spacer.alignment) {
SpacerAlignment.left => TextAlign.left,
SpacerAlignment.right => TextAlign.right,
SpacerAlignment.center => TextAlign.center,
null => TextAlign.center,
},
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
);
}
}
class _SpacerLinePainter extends CustomPainter {
const _SpacerLinePainter({required this.color, required this.type});
final Color color;
final SpacerSpecialType type;
@override
void paint(Canvas canvas, Size size) {
final y = size.height / 2;
final paint = Paint()
..color = color
..strokeCap = StrokeCap.square
..strokeWidth = 1.4;
switch (type) {
case SpacerSpecialType.solidLine:
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
case SpacerSpecialType.dashLine:
_drawPattern(canvas, size.width, y, paint, const [8, 5]);
case SpacerSpecialType.dotLine:
final dotPaint = Paint()..color = color;
for (var x = 1.5; x < size.width; x += 7) {
canvas.drawCircle(Offset(x, y), 1.5, dotPaint);
}
case SpacerSpecialType.dashDotLine:
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4]);
case SpacerSpecialType.dashDotDotLine:
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4, 2, 4]);
}
}
void _drawPattern(
Canvas canvas,
double width,
double y,
Paint paint,
List<double> pattern,
) {
var x = 0.0;
var index = 0;
while (x < width) {
final length = pattern[index % pattern.length];
if (index.isEven) {
final end = x + length > width ? width : x + length;
canvas.drawLine(Offset(x, y), Offset(end, y), paint);
}
x += length;
index += 1;
}
}
@override
bool shouldRepaint(covariant _SpacerLinePainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.type != type;
}
}
class _ChannelTree {
const _ChannelTree({required this.roots});
final List<_ChannelTreeNode> roots;
}
class _ChannelTreeNode {
_ChannelTreeNode(this.channel);
final rust.BridgeChannel channel;
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};
for (final channel in channels) {
final parent = knownIds.contains(channel.parent)
? channel.parent
: BigInt.zero;
byParent.putIfAbsent(parent, () => []).add(channel);
}
_ChannelTreeNode buildNode(rust.BridgeChannel channel) {
final node = _ChannelTreeNode(channel);
for (final child in byParent[channel.id] ?? const <rust.BridgeChannel>[]) {
node.children.add(buildNode(child));
}
return node;
}
return _ChannelTree(
roots: [
for (final channel
in byParent[BigInt.zero] ?? const <rust.BridgeChannel>[])
buildNode(channel),
],
);
}
class _WelcomeMessageTile extends StatelessWidget {
const _WelcomeMessageTile({
super.key,
required this.welcomeMessage,
required this.expanded,
required this.onToggle,
this.onTs3ServerLink,
});
final String welcomeMessage;
final bool expanded;
final VoidCallback onToggle;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
HapticFeedback.selectionClick();
onToggle();
},
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Icon(
expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Server welcome message',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
welcomeMessage,
linkTrust: LinkTrustService.instance,
onTs3ServerLink: onTs3ServerLink,
),
),
secondChild: const SizedBox(width: double.infinity),
crossFadeState: expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
),
);
}
}