diff --git a/mobile/HUDDLES.md b/mobile/HUDDLES.md
index 0a49a71148..01082b2b76 100644
--- a/mobile/HUDDLES.md
+++ b/mobile/HUDDLES.md
@@ -25,9 +25,7 @@ history reconstructs the visible card without silently reopening a microphone.
3. Sign NIP-42 kind `22242` with `relay=` 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
diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HuddleMediaPlugin.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HuddleMediaPlugin.kt
index 76dfa18290..b08bd51f8a 100644
--- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HuddleMediaPlugin.kt
+++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/HuddleMediaPlugin.kt
@@ -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
diff --git a/mobile/ios/Runner/HuddleMediaPlugin.swift b/mobile/ios/Runner/HuddleMediaPlugin.swift
index 6f15bae720..5421f176d3 100644
--- a/mobile/ios/Runner/HuddleMediaPlugin.swift
+++ b/mobile/ios/Runner/HuddleMediaPlugin.swift
@@ -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
)
)
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 354ee4471e..b195dc9eb8 100644
--- a/mobile/lib/features/channels/channel_detail_page.dart
+++ b/mobile/lib/features/channels/channel_detail_page.dart
@@ -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';
diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart
index c52d968fa9..d89ca027e1 100644
--- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart
+++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.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));
+}
diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart
index 5a9b83878e..f993a6c422 100644
--- a/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart
+++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_controls.dart
@@ -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,
diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart
index c7ef3dda57..ee2a564b2e 100644
--- a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart
+++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart
@@ -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 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(
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(
- 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(
- 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,
- );
- }
-}
diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart b/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart
index 3c0bbf339c..6c031c6709 100644
--- a/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart
+++ b/mobile/lib/features/channels/channel_detail_page/huddle_drawer.dart
@@ -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,
),
],
diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart
new file mode 100644
index 0000000000..7f0f80b17d
--- /dev/null
+++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart
@@ -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 pubkeys;
+ final Map profiles;
+ final Map fallbackLabels;
+ final Set activeSpeakerPubkeys;
+ final Map speakerLevels;
+ final Duration movementDuration;
+ final Duration entryDuration;
+ final Duration exitDuration;
+ final ValueChanged onParticipantTap;
+ final VoidCallback onOverflowTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final visiblePubkeys = pubkeys
+ .take(_huddleClusterVisibleParticipantCount)
+ .toList(growable: false);
+ final visiblePubkeySet = visiblePubkeys.toSet();
+ final renderedPubkeys = useState>(visiblePubkeys);
+ final removalTimers = useMemoized(() => {});
+ final lastSlots = useMemoized(() => {});
+ 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(
+ 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(
+ 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(
+ 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),
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart
new file mode 100644
index 0000000000..247d116830
--- /dev/null
+++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart
@@ -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(
+ 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 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,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart
index bb26eb4263..6f1b21339c 100644
--- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart
+++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart
@@ -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,
),
],
diff --git a/mobile/lib/shared/huddle/huddle_auth.dart b/mobile/lib/shared/huddle/huddle_auth.dart
index c74bdf2177..f6829c7a12 100644
--- a/mobile/lib/shared/huddle/huddle_auth.dart
+++ b/mobile/lib/shared/huddle/huddle_auth.dart
@@ -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 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,
};
}
diff --git a/mobile/lib/shared/huddle/huddle_media.dart b/mobile/lib/shared/huddle/huddle_media.dart
index 78c4b05c5a..5045c5de80 100644
--- a/mobile/lib/shared/huddle/huddle_media.dart
+++ b/mobile/lib/shared/huddle/huddle_media.dart
@@ -396,10 +396,10 @@ final class MethodChannelHuddleMedia implements HuddleMedia {
try {
final result = await _channel
.invokeMapMethod('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(
diff --git a/mobile/lib/shared/huddle/huddle_transport.dart b/mobile/lib/shared/huddle/huddle_transport.dart
index b4efaa507f..87ed660d04 100644
--- a/mobile/lib/shared/huddle/huddle_transport.dart
+++ b/mobile/lib/shared/huddle/huddle_transport.dart
@@ -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 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;
diff --git a/mobile/lib/shared/huddle/huddle_wire.dart b/mobile/lib/shared/huddle/huddle_wire.dart
index 50702ee736..090d673bad 100644
--- a/mobile/lib/shared/huddle/huddle_wire.dart
+++ b/mobile/lib/shared/huddle/huddle_wire.dart
@@ -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;
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 726f92a569..4178dbc76a 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -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 = [];
+ 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(milesReveal).opacity.value, 0);
- await tester.pump(const Duration(milliseconds: 90));
+ expect(hapticCalls, hasLength(1));
+ expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
expect(
- tester.widget(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(
+ 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 {});
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(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(
@@ -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(
@@ -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(addedMemberScale).transform.storage[0],
+ closeTo(0.72, 0.01),
+ );
+ await tester.pump(const Duration(milliseconds: 120));
+ final movingDesktopCenter = tester.getCenter(desktopAvatar);
+ expect(
+ tester.widget(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(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(addedMemberScale).transform.storage[0],
+ greaterThan(1.05),
+ );
+ await tester.pump(const Duration(milliseconds: 140));
+ expect(
+ tester.widget(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 = [];
+ 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(
diff --git a/mobile/test/shared/huddle/huddle_media_test.dart b/mobile/test/shared/huddle/huddle_media_test.dart
index b805abb1ca..98bc169d29 100644
--- a/mobile/test/shared/huddle/huddle_media_test.dart
+++ b/mobile/test/shared/huddle/huddle_media_test.dart
@@ -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 = [];
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,
diff --git a/mobile/test/shared/huddle/huddle_transport_test.dart b/mobile/test/shared/huddle/huddle_transport_test.dart
index ce2d288a8e..808a5eeebb 100644
--- a/mobile/test/shared/huddle/huddle_transport_test.dart
+++ b/mobile/test/shared/huddle/huddle_transport_test.dart
@@ -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