From 4baccd5394d6166bb68ff03b24e376e322281a59 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 22 Aug 2026 18:37:08 +0100 Subject: [PATCH 1/2] Downgrade mobile Huddles to audio protocol v2 (#6558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - downgrade Mobile Huddle authentication and native media configuration from protocol v3 to the currently deployed relay's v2 contract - restore the released one-byte relay peer prefix while retaining later reconnect, roster, and playout-reset reliability fixes - update Android, iOS, protocol documentation, and focused tests together Protocol v2 does not carry v3's occupancy epoch on audio frames, so it cannot fence the narrow delayed-packet/peer-index-reuse race. This is an intentional compatibility tradeoff until the relay v3 rollout is ready. ### Related issue None found. ### Testing - `just mobile-check` - `just mobile-test` — 1,661 tests passed - Android debug build installed and launched on Pixel 10 as `xyz.block.buzz.mobile.sprout_mobile_profile_settings`; foreground process verified - signed iOS Release build installed and launched on iPhone as `com.buzz.buzzMobile`; running process verified A live two-device Huddle audio call remains a manual verification step. Signed-off-by: kenny lopez --- mobile/HUDDLES.md | 15 ++++---- .../block/buzz/mobile/HuddleMediaPlugin.kt | 6 ++-- mobile/ios/Runner/HuddleMediaPlugin.swift | 4 +-- mobile/lib/shared/huddle/huddle_auth.dart | 4 +-- mobile/lib/shared/huddle/huddle_media.dart | 8 ++--- .../lib/shared/huddle/huddle_transport.dart | 32 ++++++++--------- mobile/lib/shared/huddle/huddle_wire.dart | 34 +++++++++---------- .../test/shared/huddle/huddle_media_test.dart | 4 +-- .../shared/huddle/huddle_transport_test.dart | 25 ++++++-------- .../test/shared/huddle/huddle_wire_test.dart | 25 +++++++------- 10 files changed, 73 insertions(+), 84 deletions(-) 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/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/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>()); 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.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 _waitForPhase( diff --git a/mobile/test/shared/huddle/huddle_wire_test.dart b/mobile/test/shared/huddle/huddle_wire_test.dart index 1b992e481b..c143c7a83f 100644 --- a/mobile/test/shared/huddle/huddle_wire_test.dart +++ b/mobile/test/shared/huddle/huddle_wire_test.dart @@ -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()), ); expect( - () => HuddleWireV3.encodeClientFrame( + () => HuddleWireV2.encodeClientFrame( header, - Uint8List(HuddleWireV3.maxBinaryFrameLength), + Uint8List(HuddleWireV2.maxBinaryFrameLength), ), throwsA(isA()), ); }); }); - 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']); From e23632941331502c0330e51d407e667bea26ef57 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 23 Aug 2026 00:06:36 +0100 Subject: [PATCH 2/2] Polish Huddle participant interactions (#6312) ## Summary - arrange Huddle participants in a responsive, equal-weight cluster with spring enter/exit motion and a `+N` overflow - spotlight tapped participants over a blurred call surface, with a roster for hidden participants and no self-avatar action - add selection haptics across full-screen and drawer controls, including both end-call buttons Screenshot_20260819-151448 Screenshot_20260819-151422 ## Validation - `just mobile-check` - focused participant, drawer-control, and full-screen end-call widget tests - Huddle-focused widget suite (15 tests) - full mobile Flutter suite (1,538 tests) ## Dependency Built on #6056 and contains only the follow-up interaction work. Merge after #6056 lands. --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Princess Donut Signed-off-by: Tom Brow Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> --- .../channels/channel_detail_page.dart | 4 + .../huddle_call_avatar.dart | 89 ++-- .../huddle_call_controls.dart | 18 +- .../huddle_call_participants.dart | 129 +----- .../channel_detail_page/huddle_drawer.dart | 4 + .../huddle_participant_cluster.dart | 407 ++++++++++++++++++ .../huddle_participant_overlay.dart | 372 ++++++++++++++++ .../channel_detail_page/huddle_sheet.dart | 18 +- .../channels/channel_detail_page_test.dart | 333 ++++++++++++-- 9 files changed, 1160 insertions(+), 214 deletions(-) create mode 100644 mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart create mode 100644 mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart 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/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(