Files
chanora/apps/chanora_flutter/lib/main.dart
T
EdisonJwa 181b3368d4 fix(audio,voice,log): six P0 issues from Korean Windows test
1. Voice Bar 'Leave voice' button removed entirely. TeamSpeak users
   are always in some channel; Discord/Mumble-style leave is the
   wrong model. To stop being heard / hearing, mute mic / speaker.
   To physically move, tap a different channel. The voiceLeave
   bridge call + _onLeaveVoice stay as dead code for now (marked
   unused) so existing tests/integrations don't break.

2. voice_join now confirms the move actually applied server-side
   by polling the snapshot for up to 1.5 s and matching our own
   client's channel against the requested one. If the server
   rejected the move (no permission, wrong password, channel
   full), voice_join rolls the selector back to in_channel=false
   and returns Err so the UI surfaces the failure instead of
   showing a fake 'joined' state.

3. ServerSnapshot + BridgeSnapshot gain own_client_id so the UI
   can identify our row without name-matching. find_own_in reads
   it directly.

4. set_self_muted now also clamps the TransmitModeSelector's
   hard_mute when input is muted server-side. Without this, the
   Opus encoder kept producing frames after setInputMuted(true),
   tsclientlib refused each one with 'Sending audio while muted',
   and the log grew to 200 MB on the Korean host.

5. tsclientlib WARN spam suppressed via tracing filter
   (tsclientlib=error). Belt-and-braces on top of fix 4.

6. Log file is now rotated at every launch (not just when >4 MiB).
   Two generations kept: chanora.log.1 (previous) and
   chanora.log.2 (the one before). The bug that produced 200 MB
   files was a chatty subsystem flooding a single session; the
   per-launch rotate keeps disk use bounded by what one session
   can produce in its lifetime.

Bonus Windows fix (separate from the six but found in the same
log): the Raw Input + Hook backends now signal readiness BEFORE
blocking on GetMessageW. Previously init_tx.send was called after
the loop returned (i.e. on WM_QUIT, which never happens during
arming), so the main thread's 2 s readiness probe always timed
out and the backend reported L0Focused even when registration
succeeded. Both run_raw_input_loop and run_hook_loop now take an
init_tx parameter and call report!(true) right after a successful
registration, and report!(false) on every early-fail return.

cargo check --workspace: clean.
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored.
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-16 01:52:47 +08:00

1446 lines
47 KiB
Dart

