Merge remote-tracking branch 'origin/main' into projects-channel-first

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-22 19:10:49 -04:00
19 changed files with 1233 additions and 298 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
)
)

View File

@@ -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';

View File

@@ -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));
}

View File

@@ -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,

View File

@@ -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,
);
}
}

View File

@@ -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,
),
],

View File

@@ -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),
);
}
}

View File

@@ -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,
),
),
),
],
),
),
);
},
),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -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,
),
],

View File

@@ -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,
};
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(

View File

@@ -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']);