mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
Merge remote-tracking branch 'origin/main' into projects-channel-first
Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -25,9 +25,7 @@ history reconstructs the visible card without silently reopening a microphone.
|
||||
3. Sign NIP-42 kind `22242` with `relay=<base relay WebSocket URL>` and the
|
||||
challenge. The `relay` tag is **not** the Huddle endpoint URL.
|
||||
4. Send an object envelope containing `type=auth`, the signed event,
|
||||
`parent_channel_id`, and `protocol_version=3`. Protocol v2 remains available
|
||||
for released clients with its one-byte relay peer prefix; v3 adds an
|
||||
occupancy epoch and cannot share a pinned room with v2 clients.
|
||||
`parent_channel_id`, and `protocol_version=2`.
|
||||
5. Treat the connection as usable only after a `joined` response. `error`,
|
||||
unexpected close, handshake timeout, and protocol failures are explicit
|
||||
states. An established session retries only its media socket with bounded
|
||||
@@ -48,7 +46,7 @@ burst origin so authorship is spatially clear; minimized calls fall back to the
|
||||
available Huddle surface. Reaction events never enter the ordinary channel
|
||||
timeline.
|
||||
|
||||
## Media plane: protocol v3
|
||||
## Media plane: protocol v2
|
||||
|
||||
Audio is 48 kHz, mono Opus in 20 ms (960-sample) frames.
|
||||
|
||||
@@ -61,14 +59,13 @@ Client to relay:
|
||||
Relay to client:
|
||||
|
||||
```text
|
||||
peer_index u8 | epoch u8 | 8-byte header | Opus payload
|
||||
peer_index u8 | 8-byte header | Opus payload
|
||||
```
|
||||
|
||||
The header is network byte order: sequence `u16`, 48 kHz timestamp `u32`,
|
||||
level dBov `i8` in `-127...0`, and flags `u8` where bit 0 marks DTX. The
|
||||
occupancy epoch advances whenever a peer index is assigned again, allowing
|
||||
clients to fence delayed media from the previous occupant. Unknown flag bits
|
||||
are ignored. Frames are capped at 4096 bytes before the relay peer prefix.
|
||||
level dBov `i8` in `-127...0`, and flags `u8` where bit 0 marks DTX. Unknown
|
||||
flag bits are ignored. Frames are capped at 4096 bytes before the relay peer
|
||||
prefix.
|
||||
|
||||
The dBov header also drives the participant speaking treatment. Values above
|
||||
the existing `-55 dBov` activity threshold are normalized for a 50 ms visual
|
||||
|
||||
@@ -23,7 +23,7 @@ import io.flutter.plugin.common.MethodChannel
|
||||
*
|
||||
* Owns microphone permission, foreground communication routing, and the native
|
||||
* realtime Opus engine. PCM remains native; Flutter only handles compressed
|
||||
* Huddle v3 packets.
|
||||
* Huddle v2 packets.
|
||||
*/
|
||||
internal class HuddleMediaPlugin(
|
||||
private val activity: Activity,
|
||||
@@ -142,14 +142,14 @@ internal class HuddleMediaPlugin(
|
||||
|
||||
private fun prepare(arguments: Any?, result: MethodChannel.Result) {
|
||||
val values = arguments as? Map<*, *>
|
||||
if (values?.get("protocolVersion") != 3 ||
|
||||
if (values?.get("protocolVersion") != 2 ||
|
||||
values["sampleRateHz"] != 48_000 ||
|
||||
values["channels"] != 1 ||
|
||||
values["frameSamples"] != 960
|
||||
) {
|
||||
result.error(
|
||||
"invalid_configuration",
|
||||
"Expected the fixed Huddle Opus v3 media configuration.",
|
||||
"Expected the fixed Huddle Opus v2 media configuration.",
|
||||
null,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -139,7 +139,7 @@ final class HuddleMediaPlugin {
|
||||
|
||||
private func prepare(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let values = arguments as? [String: Any],
|
||||
(values["protocolVersion"] as? NSNumber)?.intValue == 3,
|
||||
(values["protocolVersion"] as? NSNumber)?.intValue == 2,
|
||||
(values["sampleRateHz"] as? NSNumber)?.intValue == 48_000,
|
||||
(values["channels"] as? NSNumber)?.intValue == 1,
|
||||
(values["frameSamples"] as? NSNumber)?.intValue == 960
|
||||
@@ -147,7 +147,7 @@ final class HuddleMediaPlugin {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_configuration",
|
||||
message: "Expected the fixed Huddle Opus v3 media configuration.",
|
||||
message: "Expected the fixed Huddle Opus v2 media configuration.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show cos, max, min, pi;
|
||||
import 'dart:ui' show ImageFilter, lerpDouble;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -79,7 +81,9 @@ part 'channel_detail_page/message_list.dart';
|
||||
part 'channel_detail_page/system_rows.dart';
|
||||
part 'channel_detail_page/huddle_sheet.dart';
|
||||
part 'channel_detail_page/huddle_call_avatar.dart';
|
||||
part 'channel_detail_page/huddle_participant_cluster.dart';
|
||||
part 'channel_detail_page/huddle_call_participants.dart';
|
||||
part 'channel_detail_page/huddle_participant_overlay.dart';
|
||||
part 'channel_detail_page/huddle_call_controls.dart';
|
||||
part 'channel_detail_page/huddle_drawer.dart';
|
||||
part 'channel_detail_page/huddle_reactions.dart';
|
||||
|
||||
@@ -7,6 +7,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
|
||||
required this.fallbackLabel,
|
||||
required this.active,
|
||||
required this.speakerLevel,
|
||||
required this.onTap,
|
||||
this.isSelf = false,
|
||||
this.frameSize = _huddleAvatarFrameSize,
|
||||
});
|
||||
@@ -16,6 +17,7 @@ class _HuddleCallAvatar extends HookConsumerWidget {
|
||||
final String? fallbackLabel;
|
||||
final bool active;
|
||||
final double speakerLevel;
|
||||
final VoidCallback? onTap;
|
||||
final bool isSelf;
|
||||
final double frameSize;
|
||||
|
||||
@@ -56,31 +58,28 @@ class _HuddleCallAvatar extends HookConsumerWidget {
|
||||
originRegistry.register(pubkey, originKey);
|
||||
return () => originRegistry.unregister(pubkey, originKey);
|
||||
}, [originRegistry, pubkey, originKey]);
|
||||
final showsLabel = useState(false);
|
||||
final profileName = profile?.displayName?.trim();
|
||||
final directoryName = fallbackLabel?.trim();
|
||||
final label = isSelf
|
||||
? 'You'
|
||||
: (profileName?.isNotEmpty == true ? profileName : null) ??
|
||||
(directoryName?.isNotEmpty == true ? directoryName : null) ??
|
||||
(pubkey.isEmpty ? 'Participant' : shortPubkey(pubkey));
|
||||
void toggleLabel() => showsLabel.value = !showsLabel.value;
|
||||
final label = _huddleParticipantLabel(
|
||||
pubkey: pubkey,
|
||||
profile: profile,
|
||||
fallbackLabel: fallbackLabel,
|
||||
isSelf: isSelf,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: frameSize,
|
||||
child: Semantics(
|
||||
label: active ? '$label, speaking' : label,
|
||||
hint: showsLabel.value
|
||||
? 'Tap to hide participant name'
|
||||
: 'Tap to show participant name',
|
||||
button: true,
|
||||
onTap: toggleLabel,
|
||||
hint: onTap == null ? null : 'Tap to focus participant',
|
||||
button: onTap != null,
|
||||
onTap: onTap,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
key: ValueKey('huddle-participant-avatar-$pubkey'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
behavior: onTap == null
|
||||
? HitTestBehavior.deferToChild
|
||||
: HitTestBehavior.opaque,
|
||||
excludeFromSemantics: true,
|
||||
onTap: toggleLabel,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@@ -136,50 +135,6 @@ class _HuddleCallAvatar extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
key: ValueKey('huddle-participant-label-reveal-$pubkey'),
|
||||
duration: reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
reverseDuration: reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 140),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: (currentChild, previousChildren) => Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [...previousChildren, ?currentChild],
|
||||
),
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: showsLabel.value
|
||||
? Padding(
|
||||
key: ValueKey('huddle-participant-label-$pubkey'),
|
||||
padding: const EdgeInsets.only(top: Grid.half),
|
||||
child: SizedBox(
|
||||
width: frameSize,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: active ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: SizedBox(
|
||||
key: ValueKey('huddle-participant-label-empty-$pubkey'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -187,3 +142,17 @@ class _HuddleCallAvatar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _huddleParticipantLabel({
|
||||
required String pubkey,
|
||||
required UserProfile? profile,
|
||||
required String? fallbackLabel,
|
||||
required bool isSelf,
|
||||
}) {
|
||||
if (isSelf) return 'You';
|
||||
final profileName = profile?.displayName?.trim();
|
||||
final directoryName = fallbackLabel?.trim();
|
||||
return (profileName?.isNotEmpty == true ? profileName : null) ??
|
||||
(directoryName?.isNotEmpty == true ? directoryName : null) ??
|
||||
(pubkey.isEmpty ? 'Participant' : shortPubkey(pubkey));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class _HuddleCallControls extends StatelessWidget {
|
||||
: context.colors.surfaceContainerHighest,
|
||||
dimension: 80,
|
||||
toggled: isSpeakerEnabled,
|
||||
useHapticFeedback: true,
|
||||
onPressed: onToggleSpeaker,
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
@@ -50,6 +51,7 @@ class _HuddleCallControls extends StatelessWidget {
|
||||
: context.colors.primary,
|
||||
dimension: 80,
|
||||
toggled: isMuted,
|
||||
useHapticFeedback: true,
|
||||
onPressed: onToggleMute,
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
@@ -60,6 +62,7 @@ class _HuddleCallControls extends StatelessWidget {
|
||||
foregroundColor: context.colors.onSurface,
|
||||
backgroundColor: context.colors.surfaceContainerHighest,
|
||||
dimension: 80,
|
||||
useHapticFeedback: true,
|
||||
onPressed: onReact,
|
||||
),
|
||||
],
|
||||
@@ -79,6 +82,7 @@ class _HuddleRoundControl extends StatelessWidget {
|
||||
this.dimension = 64,
|
||||
this.showTooltip = true,
|
||||
this.toggled,
|
||||
this.useHapticFeedback = false,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
@@ -89,21 +93,31 @@ class _HuddleRoundControl extends StatelessWidget {
|
||||
final double dimension;
|
||||
final bool showTooltip;
|
||||
final bool? toggled;
|
||||
final bool useHapticFeedback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveOnPressed = onPressed == null
|
||||
? null
|
||||
: () {
|
||||
if (useHapticFeedback) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
onPressed!();
|
||||
};
|
||||
|
||||
return Semantics(
|
||||
label: tooltip,
|
||||
button: true,
|
||||
enabled: onPressed != null,
|
||||
toggled: toggled,
|
||||
onTap: onPressed,
|
||||
onTap: effectiveOnPressed,
|
||||
child: ExcludeSemantics(
|
||||
child: SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: IconButton(
|
||||
tooltip: showTooltip ? tooltip : null,
|
||||
onPressed: onPressed,
|
||||
onPressed: effectiveOnPressed,
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: foregroundColor,
|
||||
backgroundColor: backgroundColor,
|
||||
|
||||
@@ -13,6 +13,8 @@ class _HuddleCallParticipants extends StatelessWidget {
|
||||
required this.retryTooltip,
|
||||
required this.retryIcon,
|
||||
required this.onRetry,
|
||||
required this.onParticipantTap,
|
||||
required this.onOverflowTap,
|
||||
});
|
||||
|
||||
final bool connected;
|
||||
@@ -26,6 +28,8 @@ class _HuddleCallParticipants extends StatelessWidget {
|
||||
final String retryTooltip;
|
||||
final IconData retryIcon;
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<String> onParticipantTap;
|
||||
final VoidCallback onOverflowTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -74,10 +78,13 @@ class _HuddleCallParticipants extends StatelessWidget {
|
||||
final localHeightFactor = hasDenseRemoteRoster ? 0.42 : 0.5;
|
||||
final movementDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 260);
|
||||
: const Duration(milliseconds: 360);
|
||||
final entryDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180);
|
||||
: const Duration(milliseconds: 420);
|
||||
final exitDuration = reducedMotion
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 360);
|
||||
|
||||
return Stack(
|
||||
key: const ValueKey('huddle-participant-stage'),
|
||||
@@ -87,7 +94,7 @@ class _HuddleCallParticipants extends StatelessWidget {
|
||||
child: TweenAnimationBuilder<double>(
|
||||
key: const ValueKey('huddle-local-participant-motion'),
|
||||
duration: movementDuration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
curve: Curves.easeOutBack,
|
||||
tween: Tween(
|
||||
begin: hasRemoteParticipants ? 1 : 0,
|
||||
end: hasRemoteParticipants ? 1 : 0,
|
||||
@@ -120,6 +127,7 @@ class _HuddleCallParticipants extends StatelessWidget {
|
||||
? 0
|
||||
: speakerLevels[localPubkey] ?? 0,
|
||||
isSelf: true,
|
||||
onTap: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -131,54 +139,17 @@ class _HuddleCallParticipants extends StatelessWidget {
|
||||
child: Align(
|
||||
key: const ValueKey('huddle-remote-participant-group'),
|
||||
alignment: const Alignment(0, 0.35),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final layout = _HuddleParticipantLayout.fit(
|
||||
availableSize: constraints.biggest,
|
||||
participantCount: remotePubkeys.length,
|
||||
dense: hasDenseRemoteRoster,
|
||||
);
|
||||
return TweenAnimationBuilder<double>(
|
||||
key: const ValueKey('huddle-remote-participants'),
|
||||
duration: movementDuration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
tween: Tween(end: layout.frameSize),
|
||||
builder: (context, frameSize, _) => Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
spacing: layout.spacing,
|
||||
runSpacing: layout.runSpacing,
|
||||
children: [
|
||||
for (final pubkey in remotePubkeys)
|
||||
SizedBox(
|
||||
key: ValueKey('huddle-participant-entry-$pubkey'),
|
||||
width: frameSize,
|
||||
height: frameSize + _huddleParticipantLabelSpace,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: entryDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
builder: (context, value, child) => Opacity(
|
||||
opacity: value,
|
||||
child: Transform.scale(
|
||||
scale: 0.95 + value * 0.05,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _HuddleCallAvatar(
|
||||
pubkey: pubkey,
|
||||
profile: profiles[pubkey],
|
||||
fallbackLabel: fallbackLabels[pubkey],
|
||||
active: activeSpeakerPubkeys.contains(pubkey),
|
||||
speakerLevel: speakerLevels[pubkey] ?? 0,
|
||||
frameSize: frameSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _HuddleParticipantCluster(
|
||||
pubkeys: remotePubkeys,
|
||||
profiles: profiles,
|
||||
fallbackLabels: fallbackLabels,
|
||||
activeSpeakerPubkeys: activeSpeakerPubkeys,
|
||||
speakerLevels: speakerLevels,
|
||||
movementDuration: movementDuration,
|
||||
entryDuration: entryDuration,
|
||||
exitDuration: exitDuration,
|
||||
onParticipantTap: onParticipantTap,
|
||||
onOverflowTap: onOverflowTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -226,59 +197,3 @@ class _HuddleLoadingBee extends HookWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HuddleParticipantLayout {
|
||||
const _HuddleParticipantLayout({
|
||||
required this.frameSize,
|
||||
required this.spacing,
|
||||
required this.runSpacing,
|
||||
});
|
||||
|
||||
final double frameSize;
|
||||
final double spacing;
|
||||
final double runSpacing;
|
||||
|
||||
static _HuddleParticipantLayout fit({
|
||||
required Size availableSize,
|
||||
required int participantCount,
|
||||
required bool dense,
|
||||
}) {
|
||||
const spacing = Grid.xs;
|
||||
const runSpacing = Grid.xxs;
|
||||
final maximumFrameSize = dense
|
||||
? _huddleDenseAvatarFrameSize
|
||||
: _huddleAvatarFrameSize;
|
||||
if (participantCount <= 0 || availableSize.isEmpty) {
|
||||
return _HuddleParticipantLayout(
|
||||
frameSize: maximumFrameSize,
|
||||
spacing: spacing,
|
||||
runSpacing: runSpacing,
|
||||
);
|
||||
}
|
||||
|
||||
var bestFrameSize = _huddleMinimumAvatarFrameSize;
|
||||
for (var columns = 1; columns <= participantCount; columns++) {
|
||||
final rows = (participantCount + columns - 1) ~/ columns;
|
||||
final widthForAvatars = availableSize.width - spacing * (columns - 1);
|
||||
final heightForAvatars =
|
||||
availableSize.height -
|
||||
runSpacing * (rows - 1) -
|
||||
_huddleParticipantLabelSpace * rows;
|
||||
if (widthForAvatars <= 0 || heightForAvatars <= 0) continue;
|
||||
|
||||
final widthBound = widthForAvatars / columns;
|
||||
final heightBound = heightForAvatars / rows;
|
||||
final candidate = min(maximumFrameSize, min(widthBound, heightBound));
|
||||
if (candidate > bestFrameSize) bestFrameSize = candidate;
|
||||
}
|
||||
|
||||
return _HuddleParticipantLayout(
|
||||
frameSize: bestFrameSize.clamp(
|
||||
_huddleMinimumAvatarFrameSize,
|
||||
maximumFrameSize,
|
||||
),
|
||||
spacing: spacing,
|
||||
runSpacing: runSpacing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
|
||||
icon: LucideIcons.chevronUp,
|
||||
foregroundColor: foreground,
|
||||
backgroundColor: controlSurface,
|
||||
useHapticFeedback: true,
|
||||
onPressed: restoreFullScreen,
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
@@ -235,6 +236,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
|
||||
? foreground
|
||||
: controlSurface,
|
||||
toggled: session.isSpeakerEnabled,
|
||||
useHapticFeedback: true,
|
||||
onPressed: () => unawaited(
|
||||
sessionController.setSpeakerEnabled(
|
||||
!session.isSpeakerEnabled,
|
||||
@@ -256,6 +258,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
|
||||
? controlSurface
|
||||
: foreground,
|
||||
toggled: session.isMuted,
|
||||
useHapticFeedback: true,
|
||||
onPressed: () => unawaited(
|
||||
sessionController.setMuted(!session.isMuted),
|
||||
),
|
||||
@@ -269,6 +272,7 @@ class _MobileHuddleDrawer extends ConsumerWidget {
|
||||
icon: LucideIcons.phoneOff,
|
||||
foregroundColor: context.colors.error,
|
||||
backgroundColor: controlSurface,
|
||||
useHapticFeedback: true,
|
||||
onPressed: leave,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
const _huddleClusterDesignSize = Size(360, 340);
|
||||
const _huddleClusterVisibleParticipantCount = 10;
|
||||
const _huddleClusterFrameScale =
|
||||
_huddleAvatarFrameSize / (_huddleAvatarRadius * 2);
|
||||
const _huddleParticipantPresenceScale = 0.72;
|
||||
|
||||
const _huddleClusterSlots = <_HuddleClusterSlot>[
|
||||
// A: lightly emphasized center participant.
|
||||
_HuddleClusterSlot(left: 130, top: 120, avatarDiameter: 100),
|
||||
// B-F: the original lower/right sequence.
|
||||
_HuddleClusterSlot(left: 226.2, top: 203.3, avatarDiameter: 88),
|
||||
_HuddleClusterSlot(left: 157.2, top: 228.5, avatarDiameter: 64),
|
||||
_HuddleClusterSlot(left: 255.3, top: 135.2, avatarDiameter: 64),
|
||||
_HuddleClusterSlot(left: 223.8, top: 108.8, avatarDiameter: 40),
|
||||
_HuddleClusterSlot(left: 103.2, top: 236, avatarDiameter: 48),
|
||||
// G-J: the mirrored upper/left sequence.
|
||||
_HuddleClusterSlot(left: 45.8, top: 48.7, avatarDiameter: 88),
|
||||
_HuddleClusterSlot(left: 138.8, top: 47.5, avatarDiameter: 64),
|
||||
_HuddleClusterSlot(left: 40.7, top: 140.8, avatarDiameter: 64),
|
||||
_HuddleClusterSlot(left: 96.2, top: 191.2, avatarDiameter: 40),
|
||||
];
|
||||
|
||||
const _huddleClusterOverflowSlot = _HuddleClusterSlot(
|
||||
left: 208.8,
|
||||
top: 56,
|
||||
avatarDiameter: 48,
|
||||
);
|
||||
|
||||
final _huddleClusterAnchor = _huddleClusterCentroid([
|
||||
..._huddleClusterSlots,
|
||||
_huddleClusterOverflowSlot,
|
||||
]);
|
||||
|
||||
class _HuddleParticipantCluster extends HookWidget {
|
||||
const _HuddleParticipantCluster({
|
||||
required this.pubkeys,
|
||||
required this.profiles,
|
||||
required this.fallbackLabels,
|
||||
required this.activeSpeakerPubkeys,
|
||||
required this.speakerLevels,
|
||||
required this.movementDuration,
|
||||
required this.entryDuration,
|
||||
required this.exitDuration,
|
||||
required this.onParticipantTap,
|
||||
required this.onOverflowTap,
|
||||
});
|
||||
|
||||
final List<String> pubkeys;
|
||||
final Map<String, UserProfile> profiles;
|
||||
final Map<String, String> fallbackLabels;
|
||||
final Set<String> activeSpeakerPubkeys;
|
||||
final Map<String, double> speakerLevels;
|
||||
final Duration movementDuration;
|
||||
final Duration entryDuration;
|
||||
final Duration exitDuration;
|
||||
final ValueChanged<String> onParticipantTap;
|
||||
final VoidCallback onOverflowTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visiblePubkeys = pubkeys
|
||||
.take(_huddleClusterVisibleParticipantCount)
|
||||
.toList(growable: false);
|
||||
final visiblePubkeySet = visiblePubkeys.toSet();
|
||||
final renderedPubkeys = useState<List<String>>(visiblePubkeys);
|
||||
final removalTimers = useMemoized(() => <String, Timer>{});
|
||||
final lastSlots = useMemoized(() => <String, _HuddleClusterSlot>{});
|
||||
useEffect(() {
|
||||
for (final pubkey in visiblePubkeys) {
|
||||
removalTimers.remove(pubkey)?.cancel();
|
||||
}
|
||||
|
||||
final nextRenderedPubkeys = [...renderedPubkeys.value];
|
||||
for (final pubkey in visiblePubkeys) {
|
||||
if (!nextRenderedPubkeys.contains(pubkey)) {
|
||||
nextRenderedPubkeys.add(pubkey);
|
||||
}
|
||||
}
|
||||
if (!listEquals(renderedPubkeys.value, nextRenderedPubkeys)) {
|
||||
renderedPubkeys.value = List.unmodifiable(nextRenderedPubkeys);
|
||||
}
|
||||
|
||||
for (final pubkey in renderedPubkeys.value) {
|
||||
if (visiblePubkeySet.contains(pubkey) ||
|
||||
removalTimers.containsKey(pubkey)) {
|
||||
continue;
|
||||
}
|
||||
removalTimers[pubkey] = Timer(exitDuration, () {
|
||||
removalTimers.remove(pubkey);
|
||||
lastSlots.remove(pubkey);
|
||||
renderedPubkeys.value = List.unmodifiable(
|
||||
renderedPubkeys.value.where((value) => value != pubkey),
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [Object.hashAll(visiblePubkeys), exitDuration]);
|
||||
useEffect(
|
||||
() => () {
|
||||
for (final timer in removalTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
removalTimers.clear();
|
||||
},
|
||||
const [],
|
||||
);
|
||||
final overflowCount =
|
||||
pubkeys.length - _huddleClusterVisibleParticipantCount;
|
||||
final layout = _huddleClusterLayout(
|
||||
participantCount: visiblePubkeys.length,
|
||||
showsOverflow: overflowCount > 0,
|
||||
);
|
||||
for (final (index, pubkey) in visiblePubkeys.indexed) {
|
||||
lastSlots[pubkey] = layout.participantSlots[index];
|
||||
}
|
||||
final orderedRenderedPubkeys = [
|
||||
for (final pubkey in renderedPubkeys.value)
|
||||
if (!visiblePubkeySet.contains(pubkey)) pubkey,
|
||||
for (final pubkey in visiblePubkeys)
|
||||
if (renderedPubkeys.value.contains(pubkey)) pubkey,
|
||||
];
|
||||
|
||||
return FittedBox(
|
||||
key: const ValueKey('huddle-participant-cluster'),
|
||||
fit: BoxFit.contain,
|
||||
child: SizedBox(
|
||||
width: _huddleClusterDesignSize.width,
|
||||
height: _huddleClusterDesignSize.height,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final pubkey in orderedRenderedPubkeys)
|
||||
_HuddleAnimatedParticipant(
|
||||
key: ValueKey('huddle-participant-entry-$pubkey'),
|
||||
pubkey: pubkey,
|
||||
profile: profiles[pubkey],
|
||||
fallbackLabel: fallbackLabels[pubkey],
|
||||
active: activeSpeakerPubkeys.contains(pubkey),
|
||||
speakerLevel: speakerLevels[pubkey] ?? 0,
|
||||
slot: lastSlots[pubkey]!,
|
||||
present: visiblePubkeySet.contains(pubkey),
|
||||
movementDuration: movementDuration,
|
||||
entryDuration: entryDuration,
|
||||
exitDuration: exitDuration,
|
||||
onTap: visiblePubkeySet.contains(pubkey)
|
||||
? () {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
onParticipantTap(pubkey);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (overflowCount > 0)
|
||||
_HuddleParticipantOverflow(
|
||||
count: overflowCount,
|
||||
slot: layout.overflowSlot!,
|
||||
entryDuration: entryDuration,
|
||||
onTap: onOverflowTap,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HuddleAnimatedParticipant extends StatelessWidget {
|
||||
const _HuddleAnimatedParticipant({
|
||||
super.key,
|
||||
required this.pubkey,
|
||||
required this.profile,
|
||||
required this.fallbackLabel,
|
||||
required this.active,
|
||||
required this.speakerLevel,
|
||||
required this.slot,
|
||||
required this.present,
|
||||
required this.movementDuration,
|
||||
required this.entryDuration,
|
||||
required this.exitDuration,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String pubkey;
|
||||
final UserProfile? profile;
|
||||
final String? fallbackLabel;
|
||||
final bool active;
|
||||
final double speakerLevel;
|
||||
final _HuddleClusterSlot slot;
|
||||
final bool present;
|
||||
final Duration movementDuration;
|
||||
final Duration entryDuration;
|
||||
final Duration exitDuration;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final frameSize = slot.avatarDiameter * _huddleClusterFrameScale;
|
||||
final frameInset = (frameSize - slot.avatarDiameter) / 2;
|
||||
final targetOffset = Offset(slot.left - frameInset, slot.top - frameInset);
|
||||
|
||||
return Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: frameSize,
|
||||
height: frameSize + _huddleParticipantLabelSpace,
|
||||
child: TweenAnimationBuilder<Offset>(
|
||||
key: ValueKey('huddle-participant-motion-$pubkey'),
|
||||
duration: movementDuration,
|
||||
curve: Curves.easeOutBack,
|
||||
tween: Tween(begin: targetOffset, end: targetOffset),
|
||||
builder: (context, offset, child) => Transform.translate(
|
||||
key: ValueKey('huddle-participant-translation-$pubkey'),
|
||||
offset: offset,
|
||||
child: child,
|
||||
),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
key: ValueKey('huddle-participant-entry-motion-$pubkey'),
|
||||
duration: present ? entryDuration : exitDuration,
|
||||
curve: present
|
||||
? Curves.easeOutBack
|
||||
: const _HuddleExitPresenceCurve(),
|
||||
tween: Tween(begin: present ? 0 : 1, end: present ? 1 : 0),
|
||||
builder: (context, value, child) => Opacity(
|
||||
opacity: value.clamp(0, 1),
|
||||
child: Transform.scale(
|
||||
key: ValueKey('huddle-participant-entry-scale-$pubkey'),
|
||||
scale:
|
||||
_huddleParticipantPresenceScale +
|
||||
value * (1 - _huddleParticipantPresenceScale),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: _HuddleCallAvatar(
|
||||
pubkey: pubkey,
|
||||
profile: profile,
|
||||
fallbackLabel: fallbackLabel,
|
||||
active: active,
|
||||
speakerLevel: speakerLevel,
|
||||
frameSize: frameSize,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HuddleParticipantOverflow extends StatelessWidget {
|
||||
const _HuddleParticipantOverflow({
|
||||
required this.count,
|
||||
required this.slot,
|
||||
required this.entryDuration,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int count;
|
||||
final _HuddleClusterSlot slot;
|
||||
final Duration entryDuration;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: slot.left,
|
||||
top: slot.top,
|
||||
width: slot.avatarDiameter,
|
||||
height: slot.avatarDiameter,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: entryDuration,
|
||||
curve: Curves.easeOutBack,
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
builder: (context, value, child) => Opacity(
|
||||
opacity: value.clamp(0, 1),
|
||||
child: Transform.scale(scale: 0.95 + value * 0.05, child: child),
|
||||
),
|
||||
child: Semantics(
|
||||
label: '$count more participants',
|
||||
hint: 'Tap to show participant list',
|
||||
button: true,
|
||||
onTap: onTap,
|
||||
child: ExcludeSemantics(
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('huddle-participant-overflow'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
excludeFromSemantics: true,
|
||||
onTap: onTap,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
),
|
||||
child: Center(
|
||||
child: AnimatedSwitcher(
|
||||
duration: entryDuration,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
child: Text(
|
||||
'+$count',
|
||||
key: ValueKey('huddle-participant-overflow-count-$count'),
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HuddleClusterSlot {
|
||||
const _HuddleClusterSlot({
|
||||
required this.left,
|
||||
required this.top,
|
||||
required this.avatarDiameter,
|
||||
});
|
||||
|
||||
final double left;
|
||||
final double top;
|
||||
final double avatarDiameter;
|
||||
|
||||
_HuddleClusterSlot translate(Offset offset) => _HuddleClusterSlot(
|
||||
left: left + offset.dx,
|
||||
top: top + offset.dy,
|
||||
avatarDiameter: avatarDiameter,
|
||||
);
|
||||
}
|
||||
|
||||
_HuddleClusterLayout _huddleClusterLayout({
|
||||
required int participantCount,
|
||||
required bool showsOverflow,
|
||||
}) {
|
||||
final participantSlots = _huddleClusterSlots
|
||||
.take(participantCount)
|
||||
.toList(growable: false);
|
||||
final occupiedSlots = [
|
||||
...participantSlots,
|
||||
if (showsOverflow) _huddleClusterOverflowSlot,
|
||||
];
|
||||
if (occupiedSlots.isEmpty) {
|
||||
return const _HuddleClusterLayout(participantSlots: []);
|
||||
}
|
||||
|
||||
final translation =
|
||||
_huddleClusterAnchor - _huddleClusterCentroid(occupiedSlots);
|
||||
return _HuddleClusterLayout(
|
||||
participantSlots: [
|
||||
for (final slot in participantSlots) slot.translate(translation),
|
||||
],
|
||||
overflowSlot: showsOverflow
|
||||
? _huddleClusterOverflowSlot.translate(translation)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Offset _huddleClusterCentroid(Iterable<_HuddleClusterSlot> slots) {
|
||||
var x = 0.0;
|
||||
var y = 0.0;
|
||||
var count = 0;
|
||||
for (final slot in slots) {
|
||||
x += slot.left + slot.avatarDiameter / 2;
|
||||
y += slot.top + slot.avatarDiameter / 2;
|
||||
count++;
|
||||
}
|
||||
return count == 0 ? Offset.zero : Offset(x / count, y / count);
|
||||
}
|
||||
|
||||
class _HuddleClusterLayout {
|
||||
const _HuddleClusterLayout({
|
||||
required this.participantSlots,
|
||||
this.overflowSlot,
|
||||
});
|
||||
|
||||
final List<_HuddleClusterSlot> participantSlots;
|
||||
final _HuddleClusterSlot? overflowSlot;
|
||||
}
|
||||
|
||||
class _HuddleExitPresenceCurve extends Curve {
|
||||
const _HuddleExitPresenceCurve();
|
||||
|
||||
@override
|
||||
double transformInternal(double t) {
|
||||
const overshootEnd = 0.3;
|
||||
const overshootPeak = overshootEnd / 2;
|
||||
const overshootAmount = 0.25;
|
||||
if (t < overshootPeak) {
|
||||
return -overshootAmount *
|
||||
Curves.easeOutCubic.transform(t / overshootPeak);
|
||||
}
|
||||
if (t < overshootEnd) {
|
||||
return -overshootAmount *
|
||||
(1 -
|
||||
Curves.easeInOutCubic.transform(
|
||||
(t - overshootPeak) / overshootPeak,
|
||||
));
|
||||
}
|
||||
return Curves.easeInOutCubic.transform(
|
||||
(t - overshootEnd) / (1 - overshootEnd),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
part of '../channel_detail_page.dart';
|
||||
|
||||
const _huddleParticipantOverlayDuration = Duration(milliseconds: 320);
|
||||
const _huddleParticipantSpotlightRadius = 68.0;
|
||||
|
||||
void _showHuddleParticipantSpotlight({
|
||||
required BuildContext context,
|
||||
required String pubkey,
|
||||
required bool isSelf,
|
||||
}) {
|
||||
_showHuddleParticipantOverlay(
|
||||
context: context,
|
||||
barrierLabel: 'Dismiss participant',
|
||||
childBuilder: (dialogContext) => _HuddleParticipantSpotlight(
|
||||
pubkey: pubkey,
|
||||
isSelf: isSelf,
|
||||
onDismiss: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHuddleParticipantRoster({required BuildContext context}) {
|
||||
_showHuddleParticipantOverlay(
|
||||
context: context,
|
||||
barrierLabel: 'Dismiss participant list',
|
||||
childBuilder: (_) => const _HuddleParticipantRoster(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHuddleParticipantOverlay({
|
||||
required BuildContext context,
|
||||
required String barrierLabel,
|
||||
required WidgetBuilder childBuilder,
|
||||
}) {
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
unawaited(
|
||||
showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: barrierLabel,
|
||||
barrierColor: Colors.transparent,
|
||||
transitionDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: _huddleParticipantOverlayDuration,
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
child,
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) =>
|
||||
_HuddleParticipantOverlay(
|
||||
animation: animation,
|
||||
child: childBuilder(dialogContext),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _HuddleParticipantOverlay extends StatelessWidget {
|
||||
const _HuddleParticipantOverlay({
|
||||
required this.animation,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final blurProgress = const Interval(
|
||||
0,
|
||||
0.30,
|
||||
curve: Curves.easeOutCubic,
|
||||
).transform(animation.value);
|
||||
final sigma = 20 * blurProgress;
|
||||
return BackdropFilter(
|
||||
key: const ValueKey('huddle-participant-modal-backdrop'),
|
||||
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||
child: ColoredBox(
|
||||
color: context.colors.inverseSurface.withValues(
|
||||
alpha: 0.10 * blurProgress,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('huddle-participant-modal-dismiss-region'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: animation,
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
final appearance = Curves.easeOutCubic.transform(
|
||||
animation.value,
|
||||
);
|
||||
final scale = Curves.easeOutBack.transform(animation.value);
|
||||
return Opacity(
|
||||
opacity: appearance,
|
||||
child: Transform.scale(
|
||||
scale: lerpDouble(0.92, 1, scale)!,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HuddleParticipantSpotlight extends ConsumerWidget {
|
||||
const _HuddleParticipantSpotlight({
|
||||
required this.pubkey,
|
||||
required this.isSelf,
|
||||
required this.onDismiss,
|
||||
});
|
||||
|
||||
final String pubkey;
|
||||
final bool isSelf;
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final participantPresent = ref.watch(
|
||||
huddleSessionProvider.select(
|
||||
(session) => isSelf || session.participantPubkeys.contains(pubkey),
|
||||
),
|
||||
);
|
||||
if (!participantPresent) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) onDismiss();
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final profile = ref.watch(
|
||||
userCacheProvider.select((profiles) => profiles[pubkey]),
|
||||
);
|
||||
final fallbackLabel = ref.watch(
|
||||
agentDirectoryDisplayNamesProvider.select((labels) => labels[pubkey]),
|
||||
);
|
||||
final active = ref.watch(
|
||||
huddleSessionProvider.select(
|
||||
(session) => session.activeSpeakerPubkeys.contains(pubkey),
|
||||
),
|
||||
);
|
||||
final label = _huddleParticipantLabel(
|
||||
pubkey: pubkey,
|
||||
profile: profile,
|
||||
fallbackLabel: fallbackLabel,
|
||||
isSelf: isSelf,
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
label: active ? '$label, speaking' : label,
|
||||
hint: 'Tap to close participant',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: GestureDetector(
|
||||
key: const ValueKey('huddle-participant-spotlight'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
excludeFromSemantics: true,
|
||||
onTap: onDismiss,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.gutter),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
key: ValueKey('huddle-participant-spotlight-avatar-$pubkey'),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 180),
|
||||
padding: EdgeInsets.all(active ? Grid.xxs : Grid.half),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: context.colors.primary.withValues(
|
||||
alpha: active ? 0.18 : 0.08,
|
||||
),
|
||||
),
|
||||
child: AvatarImage(
|
||||
imageUrl: profile?.avatarUrl,
|
||||
radius: _huddleParticipantSpotlightRadius,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
fallback: Icon(
|
||||
LucideIcons.userRound,
|
||||
size: 56,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
Material(
|
||||
color: context.colors.surface.withValues(alpha: 0.92),
|
||||
shape: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Text(
|
||||
label,
|
||||
key: const ValueKey('huddle-participant-spotlight-name'),
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HuddleParticipantRoster extends ConsumerWidget {
|
||||
const _HuddleParticipantRoster();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(huddleSessionProvider);
|
||||
final localPubkey = session.currentPubkey?.toLowerCase();
|
||||
final pubkeys = session.participantPubkeys
|
||||
.where((pubkey) => pubkey != localPubkey)
|
||||
.skip(_huddleClusterVisibleParticipantCount)
|
||||
.toList(growable: false);
|
||||
if (pubkeys.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final fallbackLabels = ref.watch(agentDirectoryDisplayNamesProvider);
|
||||
final activeSpeakerPubkeys = ref.watch(
|
||||
huddleSessionProvider.select((session) => session.activeSpeakerPubkeys),
|
||||
);
|
||||
final maxListHeight = min(MediaQuery.sizeOf(context).height * 0.55, 416.0);
|
||||
final listHeight = min(pubkeys.length * 64.0, maxListHeight);
|
||||
|
||||
return Semantics(
|
||||
label: '${pubkeys.length} more participants',
|
||||
container: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
child: Material(
|
||||
key: const ValueKey('huddle-participant-roster'),
|
||||
color: context.colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 8,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
Grid.gutter,
|
||||
Grid.xs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'${pubkeys.length} more ${pubkeys.length == 1 ? 'person' : 'people'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
SizedBox(
|
||||
height: listHeight,
|
||||
child: ListView.separated(
|
||||
key: const ValueKey('huddle-participant-roster-list'),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: pubkeys.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: Grid.xxs,
|
||||
color: context.colors.outlineVariant.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final pubkey = pubkeys[index];
|
||||
final profile = profiles[pubkey];
|
||||
final active = activeSpeakerPubkeys.contains(pubkey);
|
||||
final label = _huddleParticipantLabel(
|
||||
pubkey: pubkey,
|
||||
profile: profile,
|
||||
fallbackLabel: fallbackLabels[pubkey],
|
||||
isSelf: false,
|
||||
);
|
||||
return Semantics(
|
||||
label: active ? '$label, speaking' : label,
|
||||
excludeSemantics: true,
|
||||
child: SizedBox(
|
||||
key: ValueKey(
|
||||
'huddle-participant-roster-row-$pubkey',
|
||||
),
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
AvatarImage(
|
||||
imageUrl: profile?.avatarUrl,
|
||||
radius: 22,
|
||||
backgroundColor:
|
||||
context.colors.primaryContainer,
|
||||
fallback: Icon(
|
||||
LucideIcons.userRound,
|
||||
size: 22,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: active
|
||||
? FontWeight.w600
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ const _huddleLifetime = Duration(hours: 1);
|
||||
const _huddleAvatarRadius = 52.0;
|
||||
const _huddleAvatarFrameSize = 128.0;
|
||||
const _huddleSpeakingRingSize = 112.0;
|
||||
const _huddleDenseAvatarFrameSize = 104.0;
|
||||
const _huddleMinimumAvatarFrameSize = 48.0;
|
||||
const _huddleParticipantLabelSpace = 28.0;
|
||||
const _huddleDenseParticipantThreshold = 6;
|
||||
|
||||
@@ -656,6 +654,16 @@ class _MobileHuddleCallPage extends ConsumerWidget {
|
||||
retryTooltip: retryTooltip,
|
||||
retryIcon: retryIcon,
|
||||
onRetry: onRetry,
|
||||
onParticipantTap: (pubkey) {
|
||||
final isSelf = pubkey == localPubkey || pubkey.isEmpty;
|
||||
_showHuddleParticipantSpotlight(
|
||||
context: context,
|
||||
pubkey: pubkey,
|
||||
isSelf: isSelf,
|
||||
);
|
||||
},
|
||||
onOverflowTap: () =>
|
||||
_showHuddleParticipantRoster(context: context),
|
||||
),
|
||||
),
|
||||
if (connected)
|
||||
@@ -742,7 +750,10 @@ class _HuddleCallHeader extends StatelessWidget {
|
||||
IconButton(
|
||||
key: const ValueKey('huddle-minimize'),
|
||||
tooltip: 'Minimize',
|
||||
onPressed: onMinimize,
|
||||
onPressed: () {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
onMinimize();
|
||||
},
|
||||
icon: const Icon(LucideIcons.chevronDown, size: 32),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -752,6 +763,7 @@ class _HuddleCallHeader extends StatelessWidget {
|
||||
icon: LucideIcons.phoneOff,
|
||||
foregroundColor: context.colors.error,
|
||||
backgroundColor: context.colors.surfaceContainerHighest,
|
||||
useHapticFeedback: true,
|
||||
onPressed: isLeaving ? null : onLeave,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -55,7 +55,7 @@ final class HuddleConnectionParameters {
|
||||
}
|
||||
|
||||
/// Fixed NIP-42 + Huddle auth envelope used after the audio relay challenge.
|
||||
abstract final class HuddleAuthV3 {
|
||||
abstract final class HuddleAuthV2 {
|
||||
static Map<String, dynamic> buildMessage({
|
||||
required HuddleConnectionParameters parameters,
|
||||
required String challenge,
|
||||
@@ -82,7 +82,7 @@ abstract final class HuddleAuthV3 {
|
||||
'type': 'auth',
|
||||
'event': event.toMap(),
|
||||
'parent_channel_id': parameters.parentChannelId,
|
||||
'protocol_version': HuddleWireV3.protocolVersion,
|
||||
'protocol_version': HuddleWireV2.protocolVersion,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -396,10 +396,10 @@ final class MethodChannelHuddleMedia implements HuddleMedia {
|
||||
try {
|
||||
final result = await _channel
|
||||
.invokeMapMethod<dynamic, dynamic>('prepare', {
|
||||
'protocolVersion': HuddleWireV3.protocolVersion,
|
||||
'sampleRateHz': HuddleWireV3.sampleRateHz,
|
||||
'channels': HuddleWireV3.channels,
|
||||
'frameSamples': HuddleWireV3.frameSamples,
|
||||
'protocolVersion': HuddleWireV2.protocolVersion,
|
||||
'sampleRateHz': HuddleWireV2.sampleRateHz,
|
||||
'channels': HuddleWireV2.channels,
|
||||
'frameSamples': HuddleWireV2.frameSamples,
|
||||
});
|
||||
if (result?['audioSessionPrepared'] != true) {
|
||||
throw const HuddleMediaError(
|
||||
|
||||
@@ -115,7 +115,7 @@ final class HuddleTransportState {
|
||||
///
|
||||
/// This socket intentionally does not reuse [RelaySocket]: the main Nostr
|
||||
/// connection speaks JSON arrays and ignores binary messages, while Huddle
|
||||
/// audio uses JSON objects for its handshake/control plane and binary Opus v3
|
||||
/// audio uses JSON objects for its handshake/control plane and binary Opus v2
|
||||
/// frames for media. [connect] completes only after the relay's `joined`
|
||||
/// response, making authentication and room admission observable to callers.
|
||||
abstract interface class HuddleTransportClient {
|
||||
@@ -295,7 +295,7 @@ final class HuddleTransport implements HuddleTransportClient {
|
||||
}
|
||||
|
||||
try {
|
||||
channel.sink.add(HuddleWireV3.encodeClientFrame(header, opusPayload));
|
||||
channel.sink.add(HuddleWireV2.encodeClientFrame(header, opusPayload));
|
||||
} catch (error) {
|
||||
throw HuddleTransportError(
|
||||
code: HuddleTransportErrorCode.sendFailed,
|
||||
@@ -409,7 +409,7 @@ final class HuddleTransport implements HuddleTransportClient {
|
||||
}
|
||||
|
||||
try {
|
||||
final auth = HuddleAuthV3.buildMessage(
|
||||
final auth = HuddleAuthV2.buildMessage(
|
||||
parameters: parameters,
|
||||
challenge: challenge,
|
||||
);
|
||||
@@ -806,12 +806,11 @@ final class HuddleTransport implements HuddleTransportClient {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final frame = HuddleWireV3.decodeRelayFrame(bytes);
|
||||
// The authoritative control roster owns the routing table. Packets from
|
||||
// absent/recycled slots are stale and must never allocate playback state.
|
||||
// The epoch fences the reuse race: an in-flight frame authored by a
|
||||
// departed occupant that arrives after its index is reassigned carries
|
||||
// the old epoch and is dropped rather than mis-attributed.
|
||||
final frame = HuddleWireV2.decodeRelayFrame(bytes);
|
||||
// The authoritative control roster owns the routing table. Protocol v2
|
||||
// carries only the peer index, so packets are accepted only while that
|
||||
// index is currently present. V3's stricter occupancy-epoch fence is not
|
||||
// available in this compatibility build.
|
||||
if (!_isCurrentOccupant(frame.peerIndex, frame.epoch)) return;
|
||||
if (_audioIngress.length == _audioIngressCapacity) {
|
||||
_audioIngress.removeFirst();
|
||||
@@ -829,14 +828,10 @@ final class HuddleTransport implements HuddleTransportClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `peerIndex` is currently occupied at exactly `epoch`. A frame is
|
||||
/// deliverable only when both match the authoritative roster: an absent slot
|
||||
/// is stale, and a slot reused by a later occupant has advanced its epoch, so
|
||||
/// a departed occupant's in-flight frame is fenced rather than mis-attributed.
|
||||
bool _isCurrentOccupant(int peerIndex, int epoch) {
|
||||
final occupant = _state.peers[peerIndex];
|
||||
return occupant != null && occupant.epoch == epoch;
|
||||
}
|
||||
/// Whether `peerIndex` is currently occupied. Protocol v2 has no media epoch,
|
||||
/// so the control-plane roster is the strongest routing boundary available.
|
||||
bool _isCurrentOccupant(int peerIndex, int _) =>
|
||||
_state.peers.containsKey(peerIndex);
|
||||
|
||||
void _purgeAudioIngress(Set<int> peerIndices) {
|
||||
if (peerIndices.isEmpty || _audioIngress.isEmpty) return;
|
||||
@@ -854,7 +849,8 @@ final class HuddleTransport implements HuddleTransportClient {
|
||||
// Control and media are handled by the same socket callback, but queued
|
||||
// media drains on later event-loop turns. Revalidate here so a removal
|
||||
// or index replacement wins over every frame queued before that control.
|
||||
// The epoch check rejects a frame whose slot was reused after it queued.
|
||||
// Revalidate occupancy after the event-loop hop so a control-plane leave
|
||||
// can purge queued v2 media before it reaches native playout.
|
||||
if (_isCurrentOccupant(frame.peerIndex, frame.epoch)) {
|
||||
_audioController.add(frame);
|
||||
break;
|
||||
|
||||
@@ -2,18 +2,17 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Fixed audio framing contract shared with Buzz Desktop and `buzz-relay`.
|
||||
///
|
||||
/// Huddle audio rooms pin the first participant's protocol version. New mobile
|
||||
/// clients request v3 because relay-to-client frames add an occupancy epoch to
|
||||
/// v2's one-byte peer prefix. They must not silently fall back: the relay
|
||||
/// rejects mixed-version rooms with `upgrade_required`.
|
||||
abstract final class HuddleWireV3 {
|
||||
static const protocolVersion = 3;
|
||||
/// Huddle audio rooms pin the first participant's protocol version. This
|
||||
/// compatibility build requests the released v2 contract so it can connect to
|
||||
/// relays that have not yet rolled out v3.
|
||||
abstract final class HuddleWireV2 {
|
||||
static const protocolVersion = 2;
|
||||
static const sampleRateHz = 48000;
|
||||
static const channels = 1;
|
||||
static const frameSamples = 960;
|
||||
static const frameDuration = Duration(milliseconds: 20);
|
||||
static const headerLength = 8;
|
||||
static const relayPeerPrefixLength = 2;
|
||||
static const relayPeerPrefixLength = 1;
|
||||
static const dtxFlag = 0x01;
|
||||
static const maxBinaryFrameLength = 4096;
|
||||
|
||||
@@ -37,7 +36,7 @@ abstract final class HuddleWireV3 {
|
||||
}
|
||||
|
||||
/// Decodes a relay-to-client frame:
|
||||
/// `peer index | epoch | header | opus payload`.
|
||||
/// `peer index | header | opus payload`.
|
||||
static HuddleRemoteAudioFrame decodeRelayFrame(Uint8List bytes) {
|
||||
final minimumLength = relayPeerPrefixLength + headerLength + 1;
|
||||
if (bytes.length < minimumLength) {
|
||||
@@ -55,7 +54,10 @@ abstract final class HuddleWireV3 {
|
||||
|
||||
return HuddleRemoteAudioFrame(
|
||||
peerIndex: bytes[0],
|
||||
epoch: bytes[1],
|
||||
// Protocol v2 has no occupancy epoch on media frames. Keep the internal
|
||||
// field at its legacy value so the rest of the playout model remains
|
||||
// unchanged while routing by the authoritative peer index.
|
||||
epoch: 0,
|
||||
header: HuddleAudioHeader.decode(bytes, offset: relayPeerPrefixLength),
|
||||
opusPayload: Uint8List.fromList(
|
||||
bytes.sublist(relayPeerPrefixLength + headerLength),
|
||||
@@ -104,11 +106,11 @@ final class HuddleAudioHeader {
|
||||
);
|
||||
}
|
||||
|
||||
bool get isDtx => flags & HuddleWireV3.dtxFlag != 0;
|
||||
bool get isDtx => flags & HuddleWireV2.dtxFlag != 0;
|
||||
|
||||
Uint8List encode() {
|
||||
_validate();
|
||||
final bytes = Uint8List(HuddleWireV3.headerLength);
|
||||
final bytes = Uint8List(HuddleWireV2.headerLength);
|
||||
final view = ByteData.sublistView(bytes);
|
||||
view.setUint16(0, sequence, Endian.big);
|
||||
view.setUint32(2, timestamp48k, Endian.big);
|
||||
@@ -118,7 +120,7 @@ final class HuddleAudioHeader {
|
||||
}
|
||||
|
||||
static HuddleAudioHeader decode(Uint8List bytes, {int offset = 0}) {
|
||||
if (offset < 0 || bytes.length - offset < HuddleWireV3.headerLength) {
|
||||
if (offset < 0 || bytes.length - offset < HuddleWireV2.headerLength) {
|
||||
throw const HuddleWireException(
|
||||
'Huddle audio header requires eight bytes.',
|
||||
);
|
||||
@@ -126,7 +128,7 @@ final class HuddleAudioHeader {
|
||||
final view = ByteData.sublistView(
|
||||
bytes,
|
||||
offset,
|
||||
offset + HuddleWireV3.headerLength,
|
||||
offset + HuddleWireV2.headerLength,
|
||||
);
|
||||
final rawLevel = view.getInt8(6);
|
||||
// Match the desktop contract: malformed VU telemetry becomes the silent
|
||||
@@ -167,10 +169,8 @@ final class HuddleAudioHeader {
|
||||
final class HuddleRemoteAudioFrame {
|
||||
final int peerIndex;
|
||||
|
||||
/// Occupancy epoch of `peerIndex` this frame was authored under. Fenced by
|
||||
/// the transport against the current roster occupant's epoch so a frame from
|
||||
/// a departed occupant that arrives after its slot is reused is dropped
|
||||
/// rather than mis-attributed to the new occupant.
|
||||
/// Always zero for protocol v2, whose relay prefix contains only
|
||||
/// `peerIndex`. Retained as an internal compatibility field.
|
||||
final int epoch;
|
||||
final HuddleAudioHeader header;
|
||||
final Uint8List opusPayload;
|
||||
|
||||
@@ -4715,8 +4715,29 @@ void main() {
|
||||
3: HuddlePeer(pubkey: 'agent', peerIndex: 3, epoch: 0),
|
||||
},
|
||||
);
|
||||
final users = _FakeUserCacheNotifier(const {
|
||||
'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'),
|
||||
'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'),
|
||||
'self': UserProfile(pubkey: 'self', displayName: 'Self'),
|
||||
});
|
||||
final navigator = _RecordingNavigatorObserver();
|
||||
String? leftChannelId;
|
||||
final hapticCalls = <MethodCall>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
hapticCalls.add(call);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
@@ -4728,11 +4749,7 @@ void main() {
|
||||
createdAt: now,
|
||||
),
|
||||
],
|
||||
users: const {
|
||||
'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'),
|
||||
'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'),
|
||||
'self': UserProfile(pubkey: 'self', displayName: 'Self'),
|
||||
},
|
||||
userCacheNotifier: users,
|
||||
huddleMembers: [
|
||||
ChannelMember(
|
||||
pubkey: 'agent',
|
||||
@@ -4851,47 +4868,103 @@ void main() {
|
||||
find.byKey(const ValueKey('huddle-participant-avatar-desktop')),
|
||||
);
|
||||
await tester.pump();
|
||||
final milesLabel = find.byKey(
|
||||
const ValueKey('huddle-participant-label-desktop'),
|
||||
);
|
||||
final milesReveal = find
|
||||
.ancestor(of: milesLabel, matching: find.byType(FadeTransition))
|
||||
.first;
|
||||
expect(tester.widget<FadeTransition>(milesReveal).opacity.value, 0);
|
||||
await tester.pump(const Duration(milliseconds: 90));
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(
|
||||
tester.widget<FadeTransition>(milesReveal).opacity.value,
|
||||
allOf(greaterThan(0), lessThan(1)),
|
||||
find.byKey(const ValueKey('huddle-participant-modal-backdrop')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-spotlight')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Miles'), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics && widget.properties.label == 'Miles',
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
transport.emitRemoteAudio();
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics &&
|
||||
widget.properties.label == 'Miles, speaking',
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
users.replace(
|
||||
const UserProfile(pubkey: 'desktop', displayName: 'Miles Davis'),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('Miles'), findsNothing);
|
||||
expect(find.text('Miles Davis'), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Semantics &&
|
||||
widget.properties.label == 'Miles Davis, speaking',
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
users.replace(
|
||||
const UserProfile(pubkey: 'desktop', displayName: 'Miles'),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('Pollen'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('huddle-leave')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-label-desktop')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
transport.emitPeerLeave(1);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-spotlight')),
|
||||
findsNothing,
|
||||
);
|
||||
transport.emitPeerJoin(
|
||||
const HuddlePeer(pubkey: 'desktop', peerIndex: 1, epoch: 1),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('huddle-participant-avatar-agent')),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(hapticCalls, hasLength(2));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(find.text('Pollen'), findsOneWidget);
|
||||
await tester.tapAt(const Offset(8, 8));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Pollen'), findsNothing);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('huddle-participant-avatar-self')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('You'), findsOneWidget);
|
||||
final selfLabel = find.byKey(
|
||||
const ValueKey('huddle-participant-label-self'),
|
||||
);
|
||||
final selfAvatar = find.byKey(
|
||||
const ValueKey('huddle-speaking-ring-self'),
|
||||
);
|
||||
expect(find.text('You'), findsNothing);
|
||||
expect(
|
||||
tester.getCenter(selfLabel).dx,
|
||||
closeTo(tester.getCenter(selfAvatar).dx, 0.01),
|
||||
find.byKey(
|
||||
const ValueKey('huddle-participant-spotlight-avatar-self'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
tester.getTopLeft(selfLabel).dy,
|
||||
greaterThanOrEqualTo(tester.getBottomLeft(selfAvatar).dy),
|
||||
expect(hapticCalls, hasLength(2));
|
||||
final selfSemantics = tester.widget<Semantics>(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is Semantics && widget.properties.label == 'You',
|
||||
),
|
||||
);
|
||||
expect(selfSemantics.properties.button, isFalse);
|
||||
expect(selfSemantics.properties.onTap, isNull);
|
||||
expect(selfSemantics.properties.hint, isNull);
|
||||
hapticCalls.clear();
|
||||
expect(find.text('Connected'), findsNothing);
|
||||
expect(find.text('Waiting for remote audio'), findsNothing);
|
||||
expect(find.text('Microphone muted'), findsNothing);
|
||||
@@ -4998,6 +5071,8 @@ void main() {
|
||||
?.resolve(const <WidgetState>{});
|
||||
await tester.tap(find.byKey(const ValueKey('huddle-speaker-toggle')));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('huddle-speaker-toggle')),
|
||||
@@ -5052,6 +5127,8 @@ void main() {
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('huddle-mute-toggle')));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(2));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('huddle-mute-toggle')),
|
||||
@@ -5092,6 +5169,8 @@ void main() {
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('huddle-emoji-reactions')));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(hapticCalls, hasLength(3));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(find.byType(EmojiPickerSheet), findsOneWidget);
|
||||
tester
|
||||
.widget<EmojiPickerSheet>(find.byType(EmojiPickerSheet))
|
||||
@@ -5102,6 +5181,8 @@ void main() {
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('huddle-minimize')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(hapticCalls, hasLength(4));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(find.widgetWithText(FilledButton, 'Open'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
@@ -5157,10 +5238,19 @@ void main() {
|
||||
);
|
||||
expect(find.bySemanticsLabel('Unmute'), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('huddle-drawer-speaker-toggle')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(5));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('huddle-drawer-mute-toggle')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(6));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedPositioned>(
|
||||
@@ -5204,6 +5294,8 @@ void main() {
|
||||
}
|
||||
await tester.tap(find.byKey(const ValueKey('huddle-drawer-expand')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(hapticCalls, hasLength(7));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(find.byKey(const ValueKey('huddle-minimize')), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
@@ -5218,8 +5310,12 @@ void main() {
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('huddle-minimize')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(hapticCalls, hasLength(8));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
await tester.tap(find.byKey(const ValueKey('huddle-drawer-leave')));
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(9));
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedPositioned>(
|
||||
@@ -5243,7 +5339,7 @@ void main() {
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'fits a dense Huddle roster without scrolling and shrinks avatars',
|
||||
'caps a dense Huddle roster at ten avatars with an overflow count',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -5314,12 +5410,60 @@ void main() {
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
for (final pubkey in remotePubkeys) {
|
||||
for (final pubkey in remotePubkeys.take(10)) {
|
||||
expect(
|
||||
find.byKey(ValueKey('huddle-participant-entry-$pubkey')),
|
||||
find.byKey(ValueKey('huddle-participant-avatar-$pubkey')),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
for (final pubkey in remotePubkeys.skip(10)) {
|
||||
expect(
|
||||
find.byKey(ValueKey('huddle-participant-avatar-$pubkey')),
|
||||
findsNothing,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-overflow')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('+14'), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('huddle-participant-overflow')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-roster')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('14 more people'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-roster-row-guest-10')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-roster-row-guest-0')),
|
||||
findsNothing,
|
||||
);
|
||||
await tester.scrollUntilVisible(
|
||||
find.byKey(const ValueKey('huddle-participant-roster-row-guest-23')),
|
||||
200,
|
||||
scrollable: find.descendant(
|
||||
of: find.byKey(const ValueKey('huddle-participant-roster-list')),
|
||||
matching: find.byType(Scrollable),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-roster-row-guest-23')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tapAt(const Offset(8, 8));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-roster')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
final firstRemoteRing = find.byKey(
|
||||
const ValueKey('huddle-speaking-ring-guest-0'),
|
||||
@@ -5467,13 +5611,12 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('does not add backing-channel members after relay admission', (
|
||||
tester,
|
||||
) async {
|
||||
const staleMemberPubkey =
|
||||
testWidgets('springs admitted participants in and out', (tester) async {
|
||||
const addedMemberPubkey =
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final membersNotifier = _MutableHuddleMembersNotifier(const []);
|
||||
final transport = _HuddleTestTransport();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
@@ -5488,9 +5631,9 @@ void main() {
|
||||
users: const {
|
||||
'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'),
|
||||
'self': UserProfile(pubkey: 'self', displayName: 'Self'),
|
||||
staleMemberPubkey: UserProfile(
|
||||
pubkey: staleMemberPubkey,
|
||||
displayName: 'Stale member',
|
||||
addedMemberPubkey: UserProfile(
|
||||
pubkey: addedMemberPubkey,
|
||||
displayName: 'Added member',
|
||||
),
|
||||
},
|
||||
huddleMembersNotifier: membersNotifier,
|
||||
@@ -5498,33 +5641,107 @@ void main() {
|
||||
relaySessionNotifier: _ReconnectingRelaySession(),
|
||||
huddleCurrentPubkey: 'self',
|
||||
huddleMediaFactory: _HuddleTestMedia.new,
|
||||
huddleTransportFactory: (_) => _HuddleTestTransport(),
|
||||
huddleTransportFactory: (_) => transport,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Join'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final desktopAvatar = find.byKey(
|
||||
const ValueKey('huddle-speaking-ring-desktop'),
|
||||
);
|
||||
final initialDesktopCenter = tester.getCenter(desktopAvatar);
|
||||
|
||||
membersNotifier.replace([
|
||||
ChannelMember(
|
||||
pubkey: staleMemberPubkey,
|
||||
pubkey: addedMemberPubkey,
|
||||
role: 'member',
|
||||
joinedAt: DateTime(2025),
|
||||
),
|
||||
]);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey('huddle-participant-avatar-$staleMemberPubkey'),
|
||||
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
transport.emitPeerJoin(
|
||||
const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final addedMemberScale = find.byKey(
|
||||
const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'),
|
||||
);
|
||||
expect(
|
||||
tester.widget<Transform>(addedMemberScale).transform.storage[0],
|
||||
closeTo(0.72, 0.01),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 120));
|
||||
final movingDesktopCenter = tester.getCenter(desktopAvatar);
|
||||
expect(
|
||||
tester.widget<Transform>(addedMemberScale).transform.storage[0],
|
||||
greaterThan(0.72),
|
||||
);
|
||||
expect(
|
||||
(movingDesktopCenter - initialDesktopCenter).distance,
|
||||
greaterThan(1),
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-participant-avatar-desktop')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
final settledDesktopCenter = tester.getCenter(desktopAvatar);
|
||||
expect(
|
||||
(settledDesktopCenter - movingDesktopCenter).distance,
|
||||
greaterThan(1),
|
||||
);
|
||||
expect(
|
||||
(settledDesktopCenter - initialDesktopCenter).distance,
|
||||
greaterThan(1),
|
||||
);
|
||||
expect(
|
||||
tester.widget<Transform>(addedMemberScale).transform.storage[0],
|
||||
closeTo(1, 0.01),
|
||||
);
|
||||
|
||||
transport.emitPeerLeave(3);
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 54));
|
||||
expect(
|
||||
tester.widget<Transform>(addedMemberScale).transform.storage[0],
|
||||
greaterThan(1.05),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 140));
|
||||
expect(
|
||||
tester.widget<Transform>(addedMemberScale).transform.storage[0],
|
||||
lessThan(1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey('huddle-participant-avatar-$addedMemberPubkey'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('top-right call end leaves audio and the backing channel', (
|
||||
@@ -5534,6 +5751,22 @@ void main() {
|
||||
final media = _HuddleTestMedia();
|
||||
final transport = _HuddleTestTransport();
|
||||
String? leftChannelId;
|
||||
final hapticCalls = <MethodCall>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
hapticCalls.add(call);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
@@ -5575,6 +5808,8 @@ void main() {
|
||||
|
||||
await tester.tap(hangup);
|
||||
await tester.pump();
|
||||
expect(hapticCalls, hasLength(1));
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.selectionClick');
|
||||
for (var attempt = 0; attempt < 100 && leftChannelId == null; attempt++) {
|
||||
await tester.pump();
|
||||
}
|
||||
@@ -12512,6 +12747,20 @@ final class _HuddleTestTransport implements HuddleTransportClient {
|
||||
);
|
||||
}
|
||||
|
||||
void emitPeerLeave(int peerIndex) {
|
||||
final peer = _peers.remove(peerIndex);
|
||||
if (peer == null) return;
|
||||
_state = HuddleTransportState(
|
||||
phase: HuddleTransportPhase.connected,
|
||||
localPeerIndex: _state.localPeerIndex,
|
||||
peers: _peers,
|
||||
);
|
||||
_states.add(_state);
|
||||
_peerEvents.add(
|
||||
HuddlePeerEvent(type: HuddlePeerEventType.left, peer: peer),
|
||||
);
|
||||
}
|
||||
|
||||
void emitRemoteAudio({int levelDbov = -30, int sequence = 1}) {
|
||||
_remoteFrames.add(
|
||||
HuddleRemoteAudioFrame(
|
||||
|
||||
@@ -13,7 +13,7 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'prepares the fixed v3 audio session without overstating codecs',
|
||||
'prepares the fixed v2 audio session without overstating codecs',
|
||||
() async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
expect(media.state.phase, HuddleMediaPhase.prepared);
|
||||
final prepare = calls.singleWhere((call) => call.method == 'prepare');
|
||||
expect(prepare.arguments, {
|
||||
'protocolVersion': 3,
|
||||
'protocolVersion': 2,
|
||||
'sampleRateHz': 48000,
|
||||
'channels': 1,
|
||||
'frameSamples': 960,
|
||||
|
||||
@@ -14,7 +14,7 @@ const _parentChannelId = '11111111-2222-4333-8444-555555555555';
|
||||
const _ephemeralChannelId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
||||
|
||||
void main() {
|
||||
test('authenticates with object JSON and admits a v3 room', () async {
|
||||
test('authenticates with object JSON and admits a v2 room', () async {
|
||||
final channel = _ControlledWebSocketChannel();
|
||||
final transport = _transport(channel);
|
||||
addTearDown(transport.dispose);
|
||||
@@ -28,7 +28,7 @@ void main() {
|
||||
expect(auth, isA<Map<String, dynamic>>());
|
||||
expect(auth['type'], 'auth');
|
||||
expect(auth['parent_channel_id'], _parentChannelId);
|
||||
expect(auth['protocol_version'], 3);
|
||||
expect(auth['protocol_version'], 2);
|
||||
|
||||
channel.emitText(
|
||||
jsonEncode({
|
||||
@@ -72,7 +72,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
channel.emitBinary(
|
||||
Uint8List.fromList([4, 0, 0, 9, 0, 0, 3, 0xc0, 0xd8, 0, 0xaa]),
|
||||
Uint8List.fromList([4, 0, 9, 0, 0, 3, 0xc0, 0xd8, 0, 0xaa]),
|
||||
);
|
||||
await inbound;
|
||||
|
||||
@@ -471,12 +471,10 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'stale-epoch frame is fenced after its index is reused by a new occupant',
|
||||
'v2 media routes through the current peer-index occupant after reuse',
|
||||
() async {
|
||||
// Blocker 1 causal race: a frame authored by the prior occupant of an
|
||||
// index can be queued in the relay's data path and delivered *after* the
|
||||
// roster control that reassigns the index. Index-only fencing would
|
||||
// mis-attribute it to the new occupant; the epoch rejects it.
|
||||
// Protocol v2 exposes only the peer index on media. After the roster
|
||||
// reassigns that index, subsequent frames route to the current occupant.
|
||||
final channel = _ControlledWebSocketChannel();
|
||||
final transport = _transport(channel);
|
||||
addTearDown(transport.dispose);
|
||||
@@ -511,14 +509,13 @@ void main() {
|
||||
expect(transport.state.peers[4]?.pubkey, 'new');
|
||||
expect(transport.state.peers[4]?.epoch, 1);
|
||||
|
||||
// A residual frame from 'old' (epoch 0) arrives after the reassignment.
|
||||
// It must be dropped, not delivered as 'new' audio.
|
||||
// V2 cannot distinguish delayed media from the old occupancy, so both
|
||||
// packets are delivered through the currently occupied index.
|
||||
channel.emitBinary(_relayFrame(peerIndex: 4, epoch: 0, sequence: 7));
|
||||
// A legitimate 'new' frame (epoch 1) is delivered.
|
||||
channel.emitBinary(_relayFrame(peerIndex: 4, epoch: 1, sequence: 8));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(received, [8]);
|
||||
expect(received, [7, 8]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -822,11 +819,11 @@ Uint8List _relayFrame({
|
||||
levelDbov: -30,
|
||||
flags: 0,
|
||||
);
|
||||
final clientFrame = HuddleWireV3.encodeClientFrame(
|
||||
final clientFrame = HuddleWireV2.encodeClientFrame(
|
||||
header,
|
||||
Uint8List.fromList([sequence & 0xff]),
|
||||
);
|
||||
return Uint8List.fromList([peerIndex, epoch, ...clientFrame]);
|
||||
return Uint8List.fromList([peerIndex, ...clientFrame]);
|
||||
}
|
||||
|
||||
Future<void> _waitForPhase(
|
||||
|
||||
@@ -10,17 +10,17 @@ const _parentChannelId = '11111111-2222-4333-8444-555555555555';
|
||||
const _ephemeralChannelId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
||||
|
||||
void main() {
|
||||
group('HuddleWireV3', () {
|
||||
test('encodes the desktop-compatible big-endian v3 golden frame', () {
|
||||
group('HuddleWireV2', () {
|
||||
test('encodes the desktop-compatible big-endian v2 golden frame', () {
|
||||
const header = HuddleAudioHeader(
|
||||
sequence: 0x1234,
|
||||
timestamp48k: 0x01020304,
|
||||
levelDbov: -42,
|
||||
flags: HuddleWireV3.dtxFlag,
|
||||
flags: HuddleWireV2.dtxFlag,
|
||||
);
|
||||
|
||||
expect(
|
||||
HuddleWireV3.encodeClientFrame(
|
||||
HuddleWireV2.encodeClientFrame(
|
||||
header,
|
||||
Uint8List.fromList([0xaa, 0xbb]),
|
||||
),
|
||||
@@ -29,10 +29,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('decodes peer prefix, v2 header, and Opus payload', () {
|
||||
final frame = HuddleWireV3.decodeRelayFrame(
|
||||
final frame = HuddleWireV2.decodeRelayFrame(
|
||||
Uint8List.fromList([
|
||||
0x07,
|
||||
0x00,
|
||||
0x12,
|
||||
0x34,
|
||||
0x01,
|
||||
@@ -84,21 +83,21 @@ void main() {
|
||||
);
|
||||
|
||||
expect(
|
||||
() => HuddleWireV3.encodeClientFrame(header, Uint8List(0)),
|
||||
() => HuddleWireV2.encodeClientFrame(header, Uint8List(0)),
|
||||
throwsA(isA<HuddleWireException>()),
|
||||
);
|
||||
expect(
|
||||
() => HuddleWireV3.encodeClientFrame(
|
||||
() => HuddleWireV2.encodeClientFrame(
|
||||
header,
|
||||
Uint8List(HuddleWireV3.maxBinaryFrameLength),
|
||||
Uint8List(HuddleWireV2.maxBinaryFrameLength),
|
||||
),
|
||||
throwsA(isA<HuddleWireException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('HuddleAuthV3', () {
|
||||
test('uses the base relay URL and fixed v3 auth envelope', () {
|
||||
group('HuddleAuthV2', () {
|
||||
test('uses the base relay URL and fixed v2 auth envelope', () {
|
||||
final parameters = HuddleConnectionParameters(
|
||||
relayWebSocketUrl: 'wss://buzz.example',
|
||||
nsec: _privateKey,
|
||||
@@ -106,7 +105,7 @@ void main() {
|
||||
ephemeralChannelId: _ephemeralChannelId,
|
||||
);
|
||||
|
||||
final auth = HuddleAuthV3.buildMessage(
|
||||
final auth = HuddleAuthV2.buildMessage(
|
||||
parameters: parameters,
|
||||
challenge: 'relay-challenge',
|
||||
createdAt: 1_700_000_000,
|
||||
@@ -117,7 +116,7 @@ void main() {
|
||||
.toList();
|
||||
|
||||
expect(auth['type'], 'auth');
|
||||
expect(auth['protocol_version'], 3);
|
||||
expect(auth['protocol_version'], 2);
|
||||
expect(auth['parent_channel_id'], _parentChannelId);
|
||||
expect(event['kind'], 22242);
|
||||
expect(tags[0], ['relay', 'wss://buzz.example']);
|
||||
|
||||
Reference in New Issue
Block a user