diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 48b71c8..4f72ed5 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -154,6 +154,12 @@ class _BetaHomeState extends State<_BetaHome> { setState(() => _audioStarted = true); case rust.BridgeEvent_AudioStopped(): setState(() => _audioStarted = false); + case rust.BridgeEvent_SnapshotChanged(): + // A.4: the supervisor's watchdog observed a tree change. + // Trigger a single async refresh so the channel/client + // list stays current without a polling timer on the Dart + // side. + unawaited(_onRefresh()); } } diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 1c62b6b..58ed6ca 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -211,6 +211,17 @@ sealed class BridgeEvent with _$BridgeEvent { /// Audio engine stopped. const factory BridgeEvent.audioStopped() = BridgeEvent_AudioStopped; + + /// Snapshot probe observed a change in channel/client counts. + /// UI uses this to drive an auto-refresh without active + /// polling. + const factory BridgeEvent.snapshotChanged({ + /// Latest channel count. + required int channels, + + /// Latest client count. + required int clients, + }) = BridgeEvent_SnapshotChanged; } /// Coarse OS-reported network state. Mirrors diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index 346e687..039a10e 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,required TResult orElse(),}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -64,7 +64,8 @@ return lost(_that);case BridgeEvent_Reconnecting() when reconnecting != null: return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected != null: return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null: return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null: -return audioStopped(_that);case _: +return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null: +return snapshotChanged(_that);case _: return orElse(); } @@ -82,7 +83,7 @@ return audioStopped(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,}){ +@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,}){ final _that = this; switch (_that) { case BridgeEvent_Connected(): @@ -91,7 +92,8 @@ return lost(_that);case BridgeEvent_Reconnecting(): return reconnecting(_that);case BridgeEvent_Disconnected(): return disconnected(_that);case BridgeEvent_AudioStarted(): return audioStarted(_that);case BridgeEvent_AudioStopped(): -return audioStopped(_that);} +return audioStopped(_that);case BridgeEvent_SnapshotChanged(): +return snapshotChanged(_that);} } /// A variant of `map` that fallback to returning `null`. /// @@ -105,7 +107,7 @@ return audioStopped(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -114,7 +116,8 @@ return lost(_that);case BridgeEvent_Reconnecting() when reconnecting != null: return reconnecting(_that);case BridgeEvent_Disconnected() when disconnected != null: return disconnected(_that);case BridgeEvent_AudioStarted() when audioStarted != null: return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != null: -return audioStopped(_that);case _: +return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null: +return snapshotChanged(_that);case _: return null; } @@ -131,7 +134,7 @@ return audioStopped(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,required TResult orElse(),}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -139,7 +142,8 @@ return lost(_that.reason);case BridgeEvent_Reconnecting() when reconnecting != n return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected() when disconnected != null: return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null: return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null: -return audioStopped();case _: +return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null: +return snapshotChanged(_that.channels,_that.clients);case _: return orElse(); } @@ -157,7 +161,7 @@ return audioStopped();case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,}) {final _that = this; switch (_that) { case BridgeEvent_Connected(): return connected(_that.serverName);case BridgeEvent_Lost(): @@ -165,7 +169,8 @@ return lost(_that.reason);case BridgeEvent_Reconnecting(): return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected(): return disconnected(_that.reason);case BridgeEvent_AudioStarted(): return audioStarted();case BridgeEvent_AudioStopped(): -return audioStopped();} +return audioStopped();case BridgeEvent_SnapshotChanged(): +return snapshotChanged(_that.channels,_that.clients);} } /// A variant of `when` that fallback to returning `null` /// @@ -179,7 +184,7 @@ return audioStopped();} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -187,7 +192,8 @@ return lost(_that.reason);case BridgeEvent_Reconnecting() when reconnecting != n return reconnecting(_that.attempt,_that.delaySecs);case BridgeEvent_Disconnected() when disconnected != null: return disconnected(_that.reason);case BridgeEvent_AudioStarted() when audioStarted != null: return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null: -return audioStopped();case _: +return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null: +return snapshotChanged(_that.channels,_that.clients);case _: return null; } @@ -530,4 +536,74 @@ String toString() { +/// @nodoc + + +class BridgeEvent_SnapshotChanged extends BridgeEvent { + const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._(); + + +/// Latest channel count. + final int channels; +/// Latest client count. + final int clients; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeEvent_SnapshotChangedCopyWith get copyWith => _$BridgeEvent_SnapshotChangedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_SnapshotChanged&&(identical(other.channels, channels) || other.channels == channels)&&(identical(other.clients, clients) || other.clients == clients)); +} + + +@override +int get hashCode => Object.hash(runtimeType,channels,clients); + +@override +String toString() { + return 'BridgeEvent.snapshotChanged(channels: $channels, clients: $clients)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeEvent_SnapshotChangedCopyWith<$Res> implements $BridgeEventCopyWith<$Res> { + factory $BridgeEvent_SnapshotChangedCopyWith(BridgeEvent_SnapshotChanged value, $Res Function(BridgeEvent_SnapshotChanged) _then) = _$BridgeEvent_SnapshotChangedCopyWithImpl; +@useResult +$Res call({ + int channels, int clients +}); + + + + +} +/// @nodoc +class _$BridgeEvent_SnapshotChangedCopyWithImpl<$Res> + implements $BridgeEvent_SnapshotChangedCopyWith<$Res> { + _$BridgeEvent_SnapshotChangedCopyWithImpl(this._self, this._then); + + final BridgeEvent_SnapshotChanged _self; + final $Res Function(BridgeEvent_SnapshotChanged) _then; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? channels = null,Object? clients = null,}) { + return _then(BridgeEvent_SnapshotChanged( +channels: null == channels ? _self.channels : channels // ignore: cast_nullable_to_non_nullable +as int,clients: null == clients ? _self.clients : clients // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + // dart format on diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 1aa4014..778766f 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -551,6 +551,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeEvent_AudioStarted(); case 5: return BridgeEvent_AudioStopped(); + case 6: + return BridgeEvent_SnapshotChanged( + channels: dco_decode_u_32(raw[1]), + clients: dco_decode_u_32(raw[2]), + ); default: throw Exception("unreachable"); } @@ -751,6 +756,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeEvent_AudioStarted(); case 5: return BridgeEvent_AudioStopped(); + case 6: + var var_channels = sse_decode_u_32(deserializer); + var var_clients = sse_decode_u_32(deserializer); + return BridgeEvent_SnapshotChanged( + channels: var_channels, + clients: var_clients, + ); default: throw UnimplementedError(''); } @@ -968,6 +980,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(4, serializer); case BridgeEvent_AudioStopped(): sse_encode_i_32(5, serializer); + case BridgeEvent_SnapshotChanged( + channels: final channels, + clients: final clients, + ): + sse_encode_i_32(6, serializer); + sse_encode_u_32(channels, serializer); + sse_encode_u_32(clients, serializer); } } diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 6359199..44c604d 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -123,6 +123,16 @@ pub enum SessionEvent { /// Audio engine stopped (e.g. before a reconnect cycle, or by /// explicit user action). AudioStopped, + /// A snapshot probe observed a change in channel or client + /// counts. Useful for UI auto-refresh without active polling + /// from the Dart side. Carries the counts so subscribers can + /// decide whether to re-fetch. + SnapshotChanged { + /// Number of channels in the latest probe snapshot. + channels: u32, + /// Number of clients in the latest probe snapshot. + clients: u32, + }, } /// Coarse OS-reported network state. Populated by the Flutter side @@ -449,6 +459,10 @@ async fn supervisor_loop( let mut lost_rx = initial_lost_rx; let mut probe = initial_probe; let mut cfg = initial_cfg; + // Last snapshot signature observed by the watchdog. Used to + // emit `SessionEvent::SnapshotChanged` only when the channel + // or client counts actually change. + let mut last_counts: Option<(u32, u32)> = None; loop { // Watch the current connection: race the protocol task's @@ -515,7 +529,7 @@ async fn supervisor_loop( } _ = watchdog.tick() => { match tokio::time::timeout(WATCHDOG_PROBE_TIMEOUT, probe.probe()).await { - Ok(Ok(_)) => { + Ok(Ok(snap)) => { if misses > 0 { info!( target: "chanora_core", @@ -524,6 +538,17 @@ async fn supervisor_loop( ); } misses = 0; + // A.4 SnapshotChanged: emit when the + // channel or client count differs from + // the previously observed snapshot. + let counts = (snap.channels.len() as u32, snap.clients.len() as u32); + if last_counts != Some(counts) { + last_counts = Some(counts); + let _ = events_tx.send(SessionEvent::SnapshotChanged { + channels: counts.0, + clients: counts.1, + }); + } } Ok(Err(e)) => { misses = misses.saturating_add(1); @@ -725,6 +750,9 @@ async fn supervisor_loop( // Loop back to waiting for the next loss. lost_rx = new_lost_rx; probe = new_probe; + // Force re-emission of SnapshotChanged + // for the freshly reconnected session. + last_counts = None; break; } Err(e) => { diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 3dcf424..f130c45 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -359,6 +359,15 @@ pub enum BridgeEvent { AudioStarted, /// Audio engine stopped. AudioStopped, + /// Snapshot probe observed a change in channel/client counts. + /// UI uses this to drive an auto-refresh without active + /// polling. + SnapshotChanged { + /// Latest channel count. + channels: u32, + /// Latest client count. + clients: u32, + }, } impl From for BridgeEvent { @@ -380,6 +389,9 @@ impl From for BridgeEvent { } chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted, chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped, + chanora_core::SessionEvent::SnapshotChanged { channels, clients } => { + BridgeEvent::SnapshotChanged { channels, clients } + } } } } diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 1b10d24..1dce0d8 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -612,6 +612,14 @@ impl SseDecode for crate::api::BridgeEvent { 5 => { return crate::api::BridgeEvent::AudioStopped; } + 6 => { + let mut var_channels = ::sse_decode(deserializer); + let mut var_clients = ::sse_decode(deserializer); + return crate::api::BridgeEvent::SnapshotChanged { + channels: var_channels, + clients: var_clients, + }; + } _ => { unimplemented!(""); } @@ -878,6 +886,12 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent { } crate::api::BridgeEvent::AudioStarted => [4.into_dart()].into_dart(), crate::api::BridgeEvent::AudioStopped => [5.into_dart()].into_dart(), + crate::api::BridgeEvent::SnapshotChanged { channels, clients } => [ + 6.into_dart(), + channels.into_into_dart().into_dart(), + clients.into_into_dart().into_dart(), + ] + .into_dart(), _ => { unimplemented!(""); } @@ -1055,6 +1069,11 @@ impl SseEncode for crate::api::BridgeEvent { crate::api::BridgeEvent::AudioStopped => { ::sse_encode(5, serializer); } + crate::api::BridgeEvent::SnapshotChanged { channels, clients } => { + ::sse_encode(6, serializer); + ::sse_encode(channels, serializer); + ::sse_encode(clients, serializer); + } _ => { unimplemented!(""); }