diff --git a/Content.Client/Administration/UI/Tabs/AdminTab/AdminTab.xaml b/Content.Client/Administration/UI/Tabs/AdminTab/AdminTab.xaml
index 8b68487547f..796af9c5735 100644
--- a/Content.Client/Administration/UI/Tabs/AdminTab/AdminTab.xaml
+++ b/Content.Client/Administration/UI/Tabs/AdminTab/AdminTab.xaml
@@ -16,6 +16,9 @@
+
+
+
diff --git a/Content.Client/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEui.cs b/Content.Client/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEui.cs
new file mode 100644
index 00000000000..806d55cd407
--- /dev/null
+++ b/Content.Client/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEui.cs
@@ -0,0 +1,138 @@
+using Content.Client.Eui;
+using Content.Client._CorvaxNext.Administration.UI.Audio.Widgets;
+using Content.Shared.Eui;
+using Content.Shared._CorvaxNext.Administration.UI.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Utility;
+
+namespace Content.Client._CorvaxNext.Administration.UI.Audio;
+
+public sealed partial class AdminAudioPanelEui : BaseEui
+{
+ [Dependency] private readonly IEntitySystemManager _entitySystem = default!;
+ [Dependency] private readonly IEntityManager _entity = default!;
+
+ private SharedAudioSystem _audioSystem;
+
+ private AdminAudioPanelEuiState? _state = null;
+ private AdminAudioPanel? _adminAudioPanel = null;
+
+ public AdminAudioPanelEui() : base()
+ {
+ IoCManager.InjectDependencies(this);
+ _audioSystem = _entitySystem.GetEntitySystem();
+ }
+
+ public override void HandleState(EuiStateBase state)
+ {
+ if (state is AdminAudioPanelEuiState adminAudioPanelState)
+ {
+ _state = adminAudioPanelState;
+ UpdateUI();
+ }
+ }
+
+ public override void Opened()
+ {
+ _adminAudioPanel = new AdminAudioPanel();
+ _adminAudioPanel.OpenCentered();
+
+ _adminAudioPanel.OnPlayButtonEnabled += () => Play();
+ _adminAudioPanel.OnPauseButtonEnabled += () => Pause();
+ _adminAudioPanel.OnStopButtonEnabled += () => Stop();
+ _adminAudioPanel.OnAddTrackPressed += (track) => AddTrack(track);
+ _adminAudioPanel.OnPlaybackReleased += (ratio) => SetPlayback(ratio);
+ _adminAudioPanel.OnGlobalCheckboxToggled += (toggled) => ChangeGlobalToggled(toggled);
+ _adminAudioPanel.OnVolumeLineTextChanged += (volume) => SetVolume(volume);
+ _adminAudioPanel.OnSelectPlayer += (guid) => SelectPlayer(guid);
+ _adminAudioPanel.OnUnselectPlayer += (guid) => UnselectPlayer(guid);
+ }
+
+ public override void Closed()
+ {
+ if (_adminAudioPanel != null)
+ _adminAudioPanel.Close();
+ }
+
+ public void Play()
+ {
+ var message = new AdminAudioPanelEuiMessage.Play();
+ SendMessage(message);
+ }
+
+ public void Stop()
+ {
+ var message = new AdminAudioPanelEuiMessage.Stop();
+ SendMessage(message);
+ }
+
+ public void Pause()
+ {
+ var message = new AdminAudioPanelEuiMessage.Pause();
+ SendMessage(message);
+ }
+
+ public void SetPlayback(float ratio)
+ {
+ var message = new AdminAudioPanelEuiMessage.SetPlaybackPosition(ratio);
+ SendMessage(message);
+ }
+
+ public void AddTrack(string track)
+ {
+ var message = new AdminAudioPanelEuiMessage.AddTrack(track);
+ SendMessage(message);
+ }
+
+ public void ChangeGlobalToggled(bool toggled)
+ {
+ var message = new AdminAudioPanelEuiMessage.GlobalToggled(toggled);
+ SendMessage(message);
+ }
+
+ public void SetVolume(float volume)
+ {
+ var message = new AdminAudioPanelEuiMessage.SetVolume(volume);
+ SendMessage(message);
+ }
+
+ private void SelectPlayer(Guid player)
+ {
+ var message = new AdminAudioPanelEuiMessage.SelectPlayer(player);
+ SendMessage(message);
+ }
+
+ private void UnselectPlayer(Guid player)
+ {
+ var message = new AdminAudioPanelEuiMessage.UnselectPlayer(player);
+ SendMessage(message);
+ }
+
+ private void UpdateUI()
+ {
+ if (_adminAudioPanel is not { })
+ return;
+
+ if (_state is not { })
+ return;
+
+ var audioEntity = _entity.GetEntity(_state.Audio);
+
+ _adminAudioPanel.SetAudioStream(audioEntity);
+ _adminAudioPanel.UpdateGlobalToggled(_state.Global);
+ _adminAudioPanel.UpdatePlayersContainer(_state.Players, _state.SelectedPlayers);
+ _adminAudioPanel.UpdatePlayingState(_state.Playing);
+ _adminAudioPanel.UpdateQueue(_state.Queue);
+ _adminAudioPanel.UpdateVolume(_state.Volume);
+
+ if (_entity.TryGetComponent(audioEntity, out var audio))
+ {
+ _adminAudioPanel.UpdateCurrentTrackLabel(audio.FileName);
+ }
+ else
+ {
+ _adminAudioPanel.UpdateCurrentTrackLabel(Loc.GetString("admin-audio-panel-track-name-nothing-playing"));
+ }
+ }
+}
diff --git a/Content.Client/_CorvaxNext/Administration/UI/Audio/Widgets/AdminAudioPanel.xaml b/Content.Client/_CorvaxNext/Administration/UI/Audio/Widgets/AdminAudioPanel.xaml
new file mode 100644
index 00000000000..79e45b2bc8d
--- /dev/null
+++ b/Content.Client/_CorvaxNext/Administration/UI/Audio/Widgets/AdminAudioPanel.xaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_CorvaxNext/Administration/UI/Audio/Widgets/AdminAudioPanel.xaml.cs b/Content.Client/_CorvaxNext/Administration/UI/Audio/Widgets/AdminAudioPanel.xaml.cs
new file mode 100644
index 00000000000..21af1696611
--- /dev/null
+++ b/Content.Client/_CorvaxNext/Administration/UI/Audio/Widgets/AdminAudioPanel.xaml.cs
@@ -0,0 +1,185 @@
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Utility;
+using System.Linq;
+using Robust.Shared.Timing;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Audio.Systems;
+
+namespace Content.Client._CorvaxNext.Administration.UI.Audio.Widgets;
+
+[GenerateTypedNameReferences]
+public sealed partial class AdminAudioPanel : DefaultWindow
+{
+ [Dependency] private readonly IEntityManager _entity = default!;
+ [Dependency] private readonly IEntitySystemManager _entitySystem = default!;
+
+ private readonly SharedAudioSystem _audioSystem;
+
+ public event Action? OnPlayButtonEnabled;
+ public event Action? OnStopButtonEnabled;
+ public event Action? OnPauseButtonEnabled;
+ public event Action? OnPlaybackReleased;
+ public event Action? OnVolumeLineTextChanged;
+ public event Action? OnAddTrackPressed;
+ public event Action? OnGlobalCheckboxToggled;
+ public event Action? OnSelectPlayer;
+ public event Action? OnUnselectPlayer;
+
+ private string _volumeLineText = "";
+ private EntityUid _audio;
+
+ public AdminAudioPanel()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ _audioSystem = _entitySystem.GetEntitySystem();
+
+ PlayButton.OnToggled += (args) =>
+ {
+ if (args.Button.Pressed)
+ {
+ OnPlayButtonEnabled?.Invoke();
+ }
+ else
+ {
+ PlayButton.Pressed = true;
+ }
+ };
+ StopButton.OnPressed += (args) =>
+ {
+ if (!PlayButton.Pressed)
+ return;
+
+ OnStopButtonEnabled?.Invoke();
+ PlayButton.Pressed = false;
+ };
+ PauseButton.OnPressed += (args) =>
+ {
+ if (!PlayButton.Pressed)
+ return;
+
+ OnPauseButtonEnabled?.Invoke();
+ PlayButton.Pressed = false;
+ };
+ PlaybackSlider.OnReleased += (slider) => OnPlaybackReleased?.Invoke(slider.Value);
+ VolumeLine.OnTextEntered += (args) =>
+ {
+ // performs validation of text the user is typing to field
+ // doesn't let type something that isn't a parsible value
+ if (float.TryParse(args.Text, out var result))
+ {
+ _volumeLineText = args.Text;
+ OnVolumeLineTextChanged?.Invoke(result);
+ }
+ else
+ {
+ args.Control.SetText(_volumeLineText);
+ }
+ };
+ AddTrackButton.OnPressed += (args) =>
+ {
+ OnAddTrackPressed?.Invoke(TrackPathLine.Text);
+ TrackPathLine.SetText("");
+ };
+ GlobalCheckbox.OnToggled += (args) => OnGlobalCheckboxToggled?.Invoke(args.Pressed);
+ }
+
+ public void SetAudioStream(EntityUid audio)
+ {
+ _audio = audio;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ if (_entity.TryGetComponent(_audio, out var audio))
+ {
+ var currentTrackLength = _audioSystem.GetAudioLength(audio.FileName);
+ var playbackPosition = TimeSpan.FromSeconds(audio.PlaybackPosition);
+
+ UpdateDurationLabel(playbackPosition, currentTrackLength);
+ if (!PlaybackSlider.Grabbed)
+ UpdatePlaybackPosition(currentTrackLength, playbackPosition);
+ }
+ }
+
+ public void UpdatePlayersContainer(Dictionary players, HashSet selectedPlayers)
+ {
+ PlayersContainer.RemoveAllChildren();
+
+ foreach (var player in players)
+ {
+ var newButton = new Button
+ {
+ ClipText = true,
+ ToggleMode = true,
+ Text = player.Value,
+ HorizontalExpand = true,
+ Pressed = selectedPlayers.FirstOrNull(selectedPlayer => selectedPlayer == player.Key) != null,
+ Disabled = GlobalCheckbox.Pressed,
+ };
+ newButton.OnToggled += (args) =>
+ {
+ if (args.Pressed)
+ {
+ OnSelectPlayer?.Invoke(player.Key);
+ }
+ else
+ {
+ OnUnselectPlayer?.Invoke(player.Key);
+ }
+ };
+
+ PlayersContainer.AddChild(newButton);
+ }
+ }
+
+ public void UpdatePlayingState(bool playing)
+ {
+ PlayButton.Pressed = playing;
+ }
+
+ public void UpdateGlobalToggled(bool toggled)
+ {
+ GlobalCheckbox.Pressed = toggled;
+ }
+
+ public void UpdatePlaybackPosition(TimeSpan currentTrackLength, TimeSpan playbackPosition)
+ {
+ PlaybackSlider.MaxValue = (float)currentTrackLength.TotalSeconds;
+ PlaybackSlider.SetValueWithoutEvent((float)playbackPosition.TotalSeconds);
+ }
+
+ public void UpdateDurationLabel(TimeSpan playbackPosition, TimeSpan currentTrackLength)
+ {
+ DurationLabel.Text = $@"{playbackPosition:mm\:ss} / {currentTrackLength:mm\:ss}";
+ }
+
+ public void UpdateCurrentTrackLabel(string currentTrack)
+ {
+ TrackName.Text = currentTrack.Split("/").Last();
+ }
+
+ public void UpdateQueue(Queue queue)
+ {
+ TrackList.RemoveAllChildren();
+
+ foreach (var track in queue.ToList())
+ {
+ var label = new Label()
+ {
+ Text = track,
+ };
+ TrackList.AddChild(label);
+ }
+ }
+
+ public void UpdateVolume(float volume)
+ {
+ VolumeLine.SetText(volume.ToString());
+ _volumeLineText = volume.ToString();
+ }
+}
diff --git a/Content.Server/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEui.cs b/Content.Server/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEui.cs
new file mode 100644
index 00000000000..325e692e97d
--- /dev/null
+++ b/Content.Server/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEui.cs
@@ -0,0 +1,120 @@
+using Content.Server.Administration.Managers;
+using Content.Server.EUI;
+using Content.Shared.Administration;
+using Content.Shared.Eui;
+using Content.Shared._CorvaxNext.Administration.UI.Audio;
+using Robust.Server.Player;
+using Robust.Shared.ContentPack;
+using Robust.Shared.Enums;
+using Robust.Shared.Player;
+
+namespace Content.Server._CorvaxNext.Administration.UI.Audio;
+
+public sealed partial class AdminAudioPanelEui : BaseEui
+{
+ [Dependency] private readonly IAdminManager _adminManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ [Dependency] private readonly IResourceManager _resourceManager = default!;
+
+ private Dictionary _availablePlayers = new();
+
+ private readonly AdminAudioPanelSystem _audioPanel;
+
+ public AdminAudioPanelEui() : base()
+ {
+ IoCManager.InjectDependencies(this);
+
+ _audioPanel = IoCManager.Resolve().GetEntitySystem();
+
+ foreach (var player in Filter.Broadcast().Recipients)
+ {
+ _availablePlayers.Add(player.UserId.UserId, player.Name);
+ }
+
+ _audioPanel.AudioUpdated += () => StateDirty();
+
+ _playerManager.PlayerStatusChanged += (object? sender, SessionStatusEventArgs args) =>
+ {
+ switch (args.NewStatus)
+ {
+ case SessionStatus.InGame:
+ _availablePlayers.Add(args.Session.UserId.UserId, args.Session.Name);
+ StateDirty();
+ break;
+ case SessionStatus.Disconnected:
+ _availablePlayers.Remove(args.Session.UserId.UserId);
+ StateDirty();
+ break;
+ }
+ };
+ }
+
+ public override void Opened()
+ {
+ StateDirty();
+ }
+
+ public override AdminAudioPanelEuiState GetNewState()
+ {
+ return new(
+ _audioPanel.Playing,
+ _entityManager.GetNetEntity(_audioPanel.AudioEntity),
+ _audioPanel.AudioParams.Volume,
+ _audioPanel.Queue,
+ _audioPanel.Global,
+ _availablePlayers,
+ _audioPanel.SelectedPlayers
+ );
+ }
+
+ public override void HandleMessage(EuiMessageBase msg)
+ {
+ base.HandleMessage(msg);
+
+ if (msg is not AdminAudioPanelEuiMessage.AdminAudioPanelEuiMessageBase)
+ return;
+
+ if (!_adminManager.HasAdminFlag(Player, AdminFlags.Fun))
+ {
+ Close();
+ return;
+ }
+
+ switch (msg)
+ {
+ case AdminAudioPanelEuiMessage.Play:
+ _audioPanel.Play();
+ break;
+ case AdminAudioPanelEuiMessage.Pause:
+ _audioPanel.Pause();
+ break;
+ case AdminAudioPanelEuiMessage.Stop:
+ _audioPanel.Stop();
+ break;
+ case AdminAudioPanelEuiMessage.AddTrack addTrack:
+ var filename = addTrack.Filename.Trim();
+ if (_resourceManager.ContentFileExists(filename))
+ _audioPanel.AddToQueue(filename);
+ break;
+ case AdminAudioPanelEuiMessage.SetVolume setVolume:
+ _audioPanel.SetVolume(setVolume.Volume);
+ break;
+ case AdminAudioPanelEuiMessage.SetPlaybackPosition setPlayback:
+ _audioPanel.SetPlaybackPosition(setPlayback.Position);
+ break;
+ case AdminAudioPanelEuiMessage.SelectPlayer selectPlayer:
+ _audioPanel.SelectPlayer(selectPlayer.Player);
+ break;
+ case AdminAudioPanelEuiMessage.UnselectPlayer unselectPlayer:
+ _audioPanel.UnselectPlayer(unselectPlayer.Player);
+ break;
+ case AdminAudioPanelEuiMessage.GlobalToggled globalToggled:
+ _audioPanel.SetGlobal(globalToggled.Toggled);
+ break;
+ default:
+ return;
+ }
+ StateDirty();
+ }
+}
diff --git a/Content.Server/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelSystem.cs b/Content.Server/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelSystem.cs
new file mode 100644
index 00000000000..a0d517d635d
--- /dev/null
+++ b/Content.Server/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelSystem.cs
@@ -0,0 +1,204 @@
+using System.Linq;
+using Robust.Server.Player;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+
+namespace Content.Server._CorvaxNext.Administration.UI.Audio;
+
+public sealed partial class AdminAudioPanelSystem : EntitySystem
+{
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private (EntityUid Entity, AudioComponent Audio)? _audioStream;
+ private List _selectedPlayers = new();
+
+ public Action? AudioUpdated;
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (!Playing)
+ return;
+
+ if (_audioStream is { } audioStream)
+ {
+ if (Exists(audioStream.Entity) &&
+ (audioStream.Audio.State == AudioState.Playing || audioStream.Audio.State == AudioState.Paused))
+ return;
+ else
+ {
+ _audioStream = null;
+ Queue.TryDequeue(out _);
+ AudioUpdated?.Invoke();
+ }
+ }
+
+ if (CurrentTrack != null)
+ {
+ _audioStream = _audio.PlayGlobal(CurrentTrack, Global ? Filter.Broadcast() : Filter.Empty().AddPlayers(_selectedPlayers), Global, AudioParams);
+ AudioUpdated?.Invoke();
+ }
+ }
+
+ private void SetStreamState(AudioState state)
+ {
+ if (_audioStream != null)
+ _audio.SetState(_audioStream.Value.Entity, state, true, _audioStream.Value.Audio);
+ AudioUpdated?.Invoke();
+ }
+
+ ///
+ /// Stops sound and starts its over with current params with playback position of stopped sound.
+ /// Used, for example, when need to update selected players.
+ ///
+ private void RecreateSound()
+ {
+ if (!Playing)
+ return;
+
+ if (_audioStream is not { } audioStream)
+ return;
+
+ Playing = false;
+
+ var playback = (float)((audioStream.Audio.PauseTime ?? _timing.CurTime) - audioStream.Audio.AudioStart).TotalSeconds;
+
+ _audio.Stop(audioStream.Entity, audioStream.Audio);
+
+ _audioStream = _audio.PlayGlobal(CurrentTrack, Global ? Filter.Broadcast() : Filter.Empty().AddPlayers(_selectedPlayers), Global, AudioParams);
+ _audio.SetPlaybackPosition(_audioStream, playback);
+
+ Playing = true;
+ AudioUpdated?.Invoke();
+ }
+
+ #region Public API
+ public readonly Queue Queue = new();
+ public string? CurrentTrack
+ {
+ get
+ {
+ if (Queue.TryPeek(out var track))
+ return track;
+ return null;
+ }
+ }
+ public AudioParams AudioParams { get; private set; } = AudioParams.Default;
+ public bool Playing { get; private set; } = false;
+ public bool Global { get; private set; } = true;
+ public HashSet SelectedPlayers => _selectedPlayers.Select(player => player.UserId.UserId).ToHashSet();
+ public EntityUid AudioEntity => _audioStream?.Entity ?? EntityUid.Invalid;
+
+ public void AddToQueue(string filename)
+ {
+ Queue.Enqueue(filename);
+ AudioUpdated?.Invoke();
+ }
+
+ public bool Play()
+ {
+ if (_audioStream != null && _audioStream.Value.Audio.State == AudioState.Paused)
+ {
+ return Resume();
+ }
+
+ Playing = true;
+ AudioUpdated?.Invoke();
+ return Playing;
+ }
+
+ public void Pause()
+ {
+ if (_audioStream != null && _audioStream.Value.Audio.State == AudioState.Playing)
+ SetStreamState(AudioState.Paused);
+
+ Playing = false;
+ AudioUpdated?.Invoke();
+ }
+
+ public bool Resume()
+ {
+ if (_audioStream != null && _audioStream.Value.Audio.State == AudioState.Paused)
+ SetStreamState(AudioState.Playing);
+
+ Playing = true;
+ AudioUpdated?.Invoke();
+ return Playing;
+ }
+
+ public bool Stop()
+ {
+ if (_audioStream != null && _audioStream.Value.Audio.State != AudioState.Stopped)
+ {
+ _audio.Stop(_audioStream.Value.Entity, _audioStream.Value.Audio);
+ _audioStream = null;
+ Queue.TryDequeue(out _);
+ }
+
+ Playing = false;
+ AudioUpdated?.Invoke();
+ return !Playing;
+ }
+
+ public void SetVolume(float volume)
+ {
+ AudioParams = AudioParams.WithVolume(volume);
+ if (_audioStream != null)
+ _audio.SetVolume(_audioStream.Value.Entity, volume, _audioStream.Value.Audio);
+ AudioUpdated?.Invoke();
+ }
+
+ public void SelectPlayer(Guid player)
+ {
+ if (SelectedPlayers.Contains(player))
+ return;
+
+ var session = _playerManager.NetworkedSessions.FirstOrDefault(session => session.UserId.UserId == player);
+
+ if (session == null)
+ return;
+
+ _selectedPlayers.Add(session);
+ RecreateSound();
+ AudioUpdated?.Invoke();
+ }
+
+ public void UnselectPlayer(Guid player)
+ {
+ if (!SelectedPlayers.Contains(player))
+ return;
+
+ var session = _playerManager.NetworkedSessions.FirstOrDefault(session => session.UserId.UserId == player);
+
+ if (session == null)
+ return;
+
+ _selectedPlayers.Remove(session);
+ RecreateSound();
+ AudioUpdated?.Invoke();
+ }
+
+ public void SetPlaybackPosition(float position)
+ {
+ if (CurrentTrack != null && _audioStream is { } audioStream)
+ {
+ _audio.SetPlaybackPosition(audioStream, position);
+ }
+ AudioUpdated?.Invoke();
+ }
+
+ public void SetGlobal(bool global)
+ {
+ Global = global;
+ RecreateSound();
+ AudioUpdated?.Invoke();
+ }
+
+ #endregion
+}
diff --git a/Content.Server/_CorvaxNext/Administration/UI/Audio/Commands/OpenAudioPanelCommand.cs b/Content.Server/_CorvaxNext/Administration/UI/Audio/Commands/OpenAudioPanelCommand.cs
new file mode 100644
index 00000000000..7b6b18e32d5
--- /dev/null
+++ b/Content.Server/_CorvaxNext/Administration/UI/Audio/Commands/OpenAudioPanelCommand.cs
@@ -0,0 +1,27 @@
+using Content.Server.Administration;
+using Content.Server.EUI;
+using Content.Shared.Administration;
+using Robust.Shared.Console;
+
+namespace Content.Server._CorvaxNext.Administration.UI.Audio.Commands;
+
+[AdminCommand(AdminFlags.Fun)]
+public sealed class OpenAudioPanelCommand : IConsoleCommand
+{
+ public string Command => "audiopanel";
+ public string Description => "Opens the admin audio panel panel.";
+ public string Help => $"Usage: {Command}";
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (shell.Player is not { } player)
+ {
+ shell.WriteError(Loc.GetString("shell-cannot-run-command-from-server"));
+ return;
+ }
+
+ var eui = IoCManager.Resolve();
+ var ui = new AdminAudioPanelEui();
+ eui.OpenEui(ui, player);
+ }
+}
diff --git a/Content.Shared/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEuiState.cs b/Content.Shared/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEuiState.cs
new file mode 100644
index 00000000000..26e57e1874a
--- /dev/null
+++ b/Content.Shared/_CorvaxNext/Administration/UI/Audio/AdminAudioPanelEuiState.cs
@@ -0,0 +1,75 @@
+using Content.Shared.Eui;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._CorvaxNext.Administration.UI.Audio;
+
+[Serializable, NetSerializable]
+public sealed partial class AdminAudioPanelEuiState(bool playing, NetEntity audio, float volume, Queue queue, bool global, Dictionary players, HashSet selectedPlayers) : EuiStateBase
+{
+ public bool Playing = playing;
+ public NetEntity Audio = audio;
+ public float Volume = volume;
+ public Queue Queue = queue;
+ public bool Global = global;
+ public Dictionary Players = players;
+ public HashSet SelectedPlayers = selectedPlayers;
+};
+
+public static class AdminAudioPanelEuiMessage
+{
+ [Serializable]
+ public abstract class AdminAudioPanelEuiMessageBase : EuiMessageBase
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class Play : AdminAudioPanelEuiMessageBase
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class Pause : AdminAudioPanelEuiMessageBase
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class Stop : AdminAudioPanelEuiMessageBase
+ {
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class AddTrack(string filename) : AdminAudioPanelEuiMessageBase
+ {
+ public string Filename = filename;
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class SetVolume(float volume) : AdminAudioPanelEuiMessageBase
+ {
+ public float Volume = volume;
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class SetPlaybackPosition(float position) : AdminAudioPanelEuiMessageBase
+ {
+ public float Position = position;
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class SelectPlayer(Guid player) : AdminAudioPanelEuiMessageBase
+ {
+ public Guid Player = player;
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class UnselectPlayer(Guid player) : AdminAudioPanelEuiMessageBase
+ {
+ public Guid Player = player;
+ }
+
+ [Serializable, NetSerializable]
+ public sealed partial class GlobalToggled(bool toggled) : AdminAudioPanelEuiMessageBase
+ {
+ public bool Toggled = toggled;
+ }
+}
diff --git a/Resources/Locale/ru-RU/_CorvaxNext/audio/widgets/admin-audio-panel.ftl b/Resources/Locale/ru-RU/_CorvaxNext/audio/widgets/admin-audio-panel.ftl
new file mode 100644
index 00000000000..5733003e8f3
--- /dev/null
+++ b/Resources/Locale/ru-RU/_CorvaxNext/audio/widgets/admin-audio-panel.ftl
@@ -0,0 +1,10 @@
+admin-audio-panel-title = Панель управления аудио
+admin-audio-panel-current-track-label = Текущий трек
+admin-audio-panel-button-play-text = Играть
+admin-audio-panel-button-pause-text = Пауза
+admin-audio-panel-button-stop-text = Стоп
+admin-audio-panel-volume-line-placeholder = Громкость
+admin-audio-panel-toggle-global-checkbox-label = Слышат все
+admin-audio-panel-trackpath-line-placeholder = Путь к файлу OGG
+admin-audio-panel-add-track-button-text = Добавить трек
+admin-audio-panel-track-name-nothing-playing = Ничего не играет
diff --git a/Resources/Locale/ru-RU/administration/ui/tabs/admin-tab/player-actions-window.ftl b/Resources/Locale/ru-RU/administration/ui/tabs/admin-tab/player-actions-window.ftl
index 5eb1438b100..f3a59f5d58d 100644
--- a/Resources/Locale/ru-RU/administration/ui/tabs/admin-tab/player-actions-window.ftl
+++ b/Resources/Locale/ru-RU/administration/ui/tabs/admin-tab/player-actions-window.ftl
@@ -8,3 +8,4 @@ admin-player-actions-window-shuttle = Вызвать/отозвать шаттл
admin-player-actions-window-admin-logs = Админ логи
admin-player-actions-window-admin-notes = Админ заметки
admin-player-actions-window-admin-fax = Админ факс
+admin-player-actions-window-audio-panel = Аудио панель