diff --git a/Content.Client/Communications/CommunicationsConsoleComponent.cs b/Content.Client/Communications/CommunicationsConsoleComponent.cs
new file mode 100644
index 0000000000..5fdb2771da
--- /dev/null
+++ b/Content.Client/Communications/CommunicationsConsoleComponent.cs
@@ -0,0 +1,14 @@
+using Content.Shared.Communications;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Communications;
+
+[RegisterComponent]
+public sealed partial class CommunicationsConsoleComponent : SharedCommunicationsConsoleComponent
+{
+ ///
+ /// The prototype ID to use in the UI to show what entities a broadcast will display on
+ ///
+ [DataField]
+ public EntProtoId ScreenDisplayId = "Screen";
+}
diff --git a/Content.Client/Communications/UI/CommunicationsConsoleBoundUserInterface.cs b/Content.Client/Communications/UI/CommunicationsConsoleBoundUserInterface.cs
index 9366e3ad12..1a26ffd191 100644
--- a/Content.Client/Communications/UI/CommunicationsConsoleBoundUserInterface.cs
+++ b/Content.Client/Communications/UI/CommunicationsConsoleBoundUserInterface.cs
@@ -1,4 +1,4 @@
-using Content.Shared.CCVar;
+using Content.Shared.CCVar;
using Content.Shared.Chat;
using Content.Shared.AlertLevel;
using Content.Shared.Communications;
@@ -23,41 +23,36 @@ public sealed partial class CommunicationsConsoleBoundUserInterface(EntityUid ow
base.Open();
_menu = this.CreateWindow();
- _menu.OnAnnounce += AnnounceButtonPressed;
- _menu.OnBroadcast += BroadcastButtonPressed;
- _menu.OnAlertLevel += AlertLevelSelected;
- _menu.OnEmergencyLevel += EmergencyShuttleButtonPressed;
+ _menu.OnRadioAnnounce += RadioAnnounceButtonPressed;
+ _menu.OnScreenBroadcast += ScreenBroadcastButtonPressed;
+ _menu.OnAlertLevelChanged += AlertLevelSelected;
+ _menu.OnShuttleCalled += CallShuttle;
+ _menu.OnShuttleRecalled += RecallShuttle;
+
+ if (EntMan.TryGetComponent(Owner, out var console))
+ {
+ _menu.SetBroadcastDisplayEntity(console.ScreenDisplayId);
+ }
}
public void AlertLevelSelected(ProtoId level)
{
- if (_menu!.AlertLevelSelectable)
- {
- // TODO: This does not work until the console UI is predicted and uses component states.
- // Also someone decided to send BUI states regularly in an update loop, so this just gets randomly bulldozed until the message reaches the server.
- // _menu.CurrentAlertLevel = level;
- // _menu.AlertLevelSelectable = false;
- // _menu.AlertLevelButton.Disabled = true;
- SendMessage(new CommunicationsConsoleSelectAlertLevelMessage(level));
- }
+ // TODO: This does not work until the console UI is predicted and uses component states.
+ // Also someone decided to send BUI states regularly in an update loop, so this just gets randomly bulldozed until the message reaches the server.
+ // _menu.CurrentAlertLevel = level;
+ // _menu.AlertLevelSelectable = false;
+ // _menu.AlertLevelButton.Disabled = true;
+ SendMessage(new CommunicationsConsoleSelectAlertLevelMessage(level));
}
- public void EmergencyShuttleButtonPressed()
- {
- if (_menu!.CountdownStarted)
- RecallShuttle();
- else
- CallShuttle();
- }
-
- public void AnnounceButtonPressed(string message)
+ public void RadioAnnounceButtonPressed(string message)
{
var maxLength = _cfg.GetCVar(CCVars.ChatMaxAnnouncementLength);
var msg = SharedChatSystem.SanitizeAnnouncement(message, maxLength);
SendMessage(new CommunicationsConsoleAnnounceMessage(msg));
}
- public void BroadcastButtonPressed(string message)
+ public void ScreenBroadcastButtonPressed(string message)
{
SendMessage(new CommunicationsConsoleBroadcastMessage(message));
}
@@ -87,23 +82,11 @@ public sealed partial class CommunicationsConsoleBoundUserInterface(EntityUid ow
if (_menu != null)
{
- _menu.CanAnnounce = commsState.CanAnnounce;
- _menu.CanBroadcast = commsState.CanBroadcast;
- _menu.CanCall = commsState.CanCall;
- _menu.CountdownStarted = commsState.CountdownStarted;
- _menu.CountdownEnd = commsState.ExpectedCountdownEnd;
+ var currentAlertLevel = alertComp.CurrentAlertLevel;
+ var selectableAlertLevels = _alertLevel.GetSelectableAlertLevels((stationUid.Value, alertComp));
+ var canChangeAlertLevel = _alertLevel.CanChangeAlertLevel((stationUid.Value, alertComp));
- _menu.CurrentAlertLevel = alertComp.CurrentAlertLevel;
- _menu.SelectableAlertLevels = _alertLevel.GetSelectableAlertLevels((stationUid.Value, alertComp));
- _menu.AlertLevelSelectable = _alertLevel.CanChangeAlertLevel((stationUid.Value, alertComp));
-
- _menu.UpdateCountdown();
- _menu.UpdateAlertLevels();
-
- _menu.AlertLevelButton.Disabled = !_menu.AlertLevelSelectable;
- _menu.EmergencyShuttleButton.Disabled = !_menu.CanCall;
- _menu.AnnounceButton.Disabled = !_menu.CanAnnounce;
- _menu.BroadcastButton.Disabled = !_menu.CanBroadcast;
+ _menu.UpdateState(commsState, currentAlertLevel, selectableAlertLevels, canChangeAlertLevel);
}
}
}
diff --git a/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml b/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml
index b74df979cf..0104fe92d3 100644
--- a/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml
+++ b/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml
@@ -1,62 +1,24 @@
-
+ MinSize="400 654" SetWidth="450"
+ Title="{Loc 'comms-console-menu-title'}">
+
+
+
+
-
-
+
-
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+
diff --git a/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml.cs b/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml.cs
index 92bc2dab9f..6801eae590 100644
--- a/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml.cs
+++ b/Content.Client/Communications/UI/CommunicationsConsoleMenu.xaml.cs
@@ -1,133 +1,58 @@
-using System.Globalization;
-using Content.Client.UserInterface.Controls;
using Content.Shared.AlertLevel;
-using Content.Shared.CCVar;
+using Content.Shared.Communications;
+using Content.Client.UserInterface.Controls;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.XAML;
-using Robust.Shared.Configuration;
-using Robust.Shared.Timing;
using Robust.Shared.Prototypes;
-using Robust.Shared.Utility;
-namespace Content.Client.Communications.UI
+namespace Content.Client.Communications.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class CommunicationsConsoleMenu : FancyWindow
{
- [GenerateTypedNameReferences]
- public sealed partial class CommunicationsConsoleMenu : FancyWindow
+ public event Action? OnShuttleCalled;
+ public event Action? OnShuttleRecalled;
+ public event Action>? OnAlertLevelChanged;
+ public event Action? OnRadioAnnounce;
+ public event Action? OnScreenBroadcast;
+
+ public CommunicationsConsoleMenu()
{
- [Dependency] private IConfigurationManager _cfg = default!;
- [Dependency] private IGameTiming _timing = default!;
- [Dependency] private ILocalizationManager _loc = default!;
- [Dependency] private IPrototypeManager _protoMan = default!;
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
- public bool CanAnnounce;
- public bool CanBroadcast;
- public bool CanCall;
- public bool CountdownStarted;
- public TimeSpan? CountdownEnd;
+ MessagingControls.OnRadioAnnounce += message => OnRadioAnnounce?.Invoke(message);
+ MessagingControls.OnScreenBroadcast += message => OnScreenBroadcast?.Invoke(message);
- public ProtoId? CurrentAlertLevel;
- public List> SelectableAlertLevels = new();
- public bool AlertLevelSelectable;
+ AlertLevelControls.OnAlertLevelChanged += newLevel => OnAlertLevelChanged?.Invoke(newLevel);
- public event Action? OnEmergencyLevel;
- public event Action>? OnAlertLevel;
- public event Action? OnAnnounce;
- public event Action? OnBroadcast;
+ ShuttleControls.OnShuttleCalled += () => OnShuttleCalled?.Invoke();
+ ShuttleControls.OnShuttleRecalled += () => OnShuttleRecalled?.Invoke();
+ }
- public CommunicationsConsoleMenu()
- {
- IoCManager.InjectDependencies(this);
- RobustXamlLoader.Load(this);
+ ///
+ /// Use the specified prototype ID as an example display in the
+ /// UI subsection where users type broadcast messages
+ ///
+ public void SetBroadcastDisplayEntity(EntProtoId broadcastEntityId)
+ {
+ MessagingControls.SetBroadcastDisplayEntity(broadcastEntityId);
+ }
- MessageInput.Placeholder = new Rope.Leaf(_loc.GetString("comms-console-menu-announcement-placeholder"));
+ ///
+ /// Configure UI to be consistent with the input state
+ ///
+ public void UpdateState(CommunicationsConsoleInterfaceState commsState,
+ ProtoId currentAlertLevel,
+ List>? selectableAlertLevels,
+ bool canChangeAlertLevel)
+ {
+ MessagingControls.CanRadioAnnounce = commsState.CanAnnounce;
+ MessagingControls.CanScreenBroadcast = commsState.CanBroadcast;
- var maxAnnounceLength = _cfg.GetCVar(CCVars.ChatMaxAnnouncementLength);
- MessageInput.OnTextChanged += (args) =>
- {
- if (args.Control.TextLength > maxAnnounceLength)
- {
- AnnounceButton.Disabled = true;
- AnnounceButton.ToolTip = Loc.GetString("comms-console-message-too-long");
- }
- else
- {
- AnnounceButton.Disabled = !CanAnnounce;
- AnnounceButton.ToolTip = null;
+ var alertLevelSelectable = selectableAlertLevels != null && canChangeAlertLevel;
+ AlertLevelControls.UpdateAlertLevels(selectableAlertLevels, currentAlertLevel, alertLevelSelectable);
- }
- };
-
- AnnounceButton.OnPressed += _ => OnAnnounce?.Invoke(Rope.Collapse(MessageInput.TextRope));
- AnnounceButton.Disabled = !CanAnnounce;
-
- BroadcastButton.OnPressed += _ => OnBroadcast?.Invoke(Rope.Collapse(MessageInput.TextRope));
- BroadcastButton.Disabled = !CanBroadcast;
-
- AlertLevelButton.OnItemSelected += args =>
- {
- var metadata = AlertLevelButton.GetItemMetadata(args.Id);
- if (metadata != null && metadata is ProtoId cast)
- {
- OnAlertLevel?.Invoke(cast);
- }
- };
-
-
- AlertLevelButton.Disabled = !AlertLevelSelectable;
-
- EmergencyShuttleButton.OnPressed += _ => OnEmergencyLevel?.Invoke();
- EmergencyShuttleButton.Disabled = !CanCall;
- }
-
- protected override void FrameUpdate(FrameEventArgs args)
- {
- base.FrameUpdate(args);
- UpdateCountdown();
- // TODO: Countdown for alert level delay
- }
-
- public void UpdateAlertLevels()
- {
- AlertLevelButton.Clear();
-
- if (CurrentAlertLevel == null)
- return;
-
- if (!AlertLevelSelectable)
- {
- // selection is locked, only show the current alert
- var name = _protoMan.Index(CurrentAlertLevel).LocalizedName;
- AlertLevelButton.AddItem(name);
- AlertLevelButton.SetItemMetadata(AlertLevelButton.ItemCount - 1, CurrentAlertLevel);
- }
- else
- {
- foreach (var alert in SelectableAlertLevels)
- {
- var name = _protoMan.Index(alert).LocalizedName;
- AlertLevelButton.AddItem(name);
- AlertLevelButton.SetItemMetadata(AlertLevelButton.ItemCount - 1, alert);
- if (alert == CurrentAlertLevel)
- AlertLevelButton.Select(AlertLevelButton.ItemCount - 1);
- }
- }
- }
-
- public void UpdateCountdown()
- {
- if (!CountdownStarted)
- {
- CountdownLabel.SetMessage(string.Empty);
- EmergencyShuttleButton.Text = Loc.GetString("comms-console-menu-call-shuttle");
- return;
- }
-
- var diff = MathHelper.Max((CountdownEnd - _timing.CurTime) ?? TimeSpan.Zero, TimeSpan.Zero);
-
- EmergencyShuttleButton.Text = Loc.GetString("comms-console-menu-recall-shuttle");
- var infoText = Loc.GetString($"comms-console-menu-time-remaining",
- ("time", diff.ToString(@"hh\:mm\:ss", CultureInfo.CurrentCulture)));
- CountdownLabel.SetMessage(infoText);
- }
+ ShuttleControls.UpdateState(commsState.CanCall, commsState.CountdownStarted, commsState.ExpectedCountdownEnd);
}
}
diff --git a/Content.Client/Communications/UI/Widgets/AlertLevelControls.xaml b/Content.Client/Communications/UI/Widgets/AlertLevelControls.xaml
new file mode 100644
index 0000000000..7af0d5b7a0
--- /dev/null
+++ b/Content.Client/Communications/UI/Widgets/AlertLevelControls.xaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Communications/UI/Widgets/AlertLevelControls.xaml.cs b/Content.Client/Communications/UI/Widgets/AlertLevelControls.xaml.cs
new file mode 100644
index 0000000000..97b49c5186
--- /dev/null
+++ b/Content.Client/Communications/UI/Widgets/AlertLevelControls.xaml.cs
@@ -0,0 +1,146 @@
+using Content.Shared.AlertLevel;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Communications.UI.Widgets;
+
+[GenerateTypedNameReferences]
+public sealed partial class AlertLevelControls : BoxContainer
+{
+ [Dependency] private ILocalizationManager _loc = default!;
+ [Dependency] private IPrototypeManager _protoman = default!;
+
+ private bool _alertLevelSelectable;
+ private ProtoId? _currentAlertLevel = null;
+
+ public event Action? OnAlertLevelChanged;
+
+ public AlertLevelControls()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ AlertLevelSelector.OnItemSelected += args =>
+ {
+ AlertLevelSelector.Select(args.Id);
+ EnableDisableConfirmLevelChangeButton();
+ };
+
+ ConfirmAlertLevelButton.OnPressed += _ =>
+ {
+ var metadata = AlertLevelSelector.GetItemMetadata(AlertLevelSelector.SelectedId);
+ if (metadata is ProtoId cast)
+ {
+ OnAlertLevelChanged?.Invoke(cast);
+ }
+ };
+
+ AlertLevelSelector.Disabled = !_alertLevelSelectable;
+ }
+
+ ///
+ /// Updates the UI components to display the current alert level and the
+ /// selectable alert levels
+ ///
+ public void UpdateAlertLevels(List>? selectableAlertLevels, ProtoId currentAlertLevel, bool alertLevelSelectable)
+ {
+ _alertLevelSelectable = alertLevelSelectable;
+ _currentAlertLevel = currentAlertLevel;
+ CurrentAlertLevelLabel.Text = GetLocalizedAlertName(currentAlertLevel);
+ if (_protoman.TryIndex(currentAlertLevel, out var alertData))
+ {
+ CurrentAlertLevelLabel.ModulateSelfOverride = alertData.Color;
+ }
+ else
+ {
+ CurrentAlertLevelLabel.ModulateSelfOverride = Color.White;
+ }
+
+ AlertLevelSelector.Disabled = selectableAlertLevels == null || !_alertLevelSelectable;
+
+ // If user had changed the selection, but hadn't pressed the confirm
+ // button at the point we received an update message, we want to
+ // remember what they had selected, so we can attempt to re-select it
+ // if it's still an option in the new set of alert level possibilities.
+ ProtoId? previousSelection = null;
+ if (AlertLevelSelector.ItemCount > 1)
+ {
+ var metadata = AlertLevelSelector.GetItemMetadata(AlertLevelSelector.SelectedId);
+ if (metadata is ProtoId cast)
+ {
+ previousSelection = cast;
+ }
+ }
+
+ // The server will only send alert levels which are selectable, but
+ // unfortunately, for a short time after changing the alert level, no
+ // level is selectable, so we won't have any levels to put into the
+ // alert level list. Here, we make a dummy item to handle that; this
+ // item also informs the user what this combo box is for.
+ AlertLevelSelector.Clear();
+ AlertLevelSelector.AddItem(Loc.GetString("comms-console-change-alert-level-button"));
+ AlertLevelSelector.Select(0);
+
+ if (selectableAlertLevels != null)
+ {
+ foreach (var alert in selectableAlertLevels)
+ {
+ AlertLevelSelector.AddItem(GetLocalizedAlertName(alert));
+ AlertLevelSelector.SetItemMetadata(AlertLevelSelector.ItemCount - 1, alert);
+
+ if (alert == previousSelection)
+ {
+ AlertLevelSelector.Select(AlertLevelSelector.ItemCount - 1);
+ }
+ }
+ }
+
+ if (_loc.TryGetString($"comms-console-level-{currentAlertLevel}-flavour-label", out var flavour))
+ {
+ CurrentAlertLevelFlavorLabel.Text = flavour;
+ }
+ else
+ {
+ CurrentAlertLevelFlavorLabel.Text = string.Empty;
+ }
+
+ EnableDisableConfirmLevelChangeButton();
+ }
+
+ ///
+ /// Configure the "confirm alert level changed" button to be consistent with
+ /// the server state and user interactions
+ ///
+ private void EnableDisableConfirmLevelChangeButton()
+ {
+ // Disable the button when:
+ // 1. Server says we can't change level
+ // 2. User selected special info option (id 0)
+ // 3. User selected the same level the station is currently on
+ var selectedId = AlertLevelSelector.SelectedId;
+ ConfirmAlertLevelButton.Disabled = !_alertLevelSelectable || selectedId == 0;
+ if (selectedId != 0)
+ {
+ var metadata = AlertLevelSelector.GetItemMetadata(selectedId);
+ if (metadata is ProtoId selected)
+ {
+ ConfirmAlertLevelButton.Disabled |= selected == _currentAlertLevel;
+ }
+ }
+ }
+
+ ///
+ /// Utility function to convert an alert level identifier into a localized string
+ ///
+ private string GetLocalizedAlertName(ProtoId alertProto)
+ {
+ if (_protoman.TryIndex(alertProto, out var alertData))
+ {
+ return alertData.LocalizedName;
+ }
+
+ return alertProto;
+ }
+}
diff --git a/Content.Client/Communications/UI/Widgets/MessagingControls.xaml b/Content.Client/Communications/UI/Widgets/MessagingControls.xaml
new file mode 100644
index 0000000000..59537d532c
--- /dev/null
+++ b/Content.Client/Communications/UI/Widgets/MessagingControls.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Communications/UI/Widgets/MessagingControls.xaml.cs b/Content.Client/Communications/UI/Widgets/MessagingControls.xaml.cs
new file mode 100644
index 0000000000..25c608b979
--- /dev/null
+++ b/Content.Client/Communications/UI/Widgets/MessagingControls.xaml.cs
@@ -0,0 +1,116 @@
+using Content.Shared.CCVar;
+using Content.Shared.TextScreen;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Configuration;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Client.Communications.UI.Widgets;
+
+[GenerateTypedNameReferences]
+public sealed partial class MessagingControls : TabContainer
+{
+ [Dependency] private IConfigurationManager _cfg = default!;
+ [Dependency] private IEntityManager _entMan = default!;
+
+ // Entity temporarily created to display a screen preview
+ private EntityUid _broadcastDisplayEntity = EntityUid.Invalid;
+
+ public event Action? OnRadioAnnounce;
+ public event Action? OnScreenBroadcast;
+
+ private bool _canRadioAnnounce;
+ public bool CanRadioAnnounce
+ {
+ set
+ {
+ _canRadioAnnounce = value;
+ SyncButtonState();
+ }
+ }
+
+ private bool _canScreenBroadcast;
+ public bool CanScreenBroadcast
+ {
+ set
+ {
+ _canScreenBroadcast = value;
+ SyncButtonState();
+ }
+ }
+
+ public MessagingControls()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ RadioMessageInput.Placeholder = new Rope.Leaf(Loc.GetString("comms-console-menu-announcement-placeholder"));
+
+ RadioMessageInput.OnTextChanged += (_) => SyncButtonState();
+
+ AnnounceButton.OnPressed += _ =>
+ {
+ OnRadioAnnounce?.Invoke(Rope.Collapse(RadioMessageInput.TextRope));
+ };
+
+ var appearanceSystem = _entMan.System();
+ ScreenMessageInput.OnTextChanged += args =>
+ {
+ if (_broadcastDisplayEntity.IsValid())
+ {
+ appearanceSystem.SetData(_broadcastDisplayEntity, TextScreenVisuals.ScreenText, args.Text);
+ }
+ };
+
+ BroadcastButton.OnPressed += _ =>
+ {
+ OnScreenBroadcast?.Invoke(ScreenMessageInput.Text);
+ };
+
+ SyncButtonState();
+ }
+
+ public void SetBroadcastDisplayEntity(EntProtoId broadcastEntityId)
+ {
+ _broadcastDisplayEntity = _entMan.Spawn(broadcastEntityId);
+ if (_broadcastDisplayEntity.IsValid())
+ {
+ BroadcastEntityDisplay.SetEntity(_broadcastDisplayEntity);
+ }
+ }
+
+ protected override void ExitedTree()
+ {
+ if (_broadcastDisplayEntity.IsValid())
+ {
+ _entMan.DeleteEntity(_broadcastDisplayEntity);
+ }
+ }
+
+ private void SyncButtonState()
+ {
+ if (_canRadioAnnounce)
+ {
+ var maxAnnounceLength = _cfg.GetCVar(CCVars.ChatMaxAnnouncementLength);
+ if (RadioMessageInput.TextLength > maxAnnounceLength)
+ {
+ AnnounceButton.Disabled = true;
+ AnnounceButton.ToolTip = Loc.GetString("comms-console-message-too-long");
+ }
+ else
+ {
+ AnnounceButton.Disabled = false;
+ AnnounceButton.ToolTip = Loc.GetString("comms-console-menu-announcement-button-tooltip");
+ }
+ }
+ else
+ {
+ AnnounceButton.Disabled = true;
+ AnnounceButton.ToolTip = Loc.GetString("comms-console-message-cannot-send");
+ }
+
+ BroadcastButton.Disabled = !_canScreenBroadcast;
+ }
+}
diff --git a/Content.Client/Communications/UI/Widgets/ShuttleControls.xaml b/Content.Client/Communications/UI/Widgets/ShuttleControls.xaml
new file mode 100644
index 0000000000..547e8725b4
--- /dev/null
+++ b/Content.Client/Communications/UI/Widgets/ShuttleControls.xaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Communications/UI/Widgets/ShuttleControls.xaml.cs b/Content.Client/Communications/UI/Widgets/ShuttleControls.xaml.cs
new file mode 100644
index 0000000000..6582043f6c
--- /dev/null
+++ b/Content.Client/Communications/UI/Widgets/ShuttleControls.xaml.cs
@@ -0,0 +1,67 @@
+using Content.Shared.CCVar;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Configuration;
+using Robust.Shared.Timing;
+
+namespace Content.Client.Communications.UI.Widgets;
+
+[GenerateTypedNameReferences]
+public sealed partial class ShuttleControls : Control
+{
+ [Dependency] private IGameTiming _timing = default!;
+
+ private bool _canCall;
+ private bool _countdownStarted;
+ private TimeSpan? _countdownEnd;
+
+ public event Action? OnShuttleCalled;
+ public event Action? OnShuttleRecalled;
+
+ public ShuttleControls()
+ {
+ IoCManager.InjectDependencies(this);
+ RobustXamlLoader.Load(this);
+
+ EmergencyShuttleCallButton.OnPressed += _ => OnShuttleCalled?.Invoke();
+ EmergencyShuttleRecallButton.OnPressed += _ => OnShuttleRecalled?.Invoke();
+ SyncButtonState();
+ }
+
+ public void UpdateState(bool canCallShuttle, bool countdownStarted, TimeSpan? expectedCountdownEnd)
+ {
+ _canCall = canCallShuttle;
+ _countdownStarted = countdownStarted;
+ _countdownEnd = expectedCountdownEnd;
+ SyncButtonState();
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+ UpdateCountdown();
+ }
+
+ ///
+ /// Syncs and animates the display of the time-to-shuttle-arrival label
+ ///
+ private void UpdateCountdown()
+ {
+ // Set the label on the LCD countdown
+ var countdown = _countdownEnd == null ? 0.0f : (float)Math.Max(_countdownEnd.Value.Subtract(_timing.CurTime).TotalSeconds, 0);
+
+ var remainingWholeSeconds = _countdownStarted ? (int)Math.Ceiling(countdown) : 0;
+ var message = (remainingWholeSeconds / 60).ToString("D2") + ":" + (remainingWholeSeconds % 60).ToString("D2");
+ CountdownLabel.Text = message;
+ }
+
+ ///
+ /// Configure the shuttle call/recall buttons to have a consistent state
+ ///
+ private void SyncButtonState()
+ {
+ EmergencyShuttleCallButton.Disabled = !(_canCall && !_countdownStarted);
+ EmergencyShuttleRecallButton.Disabled = !(_canCall && _countdownStarted);
+ }
+}
diff --git a/Resources/Locale/en-US/communications/communications-console-component.ftl b/Resources/Locale/en-US/communications/communications-console-component.ftl
index 209be1adeb..5bab4a0fea 100644
--- a/Resources/Locale/en-US/communications/communications-console-component.ftl
+++ b/Resources/Locale/en-US/communications/communications-console-component.ftl
@@ -1,20 +1,29 @@
# User interface
comms-console-menu-title = Communications Console
comms-console-menu-announcement-placeholder = Announcement text...
-comms-console-menu-announcement-button = Announce
+comms-console-menu-broadcast-placeholder = Broadcast text...
+comms-console-menu-announcement-button = Announce over radio
comms-console-menu-announcement-button-tooltip = Send your message as a station-wide radio announcement.
-comms-console-menu-broadcast-button = Broadcast
+comms-console-menu-broadcast-button = Display on screens
comms-console-menu-broadcast-button-tooltip = Broadcast your message to wall-mounted screens around the station. Note: They fit only ten characters!
-comms-console-menu-alert-level-button-tooltip = Change the station alert level. Applies immediately on selecting.
-comms-console-menu-call-shuttle = Call emergency shuttle
-comms-console-menu-recall-shuttle = Recall emergency shuttle
-comms-console-menu-emergency-shuttle-button-tooltip = Calls or recalls the emergency shuttle. You can only recall when there's enough time left.
-comms-console-menu-time-remaining = Time remaining: {$time}
+comms-console-menu-alert-level-button-tooltip = Change the station alert level.
+comms-console-change-alert-level-button = Change status
+comms-console-confirm-alert-level-button = Confirm
+comms-console-call-button-label = Call
+comms-console-recall-button-label = Recall
+comms-console-shuttle-status-label = Arrival time
+comms-console-shuttle-controls-header = EMERGENCY SHUTTLE CONTROLS
+comms-console-alert-current-level-header = Alert level
+comms-console-station-announcements-header = Station Announcements
+comms-console-station-broadcast-header = Station Broadcast
+comms-console-announce-tab-title = Radio
+comms-console-broadcast-tab-title = Screen
# Popup
comms-console-permission-denied = Permission denied
comms-console-shuttle-unavailable = Shuttle is currently unavailable
comms-console-message-too-long = Message is too long
+comms-console-message-cannot-send = Communications temporarily disabled
# Placeholder values
comms-console-announcement-sent-by = Sent by
@@ -26,3 +35,13 @@ comms-console-announcement-title-centcom = Central Command
comms-console-announcement-title-nukie = Syndicate Nuclear Operative
comms-console-announcement-title-station-ai = Station AI
comms-console-announcement-title-wizard = Wizard
+
+# Flavor text for alert levels
+comms-console-level-Green-flavour-label = Work is to proceed as normal
+comms-console-level-Blue-flavour-label = Record suspicious activity with form NT-4772-b
+comms-console-level-Violet-flavour-label = Maintain social distancing
+comms-console-level-Yellow-flavour-label = Prepare to hold your breath
+comms-console-level-Red-flavour-label = Remain vigilant
+comms-console-level-Gamma-flavour-label = Suggest extreme caution
+comms-console-level-Delta-flavour-label = Good luck
+comms-console-level-Epsilon-flavour-label = You're fired