// Chanora Flutter application — External Beta build.
//
// Builds on Internal Beta v0.3.0-beta.1:
// * server password field
// * bookmark list with add / connect / delete actions
// * channel tap-to-join with optional channel password
// * self input + output mute toggles + master output gain slider
// * diagnostics dialog + reconnect banner + identity persistence
// (all carried over from v0.3.0-beta.1)
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'l10n/generated/app_localizations.dart';
import 'src/rust/api.dart' as rust;
import 'src/rust/frb_generated.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_settings.dart';
/// Public version string shown in the About dialog. Aligned with
/// `pubspec.yaml` and the git tag for the MVP release candidate.
/// Bumped to rc.8 for the post-rc.7 v1 audio + PTT lifecycle work
/// (SDD-094..097, DEC-029..031).
const String _kAppVersion = 'v1.0.0-rc.8';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await RustLib.init();
unawaited(_wireStorage());
unawaited(_wireConnectivity());
runApp(const ChanoraApp());
}
Future<void> _wireStorage() async {
try {
final dir = await getApplicationSupportDirectory();
await rust.initStorage(dir: dir.path);
} catch (_) {
// Best-effort; missing storage just means no identity persistence
// and no bookmark list this session.
}
}
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
final allNone = results.every((r) => r == ConnectivityResult.none);
if (allNone) return rust.BridgeNetworkState.offline;
return rust.BridgeNetworkState.online;
}
Future<void> _wireConnectivity() async {
final connectivity = Connectivity();
try {
final initial = await connectivity.checkConnectivity();
rust.setNetworkState(state: _mapConnectivity(initial));
} catch (_) {}
connectivity.onConnectivityChanged.listen((results) {
rust.setNetworkState(state: _mapConnectivity(results));
});
}
class ChanoraApp extends StatelessWidget {
const ChanoraApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF3F51B5),
),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: const _BetaHome(),
);
}
}
enum _Phase { idle, connecting, connected }
class _BetaHome extends StatefulWidget {
const _BetaHome();
@override
State<_BetaHome> createState() => _BetaHomeState();
}
class _BetaHomeState extends State<_BetaHome> {
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
final _passwordCtl = TextEditingController();
_Phase _phase = _Phase.idle;
rust.BridgeSnapshot? _snapshot;
String? _error;
// ignore: unused_field
bool _audioStarted = false;
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
StreamSubscription<rust.BridgeEvent>? _eventsSub;
// v1 voice subsystem state (SDD-094/095/096/097). Driven by
// BridgeEvent::VoiceState.
bool _inChannel = false;
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
bool _hardMute = false;
int _releaseTailMs = 200;
BigInt? _currentVoiceChannelId;
String? _lostReason;
int? _reconnectAttempt;
int? _reconnectDelay;
bool _inputMuted = false;
bool _outputMuted = false;
// ignore: unused_field
double _outputGain = 1.0;
// Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091).
// Populated by `BridgeEvent.PttCapability`. Defaults match the
// universal `FocusedPttBackend::focused()` descriptor so the UI
// shows an honest baseline even before the first event arrives.
String _pttLevel = 'L0Focused';
String _pttBackendId = 'focused';
String _pttBoundInputClass = 'keyboard';
// Last platform-neutral key label the user saved in the
// `_PttBindingCaptureDialog` (e.g. "Space", "F10",
// "mouse-side-button:8"). Surfaced next to the capability
// badge so the user can remember which key drives PTT. The
// bridge-side `PttController` (SDD-088) holds the authoritative
// binding; this is a display-only cache that resets on app
// restart. Per DEC-027 the raw OS key code never lives here —
// only the platform-neutral label that already crossed into
// the Rust side.
String _pttBoundKeyLabel = '';
List<rust.BridgeBookmark> _bookmarks = const [];
@override
void initState() {
super.initState();
_eventsSub = rust.eventsStream().listen(_onEvent);
unawaited(_reloadBookmarks());
unawaited(_hydratePttBinding());
}
/// Hydrate the bound-key display state from the bridge so the
/// Voice Bar shows the user's persisted hotkey label immediately
/// at launch — without having to wait for them to open the
/// binding dialog. The bridge returns empty strings when no
/// binding has ever been saved.
Future<void> _hydratePttBinding() async {
try {
final (inputClass, keyLabel) = await rust.getPttBinding();
if (!mounted) return;
if (keyLabel.isNotEmpty || inputClass.isNotEmpty) {
setState(() {
_pttBoundKeyLabel = keyLabel;
_pttBoundInputClass = inputClass;
});
}
} catch (_) {
// No persisted binding or storage not yet wired; not an error.
}
}
Future<void> _reloadBookmarks() async {
try {
final list = await rust.listBookmarks();
if (!mounted) return;
setState(() => _bookmarks = list);
} catch (_) {
// Bookmark store missing on this platform — empty list is fine.
}
}
void _onEvent(rust.BridgeEvent evt) {
if (!mounted) return;
switch (evt) {
case rust.BridgeEvent_Connected():
setState(() {
_phase = _Phase.connected;
_lostReason = null;
_reconnectAttempt = null;
_reconnectDelay = null;
});
case rust.BridgeEvent_Lost(:final reason):
setState(() {
_lostReason = reason;
_reconnectAttempt = null;
_reconnectDelay = null;
});
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
setState(() {
_reconnectAttempt = attempt;
_reconnectDelay = delaySecs;
});
case rust.BridgeEvent_Disconnected():
setState(() {
_phase = _Phase.idle;
_lostReason = null;
_reconnectAttempt = null;
_reconnectDelay = null;
});
case rust.BridgeEvent_AudioStarted():
setState(() => _audioStarted = true);
case rust.BridgeEvent_AudioStopped():
setState(() => _audioStarted = false);
case rust.BridgeEvent_SnapshotChanged():
unawaited(_onRefresh());
case rust.BridgeEvent_PttCapability(
:final level,
:final backendId,
:final boundInputClass,
):
setState(() {
_pttLevel = level;
_pttBackendId = backendId;
_pttBoundInputClass = boundInputClass;
});
case rust.BridgeEvent_VoiceState(
:final inChannel,
:final transmitMode,
:final mute,
:final releaseTailMs,
):
setState(() {
_inChannel = inChannel;
_transmitMode = transmitMode;
_hardMute = mute;
_releaseTailMs = releaseTailMs;
_audioStarted = inChannel;
});
if (inChannel) {
_ensureStatsTimer();
} else {
_statsTimer?.cancel();
_statsTimer = null;
}
}
}
void _ensureStatsTimer() {
if (_statsTimer != null) return;
// Poll at 80 ms (~12 Hz) — this is the loop that drives the
// Voice Bar's "PTT=on/off" indicator and the level meter, so
// it needs to be fast enough that users don't perceive a lag
// between physically pressing the bound key and seeing the
// UI change. 80 ms is well below the ~150 ms perceptual
// delay threshold and adds only a dozen tiny FFI calls per
// second to the load. A future push-based BridgeEvent for
// transmit-active transitions would let us drop this poll
// entirely.
_statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() => _audioStats = s);
} catch (_) {}
});
}
@override
void dispose() {
_eventsSub?.cancel();
_statsTimer?.cancel();
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
super.dispose();
}
Future<void> _onConnect({
String? host,
String? nickname,
String? password,
}) async {
setState(() {
_phase = _Phase.connecting;
_error = null;
_snapshot = null;
});
try {
final snap = await rust.connect(
host: (host ?? _hostCtl.text).trim(),
nickname: (nickname ?? _nickCtl.text).trim(),
password: password ?? _passwordCtl.text,
);
if (!mounted) return;
setState(() {
_phase = _Phase.connected;
_snapshot = snap;
});
} catch (e) {
if (!mounted) return;
setState(() {
_phase = _Phase.idle;
_error = e.toString();
});
}
}
// ignore: unused_element
Future<void> _setPtt(bool active) async {
try {
await rust.setPtt(active: active);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
// ignore: unused_element
Future<void> _toggleInputMute() async {
final next = !_inputMuted;
try {
await rust.setInputMuted(muted: next);
if (!mounted) return;
setState(() => _inputMuted = next);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _toggleOutputMute() async {
final next = !_outputMuted;
try {
await rust.setOutputMuted(muted: next);
if (!mounted) return;
setState(() => _outputMuted = next);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
// ignore: unused_element
Future<void> _setOutputGain(double value) async {
setState(() => _outputGain = value);
try {
await rust.setOutputGain(gain: value);
} catch (_) {
// Audio may not be started yet — that's fine; the next start_audio
// picks up the current slider position next time we plumb it.
}
}
Future<void> _onJoinChannel(rust.BridgeChannel ch) async {
final l10n = AppL10n.of(context);
String? password;
// Heuristic: a channel name annotated with a lock prompts.
if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) {
password = await _askChannelPassword(l10n);
if (password == null) return; // cancelled
}
try {
await rust.voiceJoin(
channelId: ch.id,
password: password ?? '',
);
if (!mounted) return;
setState(() => _currentVoiceChannelId = ch.id);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
// ignore: unused_element
Future<void> _onLeaveVoice() async {
try {
await rust.voiceLeave();
if (!mounted) return;
setState(() => _currentVoiceChannelId = null);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onToggleHardMute() async {
final next = !_hardMute;
try {
// Hard-mute is two coordinated effects:
// * setHardMute — local TransmitGate clamp; we stop sending
// Opus frames the instant this returns.
// * setInputMuted — server-side ClientMuted flag so other
// clients see the mic-off icon next to our name and the
// server stops relaying any in-flight frames.
// Sending only one of them is user-confusing; clients see
// silence but no icon, or icon but a beat of audio leaks
// through. Drive them together.
await rust.setHardMute(muted: next);
await rust.setInputMuted(muted: next);
if (!mounted) return;
setState(() => _inputMuted = next);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onOpenVoiceSettings() async {
final result = await showDialog<VoiceSettingsResult>(
context: context,
builder: (ctx) => VoiceSettingsDialog(
initialMode: _transmitMode,
initialReleaseTailMs: _releaseTailMs,
),
);
if (result == null) return;
try {
await rust.setTransmitMode(mode: result.mode);
await rust.setReleaseTailMs(ms: result.releaseTailMs);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
if (result.bindKeyRequested && mounted) {
await _onConfigurePtt(context);
}
}
Future<String?> _askChannelPassword(AppL10n l10n) async {
final ctl = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.channelPasswordTitle),
content: TextField(
controller: ctl,
obscureText: true,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldPassword),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(ctl.text),
child: Text(l10n.connectAction),
),
],
),
);
ctl.dispose();
return result;
}
Future<void> _onRefresh() async {
try {
final snap = await rust.snapshot();
if (!mounted) return;
setState(() => _snapshot = snap);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onDisconnect() async {
_statsTimer?.cancel();
_statsTimer = null;
try {
await rust.disconnect();
} catch (_) {}
if (!mounted) return;
setState(() {
_phase = _Phase.idle;
_snapshot = null;
_audioStarted = false;
_audioStats = null;
_error = null;
_inputMuted = false;
_outputMuted = false;
_inChannel = false;
_currentVoiceChannelId = null;
});
}
String _currentVoiceChannelName() {
final id = _currentVoiceChannelId;
final snap = _snapshot;
if (id == null || snap == null) return '';
for (final ch in snap.channels) {
if (ch.id == id) return ch.name;
}
return '';
}
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final text = rust.exportDiagnostics();
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.diagnosticsAction),
content: SingleChildScrollView(
child: SelectableText(
text,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
actions: [
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: text));
if (!ctx.mounted) return;
Navigator.of(ctx).pop();
},
child: Text(l10n.copyAction),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
],
),
);
}
Future<void> _onConfigurePtt(BuildContext context) async {
// On the Linux GNOME-Wayland portal backend, the portal hosts
// its own system-managed binding dialog (gen2 v0.9.3 / Q3a).
// Skip the in-app capture dialog entirely on that backend and
// delegate to the portal via `setPttBinding` with a sentinel
// platform_key. Show a SnackBar so the user isn't surprised
// when their compositor opens a separate dialog.
final l10n = AppL10n.of(context);
if (!mounted) return;
if (_pttBackendId == 'gnome-wayland-portal') {
try {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(SnackBar(
content: Text(l10n.pttConfigurePortalRedirect),
));
await rust.setPttBinding(
inputClass: rust.BridgePttInputClass.keyboard,
platformKey: 'portal',
);
} catch (e) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(SnackBar(
content: Text(l10n.statusError(e.toString())),
));
}
return;
}
// Other backends: open the in-app focus-scoped capture
// dialog. The bridge carries only the coarse input class and
// an opaque platform-key string; the actual key value never
// appears in any log record (DEC-027 / SRS-202).
final binding = await showDialog<_CapturedBinding>(
context: context,
builder: (ctx) => const _PttBindingCaptureDialog(),
);
if (binding == null) return;
try {
await rust.setPttBinding(
inputClass: binding.inputClass,
platformKey: binding.platformKey,
);
// Cache the captured label so the badge can surface "Key:
// Space" / "Key: Mouse4" next to the capability descriptor.
// The bridge holds the authoritative binding; this is only
// for display continuity until the next app restart.
if (mounted) {
setState(() {
_pttBoundKeyLabel = binding.platformKey;
});
}
} catch (e) {
if (!mounted) return;
// Surface the failure as a snackbar so the user sees that
// their binding did not stick.
if (!mounted) return;
// Use the State's context (guaranteed valid because we
// re-checked `mounted` immediately above).
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(SnackBar(
content: Text(l10n.statusError(e.toString())),
));
}
}
Future<void> _onShowAbout(BuildContext context) async {
// DEC-018 / DEC-019 / DEC-020 surface: public name, non-
// affiliation statement, dual-license declaration. The Flutter
// showAboutDialog widget is intentionally bare so the legal
// text comes from us, not a framework default.
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.aboutAction),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.appTitle,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
l10n.aboutVersion(_kAppVersion),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 16),
Text(
l10n.aboutNonAffiliation,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Text(
l10n.aboutLicenseHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.aboutLicenseBody,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
Text(
l10n.aboutThirdPartyHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.aboutThirdPartyBody,
style: theme.textTheme.bodySmall,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
],
),
);
}
Future<void> _onAddCurrentBookmark() async {
final l10n = AppL10n.of(context);
final nameCtl = TextEditingController(text: _hostCtl.text.trim());
final name = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.bookmarkAddTitle),
content: TextField(
controller: nameCtl,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(nameCtl.text),
child: Text(l10n.bookmarkAddAction),
),
],
),
);
nameCtl.dispose();
if (name == null || name.trim().isEmpty) return;
try {
await rust.addBookmark(
b: rust.BridgeBookmark(
id: 0,
displayName: name.trim(),
host: _hostCtl.text.trim(),
nickname: _nickCtl.text.trim(),
password: _passwordCtl.text,
),
);
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
try {
await rust.deleteBookmark(id: b.id);
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
Future<void> _onUseBookmark(rust.BridgeBookmark b) async {
_hostCtl.text = b.host;
_nickCtl.text = b.nickname;
_passwordCtl.text = b.password;
await _onConnect(
host: b.host,
nickname: b.nickname,
password: b.password,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
String statusText() {
switch (_phase) {
case _Phase.idle:
return _error != null ? l10n.statusError(_error!) : l10n.statusIdle;
case _Phase.connecting:
return l10n.statusConnecting;
case _Phase.connected:
return l10n.statusConnected(_snapshot?.serverName ?? '');
}
}
return Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
onPressed: () => _onShowAbout(context),
),
IconButton(
tooltip: l10n.diagnosticsAction,
icon: const Icon(Icons.bug_report_outlined),
onPressed: () => _onShowDiagnostics(context),
),
if (_phase == _Phase.connected) ...[
IconButton(
tooltip: l10n.refreshAction,
icon: const Icon(Icons.refresh),
onPressed: _onRefresh,
),
IconButton(
tooltip: l10n.disconnectAction,
icon: const Icon(Icons.logout),
onPressed: _onDisconnect,
),
],
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
l10n.homeNotProductionReadyBanner,
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
),
),
const SizedBox(height: 12),
Text(statusText(), style: theme.textTheme.titleMedium),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onErrorContainer,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_reconnectAttempt != null
? l10n.statusReconnecting(
_reconnectAttempt!,
_reconnectDelay ?? 0,
)
: l10n.statusConnectionLost(_lostReason ?? ''),
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
),
],
const SizedBox(height: 12),
if (_phase == _Phase.idle) ...[
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ConnectForm(
hostCtl: _hostCtl,
nickCtl: _nickCtl,
passwordCtl: _passwordCtl,
onConnect: () => _onConnect(),
onAddBookmark: _onAddCurrentBookmark,
),
const SizedBox(height: 16),
_BookmarkList(
bookmarks: _bookmarks,
onConnect: _onUseBookmark,
onDelete: _onDeleteBookmark,
),
],
),
),
),
] else if (_phase == _Phase.connecting) ...[
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
),
] else if (_phase == _Phase.connected && _snapshot != null) ...[
VoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
hardMute: _hardMute,
outputMuted: _outputMuted,
releaseTailMs: _releaseTailMs,
channelName: _currentVoiceChannelName(),
audioStats: _audioStats,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
pttBoundKeyLabel: _pttBoundKeyLabel,
onToggleMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
onConfigure: _onOpenVoiceSettings,
),
const SizedBox(height: 12),
Expanded(
child: _SnapshotView(
snapshot: _snapshot!,
onJoinChannel: _onJoinChannel,
),
),
],
],
),
),
);
}
}
class _ConnectForm extends StatelessWidget {
const _ConnectForm({
required this.hostCtl,
required this.nickCtl,
required this.passwordCtl,
required this.onConnect,
required this.onAddBookmark,
});
final TextEditingController hostCtl;
final TextEditingController nickCtl;
final TextEditingController passwordCtl;
final VoidCallback onConnect;
final VoidCallback onAddBookmark;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: hostCtl,
decoration: InputDecoration(
labelText: l10n.fieldServerHost,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: nickCtl,
decoration: InputDecoration(
labelText: l10n.fieldNickname,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: passwordCtl,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.fieldServerPassword,
helperText: l10n.fieldServerPasswordHelp,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
onPressed: onConnect,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
icon: const Icon(Icons.bookmark_add_outlined),
label: Text(l10n.bookmarkAddAction),
onPressed: onAddBookmark,
),
],
),
],
);
}
}
class _BookmarkList extends StatelessWidget {
const _BookmarkList({
required this.bookmarks,
required this.onConnect,
required this.onDelete,
});
final List<rust.BridgeBookmark> bookmarks;
final ValueChanged<rust.BridgeBookmark> onConnect;
final ValueChanged<rust.BridgeBookmark> onDelete;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
if (bookmarks.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.bookmarksEmpty,
style: theme.textTheme.bodySmall,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
for (final b in bookmarks)
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
title: Text(b.displayName),
subtitle: Text('${b.host}${b.nickname}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.login),
tooltip: l10n.connectAction,
onPressed: () => onConnect(b),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: l10n.bookmarkDeleteAction,
onPressed: () => onDelete(b),
),
],
),
),
),
],
);
}
}
/// PTT capability badge (gen2 v0.9.3 / SDD-091).
///
/// Renders the active PTT level + backend in the Voice Bar so the
/// user understands which input path is in effect. When the
/// resolved capability is `L0Focused` an info icon appears that
/// opens a per-platform explanation sheet describing why Global
/// PTT is not active and what the user can do to engage it.
///
/// Driven by the `BridgeEvent::PttCapability` stream published by
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
/// updates the props on each transition.
class PttCapabilityBadge extends StatelessWidget {
/// Construct a badge.
const PttCapabilityBadge({
super.key,
required this.level,
required this.backendId,
required this.boundInputClass,
});
/// Resolved capability level as the bridge emits it
/// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` /
/// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`).
final String level;
/// Stable backend identifier (`focused`, `windows-raw-input`, …).
final String backendId;
/// Privacy-safe input class (`keyboard`, `mouse-side-button`,
/// or empty when no binding is set).
final String boundInputClass;
bool get _isFocused => level == 'L0Focused';
String _explainBodyForPlatform(AppL10n l10n) {
// Use `defaultTargetPlatform` rather than `Theme.of(context).platform`
// because the latter is influenced by debug platform overrides
// that callers may toggle in dev mode. We want the badge's
// explanation to match the actual host OS.
switch (defaultTargetPlatform) {
case TargetPlatform.windows:
return l10n.pttCapabilityExplainGoGlobalWindows;
case TargetPlatform.macOS:
return l10n.pttCapabilityExplainGoGlobalMacos;
case TargetPlatform.linux:
return l10n.pttCapabilityExplainGoGlobalLinux;
default:
return l10n.pttCapabilityExplainGoGlobalGeneric;
}
}
void _openExplanationSheet(BuildContext context) {
final l10n = AppL10n.of(context);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttCapabilityExplainTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
l10n.pttCapabilityExplainFocusedHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pttCapabilityExplainFocusedBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
_explainBodyForPlatform(l10n),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(l10n.closeAction),
),
),
],
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final badgeLabel = l10n.pttCapabilityBadge(level, backendId);
final tooltipMessage = boundInputClass.isEmpty
? badgeLabel
: '$badgeLabel\n($boundInputClass)';
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Tooltip(
message: tooltipMessage,
child: Row(
children: [
Icon(
_isFocused ? Icons.crop_free : Icons.public,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Expanded(
child: Text(
badgeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
// Info icon only for L0Focused — the explanation sheet
// tells the user why their PTT may not work outside the
// app window and how to grant the permission. There is
// intentionally NO 'Configure' button here: the single
// configuration entry point is the Voice Bar's
// settings gear (onConfigure on `VoiceBar`). Having two
// identical bind-key entry points just confuses users.
if (_isFocused)
IconButton(
icon: const Icon(Icons.info_outline, size: 16),
tooltip: l10n.pttCapabilityExplainTitle,
visualDensity: VisualDensity.compact,
onPressed: () => _openExplanationSheet(context),
),
],
),
),
);
}
}
class _SnapshotView extends StatelessWidget {
const _SnapshotView({
required this.snapshot,
required this.onJoinChannel,
});
final rust.BridgeSnapshot snapshot;
final ValueChanged<rust.BridgeChannel> onJoinChannel;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
// Channels arrive pre-sorted from the bridge: the Rust
// adapter (chanora_protocol::adapter::sort_channels_tree)
// emits them in root-first depth-first order following the
// TeamSpeak linked-list `order` predecessor pointers. We
// therefore trust the server order verbatim — re-sorting by
// `order` numerically here would re-introduce the bug fixed
// in that adapter (TS3 `order` is NOT a numeric rank).
final channels = snapshot.channels;
final byChannel = <BigInt, List<rust.BridgeClient>>{};
for (final c in snapshot.clients) {
byChannel.putIfAbsent(c.channel, () => []).add(c);
}
return ListView(
children: [
Text(
l10n.countChannelsAndClients(
snapshot.channels.length,
snapshot.clients.length,
),
style: theme.textTheme.bodyMedium,
),
if (snapshot.welcomeMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(snapshot.welcomeMessage, style: theme.textTheme.bodySmall),
),
],
const Divider(height: 24),
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
for (final ch in channels) ...[
ListTile(
dense: true,
leading: const Icon(Icons.tag),
title: Text(ch.name),
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
trailing: IconButton(
icon: const Icon(Icons.login),
tooltip: l10n.joinChannelAction,
onPressed: () => onJoinChannel(ch),
),
onTap: () => onJoinChannel(ch),
),
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
Padding(
padding: const EdgeInsets.only(left: 64),
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: const Icon(Icons.person, size: 18),
title: Text(cl.name),
),
),
],
],
);
}
}
/// Result of a successful PTT binding capture. Carries only the
/// coarse input class and an opaque platform-key label —
/// per DEC-027 the actual key code never crosses out of the
/// capture dialog.
class _CapturedBinding {
const _CapturedBinding({required this.inputClass, required this.platformKey});
final rust.BridgePttInputClass inputClass;
final String platformKey;
}
/// Focus-scoped dialog that captures the next key press or mouse
/// side-button click and returns it as a `_CapturedBinding`. The
/// captured value is the platform-neutral `LogicalKeyboardKey`
/// debug label or `"mouse-side-button:{button}"`; the platform
/// backend interprets the string and never logs it.
class _PttBindingCaptureDialog extends StatefulWidget {
const _PttBindingCaptureDialog();
@override
State<_PttBindingCaptureDialog> createState() =>
_PttBindingCaptureDialogState();
}
class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> {
final FocusNode _focusNode = FocusNode();
String? _captured;
rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final label = _displayLabelForKey(event.logicalKey);
if (label == null) return KeyEventResult.ignored;
setState(() {
_captured = label;
_capturedClass = rust.BridgePttInputClass.keyboard;
});
return KeyEventResult.handled;
}
/// Translate a [LogicalKeyboardKey] into the platform-neutral
/// label string the bridge expects (matching the entries in
/// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`).
///
/// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty
/// for pure modifiers (shift / ctrl / alt / meta), and localised
/// strings for some special keys; we normalise to the canonical
/// English label so the badge displays something readable AND so
/// the Windows backend's keymap can resolve to a `VK_*`. Pure
/// modifier keys are intentionally rejected — chording into the
/// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the
/// individual modifier-down events.
String? _displayLabelForKey(LogicalKeyboardKey k) {
// Whitespace / common control keys whose keyLabel is unhelpful.
if (k == LogicalKeyboardKey.space) return 'Space';
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'Enter';
}
if (k == LogicalKeyboardKey.tab) return 'Tab';
if (k == LogicalKeyboardKey.escape) return 'Escape';
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
if (k == LogicalKeyboardKey.delete) return 'Delete';
if (k == LogicalKeyboardKey.insert) return 'Insert';
if (k == LogicalKeyboardKey.home) return 'Home';
if (k == LogicalKeyboardKey.end) return 'End';
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
// Pure modifier keys are not bindable on their own (user can
// still chord by pressing a non-modifier while holding them).
if (k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.capsLock ||
k == LogicalKeyboardKey.numLock ||
k == LogicalKeyboardKey.scrollLock) {
return null;
}
// Fall back to keyLabel for letters, digits, function keys,
// numpad digits, and punctuation. Trim whitespace as a final
// belt-and-braces guard.
final fallback = k.keyLabel.trim();
if (fallback.isEmpty) return null;
return fallback;
}
void _captureMouseSideButton(int button) {
setState(() {
_captured = 'mouse-side-button:$button';
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.pttConfigureTitle),
content: SizedBox(
width: 360,
child: Focus(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
autofocus: true,
child: Listener(
// Capture mouse side buttons (4 and 5) without
// capturing primary / secondary clicks which the
// user uses to interact with the dialog itself. The
// raw button bitmask values are stable per Flutter's
// `PointerEvent.buttons` documentation (back = 0x08,
// forward = 0x10).
onPointerDown: (e) {
const int back = 0x08;
const int forward = 0x10;
if (e.buttons == back || e.buttons == forward) {
_captureMouseSideButton(e.buttons);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttConfigurePrompt,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
_captured == null
? l10n.pttConfigureWaiting
: '${l10n.pttConfigureCaptured}: $_captured',
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
),
),
const SizedBox(height: 12),
Text(
l10n.pttConfigurePrivacyNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: _captured == null
? null
: () => Navigator.of(context).pop(
_CapturedBinding(
inputClass: _capturedClass,
platformKey: _captured!,
),
),
child: Text(l10n.pttConfigureSaveAction),
),
],
);
}
}