fix(android): clarify channel and voice status icons
This commit is contained in:
@@ -255,6 +255,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
bool _audioStarted = false;
|
bool _audioStarted = false;
|
||||||
rust.BridgeAudioStats? _audioStats;
|
rust.BridgeAudioStats? _audioStats;
|
||||||
Timer? _statsTimer;
|
Timer? _statsTimer;
|
||||||
|
int _statsTick = 0;
|
||||||
|
bool _voiceStatusRefreshInFlight = false;
|
||||||
StreamSubscription<rust.BridgeEvent>? _eventsSub;
|
StreamSubscription<rust.BridgeEvent>? _eventsSub;
|
||||||
|
|
||||||
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
|
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
|
||||||
@@ -563,15 +565,35 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
final s = await rust.audioStats();
|
final s = await rust.audioStats();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _audioStats = s);
|
setState(() => _audioStats = s);
|
||||||
|
_statsTick += 1;
|
||||||
|
if (_statsTick % 5 == 0) {
|
||||||
|
unawaited(_refreshSnapshotForVoiceStatus());
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshSnapshotForVoiceStatus() async {
|
||||||
|
if (_voiceStatusRefreshInFlight) return;
|
||||||
|
_voiceStatusRefreshInFlight = true;
|
||||||
|
try {
|
||||||
|
final snap = await rust.snapshot();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _applySnapshot(snap));
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort visual refresh only. Connection/loss paths still
|
||||||
|
// surface via the normal bridge events and explicit refreshes.
|
||||||
|
} finally {
|
||||||
|
_voiceStatusRefreshInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
|
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
|
||||||
_eventsSub?.cancel();
|
_eventsSub?.cancel();
|
||||||
_statsTimer?.cancel();
|
_statsTimer?.cancel();
|
||||||
|
_voiceStatusRefreshInFlight = false;
|
||||||
_hostCtl.dispose();
|
_hostCtl.dispose();
|
||||||
_nickCtl.dispose();
|
_nickCtl.dispose();
|
||||||
_passwordCtl.dispose();
|
_passwordCtl.dispose();
|
||||||
@@ -978,6 +1000,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
Future<void> _onDisconnect() async {
|
Future<void> _onDisconnect() async {
|
||||||
_statsTimer?.cancel();
|
_statsTimer?.cancel();
|
||||||
_statsTimer = null;
|
_statsTimer = null;
|
||||||
|
_statsTick = 0;
|
||||||
|
_voiceStatusRefreshInFlight = false;
|
||||||
try {
|
try {
|
||||||
await rust.disconnect();
|
await rust.disconnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -1014,6 +1038,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
for (final client in snap.clients) {
|
for (final client in snap.clients) {
|
||||||
if (client.id == snap.ownClientId) {
|
if (client.id == snap.ownClientId) {
|
||||||
_currentVoiceChannelId = client.channel;
|
_currentVoiceChannelId = client.channel;
|
||||||
|
_inputMuted = client.inputMuted;
|
||||||
|
_outputMuted = client.outputMuted;
|
||||||
_pendingVoiceChannelId = null;
|
_pendingVoiceChannelId = null;
|
||||||
_inChannel = true;
|
_inChannel = true;
|
||||||
_canJoinVoiceChannel = true;
|
_canJoinVoiceChannel = true;
|
||||||
@@ -1412,8 +1438,11 @@ class _BetaHomeState extends State<_BetaHome> {
|
|||||||
);
|
);
|
||||||
final snapshotView = _SnapshotView(
|
final snapshotView = _SnapshotView(
|
||||||
snapshot: _snapshot!,
|
snapshot: _snapshot!,
|
||||||
|
audioStats: _audioStats,
|
||||||
currentVoiceChannelId: _currentVoiceChannelId,
|
currentVoiceChannelId: _currentVoiceChannelId,
|
||||||
pendingVoiceChannelId: _pendingVoiceChannelId,
|
pendingVoiceChannelId: _pendingVoiceChannelId,
|
||||||
|
localInputMuted: _inputMuted || _hardMute,
|
||||||
|
localOutputMuted: _outputMuted,
|
||||||
hasJoinPending: _pendingVoiceChannelId != null,
|
hasJoinPending: _pendingVoiceChannelId != null,
|
||||||
canJoinVoiceChannel: _canJoinVoiceChannel,
|
canJoinVoiceChannel: _canJoinVoiceChannel,
|
||||||
onJoinChannel: (ch) => _onJoinChannel(ch),
|
onJoinChannel: (ch) => _onJoinChannel(ch),
|
||||||
@@ -2056,8 +2085,11 @@ class PttCapabilityBadge extends StatelessWidget {
|
|||||||
class _SnapshotView extends StatelessWidget {
|
class _SnapshotView extends StatelessWidget {
|
||||||
const _SnapshotView({
|
const _SnapshotView({
|
||||||
required this.snapshot,
|
required this.snapshot,
|
||||||
|
required this.audioStats,
|
||||||
required this.currentVoiceChannelId,
|
required this.currentVoiceChannelId,
|
||||||
required this.pendingVoiceChannelId,
|
required this.pendingVoiceChannelId,
|
||||||
|
required this.localInputMuted,
|
||||||
|
required this.localOutputMuted,
|
||||||
required this.hasJoinPending,
|
required this.hasJoinPending,
|
||||||
required this.canJoinVoiceChannel,
|
required this.canJoinVoiceChannel,
|
||||||
required this.onJoinChannel,
|
required this.onJoinChannel,
|
||||||
@@ -2065,8 +2097,11 @@ class _SnapshotView extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final rust.BridgeSnapshot snapshot;
|
final rust.BridgeSnapshot snapshot;
|
||||||
|
final rust.BridgeAudioStats? audioStats;
|
||||||
final BigInt? currentVoiceChannelId;
|
final BigInt? currentVoiceChannelId;
|
||||||
final BigInt? pendingVoiceChannelId;
|
final BigInt? pendingVoiceChannelId;
|
||||||
|
final bool localInputMuted;
|
||||||
|
final bool localOutputMuted;
|
||||||
final bool hasJoinPending;
|
final bool hasJoinPending;
|
||||||
final bool canJoinVoiceChannel;
|
final bool canJoinVoiceChannel;
|
||||||
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
final ValueChanged<rust.BridgeChannel> onJoinChannel;
|
||||||
@@ -2143,19 +2178,13 @@ class _SnapshotView extends StatelessWidget {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
ch.id == currentVoiceChannelId ? Icons.volume_up : Icons.tag,
|
ch.hasPassword ? Icons.lock_outline : Icons.tag,
|
||||||
|
color: ch.hasPassword
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
title: Text(ch.name),
|
title: Text(ch.name),
|
||||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||||
trailing: ch.hasPassword && ch.id != currentVoiceChannelId
|
|
||||||
? Tooltip(
|
|
||||||
message: l10n.channelPasswordTitle,
|
|
||||||
child: Icon(
|
|
||||||
Icons.lock_outline,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
selected: ch.id == currentVoiceChannelId,
|
selected: ch.id == currentVoiceChannelId,
|
||||||
onTap:
|
onTap:
|
||||||
hasJoinPending ||
|
hasJoinPending ||
|
||||||
@@ -2175,10 +2204,7 @@ class _SnapshotView extends StatelessWidget {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
leading: Icon(
|
leading: _clientVoiceStatusIcon(theme, cl),
|
||||||
cl.isServerQuery ? Icons.terminal : Icons.person,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
title: Text(
|
title: Text(
|
||||||
cl.name,
|
cl.name,
|
||||||
style: cl.isServerQuery
|
style: cl.isServerQuery
|
||||||
@@ -2191,6 +2217,41 @@ class _SnapshotView extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _clientVoiceStatusIcon(ThemeData theme, rust.BridgeClient client) {
|
||||||
|
final isSelf = client.id == snapshot.ownClientId;
|
||||||
|
final outputMuted = isSelf ? localOutputMuted : client.outputMuted;
|
||||||
|
final inputMuted = isSelf ? localInputMuted : client.inputMuted;
|
||||||
|
final speaking = isSelf
|
||||||
|
? (audioStats?.pttActive ?? false)
|
||||||
|
: client.isSpeaking;
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
final String tooltip;
|
||||||
|
if (outputMuted) {
|
||||||
|
icon = Icons.volume_off;
|
||||||
|
color = theme.colorScheme.error;
|
||||||
|
tooltip = 'Speaker muted';
|
||||||
|
} else if (inputMuted) {
|
||||||
|
icon = Icons.mic_off;
|
||||||
|
color = theme.colorScheme.error;
|
||||||
|
tooltip = 'Microphone muted';
|
||||||
|
} else if (speaking) {
|
||||||
|
icon = Icons.volume_up;
|
||||||
|
color = theme.colorScheme.primary;
|
||||||
|
tooltip = 'Speaking';
|
||||||
|
} else {
|
||||||
|
icon = Icons.volume_up_outlined;
|
||||||
|
color = theme.colorScheme.onSurfaceVariant;
|
||||||
|
tooltip = 'Not speaking';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Icon(icon, size: 18, color: color),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a successful PTT binding capture. Carries only the
|
/// Result of a successful PTT binding capture. Carries only the
|
||||||
|
|||||||
@@ -356,6 +356,15 @@ class BridgeClient {
|
|||||||
/// Nickname.
|
/// Nickname.
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
|
/// True when this client has muted microphone/input capture.
|
||||||
|
final bool inputMuted;
|
||||||
|
|
||||||
|
/// True when this client has muted speaker/output audio.
|
||||||
|
final bool outputMuted;
|
||||||
|
|
||||||
|
/// True when recent inbound voice activity was observed for this client.
|
||||||
|
final bool isSpeaking;
|
||||||
|
|
||||||
/// True for TeamSpeak ServerQuery clients.
|
/// True for TeamSpeak ServerQuery clients.
|
||||||
final bool isServerQuery;
|
final bool isServerQuery;
|
||||||
|
|
||||||
@@ -363,12 +372,21 @@ class BridgeClient {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.channel,
|
required this.channel,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
required this.inputMuted,
|
||||||
|
required this.outputMuted,
|
||||||
|
required this.isSpeaking,
|
||||||
required this.isServerQuery,
|
required this.isServerQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
id.hashCode ^ channel.hashCode ^ name.hashCode ^ isServerQuery.hashCode;
|
id.hashCode ^
|
||||||
|
channel.hashCode ^
|
||||||
|
name.hashCode ^
|
||||||
|
inputMuted.hashCode ^
|
||||||
|
outputMuted.hashCode ^
|
||||||
|
isSpeaking.hashCode ^
|
||||||
|
isServerQuery.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
@@ -378,6 +396,9 @@ class BridgeClient {
|
|||||||
id == other.id &&
|
id == other.id &&
|
||||||
channel == other.channel &&
|
channel == other.channel &&
|
||||||
name == other.name &&
|
name == other.name &&
|
||||||
|
inputMuted == other.inputMuted &&
|
||||||
|
outputMuted == other.outputMuted &&
|
||||||
|
isSpeaking == other.isSpeaking &&
|
||||||
isServerQuery == other.isServerQuery;
|
isServerQuery == other.isServerQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1172,13 +1172,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
BridgeClient dco_decode_bridge_client(dynamic raw) {
|
BridgeClient dco_decode_bridge_client(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
final arr = raw as List<dynamic>;
|
final arr = raw as List<dynamic>;
|
||||||
if (arr.length != 4)
|
if (arr.length != 7)
|
||||||
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
|
throw Exception('unexpected arr length: expect 7 but see ${arr.length}');
|
||||||
return BridgeClient(
|
return BridgeClient(
|
||||||
id: dco_decode_u_64(arr[0]),
|
id: dco_decode_u_64(arr[0]),
|
||||||
channel: dco_decode_u_64(arr[1]),
|
channel: dco_decode_u_64(arr[1]),
|
||||||
name: dco_decode_String(arr[2]),
|
name: dco_decode_String(arr[2]),
|
||||||
isServerQuery: dco_decode_bool(arr[3]),
|
inputMuted: dco_decode_bool(arr[3]),
|
||||||
|
outputMuted: dco_decode_bool(arr[4]),
|
||||||
|
isSpeaking: dco_decode_bool(arr[5]),
|
||||||
|
isServerQuery: dco_decode_bool(arr[6]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1535,11 +1538,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
var var_id = sse_decode_u_64(deserializer);
|
var var_id = sse_decode_u_64(deserializer);
|
||||||
var var_channel = sse_decode_u_64(deserializer);
|
var var_channel = sse_decode_u_64(deserializer);
|
||||||
var var_name = sse_decode_String(deserializer);
|
var var_name = sse_decode_String(deserializer);
|
||||||
|
var var_inputMuted = sse_decode_bool(deserializer);
|
||||||
|
var var_outputMuted = sse_decode_bool(deserializer);
|
||||||
|
var var_isSpeaking = sse_decode_bool(deserializer);
|
||||||
var var_isServerQuery = sse_decode_bool(deserializer);
|
var var_isServerQuery = sse_decode_bool(deserializer);
|
||||||
return BridgeClient(
|
return BridgeClient(
|
||||||
id: var_id,
|
id: var_id,
|
||||||
channel: var_channel,
|
channel: var_channel,
|
||||||
name: var_name,
|
name: var_name,
|
||||||
|
inputMuted: var_inputMuted,
|
||||||
|
outputMuted: var_outputMuted,
|
||||||
|
isSpeaking: var_isSpeaking,
|
||||||
isServerQuery: var_isServerQuery,
|
isServerQuery: var_isServerQuery,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2035,6 +2035,9 @@ mod tests {
|
|||||||
id: chanora_protocol::ClientId(10),
|
id: chanora_protocol::ClientId(10),
|
||||||
channel: chanora_protocol::ChannelId(1),
|
channel: chanora_protocol::ChannelId(1),
|
||||||
name: "u".into(),
|
name: "u".into(),
|
||||||
|
input_muted: false,
|
||||||
|
output_muted: false,
|
||||||
|
is_speaking: false,
|
||||||
is_server_query: false,
|
is_server_query: false,
|
||||||
}],
|
}],
|
||||||
own_client_id: 10,
|
own_client_id: 10,
|
||||||
|
|||||||
@@ -368,6 +368,12 @@ pub struct BridgeClient {
|
|||||||
pub channel: u64,
|
pub channel: u64,
|
||||||
/// Nickname.
|
/// Nickname.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// True when this client has muted microphone/input capture.
|
||||||
|
pub input_muted: bool,
|
||||||
|
/// True when this client has muted speaker/output audio.
|
||||||
|
pub output_muted: bool,
|
||||||
|
/// True when recent inbound voice activity was observed for this client.
|
||||||
|
pub is_speaking: bool,
|
||||||
/// True for TeamSpeak ServerQuery clients.
|
/// True for TeamSpeak ServerQuery clients.
|
||||||
pub is_server_query: bool,
|
pub is_server_query: bool,
|
||||||
}
|
}
|
||||||
@@ -418,6 +424,9 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
|
|||||||
id: c.id.0,
|
id: c.id.0,
|
||||||
channel: c.channel.0,
|
channel: c.channel.0,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
|
input_muted: c.input_muted,
|
||||||
|
output_muted: c.output_muted,
|
||||||
|
is_speaking: c.is_speaking,
|
||||||
is_server_query: c.is_server_query,
|
is_server_query: c.is_server_query,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|||||||
@@ -1292,11 +1292,17 @@ impl SseDecode for crate::api::BridgeClient {
|
|||||||
let mut var_id = <u64>::sse_decode(deserializer);
|
let mut var_id = <u64>::sse_decode(deserializer);
|
||||||
let mut var_channel = <u64>::sse_decode(deserializer);
|
let mut var_channel = <u64>::sse_decode(deserializer);
|
||||||
let mut var_name = <String>::sse_decode(deserializer);
|
let mut var_name = <String>::sse_decode(deserializer);
|
||||||
|
let mut var_input_muted = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_output_muted = <bool>::sse_decode(deserializer);
|
||||||
|
let mut var_is_speaking = <bool>::sse_decode(deserializer);
|
||||||
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
|
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
|
||||||
return crate::api::BridgeClient {
|
return crate::api::BridgeClient {
|
||||||
id: var_id,
|
id: var_id,
|
||||||
channel: var_channel,
|
channel: var_channel,
|
||||||
name: var_name,
|
name: var_name,
|
||||||
|
input_muted: var_input_muted,
|
||||||
|
output_muted: var_output_muted,
|
||||||
|
is_speaking: var_is_speaking,
|
||||||
is_server_query: var_isServerQuery,
|
is_server_query: var_isServerQuery,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1820,6 +1826,9 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
|
|||||||
self.id.into_into_dart().into_dart(),
|
self.id.into_into_dart().into_dart(),
|
||||||
self.channel.into_into_dart().into_dart(),
|
self.channel.into_into_dart().into_dart(),
|
||||||
self.name.into_into_dart().into_dart(),
|
self.name.into_into_dart().into_dart(),
|
||||||
|
self.input_muted.into_into_dart().into_dart(),
|
||||||
|
self.output_muted.into_into_dart().into_dart(),
|
||||||
|
self.is_speaking.into_into_dart().into_dart(),
|
||||||
self.is_server_query.into_into_dart().into_dart(),
|
self.is_server_query.into_into_dart().into_dart(),
|
||||||
]
|
]
|
||||||
.into_dart()
|
.into_dart()
|
||||||
@@ -2193,6 +2202,9 @@ impl SseEncode for crate::api::BridgeClient {
|
|||||||
<u64>::sse_encode(self.id, serializer);
|
<u64>::sse_encode(self.id, serializer);
|
||||||
<u64>::sse_encode(self.channel, serializer);
|
<u64>::sse_encode(self.channel, serializer);
|
||||||
<String>::sse_encode(self.name, serializer);
|
<String>::sse_encode(self.name, serializer);
|
||||||
|
<bool>::sse_encode(self.input_muted, serializer);
|
||||||
|
<bool>::sse_encode(self.output_muted, serializer);
|
||||||
|
<bool>::sse_encode(self.is_speaking, serializer);
|
||||||
<bool>::sse_encode(self.is_server_query, serializer);
|
<bool>::sse_encode(self.is_server_query, serializer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//! * Disconnect is requested via a `oneshot`; the task drains
|
//! * Disconnect is requested via a `oneshot`; the task drains
|
||||||
//! `tsclientlib`'s outbound events and exits.
|
//! `tsclientlib`'s outbound events and exits.
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use futures::prelude::*;
|
use futures::prelude::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -36,6 +36,8 @@ use tsproto_types::ClientType;
|
|||||||
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
|
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
|
||||||
use crate::ProtocolError;
|
use crate::ProtocolError;
|
||||||
|
|
||||||
|
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
||||||
|
|
||||||
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
||||||
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
||||||
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
||||||
@@ -509,6 +511,7 @@ async fn connection_task(
|
|||||||
std::time::Instant,
|
std::time::Instant,
|
||||||
),
|
),
|
||||||
> = HashMap::new();
|
> = HashMap::new();
|
||||||
|
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||||
|
|
||||||
// Main loop: pump events, service requests, forward voice.
|
// Main loop: pump events, service requests, forward voice.
|
||||||
loop {
|
loop {
|
||||||
@@ -530,6 +533,7 @@ async fn connection_task(
|
|||||||
StreamItem::Audio(buf) => {
|
StreamItem::Audio(buf) => {
|
||||||
let from = packet_sender_id(&buf);
|
let from = packet_sender_id(&buf);
|
||||||
if let Some(from) = from {
|
if let Some(from) = from {
|
||||||
|
voice_activity.insert(from, Instant::now());
|
||||||
if voice_in_tx
|
if voice_in_tx
|
||||||
.try_send(InboundVoice {
|
.try_send(InboundVoice {
|
||||||
from_client: from,
|
from_client: from,
|
||||||
@@ -612,7 +616,7 @@ async fn connection_task(
|
|||||||
// 3. Service at most one control request (non-blocking).
|
// 3. Service at most one control request (non-blocking).
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(Request::Snapshot(reply)) => {
|
Ok(Request::Snapshot(reply)) => {
|
||||||
let snap = build_snapshot(&con);
|
let snap = build_snapshot(&con, &voice_activity);
|
||||||
let _ = reply.send(snap);
|
let _ = reply.send(snap);
|
||||||
}
|
}
|
||||||
Ok(Request::MoveToChannel {
|
Ok(Request::MoveToChannel {
|
||||||
@@ -826,7 +830,10 @@ fn emit_subtree<'a, T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_snapshot(con: &Connection) -> Result<ServerSnapshot, ProtocolError> {
|
fn build_snapshot(
|
||||||
|
con: &Connection,
|
||||||
|
voice_activity: &HashMap<u64, Instant>,
|
||||||
|
) -> Result<ServerSnapshot, ProtocolError> {
|
||||||
let state: &data::Connection = con
|
let state: &data::Connection = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||||
@@ -874,6 +881,11 @@ fn build_snapshot(con: &Connection) -> Result<ServerSnapshot, ProtocolError> {
|
|||||||
id: ClientId(c.id.0 as u64),
|
id: ClientId(c.id.0 as u64),
|
||||||
channel: ChannelId(c.channel.0),
|
channel: ChannelId(c.channel.0),
|
||||||
name: sanitize(&c.name),
|
name: sanitize(&c.name),
|
||||||
|
input_muted: c.input_muted,
|
||||||
|
output_muted: c.output_muted || c.output_only_muted,
|
||||||
|
is_speaking: voice_activity
|
||||||
|
.get(&(c.id.0 as u64))
|
||||||
|
.is_some_and(|last| last.elapsed() <= SPEAKING_ACTIVITY_WINDOW),
|
||||||
is_server_query: is_server_query_client_type(&c.client_type),
|
is_server_query: is_server_query_client_type(&c.client_type),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ pub struct ClientInfo {
|
|||||||
pub channel: ChannelId,
|
pub channel: ChannelId,
|
||||||
/// Nickname, preserved verbatim per ADR-008.
|
/// Nickname, preserved verbatim per ADR-008.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// True when this client has muted microphone/input capture.
|
||||||
|
pub input_muted: bool,
|
||||||
|
/// True when this client has muted speaker/output audio.
|
||||||
|
pub output_muted: bool,
|
||||||
|
/// True when recent inbound voice activity was observed for this client.
|
||||||
|
pub is_speaking: bool,
|
||||||
/// True for TeamSpeak ServerQuery clients.
|
/// True for TeamSpeak ServerQuery clients.
|
||||||
pub is_server_query: bool,
|
pub is_server_query: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user