Extract BetaHome (2374 lines) to screens/home_screen.dart and LiveDiagnosticsDialog (97 lines) to screens/diagnostics_dialog.dart. main.dart reduced from 2910 to 255 lines. No behavioral changes.
2375 lines
77 KiB
Dart
2375 lines
77 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show Platform, Process;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
|
|
|
import '../design/breakpoints.dart';
|
|
import '../design/viewport_info.dart';
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import '../main.dart' show ChanoraThemeModeMenu, ChanoraMobileScaffold, kAppVersion, isMacOS, macOSTrafficLightPad, isPokeSenderActiveChat;
|
|
import '../services/android_permissions_service.dart';
|
|
import '../services/app_bootstrap.dart';
|
|
import '../services/ios_audio_session_controller.dart';
|
|
import '../services/channel_join_error_mapper.dart';
|
|
import '../services/connection_phase_state.dart';
|
|
import '../services/hard_mute_owners.dart';
|
|
import '../services/ios_permissions_service.dart';
|
|
import '../services/macos_permissions_service.dart';
|
|
import '../services/poke_notification_service.dart';
|
|
import '../services/poke_preferences_service.dart';
|
|
import '../services/prefetch_debouncer.dart';
|
|
import '../services/snapshot_state_mapper.dart';
|
|
import '../services/ts3_server_link.dart';
|
|
import '../services/ui_preferences_service.dart';
|
|
import '../services/voice_join_ordering.dart';
|
|
import '../src/rust/api.dart' as rust;
|
|
import '../src/rust/lib.dart' as rust_err;
|
|
import '../widgets/audio_processing_config_state.dart';
|
|
import '../widgets/chat_panel.dart';
|
|
import '../widgets/chat_views.dart';
|
|
import '../widgets/client_info_sheet.dart';
|
|
import '../widgets/connect_widgets.dart';
|
|
import '../widgets/input_dialogs.dart';
|
|
import '../widgets/permission_state_banner.dart';
|
|
import '../widgets/poke_notification_settings.dart';
|
|
import '../widgets/snapshot_view.dart';
|
|
import '../widgets/voice_bar.dart';
|
|
import '../widgets/voice_compact.dart';
|
|
import '../widgets/voice_platform.dart';
|
|
import '../widgets/voice_settings.dart';
|
|
import 'diagnostics_dialog.dart';
|
|
|
|
class BetaHome extends StatefulWidget {
|
|
const BetaHome({super.key, required this.themeMode, required this.onThemeModeChanged});
|
|
|
|
final ThemeMode themeMode;
|
|
final Future<void> Function(ThemeMode mode) onThemeModeChanged;
|
|
|
|
@override
|
|
State<BetaHome> createState() => BetaHomeState();
|
|
}
|
|
|
|
class BetaHomeState extends State<BetaHome> with WidgetsBindingObserver {
|
|
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
|
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
|
final _passwordCtl = TextEditingController();
|
|
|
|
ConnectionPhase _phase = ConnectionPhase.idle;
|
|
bool get _serverReachable => _phase.isServerReachable;
|
|
rust.BridgeSnapshot? _snapshot;
|
|
final List<String> _uiDiagnostics = [];
|
|
rust.BridgeAudioStats? _audioStats;
|
|
double? _inputLevel;
|
|
Timer? _statsTimer;
|
|
bool _snapshotRefreshInFlight = false;
|
|
bool _snapshotRefreshQueued = false;
|
|
bool _snapshotRefreshQueuedRecordActivity = false;
|
|
bool _snapshotRefreshQueuedReportErrors = false;
|
|
int _connectionEpoch = 0;
|
|
StreamSubscription<rust.BridgeEvent>? _eventsSub;
|
|
StreamSubscription<double>? _inputLevelSub;
|
|
late final PrefetchDebouncer _prefetch;
|
|
|
|
bool _inChannel = false;
|
|
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
|
bool _hardMute = false;
|
|
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
|
|
bool get _hardMuteByPermission => _hardMuteOwners.permission;
|
|
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
|
|
bool _permissionHardMuteClearInFlight = false;
|
|
int _releaseTailMs = 200;
|
|
BigInt? _currentVoiceChannelId;
|
|
BigInt? _pendingVoiceChannelId;
|
|
bool _canJoinVoiceChannel = true;
|
|
bool _voiceStateInitialized = false;
|
|
|
|
String? _lostReason;
|
|
int? _reconnectAttempt;
|
|
int? _reconnectDelay;
|
|
|
|
bool _inputMuted = false;
|
|
bool _outputMuted = false;
|
|
|
|
String _pttLevel = 'L0Focused';
|
|
String _pttBackendId = 'focused';
|
|
String _pttBoundInputClass = 'keyboard';
|
|
String _pttBoundKeyLabel = '';
|
|
final Set<LogicalKeyboardKey> _focusedPttHeldKeys = <LogicalKeyboardKey>{};
|
|
|
|
List<rust.BridgeBookmark> _bookmarks = const [];
|
|
final List<ChatEntry> _chatMessages = [];
|
|
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
|
int _chatUnread = 0;
|
|
bool _chatOpen = false;
|
|
rust.BridgeMessageTarget? _inlineChatTarget;
|
|
String _inlineChatClientName = '';
|
|
bool _inlineChatCollapseNoticeShown = false;
|
|
|
|
final Map<String, String> _chatDrafts = {};
|
|
|
|
Set<BigInt> get _unreadChannelIds {
|
|
if (_chatOpen) return const {};
|
|
final ids = <BigInt>{};
|
|
for (final entry in _chatMessages) {
|
|
if (!entry.countsTowardUnread || entry.isSelf) continue;
|
|
final target = entry.target;
|
|
if (target is rust.BridgeMessageTarget_Channel) {
|
|
if (_currentVoiceChannelId != null) {
|
|
ids.add(_currentVoiceChannelId!);
|
|
}
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
rust.BridgeMessageTarget? _lastDismissedTarget;
|
|
String _lastDismissedClientName = '';
|
|
final AndroidPermissionsService _androidPermissions =
|
|
AndroidPermissionsService();
|
|
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
|
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
|
|
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
|
final PokeNotificationService _pokeNotifications = PokeNotificationService();
|
|
final PokePreferencesService _pokePreferences = PokePreferencesService();
|
|
late final Future<void> _pokePreferencesReady;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
HardwareKeyboard.instance.addHandler(_handleFocusedPttKey);
|
|
_prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer);
|
|
_hostCtl.addListener(_onHostEdited);
|
|
_eventsSub = rust.eventsStream().listen(_onEvent);
|
|
_inputLevelSub = rust.inputLevelStream().listen((level) {
|
|
if (mounted) setState(() => _inputLevel = level);
|
|
});
|
|
_androidPermissions.start();
|
|
unawaited(_iosPermissions.start());
|
|
_androidPermissions.recordAudioState.addListener(
|
|
_onRecordAudioPermissionChanged,
|
|
);
|
|
_iosPermissions.recordAudioState.addListener(
|
|
_onRecordAudioPermissionChanged,
|
|
);
|
|
_macOSPermissions.start();
|
|
_macOSPermissions.pttCapabilityState.addListener(
|
|
_onMacOSPttCapabilityChanged,
|
|
);
|
|
_macOSPermissions.checkInitialStates();
|
|
unawaited(_pokeNotifications.init());
|
|
_pokePreferencesReady = _pokePreferences.load();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
unawaited(_requestRecordAudioOnStartup());
|
|
});
|
|
unawaited(_reloadBookmarks());
|
|
unawaited(_hydratePttBinding());
|
|
unawaited(_loadUiSettings());
|
|
}
|
|
|
|
Future<void> _loadUiSettings() async {
|
|
try {
|
|
final settings = await _uiPreferences.loadSettings();
|
|
if (settings.host.isNotEmpty) {
|
|
_hostCtl.text = settings.host;
|
|
}
|
|
if (settings.nickname.isNotEmpty) {
|
|
_nickCtl.text = settings.nickname;
|
|
}
|
|
if (settings.host.isNotEmpty) {
|
|
_prefetch.schedule(settings.host);
|
|
}
|
|
// Restore persisted voice settings (SRS-087).
|
|
if (settings.transmitModeIndex != null) {
|
|
final modes = rust.BridgeTransmitMode.values;
|
|
final idx = settings.transmitModeIndex!;
|
|
if (idx >= 0 && idx < modes.length) {
|
|
_transmitMode = modes[idx];
|
|
}
|
|
}
|
|
if (settings.releaseTailMs != null) {
|
|
_releaseTailMs = settings.releaseTailMs!.clamp(0, 500);
|
|
}
|
|
// Restore persisted audio device selection.
|
|
if (settings.inputDeviceId != null) {
|
|
try {
|
|
await rust.setInputDevice(id: settings.inputDeviceId);
|
|
} catch (_) {}
|
|
}
|
|
if (settings.outputDeviceId != null) {
|
|
try {
|
|
await rust.setOutputDevice(id: settings.outputDeviceId);
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
void _onHostEdited() {
|
|
_prefetch.schedule(_hostCtl.text);
|
|
}
|
|
|
|
Future<void> _onPrefetchServer(String host) async {
|
|
try {
|
|
await rust.prefetchServer(host: host);
|
|
} catch (e) {
|
|
_recordUiDiagnostic('prefetch server', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _saveUiSettings({String? host, String? nickname}) async {
|
|
try {
|
|
await _uiPreferences.saveSettings(host: host, nickname: nickname);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _requestRecordAudioOnStartup() async {
|
|
try {
|
|
if (Platform.isAndroid) {
|
|
final permsExplained = await _uiPreferences.hasExplainedPermissions();
|
|
if (!permsExplained) {
|
|
if (!mounted) return;
|
|
await showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(AppL10n.of(ctx).startupPermissionsTitle),
|
|
content: Text(AppL10n.of(ctx).startupPermissionsBody),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: Text(AppL10n.of(ctx).startupPermissionsNotNow),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(ctx);
|
|
_androidPermissions.ensureStartupPermissions();
|
|
},
|
|
child: Text(AppL10n.of(ctx).startupPermissionsAllow),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
await _uiPreferences.markPermissionsExplained();
|
|
} else {
|
|
await _androidPermissions.ensureStartupPermissions();
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
ValueListenable<AndroidRecordAudioPermissionState>
|
|
get _activeRecordAudioState => Platform.isIOS
|
|
? _iosPermissions.recordAudioState
|
|
: _androidPermissions.recordAudioState;
|
|
|
|
Future<AndroidRecordAudioPermissionState> _ensureActiveRecordAudio() {
|
|
return Platform.isIOS
|
|
? _iosPermissions.ensureRecordAudio()
|
|
: _androidPermissions.ensureRecordAudio();
|
|
}
|
|
|
|
Future<void> _openActivePermissionSettings() {
|
|
return Platform.isIOS
|
|
? _iosPermissions.openAppSettings()
|
|
: _androidPermissions.openAppSettings();
|
|
}
|
|
|
|
void _onRecordAudioPermissionChanged() {
|
|
if (_activeRecordAudioState.value ==
|
|
AndroidRecordAudioPermissionState.granted) {
|
|
unawaited(_clearPermissionHardMute());
|
|
}
|
|
}
|
|
|
|
void _onMacOSPttCapabilityChanged() {
|
|
final level = _macOSPermissions.pttCapabilityState.value;
|
|
if (isMacOS && level != _pttLevel) {
|
|
setState(() {
|
|
_pttLevel = level;
|
|
if (level == 'L1MacOSEventTap') {
|
|
_pttBackendId = 'macos-event-tap';
|
|
} else if (level == 'L0Focused') {
|
|
_pttBackendId = 'focused';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _clearPermissionHardMute() async {
|
|
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
|
|
_permissionHardMuteClearInFlight = true;
|
|
try {
|
|
await rust.setHardMute(muted: false);
|
|
if (!mounted || !_hardMuteByPermission) return;
|
|
setState(() {
|
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
|
_hardMute = _hardMuteOwners.effective;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiError('clear permission mute', e);
|
|
} finally {
|
|
_permissionHardMuteClearInFlight = false;
|
|
}
|
|
}
|
|
|
|
void _recordUiDiagnostic(String area, Object error) {
|
|
final line = '${DateTime.now().toIso8601String()} [$area] $error';
|
|
debugPrint('chanora: $line');
|
|
_uiDiagnostics.add(line);
|
|
if (_uiDiagnostics.length > 100) {
|
|
_uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100);
|
|
}
|
|
}
|
|
|
|
void _showUiError(String area, Object error) {
|
|
if (!mounted) return;
|
|
_showUiErrorSnackBar(area: area, error: error);
|
|
}
|
|
|
|
void _showUiErrorSnackBar({
|
|
required String area,
|
|
required Object error,
|
|
String? displayMessage,
|
|
}) {
|
|
_recordUiDiagnostic(area, error);
|
|
final l10n = AppL10n.of(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
content: Text(
|
|
displayMessage ?? l10n.statusError(error.toString()),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showLocalNetworkDeniedSnackBar() {
|
|
if (!mounted) return;
|
|
final l10n = AppL10n.of(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 8),
|
|
content: Text(
|
|
l10n.networkPermissionBody,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
action: SnackBarAction(
|
|
label: l10n.networkPermissionOpenSettings,
|
|
onPressed: () {
|
|
unawaited(
|
|
Future<void>(() async {
|
|
await Process.run('open', [
|
|
'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork',
|
|
]);
|
|
}).catchError((_) {}),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _handleFocusedPttKey(KeyEvent event) {
|
|
final label = pttDisplayLabelForKey(event.logicalKey);
|
|
final isBoundKey =
|
|
_pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel;
|
|
if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) {
|
|
if (_focusedPttHeldKeys.remove(event.logicalKey)) {
|
|
unawaited(_setPtt(false));
|
|
}
|
|
return true;
|
|
}
|
|
if (_pttBackendId != 'focused' ||
|
|
!_serverReachable ||
|
|
!_inChannel ||
|
|
_transmitMode != rust.BridgeTransmitMode.ptt ||
|
|
!isBoundKey) {
|
|
return false;
|
|
}
|
|
if (event is KeyDownEvent) {
|
|
if (_focusedPttHeldKeys.add(event.logicalKey)) {
|
|
unawaited(_setPtt(true));
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _releaseFocusedPttIfHeld() {
|
|
if (_focusedPttHeldKeys.isEmpty) return;
|
|
_focusedPttHeldKeys.clear();
|
|
unawaited(_setPtt(false, reportError: false));
|
|
}
|
|
|
|
Future<void> _hydratePttBinding() async {
|
|
try {
|
|
final binding = await rust.getPttBinding();
|
|
if (!mounted) return;
|
|
if (binding.keyLabel.isNotEmpty || binding.inputClass.isNotEmpty) {
|
|
setState(() {
|
|
_pttBoundKeyLabel = binding.keyLabel;
|
|
_pttBoundInputClass = binding.inputClass;
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _reloadBookmarks() async {
|
|
try {
|
|
await wireStorage();
|
|
final list = await rust.listBookmarks();
|
|
if (!mounted) return;
|
|
setState(() => _bookmarks = list);
|
|
} catch (_) {}
|
|
}
|
|
|
|
void _onEvent(rust.BridgeEvent evt) {
|
|
if (!mounted) return;
|
|
switch (evt) {
|
|
case rust.BridgeEvent_Connected():
|
|
final hadSnapshot = _snapshot != null;
|
|
setState(() {
|
|
_phase = phaseAfterConnectedEvent(_phase, hasSnapshot: hadSnapshot);
|
|
_lostReason = null;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
unawaited(iosAudioSessionController.activate());
|
|
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true));
|
|
case rust.BridgeEvent_Lost(:final reason):
|
|
_recordUiDiagnostic('connection', 'lost: $reason');
|
|
setState(() {
|
|
_phase = ConnectionPhase.reconnecting;
|
|
_lostReason = reason;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
});
|
|
case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs):
|
|
_recordUiDiagnostic(
|
|
'connection',
|
|
'reconnecting attempt=$attempt delay=${delaySecs}s',
|
|
);
|
|
setState(() {
|
|
_phase = ConnectionPhase.reconnecting;
|
|
_reconnectAttempt = attempt;
|
|
_reconnectDelay = delaySecs;
|
|
});
|
|
case rust.BridgeEvent_Disconnected():
|
|
_recordUiDiagnostic('connection', 'disconnected');
|
|
setState(() {
|
|
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
|
});
|
|
case rust.BridgeEvent_AudioStarted():
|
|
unawaited(iosAudioSessionController.activate());
|
|
_ensureStatsTimer();
|
|
case rust.BridgeEvent_AudioStopped():
|
|
unawaited(iosAudioSessionController.deactivate());
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
case rust.BridgeEvent_PttCapability(
|
|
:final level,
|
|
:final backendId,
|
|
:final boundInputClass,
|
|
):
|
|
setState(() {
|
|
_pttLevel = level;
|
|
_pttBackendId = backendId;
|
|
_pttBoundInputClass = boundInputClass;
|
|
});
|
|
if (backendId != 'focused') {
|
|
_releaseFocusedPttIfHeld();
|
|
}
|
|
case rust.BridgeEvent_VoiceState(
|
|
:final inChannel,
|
|
:final transmitMode,
|
|
:final mute,
|
|
:final releaseTailMs,
|
|
:final currentChannelId,
|
|
:final pendingTargetChannelId,
|
|
:final canJoin,
|
|
):
|
|
setState(() {
|
|
_voiceStateInitialized = true;
|
|
_inChannel = inChannel;
|
|
_transmitMode = transmitMode;
|
|
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
|
|
_hardMute = _hardMuteOwners.effective;
|
|
_releaseTailMs = releaseTailMs;
|
|
_currentVoiceChannelId = currentChannelId;
|
|
_pendingVoiceChannelId = pendingTargetChannelId;
|
|
_canJoinVoiceChannel = canJoin;
|
|
});
|
|
if (inChannel) {
|
|
_ensureStatsTimer();
|
|
} else {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
_releaseFocusedPttIfHeld();
|
|
}
|
|
if (transmitMode != rust.BridgeTransmitMode.ptt) {
|
|
_releaseFocusedPttIfHeld();
|
|
}
|
|
case rust.BridgeEvent_InterruptionState(
|
|
:final began,
|
|
:final shouldResume,
|
|
):
|
|
if (!mounted) return;
|
|
unawaited(() async {
|
|
if (!mounted) return;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
if (began) {
|
|
_recordUiDiagnostic('audio', 'interruption began');
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppL10n.of(context).iosAudioInterrupted),
|
|
duration: Duration(seconds: 3),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
} else if (shouldResume) {
|
|
_recordUiDiagnostic('audio', 'interruption ended, resuming');
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppL10n.of(context).iosAudioResuming),
|
|
duration: Duration(seconds: 2),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
}());
|
|
case rust.BridgeEvent_PermissionState(:final permission, :final state):
|
|
debugPrint(
|
|
'bridge permission_state: permission=$permission state=$state',
|
|
);
|
|
if (permission == 'android.permission.RECORD_AUDIO' &&
|
|
state == rust.PermissionStateKind.granted) {
|
|
unawaited(_clearPermissionHardMute());
|
|
}
|
|
case rust.BridgeEvent_ChatMessage(
|
|
:final senderId,
|
|
:final senderName,
|
|
:final message,
|
|
:final target,
|
|
:final pokeStrength,
|
|
):
|
|
final isPoke = target is rust.BridgeMessageTarget_Poke;
|
|
if (!isPoke && senderId == _snapshot?.ownClientId) return;
|
|
final receivedAt = DateTime.now();
|
|
setState(() {
|
|
_appendChatEntryUnlocked(
|
|
ChatEntry(
|
|
senderId: senderId,
|
|
senderName: senderName,
|
|
message: message,
|
|
target: target,
|
|
isSelf: senderId == _snapshot?.ownClientId,
|
|
timestamp: receivedAt,
|
|
),
|
|
);
|
|
});
|
|
if (isPoke) {
|
|
unawaited(
|
|
_handleIncomingPoke(
|
|
senderId: senderId,
|
|
senderName: senderName,
|
|
message: message,
|
|
strength: pokeStrength,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (!_chatOpen && _chatUnread > 0) {
|
|
if (!mounted) return;
|
|
unawaited(() async {
|
|
if (!mounted) return;
|
|
_showChatMessageSnackBar(
|
|
senderName: senderName,
|
|
message: message,
|
|
target: target,
|
|
);
|
|
}());
|
|
}
|
|
case rust.BridgeEvent_ServerActivity(:final message):
|
|
setState(() {
|
|
_appendChatEntryUnlocked(
|
|
ChatEntry(
|
|
senderId: BigInt.zero,
|
|
senderName: 'Server',
|
|
message: message,
|
|
target: const rust.BridgeMessageTarget.server(),
|
|
timestamp: DateTime.now(),
|
|
countsTowardUnread: false,
|
|
),
|
|
);
|
|
});
|
|
case rust.BridgeEvent_AudioRouteChanged():
|
|
break;
|
|
case rust.BridgeEvent_ClientMoved(:final clientId, :final newChannelId):
|
|
_applyClientDelta((c) => c.id == clientId, (c) {
|
|
final updated = rust.BridgeClient(
|
|
id: c.id,
|
|
channel: newChannelId,
|
|
name: c.name,
|
|
inputMuted: c.inputMuted,
|
|
outputMuted: c.outputMuted,
|
|
isSpeaking: c.isSpeaking,
|
|
isServerQuery: c.isServerQuery,
|
|
talkPower: c.talkPower,
|
|
talkPowerGranted: c.talkPowerGranted,
|
|
);
|
|
return updated;
|
|
});
|
|
case rust.BridgeEvent_ClientJoined(
|
|
:final clientId,
|
|
:final channelId,
|
|
:final name,
|
|
:final inputMuted,
|
|
:final outputMuted,
|
|
:final isServerQuery,
|
|
:final talkPower,
|
|
:final talkPowerGranted,
|
|
):
|
|
if (!isServerQuery) {
|
|
_applyClientAdd(
|
|
rust.BridgeClient(
|
|
id: clientId,
|
|
channel: channelId,
|
|
name: name,
|
|
inputMuted: inputMuted,
|
|
outputMuted: outputMuted,
|
|
isSpeaking: false,
|
|
isServerQuery: isServerQuery,
|
|
talkPower: talkPower,
|
|
talkPowerGranted: talkPowerGranted,
|
|
),
|
|
);
|
|
}
|
|
case rust.BridgeEvent_ClientLeft(:final clientId):
|
|
_applyClientRemove(clientId);
|
|
case rust.BridgeEvent_ClientUpdated(
|
|
:final clientId,
|
|
:final inputMuted,
|
|
:final outputMuted,
|
|
:final isServerQuery,
|
|
:final talkPower,
|
|
:final talkPowerGranted,
|
|
):
|
|
_applyClientDelta((c) => c.id == clientId, (c) {
|
|
final updated = rust.BridgeClient(
|
|
id: c.id,
|
|
channel: c.channel,
|
|
name: c.name,
|
|
inputMuted: inputMuted,
|
|
outputMuted: outputMuted,
|
|
isSpeaking: c.isSpeaking,
|
|
isServerQuery: isServerQuery,
|
|
talkPower: talkPower,
|
|
talkPowerGranted: talkPowerGranted,
|
|
);
|
|
return updated;
|
|
});
|
|
case rust.BridgeEvent_ChannelAdded(
|
|
:final id,
|
|
:final parent,
|
|
:final name,
|
|
:final order,
|
|
:final hasPassword,
|
|
:final neededTalkPower,
|
|
):
|
|
_applyChannelAdd(
|
|
rust.BridgeChannel(
|
|
id: id,
|
|
parent: parent,
|
|
name: name,
|
|
order: order,
|
|
hasPassword: hasPassword,
|
|
neededTalkPower: neededTalkPower,
|
|
),
|
|
);
|
|
case rust.BridgeEvent_ChannelRemoved(:final id):
|
|
_applyChannelRemove(id);
|
|
case rust.BridgeEvent_ChannelUpdated(
|
|
:final id,
|
|
:final name,
|
|
:final hasPassword,
|
|
:final neededTalkPower,
|
|
):
|
|
_applyChannelDelta((ch) => ch.id == id, (ch) {
|
|
return rust.BridgeChannel(
|
|
id: ch.id,
|
|
parent: ch.parent,
|
|
name: name,
|
|
order: ch.order,
|
|
hasPassword: hasPassword,
|
|
neededTalkPower: neededTalkPower,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _stopStatsPolling() {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
}
|
|
|
|
void _startStatsPollingIfConnected() {
|
|
if (_serverReachable) _ensureStatsTimer();
|
|
}
|
|
|
|
DateTime? _lastSpeakingRefresh;
|
|
static const _speakingRefreshInterval = Duration(milliseconds: 750);
|
|
|
|
void _ensureStatsTimer() {
|
|
if (_statsTimer != null) return;
|
|
_lastSpeakingRefresh = null;
|
|
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
|
|
try {
|
|
final s = await rust.audioStats();
|
|
if (!mounted) return;
|
|
setState(() => _audioStats = s);
|
|
} catch (_) {}
|
|
final now = DateTime.now();
|
|
if (_inChannel &&
|
|
(_lastSpeakingRefresh == null ||
|
|
now.difference(_lastSpeakingRefresh!) >=
|
|
_speakingRefreshInterval)) {
|
|
_lastSpeakingRefresh = now;
|
|
unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false));
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
final stateName = state.name;
|
|
debugPrint('chanora: app $stateName');
|
|
rust.recordLifecycleEvent(state: stateName);
|
|
switch (state) {
|
|
case AppLifecycleState.paused:
|
|
_stopStatsPolling();
|
|
case AppLifecycleState.resumed:
|
|
_startStatsPollingIfConnected();
|
|
case AppLifecycleState.hidden:
|
|
case AppLifecycleState.inactive:
|
|
break;
|
|
case AppLifecycleState.detached:
|
|
_stopStatsPolling();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey);
|
|
_eventsSub?.cancel();
|
|
_inputLevelSub?.cancel();
|
|
_statsTimer?.cancel();
|
|
_snapshotRefreshInFlight = false;
|
|
_snapshotRefreshQueued = false;
|
|
_snapshotRefreshQueuedRecordActivity = false;
|
|
_snapshotRefreshQueuedReportErrors = false;
|
|
_hostCtl.removeListener(_onHostEdited);
|
|
_prefetch.dispose();
|
|
_hostCtl.dispose();
|
|
_nickCtl.dispose();
|
|
_passwordCtl.dispose();
|
|
_chatFeedRevision.dispose();
|
|
_androidPermissions.recordAudioState.removeListener(
|
|
_onRecordAudioPermissionChanged,
|
|
);
|
|
_iosPermissions.recordAudioState.removeListener(
|
|
_onRecordAudioPermissionChanged,
|
|
);
|
|
_macOSPermissions.pttCapabilityState.removeListener(
|
|
_onMacOSPttCapabilityChanged,
|
|
);
|
|
_androidPermissions.stop();
|
|
_iosPermissions.stop();
|
|
_macOSPermissions.stop();
|
|
_pokePreferences.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _onConnect({
|
|
String? host,
|
|
String? nickname,
|
|
String? password,
|
|
}) async {
|
|
final connectEpoch = ++_connectionEpoch;
|
|
|
|
final lnState = _macOSPermissions.localNetworkState.value;
|
|
if (lnState == MacOSLocalNetworkState.unknown ||
|
|
lnState == MacOSLocalNetworkState.notDetermined) {
|
|
await _macOSPermissions.triggerLocalNetworkPrompt();
|
|
}
|
|
|
|
final resolvedState = _macOSPermissions.localNetworkState.value;
|
|
if (resolvedState == MacOSLocalNetworkState.denied) {
|
|
final targetHost = (host ?? _hostCtl.text).trim();
|
|
final accessState = await _macOSPermissions.checkLocalNetworkAccess(
|
|
host: targetHost,
|
|
port: 9987,
|
|
);
|
|
if (accessState == MacOSLocalNetworkState.denied) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = ConnectionPhase.idle;
|
|
});
|
|
_showLocalNetworkDeniedSnackBar();
|
|
return;
|
|
}
|
|
}
|
|
|
|
setState(() {
|
|
_phase = ConnectionPhase.connecting;
|
|
_snapshot = null;
|
|
_chatMessages.clear();
|
|
});
|
|
_releaseFocusedPttIfHeld();
|
|
await iosAudioSessionController.activate();
|
|
try {
|
|
final snap = await rust.connect(
|
|
host: (host ?? _hostCtl.text).trim(),
|
|
nickname: (nickname ?? _nickCtl.text).trim(),
|
|
password: password ?? _passwordCtl.text,
|
|
);
|
|
if (!mounted) return;
|
|
final connectedHost = host ?? _hostCtl.text.trim();
|
|
final connectedNickname = nickname ?? _nickCtl.text.trim();
|
|
setState(() {
|
|
_phase = ConnectionPhase.synchronizing;
|
|
_applySnapshot(snap);
|
|
});
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted ||
|
|
_connectionEpoch != connectEpoch ||
|
|
!_serverReachable ||
|
|
_snapshot?.serverName != snap.serverName) {
|
|
return;
|
|
}
|
|
unawaited(
|
|
_runPostConnectSideEffects(
|
|
snap: snap,
|
|
host: connectedHost,
|
|
nickname: connectedNickname,
|
|
),
|
|
);
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_phase = ConnectionPhase.idle;
|
|
});
|
|
_showUiErrorSnackBar(area: 'connect', error: e);
|
|
}
|
|
}
|
|
|
|
Future<void> _runPostConnectSideEffects({
|
|
required rust.BridgeSnapshot snap,
|
|
required String host,
|
|
required String nickname,
|
|
}) async {
|
|
unawaited(_saveUiSettings(host: host, nickname: nickname));
|
|
try {
|
|
await FlutterForegroundTask.startService(
|
|
notificationTitle: 'Chanora',
|
|
notificationText: 'Connected to ${snap.serverName}',
|
|
notificationButtons: const [
|
|
NotificationButton(id: 'disconnect', text: 'Disconnect'),
|
|
],
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> _setPtt(bool active, {bool reportError = true}) async {
|
|
if (active) {
|
|
try {
|
|
HapticFeedback.lightImpact();
|
|
} catch (_) {}
|
|
}
|
|
try {
|
|
await rust.setPtt(active: active);
|
|
} catch (e) {
|
|
if (!mounted || !reportError) return;
|
|
_showUiError('ptt', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _toggleOutputMute() async {
|
|
final next = !_outputMuted;
|
|
setState(() {
|
|
_outputMuted = next;
|
|
});
|
|
try {
|
|
await rust.setOutputMuted(muted: next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_outputMuted = !next;
|
|
});
|
|
_showUiError('output mute', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _onJoinChannel(
|
|
rust.BridgeChannel ch, {
|
|
bool askForPassword = false,
|
|
}) async {
|
|
if (!_canJoinVoiceChannel) return;
|
|
if (_pendingVoiceChannelId != null) return;
|
|
if (ch.id == _currentVoiceChannelId) return;
|
|
final l10n = AppL10n.of(context);
|
|
String? password;
|
|
if (askForPassword) {
|
|
password = await _askChannelPassword(l10n);
|
|
if (password == null) return;
|
|
}
|
|
setState(() => _pendingVoiceChannelId = ch.id);
|
|
try {
|
|
final permState = Platform.isAndroid
|
|
? await _androidPermissions.ensureRecordAudio()
|
|
: Platform.isIOS
|
|
? await _iosPermissions.ensureRecordAudio()
|
|
: AndroidRecordAudioPermissionState.granted;
|
|
if (permState != AndroidRecordAudioPermissionState.granted) {
|
|
try {
|
|
await rust.setHardMute(muted: true);
|
|
if (mounted) {
|
|
setState(() {
|
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
|
|
_hardMute = _hardMuteOwners.effective;
|
|
});
|
|
}
|
|
} catch (_) {}
|
|
} else if (_hardMuteByPermission) {
|
|
await rust.setHardMute(muted: false);
|
|
if (mounted) {
|
|
setState(() {
|
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
|
_hardMute = _hardMuteOwners.effective;
|
|
});
|
|
}
|
|
}
|
|
await joinVoiceChannelWithIosAudioSession(
|
|
channelId: ch.id,
|
|
password: password ?? '',
|
|
voiceJoin: rust.voiceJoin,
|
|
activateIosAudioSession: iosAudioSessionController.activate,
|
|
deactivateIosAudioSession: iosAudioSessionController.deactivate,
|
|
isJoinSuccess: _isAlreadyInChannel,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_currentVoiceChannelId = ch.id;
|
|
_pendingVoiceChannelId = null;
|
|
_inChannel = true;
|
|
_canJoinVoiceChannel = true;
|
|
_voiceStateInitialized = true;
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
if (_isAlreadyInChannel(e)) {
|
|
setState(() {
|
|
_currentVoiceChannelId = ch.id;
|
|
_pendingVoiceChannelId = null;
|
|
_inChannel = true;
|
|
});
|
|
return;
|
|
}
|
|
if (_isFlooding(e)) {
|
|
setState(() {
|
|
_pendingVoiceChannelId = null;
|
|
});
|
|
_showUiErrorSnackBar(
|
|
area: 'join channel',
|
|
error: e,
|
|
displayMessage: l10n.channelJoinFailedFlooding,
|
|
);
|
|
return;
|
|
}
|
|
final message = channelJoinErrorMessage(l10n, e);
|
|
setState(() {
|
|
_pendingVoiceChannelId = null;
|
|
});
|
|
_showUiErrorSnackBar(
|
|
area: 'join channel',
|
|
error: e,
|
|
displayMessage: message,
|
|
);
|
|
}
|
|
}
|
|
|
|
bool _isAlreadyInChannel(Object error) {
|
|
if (error is rust_err.BridgeError_ServerRejected) {
|
|
return error.code == 0x0302;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool _isFlooding(Object error) {
|
|
if (error is rust_err.BridgeError_ServerRejected) {
|
|
return error.code == 0x020c;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future<void> _onToggleHardMute() async {
|
|
if (_hardMuteByTalkPower && _hardMute) {
|
|
return;
|
|
}
|
|
final next = !_hardMute;
|
|
final previousInputMuted = _inputMuted;
|
|
final previousHardMute = _hardMute;
|
|
final previousHardMuteOwners = _hardMuteOwners;
|
|
setState(() {
|
|
_inputMuted = next;
|
|
_hardMuteOwners = _hardMuteOwners.copyWith(
|
|
manual: next,
|
|
permission: next ? false : null,
|
|
);
|
|
_hardMute = _hardMuteOwners.effective;
|
|
});
|
|
try {
|
|
await rust.setHardMute(muted: next);
|
|
await rust.setInputMuted(muted: next);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_inputMuted = previousInputMuted;
|
|
_hardMute = previousHardMute;
|
|
_hardMuteOwners = previousHardMuteOwners;
|
|
});
|
|
_showUiError('hard mute', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _onOnscreenPttHeldChanged(bool held) async {
|
|
try {
|
|
await rust.setPtt(active: held);
|
|
} catch (e) {
|
|
if (held) {
|
|
if (!mounted) return;
|
|
_showUiError('onscreen ptt', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<rust.BridgeAudioProcessingConfig> _loadAudioProcessingConfig() async {
|
|
try {
|
|
return await rust.getAudioProcessingConfig();
|
|
} catch (_) {
|
|
return defaultAudioProcessingConfig();
|
|
}
|
|
}
|
|
|
|
void _appendChatEntryUnlocked(ChatEntry entry) {
|
|
_chatMessages.add(entry);
|
|
if (_chatMessages.length > 200) {
|
|
_chatMessages.removeRange(0, _chatMessages.length - 200);
|
|
}
|
|
_notifyChatFeedChanged();
|
|
if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) {
|
|
_chatUnread++;
|
|
}
|
|
}
|
|
|
|
void _notifyChatFeedChanged() {
|
|
_chatFeedRevision.value++;
|
|
}
|
|
|
|
Future<void> _onOpenVoiceDetailsSheet() async {
|
|
final audioConfig = await _loadAudioProcessingConfig();
|
|
if (!mounted) return;
|
|
|
|
await showVoiceDetailsSheet(
|
|
context,
|
|
transmitMode: _transmitMode,
|
|
releaseTailMs: _releaseTailMs,
|
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
|
pttLevel: _pttLevel,
|
|
pttBackendId: _pttBackendId,
|
|
pttBoundInputClass: _pttBoundInputClass,
|
|
isTouchOnly: isTouchOnlyPttHost,
|
|
initialAudioConfig: audioConfig,
|
|
onModeChanged: (mode) async {
|
|
try {
|
|
await rust.setTransmitMode(mode: mode);
|
|
if (!mounted) return;
|
|
setState(() => _transmitMode = mode);
|
|
unawaited(_uiPreferences.saveTransmitModeIndex(mode.index));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiError('transmit mode', e);
|
|
}
|
|
},
|
|
onReleaseTailChanged: (ms) async {
|
|
try {
|
|
await rust.setReleaseTailMs(ms: ms);
|
|
if (!mounted) return;
|
|
setState(() => _releaseTailMs = ms);
|
|
unawaited(_uiPreferences.saveReleaseTailMs(ms));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiError('release tail', e);
|
|
}
|
|
},
|
|
onAudioConfigChanged: (config) async {
|
|
try {
|
|
await rust.setAudioProcessingConfig(config: config);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiError('audio processing config', e);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _onOpenVoiceSettings() async {
|
|
final audioConfig = await _loadAudioProcessingConfig();
|
|
if (!mounted) return;
|
|
|
|
final result = await showDialog<VoiceSettingsResult>(
|
|
context: context,
|
|
builder: (ctx) => VoiceSettingsDialog(
|
|
initialMode: _transmitMode,
|
|
initialReleaseTailMs: _releaseTailMs,
|
|
initialAudioConfig: audioConfig,
|
|
pttLevel: _pttLevel,
|
|
pttBackendId: _pttBackendId,
|
|
pttBoundInputClass: _pttBoundInputClass,
|
|
onInputDeviceChanged: (id) =>
|
|
unawaited(_uiPreferences.saveInputDeviceId(id)),
|
|
onOutputDeviceChanged: (id) =>
|
|
unawaited(_uiPreferences.saveOutputDeviceId(id)),
|
|
),
|
|
);
|
|
if (result == null) return;
|
|
try {
|
|
await rust.setTransmitMode(mode: result.mode);
|
|
await rust.setReleaseTailMs(ms: result.releaseTailMs);
|
|
await rust.setAudioProcessingConfig(config: result.audioConfig);
|
|
// Persist voice settings (SRS-087).
|
|
unawaited(_uiPreferences.saveTransmitModeIndex(result.mode.index));
|
|
unawaited(_uiPreferences.saveReleaseTailMs(result.releaseTailMs));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiError('voice settings', e);
|
|
}
|
|
if (result.bindKeyRequested && mounted) {
|
|
await _onConfigurePtt(context);
|
|
if (mounted) {
|
|
unawaited(_onOpenVoiceSettings());
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _onOpenPokeSettings() async {
|
|
await _pokePreferences.load();
|
|
if (!mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) =>
|
|
PokeNotificationSettingsDialog(preferences: _pokePreferences, notificationService: _pokeNotifications),
|
|
);
|
|
}
|
|
|
|
Future<String?> _askChannelPassword(AppL10n l10n) async {
|
|
return await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => const ChannelPasswordDialog(),
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshSnapshot({
|
|
required bool recordActivity,
|
|
required bool reportErrors,
|
|
}) async {
|
|
if (_snapshotRefreshInFlight) {
|
|
_snapshotRefreshQueued = true;
|
|
_snapshotRefreshQueuedRecordActivity =
|
|
_snapshotRefreshQueuedRecordActivity || recordActivity;
|
|
_snapshotRefreshQueuedReportErrors =
|
|
_snapshotRefreshQueuedReportErrors || reportErrors;
|
|
return;
|
|
}
|
|
|
|
_snapshotRefreshInFlight = true;
|
|
var nextRecordActivity = recordActivity;
|
|
var nextReportErrors = reportErrors;
|
|
|
|
try {
|
|
while (mounted) {
|
|
_snapshotRefreshQueued = false;
|
|
_snapshotRefreshQueuedRecordActivity = false;
|
|
_snapshotRefreshQueuedReportErrors = false;
|
|
|
|
final previousSnapshot = _snapshot;
|
|
try {
|
|
final snap = await rust.snapshot();
|
|
if (!mounted) return;
|
|
final activityEntries = nextRecordActivity && previousSnapshot != null
|
|
? buildServerActivityEntries(
|
|
previous: previousSnapshot,
|
|
current: snap,
|
|
)
|
|
: const <ChatEntry>[];
|
|
setState(() {
|
|
_applySnapshot(snap);
|
|
for (final entry in activityEntries) {
|
|
_appendChatEntryUnlocked(entry);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
if (nextReportErrors && mounted) {
|
|
_showUiError('refresh snapshot', e);
|
|
}
|
|
}
|
|
|
|
if (!_snapshotRefreshQueued) {
|
|
break;
|
|
}
|
|
nextRecordActivity = _snapshotRefreshQueuedRecordActivity;
|
|
nextReportErrors = _snapshotRefreshQueuedReportErrors;
|
|
}
|
|
} finally {
|
|
_snapshotRefreshInFlight = false;
|
|
_snapshotRefreshQueued = false;
|
|
_snapshotRefreshQueuedRecordActivity = false;
|
|
_snapshotRefreshQueuedReportErrors = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _onDisconnect() async {
|
|
_connectionEpoch++;
|
|
_releaseFocusedPttIfHeld();
|
|
if (mounted) {
|
|
setState(() {
|
|
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
|
});
|
|
}
|
|
unawaited(_finishDisconnect());
|
|
}
|
|
|
|
Future<void> _onConfirmDisconnect() async {
|
|
final l10n = AppL10n.of(context);
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.disconnectConfirmTitle),
|
|
content: Text(l10n.disconnectConfirmBody),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
|
child: Text(l10n.disconnectConfirmCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
|
child: Text(l10n.disconnectConfirmAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true && mounted && _phase.canDisconnect) {
|
|
await _onDisconnect();
|
|
}
|
|
}
|
|
|
|
Future<void> _finishDisconnect() async {
|
|
_statsTimer?.cancel();
|
|
_statsTimer = null;
|
|
_snapshotRefreshInFlight = false;
|
|
_snapshotRefreshQueued = false;
|
|
_snapshotRefreshQueuedRecordActivity = false;
|
|
_snapshotRefreshQueuedReportErrors = false;
|
|
try {
|
|
await rust.disconnect();
|
|
} catch (_) {}
|
|
try {
|
|
await FlutterForegroundTask.stopService();
|
|
} catch (_) {}
|
|
unawaited(_reloadBookmarks());
|
|
}
|
|
|
|
void _resetConnectionUiState({required ConnectionPhase phase}) {
|
|
_phase = phase;
|
|
_snapshot = null;
|
|
_audioStats = null;
|
|
_inputMuted = false;
|
|
_outputMuted = false;
|
|
_hardMute = false;
|
|
_hardMuteOwners = const HardMuteOwners();
|
|
_inChannel = false;
|
|
_currentVoiceChannelId = null;
|
|
_pendingVoiceChannelId = null;
|
|
_canJoinVoiceChannel = true;
|
|
_voiceStateInitialized = false;
|
|
_lostReason = null;
|
|
_reconnectAttempt = null;
|
|
_reconnectDelay = null;
|
|
_chatMessages.clear();
|
|
_chatDrafts.clear();
|
|
_chatUnread = 0;
|
|
_chatOpen = false;
|
|
_inlineChatTarget = null;
|
|
_inlineChatClientName = '';
|
|
_inlineChatCollapseNoticeShown = false;
|
|
_lastDismissedTarget = null;
|
|
_lastDismissedClientName = '';
|
|
_notifyChatFeedChanged();
|
|
}
|
|
|
|
String _draftKeyForTarget(rust.BridgeMessageTarget target) {
|
|
return switch (target) {
|
|
rust.BridgeMessageTarget_Server() => 'server',
|
|
rust.BridgeMessageTarget_Channel() => 'channel',
|
|
rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0',
|
|
rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0',
|
|
};
|
|
}
|
|
|
|
String? _currentDraftFor(rust.BridgeMessageTarget target) =>
|
|
_chatDrafts[_draftKeyForTarget(target)];
|
|
|
|
Future<void> _onOpenChat({
|
|
rust.BridgeMessageTarget? target,
|
|
String clientName = '',
|
|
}) async {
|
|
final initialSnapshot = _snapshot!;
|
|
|
|
rust.BridgeMessageTarget newTarget;
|
|
if (target != null) {
|
|
newTarget = target;
|
|
} else if (_chatOpen && _currentVoiceChannelId != null) {
|
|
newTarget = const rust.BridgeMessageTarget.channel();
|
|
} else if (_inlineChatTarget != null) {
|
|
newTarget = _inlineChatTarget!;
|
|
} else {
|
|
newTarget =
|
|
resolveInitialChatTarget(
|
|
messages: _chatMessages,
|
|
currentVoiceChannelId: _currentVoiceChannelId,
|
|
) ??
|
|
const rust.BridgeMessageTarget.server();
|
|
}
|
|
|
|
final newClientName = clientName.isNotEmpty
|
|
? clientName
|
|
: (newTarget == _inlineChatTarget)
|
|
? _inlineChatClientName
|
|
: (newTarget == _lastDismissedTarget)
|
|
? _lastDismissedClientName
|
|
: '';
|
|
|
|
final isExpanded =
|
|
layoutClassFromWidth(MediaQuery.sizeOf(context).width) ==
|
|
LayoutClass.expanded;
|
|
if (isExpanded) {
|
|
setState(() {
|
|
_chatUnread = 0;
|
|
_chatOpen = true;
|
|
_inlineChatTarget = newTarget;
|
|
_inlineChatClientName = newClientName;
|
|
_inlineChatCollapseNoticeShown = false;
|
|
});
|
|
return;
|
|
}
|
|
setState(() {
|
|
_chatUnread = 0;
|
|
_chatOpen = true;
|
|
});
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => ChatPage(
|
|
messages: _chatMessages,
|
|
snapshot: initialSnapshot,
|
|
messagesSource: () => _chatMessages,
|
|
snapshotSource: () => _snapshot ?? initialSnapshot,
|
|
refreshListenable: _chatFeedRevision,
|
|
initialTarget: newTarget,
|
|
initialClientName: newClientName,
|
|
onTs3ServerLink: _onTs3ServerLink,
|
|
),
|
|
),
|
|
);
|
|
if (mounted) setState(() => _chatOpen = false);
|
|
}
|
|
|
|
void _closeInlineChat() {
|
|
setState(() {
|
|
_lastDismissedTarget = _inlineChatTarget;
|
|
_lastDismissedClientName = _inlineChatClientName;
|
|
_chatOpen = false;
|
|
});
|
|
}
|
|
|
|
void _handleInlineChatViewport(LayoutClass layoutClass) {
|
|
if (_inlineChatTarget == null) return;
|
|
if (layoutClass == LayoutClass.expanded) {
|
|
_inlineChatCollapseNoticeShown = false;
|
|
if (!_chatOpen) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _inlineChatTarget != null) {
|
|
setState(() => _chatOpen = true);
|
|
}
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (_chatOpen) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted && _inlineChatTarget != null) {
|
|
setState(() => _chatOpen = false);
|
|
}
|
|
});
|
|
}
|
|
if (_inlineChatCollapseNoticeShown) return;
|
|
_inlineChatCollapseNoticeShown = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _inlineChatTarget == null) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)),
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
showDragHandle: false,
|
|
builder: (context) => FractionallySizedBox(
|
|
heightFactor: 0.86,
|
|
child: ClientInfoSheet(
|
|
clientName: client.name,
|
|
loadProfile: () => rust.clientProfile(clientId: client.id),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleIncomingPoke({
|
|
required BigInt senderId,
|
|
required String senderName,
|
|
required String message,
|
|
required rust.BridgePokeStrength? strength,
|
|
}) async {
|
|
if (senderId == _snapshot?.ownClientId) return;
|
|
await _pokePreferencesReady;
|
|
if (!_pokePreferences.pokesEnabled.value) return;
|
|
if (_pokePreferences.isMuted(senderId)) return;
|
|
final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed;
|
|
if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) {
|
|
_showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName);
|
|
}
|
|
if (_isPokeSenderActiveChat(senderId)) return;
|
|
await _pokeNotifications.show(
|
|
senderName: senderName,
|
|
message: message,
|
|
senderId: senderId,
|
|
strength: pokeStrength,
|
|
);
|
|
}
|
|
|
|
bool _isPokeSenderActiveChat(BigInt senderId) {
|
|
return isPokeSenderActiveChat(
|
|
chatOpen: _chatOpen,
|
|
inlineChatTarget: _inlineChatTarget,
|
|
senderId: senderId,
|
|
);
|
|
}
|
|
|
|
void _showPokeOverflowMutePrompt({
|
|
required BigInt senderId,
|
|
required String senderName,
|
|
}) {
|
|
final l10n = AppL10n.of(context);
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: _chatSnackBarMargin(),
|
|
duration: const Duration(seconds: 8),
|
|
content: Text(
|
|
l10n.pokeOverflowMutePrompt(senderName),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
action: SnackBarAction(
|
|
label: l10n.pokeOverflowMuteAction,
|
|
onPressed: () {
|
|
unawaited(_pokePreferences.muteSender(senderId));
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: _chatSnackBarMargin(),
|
|
content: Text(l10n.pokeMutedSenderConfirmation(senderName)),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showChatMessageSnackBar({
|
|
required String senderName,
|
|
required String message,
|
|
required rust.BridgeMessageTarget target,
|
|
}) {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
messenger.hideCurrentSnackBar();
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
behavior: SnackBarBehavior.floating,
|
|
margin: _chatSnackBarMargin(),
|
|
duration: const Duration(seconds: 7),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'$senderName (${_chatTargetLabel(target)})',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(message, maxLines: 3, overflow: TextOverflow.ellipsis),
|
|
],
|
|
),
|
|
action: SnackBarAction(
|
|
label: AppL10n.of(context).openAction,
|
|
onPressed: () => unawaited(
|
|
_onOpenChat(
|
|
target: target,
|
|
clientName: _chatClientNameForTarget(target, senderName),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
EdgeInsetsGeometry _chatSnackBarMargin() {
|
|
const side = 16.0;
|
|
var bottom = 16.0;
|
|
final wideConnectedLayout =
|
|
MediaQuery.sizeOf(context).width >= ChanoraBreakpoints.medium &&
|
|
_serverReachable &&
|
|
_snapshot != null;
|
|
|
|
if (_serverReachable && _snapshot != null && !wideConnectedLayout) {
|
|
bottom += 64;
|
|
if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) {
|
|
bottom += 64;
|
|
}
|
|
}
|
|
|
|
return EdgeInsetsDirectional.fromSTEB(side, 0, side, bottom);
|
|
}
|
|
|
|
String _chatTargetLabel(rust.BridgeMessageTarget target) {
|
|
return switch (target) {
|
|
rust.BridgeMessageTarget_Client() => 'Private Chat',
|
|
rust.BridgeMessageTarget_Poke() => 'Poke',
|
|
rust.BridgeMessageTarget_Channel() => 'Channel Chat',
|
|
rust.BridgeMessageTarget_Server() => 'Server',
|
|
};
|
|
}
|
|
|
|
String _chatClientNameForTarget(
|
|
rust.BridgeMessageTarget target,
|
|
String senderName,
|
|
) {
|
|
return switch (target) {
|
|
rust.BridgeMessageTarget_Client() ||
|
|
rust.BridgeMessageTarget_Poke() => senderName,
|
|
_ => '',
|
|
};
|
|
}
|
|
|
|
void _applyClientDelta(
|
|
bool Function(rust.BridgeClient) test,
|
|
rust.BridgeClient Function(rust.BridgeClient) update,
|
|
) {
|
|
final snap = _snapshot;
|
|
if (snap == null) return;
|
|
final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList();
|
|
setState(() {
|
|
_snapshot = rust.BridgeSnapshot(
|
|
serverName: snap.serverName,
|
|
welcomeMessage: snap.welcomeMessage,
|
|
platform: snap.platform,
|
|
version: snap.version,
|
|
channels: snap.channels,
|
|
clients: clients,
|
|
ownClientId: snap.ownClientId,
|
|
);
|
|
_notifyChatFeedChanged();
|
|
});
|
|
}
|
|
|
|
void _applyClientAdd(rust.BridgeClient client) {
|
|
final snap = _snapshot;
|
|
if (snap == null) return;
|
|
setState(() {
|
|
_snapshot = rust.BridgeSnapshot(
|
|
serverName: snap.serverName,
|
|
welcomeMessage: snap.welcomeMessage,
|
|
platform: snap.platform,
|
|
version: snap.version,
|
|
channels: snap.channels,
|
|
clients: [...snap.clients, client],
|
|
ownClientId: snap.ownClientId,
|
|
);
|
|
_notifyChatFeedChanged();
|
|
});
|
|
}
|
|
|
|
void _applyClientRemove(BigInt clientId) {
|
|
final snap = _snapshot;
|
|
if (snap == null) return;
|
|
setState(() {
|
|
_snapshot = rust.BridgeSnapshot(
|
|
serverName: snap.serverName,
|
|
welcomeMessage: snap.welcomeMessage,
|
|
platform: snap.platform,
|
|
version: snap.version,
|
|
channels: snap.channels,
|
|
clients: snap.clients.where((c) => c.id != clientId).toList(),
|
|
ownClientId: snap.ownClientId,
|
|
);
|
|
_notifyChatFeedChanged();
|
|
});
|
|
}
|
|
|
|
void _applyChannelAdd(rust.BridgeChannel channel) {
|
|
final snap = _snapshot;
|
|
if (snap == null) return;
|
|
setState(() {
|
|
_snapshot = rust.BridgeSnapshot(
|
|
serverName: snap.serverName,
|
|
welcomeMessage: snap.welcomeMessage,
|
|
platform: snap.platform,
|
|
version: snap.version,
|
|
channels: [...snap.channels, channel],
|
|
clients: snap.clients,
|
|
ownClientId: snap.ownClientId,
|
|
);
|
|
_notifyChatFeedChanged();
|
|
});
|
|
}
|
|
|
|
void _applyChannelRemove(BigInt channelId) {
|
|
final snap = _snapshot;
|
|
if (snap == null) return;
|
|
setState(() {
|
|
_snapshot = rust.BridgeSnapshot(
|
|
serverName: snap.serverName,
|
|
welcomeMessage: snap.welcomeMessage,
|
|
platform: snap.platform,
|
|
version: snap.version,
|
|
channels: snap.channels.where((ch) => ch.id != channelId).toList(),
|
|
clients: snap.clients,
|
|
ownClientId: snap.ownClientId,
|
|
);
|
|
_notifyChatFeedChanged();
|
|
});
|
|
}
|
|
|
|
void _applyChannelDelta(
|
|
bool Function(rust.BridgeChannel) test,
|
|
rust.BridgeChannel Function(rust.BridgeChannel) update,
|
|
) {
|
|
final snap = _snapshot;
|
|
if (snap == null) return;
|
|
final channels = snap.channels
|
|
.map((ch) => test(ch) ? update(ch) : ch)
|
|
.toList();
|
|
setState(() {
|
|
_snapshot = rust.BridgeSnapshot(
|
|
serverName: snap.serverName,
|
|
welcomeMessage: snap.welcomeMessage,
|
|
platform: snap.platform,
|
|
version: snap.version,
|
|
channels: channels,
|
|
clients: snap.clients,
|
|
ownClientId: snap.ownClientId,
|
|
);
|
|
_notifyChatFeedChanged();
|
|
});
|
|
}
|
|
|
|
void _applySnapshot(rust.BridgeSnapshot snap) {
|
|
_snapshot = snap;
|
|
_phase = phaseAfterSnapshotApplied(_phase);
|
|
final own = ownClientSnapshotState(snap);
|
|
if (own == null) return;
|
|
_inputMuted = own.inputMuted;
|
|
_outputMuted = own.outputMuted;
|
|
if (!_voiceStateInitialized || _currentVoiceChannelId == null) {
|
|
_currentVoiceChannelId = own.channelId;
|
|
_pendingVoiceChannelId = null;
|
|
_inChannel = true;
|
|
_canJoinVoiceChannel = true;
|
|
_voiceStateInitialized = true;
|
|
} else if (_pendingVoiceChannelId == null) {
|
|
_currentVoiceChannelId = own.channelId;
|
|
}
|
|
_notifyChatFeedChanged();
|
|
|
|
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
|
final talkPowerEpoch = _connectionEpoch;
|
|
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
|
|
_hardMute = _hardMuteOwners.effective;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted ||
|
|
_connectionEpoch != talkPowerEpoch ||
|
|
!_serverReachable ||
|
|
!_hardMuteByTalkPower) {
|
|
return;
|
|
}
|
|
unawaited(rust.setHardMute(muted: true));
|
|
unawaited(rust.setInputMuted(muted: true));
|
|
});
|
|
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
|
final talkPowerEpoch = _connectionEpoch;
|
|
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
|
|
_hardMute = _hardMuteOwners.effective;
|
|
if (!_hardMuteOwners.effective) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted ||
|
|
_connectionEpoch != talkPowerEpoch ||
|
|
!_serverReachable ||
|
|
_hardMuteOwners.effective) {
|
|
return;
|
|
}
|
|
unawaited(rust.setHardMute(muted: false));
|
|
unawaited(rust.setInputMuted(muted: false));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
OwnClientSnapshotState? get _ownClientState {
|
|
final snap = _snapshot;
|
|
return snap == null ? null : ownClientSnapshotState(snap);
|
|
}
|
|
|
|
String _diagnosticsText() {
|
|
final rustText = rust.exportDiagnostics();
|
|
final uiText = _uiDiagnostics.isEmpty
|
|
? 'UI diagnostics: none'
|
|
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
|
|
return '$uiText\n\nRust diagnostics:\n$rustText';
|
|
}
|
|
|
|
Future<void> _onShowDiagnostics() async {
|
|
if (!mounted) return;
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) =>
|
|
LiveDiagnosticsDialog(diagnosticsTextBuilder: _diagnosticsText),
|
|
);
|
|
}
|
|
|
|
Future<void> _onConfigurePtt(BuildContext context) async {
|
|
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;
|
|
_showUiErrorSnackBar(area: 'ptt portal binding', error: e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
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,
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
_pttBoundKeyLabel = binding.platformKey;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiErrorSnackBar(area: 'ptt binding', error: e);
|
|
}
|
|
}
|
|
|
|
Future<void> _onShowAbout(BuildContext context) async {
|
|
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: 4),
|
|
Text(l10n.aboutAuthor, 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: () async {
|
|
Navigator.of(ctx).pop();
|
|
await Future<void>.delayed(Duration.zero);
|
|
if (!mounted) return;
|
|
await _onShowDiagnostics();
|
|
},
|
|
child: Text(l10n.diagnosticsAction),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(),
|
|
child: Text(l10n.closeAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _onAddCurrentBookmark() async {
|
|
final initial = _hostCtl.text.trim();
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => BookmarkNameDialog(initialName: initial),
|
|
);
|
|
if (name == null || name.trim().isEmpty) return;
|
|
if (!mounted) return;
|
|
try {
|
|
await wireStorage();
|
|
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;
|
|
_showUiError('add bookmark', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _onDeleteBookmark(rust.BridgeBookmark b) async {
|
|
try {
|
|
await wireStorage();
|
|
await rust.deleteBookmark(id: b.id);
|
|
await _reloadBookmarks();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiError('delete bookmark', e);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
Future<void> _onTs3ServerLink(Ts3ServerLink link) async {
|
|
final host = link.hostWithPort.trim();
|
|
if (host.isEmpty) return;
|
|
|
|
final nickname = link.nickname ?? _nickCtl.text.trim();
|
|
final password = link.password ?? '';
|
|
final bookmarkName = link.addBookmark?.trim();
|
|
|
|
_hostCtl.text = host;
|
|
if (link.nickname != null) _nickCtl.text = link.nickname!;
|
|
if (link.password != null) _passwordCtl.text = link.password!;
|
|
await _saveUiSettings(host: host, nickname: _nickCtl.text.trim());
|
|
|
|
if (bookmarkName == null || bookmarkName.isEmpty) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Loaded TeamSpeak server link for $host')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await wireStorage();
|
|
await rust.addBookmark(
|
|
b: rust.BridgeBookmark(
|
|
id: 0,
|
|
displayName: bookmarkName,
|
|
host: host,
|
|
nickname: nickname,
|
|
password: password,
|
|
),
|
|
);
|
|
await _reloadBookmarks();
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"')));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
_showUiError('teamspeak link bookmark', e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
|
|
final headerActions = [
|
|
IconButton(
|
|
tooltip: l10n.aboutAction,
|
|
icon: const Icon(Icons.info_outline),
|
|
onPressed: () => _onShowAbout(context),
|
|
),
|
|
ChanoraThemeModeMenu(
|
|
themeMode: widget.themeMode,
|
|
onThemeModeChanged: widget.onThemeModeChanged,
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.pokeSettingsAction,
|
|
icon: const Icon(Icons.notifications_outlined),
|
|
onPressed: () => unawaited(_onOpenPokeSettings()),
|
|
),
|
|
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
|
|
Padding(
|
|
padding: const EdgeInsetsDirectional.only(end: 12),
|
|
child: Badge(
|
|
isLabelVisible: _chatUnread > 0,
|
|
label: Text(_chatUnread.toString()),
|
|
child: IconButton(
|
|
tooltip: l10n.chatAction,
|
|
icon: const Icon(Icons.chat_bubble_outline),
|
|
onPressed: _onOpenChat,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
];
|
|
|
|
const headerTitle = SizedBox.shrink();
|
|
|
|
final appBarTitle = _serverReachable
|
|
? Text(
|
|
_snapshot?.serverName ?? l10n.appTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
)
|
|
: headerTitle;
|
|
final compactIdleChrome =
|
|
!_serverReachable &&
|
|
MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium;
|
|
final compactIdleHeader = Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l10n.appTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
...headerActions,
|
|
],
|
|
);
|
|
|
|
final connTokens = _phase.tokens(theme.colorScheme);
|
|
final statusText = connectionStatusText(
|
|
phase: _phase,
|
|
l10n: l10n,
|
|
serverName: _snapshot?.serverName,
|
|
lostReason: _lostReason,
|
|
reconnectAttempt: _reconnectAttempt,
|
|
reconnectDelay: _reconnectDelay,
|
|
);
|
|
final awaitingServerSnapshot = _phase.shouldShowSnapshotLoading(
|
|
hasSnapshot: _snapshot != null,
|
|
);
|
|
|
|
final bodyContent = LayoutBuilder(
|
|
builder: (ctx, bodyConstraints) {
|
|
final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth);
|
|
_handleInlineChatViewport(layoutClass);
|
|
final isWideSnapshot =
|
|
bodyConstraints.maxWidth >= ChanoraBreakpoints.medium &&
|
|
_serverReachable &&
|
|
_snapshot != null;
|
|
final banner = 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),
|
|
),
|
|
);
|
|
return ViewportInfo(
|
|
layoutClass: layoutClass,
|
|
width: bodyConstraints.maxWidth,
|
|
height: bodyConstraints.maxHeight,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
|
if (!_serverReachable)
|
|
Row(
|
|
children: [
|
|
Icon(connTokens.icon, size: 18, color: connTokens.color),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
statusText,
|
|
softWrap: true,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_lostReason != null || _reconnectAttempt != null) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
connTokens.background ??
|
|
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 == ConnectionPhase.idle ||
|
|
_phase == ConnectionPhase.disconnected) ...[
|
|
Expanded(
|
|
child: AnimatedPadding(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOut,
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
|
|
),
|
|
child: SingleChildScrollView(
|
|
keyboardDismissBehavior:
|
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
|
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 == ConnectionPhase.connecting ||
|
|
awaitingServerSnapshot) ...[
|
|
Expanded(
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
statusText,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
] else if (_serverReachable && _snapshot != null) ...[
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (ctx, constraints) {
|
|
final voiceBar = VoiceBar(
|
|
inChannel: _inChannel,
|
|
transmitMode: _transmitMode,
|
|
hardMute: _hardMute,
|
|
outputMuted: _outputMuted,
|
|
talkPowerBlocked: _hardMuteByTalkPower,
|
|
releaseTailMs: _releaseTailMs,
|
|
channelName: snapshotChannelName(
|
|
_snapshot,
|
|
_currentVoiceChannelId,
|
|
),
|
|
audioStats: _audioStats,
|
|
inputLevel: _inputLevel,
|
|
pttLevel: _pttLevel,
|
|
pttBackendId: _pttBackendId,
|
|
pttBoundInputClass: _pttBoundInputClass,
|
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
|
onConfigure: _onOpenVoiceSettings,
|
|
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
|
);
|
|
final permissionBanner =
|
|
PermissionStateBanner.fromCallbacks(
|
|
recordAudioState: _activeRecordAudioState,
|
|
ensureRecordAudio: _ensureActiveRecordAudio,
|
|
openAppSettings: _openActivePermissionSettings,
|
|
);
|
|
final snapshotView = SnapshotView(
|
|
snapshot: _snapshot!,
|
|
audioStats: _audioStats,
|
|
currentVoiceChannelId: _currentVoiceChannelId,
|
|
pendingVoiceChannelId: _pendingVoiceChannelId,
|
|
localInputMuted: _inputMuted || _hardMute,
|
|
localOutputMuted: _outputMuted,
|
|
hasJoinPending: _pendingVoiceChannelId != null,
|
|
canJoinVoiceChannel: _canJoinVoiceChannel,
|
|
unreadChannelIds: _unreadChannelIds,
|
|
onJoinChannel: (ch) => _onJoinChannel(ch),
|
|
onJoinChannelWithPassword: (ch) =>
|
|
_onJoinChannel(ch, askForPassword: true),
|
|
enableClientLongPressMenu: isTouchOnlyPttHost,
|
|
onOpenClientInfo: (client) =>
|
|
unawaited(_onOpenClientInfo(client)),
|
|
onOpenClientChat: (client) => unawaited(
|
|
_onOpenChat(
|
|
target: rust.BridgeMessageTarget.client(client.id),
|
|
clientName: client.name,
|
|
),
|
|
),
|
|
onOpenClientPoke: (client) => unawaited(
|
|
_onOpenChat(
|
|
target: rust.BridgeMessageTarget.poke(client.id),
|
|
clientName: client.name,
|
|
),
|
|
),
|
|
onOpenChannelChat: (channel) => unawaited(
|
|
_onOpenChat(
|
|
target: const rust.BridgeMessageTarget.channel(),
|
|
),
|
|
),
|
|
onTs3ServerLink: _onTs3ServerLink,
|
|
);
|
|
final ownClientState = _ownClientState;
|
|
final layoutInfo = ViewportInfo.of(ctx);
|
|
if (layoutInfo.isWide) {
|
|
final inlineChatTarget = _inlineChatTarget;
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: ChanoraBreakpoints.voicePanelWidth,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
banner,
|
|
const SizedBox(height: 12),
|
|
permissionBanner,
|
|
voiceBar,
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: ChanoraBreakpoints.panelGap),
|
|
Expanded(child: snapshotView),
|
|
if (layoutInfo.isExpanded &&
|
|
inlineChatTarget != null) ...[
|
|
const SizedBox(
|
|
width: ChanoraBreakpoints.panelGap,
|
|
),
|
|
ChatPanel(
|
|
messages: _chatMessages,
|
|
snapshot: _snapshot!,
|
|
target: inlineChatTarget,
|
|
clientName: _inlineChatClientName,
|
|
restoredDraft: _currentDraftFor(
|
|
inlineChatTarget,
|
|
),
|
|
onDraftChanged: (text) {
|
|
final draftKey = _draftKeyForTarget(
|
|
inlineChatTarget,
|
|
);
|
|
_chatDrafts[draftKey] = text;
|
|
},
|
|
onTs3ServerLink: _onTs3ServerLink,
|
|
onClose: _closeInlineChat,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Expanded(child: snapshotView),
|
|
const SizedBox(height: 8),
|
|
permissionBanner,
|
|
VoiceStatusChip(
|
|
transmitMode: _transmitMode,
|
|
releaseTailMs: _releaseTailMs,
|
|
pttBoundKeyLabel: _pttBoundKeyLabel,
|
|
audioStats: _audioStats,
|
|
isTouchOnly: isTouchOnlyPttHost,
|
|
inputMuted: _hardMute,
|
|
outputMuted: _outputMuted,
|
|
hardMuteByTalkPower: _hardMuteByTalkPower,
|
|
talkPower: ownClientState?.talkPower,
|
|
neededTalkPower: ownClientState?.neededTalkPower,
|
|
talkPowerGranted: ownClientState?.talkPowerGranted,
|
|
onTap: () => _onOpenVoiceDetailsSheet(),
|
|
onToggleInputMute: _onToggleHardMute,
|
|
onToggleOutputMute: _toggleOutputMute,
|
|
),
|
|
if (_inChannel &&
|
|
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
|
|
const SizedBox(height: 8),
|
|
VoicePttButton(
|
|
active: _audioStats?.pttActive ?? false,
|
|
onHeldChanged: _onOnscreenPttHeldChanged,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (isMacOS) {
|
|
return Scaffold(
|
|
body: Padding(
|
|
padding: const EdgeInsets.only(top: macOSTrafficLightPad),
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: headerTitle),
|
|
...headerActions,
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: bodyContent,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ChanoraMobileScaffold(
|
|
compactIdleChrome: compactIdleChrome,
|
|
canDisconnect: _phase.canDisconnect,
|
|
title: appBarTitle,
|
|
actions: headerActions,
|
|
onDisconnect: _onConfirmDisconnect,
|
|
compactHeader: compactIdleHeader,
|
|
disconnectTooltip: l10n.disconnectAction,
|
|
body: bodyContent,
|
|
);
|
|
}
|
|
}
|