feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix
- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024) - Add ViewportInfo InheritedWidget for layout-aware descendants - Add inline ChatPanel (380dp right column) for expanded desktop layout - Add channel right-click context menu with Chat option for in-place switching - Add per-target draft persistence via restoredDraft/onDraftChanged callbacks - Fix header chat button to switch to current voice channel when panel open - Fix close = dismiss (preserves last target and draft for reopen) - Add unread dot indicator on channel tiles when chat is closed - Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz) to avoid buffer underruns on macOS CoreAudio real-time thread - Add tools/build-macos.sh release build script (7-step process) - Add chat panel switching implementation plan and 3-panel design spec Tests: 183 passed, 2 skipped. Flutter analyze clean.
This commit is contained in:
+402
-223
@@ -16,6 +16,8 @@ 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 'services/android_permissions_service.dart';
|
||||
import 'services/app_bootstrap.dart';
|
||||
@@ -31,12 +33,13 @@ import 'services/ui_preferences_service.dart';
|
||||
import 'src/rust/api.dart' as rust;
|
||||
import 'src/rust/frb_generated.dart';
|
||||
import 'src/rust/lib.dart' as rust_err;
|
||||
import 'widgets/permission_state_banner.dart';
|
||||
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/snapshot_view.dart';
|
||||
import 'widgets/voice_platform.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
@@ -301,8 +304,6 @@ class _ReceivedPoke {
|
||||
}
|
||||
|
||||
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
static const _wideBreakpoint = 600.0;
|
||||
|
||||
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
||||
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
||||
final _passwordCtl = TextEditingController();
|
||||
@@ -368,6 +369,37 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final ValueNotifier<int> _chatFeedRevision = ValueNotifier(0);
|
||||
int _chatUnread = 0;
|
||||
bool _chatOpen = false;
|
||||
rust.BridgeMessageTarget? _inlineChatTarget;
|
||||
String _inlineChatClientName = '';
|
||||
bool _inlineChatCollapseNoticeShown = false;
|
||||
|
||||
/// Per-target draft text. Populated when switching away from a conversation
|
||||
/// so the user's unfinished message is preserved.
|
||||
final Map<String, String> _chatDrafts = {};
|
||||
|
||||
/// Channel IDs that have unread chat messages (used for dot indicators).
|
||||
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) {
|
||||
// Channel target has no ID payload — it means "current channel".
|
||||
// We can't distinguish per-channel without the channel ID in the target.
|
||||
// For now, if there are any unread channel messages, mark the current voice channel.
|
||||
if (_currentVoiceChannelId != null) {
|
||||
ids.add(_currentVoiceChannelId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// The last chat target before the panel was closed. Used to restore the
|
||||
/// previous conversation when the user reopens chat.
|
||||
rust.BridgeMessageTarget? _lastDismissedTarget;
|
||||
String _lastDismissedClientName = '';
|
||||
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
||||
const [],
|
||||
);
|
||||
@@ -1613,16 +1645,86 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_reconnectAttempt = null;
|
||||
_reconnectDelay = null;
|
||||
_chatMessages.clear();
|
||||
_chatDrafts.clear();
|
||||
_chatUnread = 0;
|
||||
_chatOpen = false;
|
||||
_inlineChatTarget = null;
|
||||
_inlineChatClientName = '';
|
||||
_inlineChatCollapseNoticeShown = false;
|
||||
_lastDismissedTarget = null;
|
||||
_lastDismissedClientName = '';
|
||||
_notifyChatFeedChanged();
|
||||
}
|
||||
|
||||
/// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage.
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
/// Saves the current draft text for the current inline chat target.
|
||||
void _saveCurrentDraft() {
|
||||
// Drafts are continuously saved via onDraftChanged callback in Task 4.
|
||||
// This method exists as an explicit save point.
|
||||
final target = _inlineChatTarget;
|
||||
if (target == null) return;
|
||||
final key = _draftKeyForTarget(target);
|
||||
if (_chatDrafts.containsKey(key)) {
|
||||
_chatDrafts[key] = _chatDrafts[key] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onOpenChat({
|
||||
rust.BridgeMessageTarget? target,
|
||||
String clientName = '',
|
||||
}) async {
|
||||
final initialSnapshot = _snapshot!;
|
||||
|
||||
// Resolve the new target.
|
||||
// If no target passed and panel is already open, switch to current voice channel.
|
||||
// If no target passed and panel is closed, resolve from history or default.
|
||||
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(() {
|
||||
_saveCurrentDraft();
|
||||
_chatUnread = 0;
|
||||
_chatOpen = true;
|
||||
_inlineChatTarget = newTarget;
|
||||
_inlineChatClientName = newClientName;
|
||||
_inlineChatCollapseNoticeShown = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_chatUnread = 0;
|
||||
_chatOpen = true;
|
||||
@@ -1635,13 +1737,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
messagesSource: () => _chatMessages,
|
||||
snapshotSource: () => _snapshot ?? initialSnapshot,
|
||||
refreshListenable: _chatFeedRevision,
|
||||
initialTarget:
|
||||
target ??
|
||||
resolveInitialChatTarget(
|
||||
messages: _chatMessages,
|
||||
currentVoiceChannelId: _currentVoiceChannelId,
|
||||
),
|
||||
initialClientName: clientName,
|
||||
initialTarget: newTarget,
|
||||
initialClientName: newClientName,
|
||||
onTs3ServerLink: _onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
@@ -1649,6 +1746,48 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
if (mounted) setState(() => _chatOpen = false);
|
||||
}
|
||||
|
||||
void _closeInlineChat() {
|
||||
setState(() {
|
||||
_saveCurrentDraft();
|
||||
_lastDismissedTarget = _inlineChatTarget;
|
||||
_lastDismissedClientName = _inlineChatClientName;
|
||||
_chatOpen = false;
|
||||
// Do NOT null _inlineChatTarget — remember it for reopen.
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
const SnackBar(
|
||||
content: Text('Tap the chat button to continue your conversation'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onOpenClientInfo(rust.BridgeClient client) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -1757,7 +1896,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
const side = 16.0;
|
||||
var bottom = 16.0;
|
||||
final wideConnectedLayout =
|
||||
MediaQuery.sizeOf(context).width >= _wideBreakpoint &&
|
||||
MediaQuery.sizeOf(context).width >= ChanoraBreakpoints.medium &&
|
||||
_serverReachable &&
|
||||
_snapshot != null;
|
||||
|
||||
@@ -2245,7 +2384,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
)
|
||||
: headerTitle;
|
||||
final compactIdleChrome =
|
||||
!_serverReachable && MediaQuery.sizeOf(context).width < _wideBreakpoint;
|
||||
!_serverReachable &&
|
||||
MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium;
|
||||
final compactIdleHeader = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -2277,8 +2417,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
final bodyContent = LayoutBuilder(
|
||||
builder: (ctx, bodyConstraints) {
|
||||
final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth);
|
||||
_handleInlineChatViewport(layoutClass);
|
||||
final isWideSnapshot =
|
||||
bodyConstraints.maxWidth >= _wideBreakpoint &&
|
||||
bodyConstraints.maxWidth >= ChanoraBreakpoints.medium &&
|
||||
_serverReachable &&
|
||||
_snapshot != null;
|
||||
final banner = Container(
|
||||
@@ -2292,237 +2434,274 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
||||
),
|
||||
);
|
||||
return 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(
|
||||
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: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Icon(connTokens.icon, size: 18, color: connTokens.color),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
statusText,
|
||||
softWrap: true,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
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: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] 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,
|
||||
),
|
||||
],
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] 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,
|
||||
);
|
||||
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
|
||||
// banner. Self-hides on granted / unknown.
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
onTs3ServerLink: _onTs3ServerLink,
|
||||
);
|
||||
final ownClientState = _ownClientState;
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= _wideBreakpoint) {
|
||||
return Row(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: voiceBarWidthWide,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
banner,
|
||||
const SizedBox(height: 12),
|
||||
permissionBanner,
|
||||
voiceBar,
|
||||
],
|
||||
),
|
||||
ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
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,
|
||||
);
|
||||
// SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only
|
||||
// banner. Self-hides on granted / unknown.
|
||||
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:
|
||||
_chatDrafts[_draftKeyForTarget(
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user