CatStation/Content.Client/Instruments/UI/FileMidiSource.xaml.cs
Ataman 0c5b250f03
Instrument UI overhaul (#41604)
* Added base type for UI panels of MIDI input sources.

* Renamed BandMenu into BandMidiSource.

* Implemented the UI overhaul, skipping channels dialog functionality for now.

* Refactored for conventions and reordered type members.
Got rid of SetInstrument/ClearInstrument again.

* Decreased background opacity for UnavailableOverlay to improve readability.

* Localization

* Added text to InputMidiSource so players know what it does.

* Changed window MinSize to SetSize. Allows players to reduce window size if desired.

* Improved button looks by adding style classes.

* cleanup

* leftover cleanup

* Reverted fetching InstrumentSystem via IoCManager (crashes it, woops).

* added filter bar to file list

* refactored almost all system/manager interaction into instrument BUI

* text changed for instruments-component-menu-input-notice-label

* fixed InstrumentBoundUserInterface.Dispose not unsubscribing from everything

* minor cleanup

* finished configuration menu & cleanup

* Comments and fixes for InstrumentMidiSourceBase

* Fixed RA0051

* Removed unncecessary null check

* Added methods for accessing the selected track.

* Limited depth of search for instrument master to prevent deadlock.

* Cached system access

* Storing midis asynchronously

* Actual MIDI data now only loaded before playback.

* async removed from InstrumentBoundUserInterface.LoadStoredUserMidis

* Replaced GetComponent calls with TryGetComponent

* Localized MIDI playback track timer

* Searching for next track using raw index now

* Fixed CS0168

* XML comments, minor fixes and disabled MIDI library management while a track is playing.

* removed timers with simple watchdog and switched to MidiFileCollectionManager

* Missed one (timer)

* Changed access for MidiFileCollectionManager.UserMidiDirectory field to public.

* Added instrument config control for MIDI collection utilities.

* redesigned instrument config menu

* Removed rename function, updated adding implementation

* more redesigns

* fixed _isMidiFileDialogueWindowOpen not resetting correctly in certain cases.

* file scoped namespaces

* replaced EntityUid with Entity<InstrumentComponent>

* if spaces

* returning Tooltip instead of Control

* replaced SetEntity with public field

* readonly system fields

* InstrumentMenu MinSize

* xml comment for InstrumentMidiSourceBase

* small refactor

* more cleanup

* TryGetComponent to GetComponent on Open()

* more formatting issues

* switched to [Dependency] for systems

* playback improvements

* disable playback button if no file selected and playing

* activating shuffle now selects track if none is selected

* rearranged playback buttons

* channels overhauled

* Play/Stop label to Play only

* Removed all channel on/off

* Program now default channel name display selection

* improved RemoveButton.Disable behavior

* channels now toggable by clicking anywhere

* Shuffle no longer selects already selected track.

* Loop & Shuffle no longer both selectable at once

* minor improvements to channel item visuals

* minor channel toggle fixes

* xml docs & code cleanup

* `RefreshBandRequest` now sent automatically when band source becomes visible

* converted 5 items tuple to MidiChannelInfo record

* added markup support to instrument menu configuration header

* added note to midi channels config header explaining update behavior.
2026-07-26 16:50:28 +00:00

463 lines
15 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Content.Client.Audio.Midi;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Input;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using static Robust.Client.UserInterface.Controls.BaseButton;
using Range = Robust.Client.UserInterface.Controls.Range;
namespace Content.Client.Instruments.UI;
[GenerateTypedNameReferences]
public sealed partial class FileMidiSource : InstrumentMidiSourceBase
{
private const double RecoveryGracePeriod = 2.0;
[Dependency] private IRobustRandom _random = default!;
[Dependency] private MidiFileCollectionManager _midiCollection = default!;
[Dependency] private IFileDialogManager _dialogs = default!;
[Dependency] private IUserInterfaceManager _userInterfaceManager = default!;
/// <summary>
/// Raised when MIDI data should be played.
/// </summary>
public event Action<byte[]>? StartPlayingRequest;
/// <summary>
/// Raised when any currently playing MIDI track should be stopped.
/// </summary>
public event Action? StopPlayingRequest;
/// <summary>
/// Raised when the track position on the currently playing MIDI track should change.
/// </summary>
public event Action<int>? TrackPositionChangeRequest;
/// <summary>
/// Raised when looping MIDI tracks is toggled.
/// </summary>
public event Action<bool>? LoopingToggled;
private readonly List<ResPath> _loadedTracks = [];
private bool _isMidiFileDialogueWindowOpen;
private bool _selectedInternally;
private float _timeSinceLastRecoverAttempt;
/// <summary>
/// The Instrument playing the tracks. This is needed for the UI to read out current track time.
/// on <see cref="FrameUpdate" />.
/// </summary>
public Entity<InstrumentComponent> Instrument;
private bool IsShuffle => ShuffleButton.Pressed;
private string CurrentFilter => FilterBar.Text;
/// <inheritdoc />
public override string ButtonName => Loc.GetString("instruments-component-menu-file-midi-source-button");
/// <summary>
/// Controls if MIDI should currently be played. This is connected to the play button.
/// </summary>
public bool IsPlaying
{
get => PlayButton.Pressed;
set => PlayButton.Pressed = value;
}
public FileMidiSource()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
CurrentTrackLabel.Text = string.Empty;
_midiCollection.MidiFileAdded += AddTrack;
_midiCollection.MidiFileRemoved += RemoveTrack;
_midiCollection.MidiFilesReset += ReloadTrackList;
FilterBar.OnTextChanged += OnFilterBarTextChanged;
TrackList.OnItemSelected += OnTrackListItemSelected;
TrackList.OnItemDeselected += OnTrackListItemDeselected;
AddButton.OnPressed += OnAddButtonPressed;
RemoveButton.OnPressed += OnRemoveButtonPressed;
PlayButton.OnToggled += OnPlayButtonToggled;
LoopButton.OnToggled += OnLoopButtonToggled;
ShuffleButton.OnPressed += OnShuffleButtonPressed;
PlaybackSlider.OnValueChanged += OnPlaybackSliderValueChanged;
PlaybackSlider.OnKeyBindUp += OnPlaybackSliderKeyBindUp;
RemoveButton.TooltipSupplier = SupplyFileManagementTooltip;
ReloadTrackList();
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
if (PlaybackSlider.Grabbed)
return;
// Disable the playback button when nothing is selected and playing.
PlayButton.Disabled = !TrackList.GetSelected().Any() && !Instrument.Comp.IsRendererAlive;
if (!Instrument.Comp.IsMidiOpen)
{
ResetTrackIndicators();
PlaybackSlider.Disabled = true;
// My patience with the InstrumentSystem ran out, the system will be asked to play tracks until it behaves.
if (!IsPlaying)
return;
_timeSinceLastRecoverAttempt += args.DeltaSeconds;
if (!(_timeSinceLastRecoverAttempt > RecoveryGracePeriod))
return;
_timeSinceLastRecoverAttempt = 0;
ReplayCurrentTrack();
}
else if (Instrument.Comp.IsRendererAlive)
{
PlaybackSlider.Disabled = false;
PlaybackSlider.MaxValue = Instrument.Comp.PlayerTotalTick;
PlaybackSlider.SetValueWithoutEvent(Instrument.Comp.PlayerTick);
// TODO: SequencerTimeScale does not return the actual ticks/seconds, find solution.
var totalTime =
TimeSpan.FromSeconds(Math.Ceiling(Instrument.Comp.PlayerTotalTick /
Instrument.Comp.Renderer!.SequencerTimeScale));
var currentTime =
TimeSpan.FromSeconds(Math.Ceiling(Instrument.Comp.PlayerTick /
Instrument.Comp.Renderer!.SequencerTimeScale));
TimeLabel.Text = totalTime.Hours < 1
? Loc.GetString(
"instruments-component-menu-files-track-playtime-seconds-minutes",
("currentMinutes", currentTime.ToString("mm")),
("currentSeconds", currentTime.ToString("ss")),
("totalMinutes", totalTime.ToString("mm")),
("totalSeconds", totalTime.ToString("ss")))
: Loc.GetString(
"instruments-component-menu-files-track-playtime-seconds-minutes-hours",
("currentHours", currentTime.ToString("hh")),
("currentMinutes", currentTime.ToString("mm")),
("currentSeconds", currentTime.ToString("ss")),
("totalHours", totalTime.ToString("hh")),
("totalMinutes", totalTime.ToString("mm")),
("totalSeconds", totalTime.ToString("ss")));
}
}
public override void Disable()
{
base.Disable();
TrackList.ClearSelected();
IsPlaying = false;
StopPlayingRequest?.Invoke();
CurrentTrackLabel.Text = string.Empty;
}
private void OnFilterBarTextChanged(LineEdit.LineEditEventArgs obj)
{
FilterTrackList();
}
private void OnTrackListItemSelected(ItemList.ItemListSelectedEventArgs obj)
{
UpdateFileRemoveButton();
if (!_selectedInternally)
{
IsPlaying = false;
StopPlaying();
}
else
{
PlayTrackListItem(obj.ItemIndex);
}
}
private void OnTrackListItemDeselected(ItemList.ItemListDeselectedEventArgs obj)
{
RemoveButton.Disabled = true;
}
private async void OnAddButtonPressed(ButtonEventArgs obj)
{
try
{
if (_isMidiFileDialogueWindowOpen)
return;
var filters = new FileDialogFilters(new FileDialogFilters.Group("mid", "midi"));
// TODO: Once the file dialogue manager can handle focusing or closing windows, improve this logic to close
// or focus the previously-opened window.
_isMidiFileDialogueWindowOpen = true;
var (stream, name) = await _dialogs.GetFileAndName(filters, FileAccess.Read);
await using (stream)
{
// did the instrument menu get closed while waiting for the user to select a file?
if (Disposed)
return;
if (stream == null)
return;
if (name != null)
await _midiCollection.AddMidiFile(new ResPath(name), stream);
}
}
catch
{
_userInterfaceManager.Popup(Loc.GetString("instruments-component-menu-files-error"));
}
finally
{
_isMidiFileDialogueWindowOpen = false;
}
}
private void OnRemoveButtonPressed(ButtonEventArgs obj)
{
if (TryGetSelectedTrackPath(out var trackPath) && trackPath is { } resPath)
_midiCollection.RemoveMidiFile(resPath);
UpdateFileRemoveButton();
}
private void OnPlayButtonToggled(ButtonToggledEventArgs obj)
{
UpdateFileRemoveButton();
if (PlayButton.Pressed)
{
if (!TryGetSelectedTrack(out var track))
return;
StartPlaying(track);
}
else
{
StopPlaying();
}
}
private void OnLoopButtonToggled(ButtonToggledEventArgs obj)
{
if (LoopButton.Pressed && ShuffleButton.Pressed)
ShuffleButton.Pressed = false;
LoopingToggled?.Invoke(LoopButton.Pressed);
}
private void OnShuffleButtonPressed(ButtonEventArgs obj)
{
if (ShuffleButton.Pressed && LoopButton.Pressed)
{
LoopingToggled?.Invoke(false);
LoopButton.Pressed = false;
}
if (obj.Button.Pressed && !TrackList.GetSelected().Any())
TrackList[_random.Next(0, TrackList.Count)].Selected = true;
}
private void OnPlaybackSliderValueChanged(Range _)
{
// Do not seek while still grabbing.
if (PlaybackSlider.Grabbed)
return;
TrackPositionChangeRequest?.Invoke((int)Math.Ceiling(PlaybackSlider.Value));
}
private void OnPlaybackSliderKeyBindUp(GUIBoundKeyEventArgs obj)
{
if (obj.Function != EngineKeyFunctions.UIClick)
return;
TrackPositionChangeRequest?.Invoke((int)Math.Ceiling(PlaybackSlider.Value));
}
private Tooltip? SupplyFileManagementTooltip(Control? sender)
{
if (sender is not Button { Disabled: true })
return null;
var tooltipText = Loc.GetString("instruments-component-menu-files-file-remove-disabled-info");
var tooltip = new Tooltip();
tooltip.SetMessage(FormattedMessage.FromUnformatted(tooltipText));
return tooltip;
}
private void UpdateFileRemoveButton()
{
RemoveButton.Disabled = !TrackList.GetSelected().Any() || IsPlaying;
}
private void ResetTrackIndicators()
{
PlaybackSlider.MaxValue = 1;
PlaybackSlider.SetValueWithoutEvent(0);
CurrentTrackLabel.Text = string.Empty;
TimeLabel.Text = "--.--/--.--";
}
private void PlayTrackListItem(int index)
{
if (index >= TrackList.Count || index < 0)
return;
if (TrackList[index] is not { } item)
return;
StartPlaying(item);
}
private void StopPlaying()
{
StopPlayingRequest?.Invoke();
ResetTrackIndicators();
UpdateFileRemoveButton();
}
private void StartPlaying(ItemList.Item item)
{
if (item.Metadata is not ResPath resPath)
return;
if (item.Text == null)
return;
_timeSinceLastRecoverAttempt = 0;
var midiData = _midiCollection.GetMidiData(resPath);
StartPlayingRequest?.Invoke(midiData);
CurrentTrackLabel.Text = item.Text;
UpdateFileRemoveButton();
}
private void FilterTrackList()
{
var filterString = CurrentFilter.Trim().ToLowerInvariant();
TrackList.Clear();
foreach (var track in _loadedTracks)
{
var trackName = track.FilenameWithoutExtension.Trim().ToLowerInvariant();
if (trackName.Contains(filterString))
TrackList.AddItem(track.FilenameWithoutExtension, null, true, track);
}
}
private bool TryGetSelectedTrackPath([NotNullWhen(true)] out ResPath? trackPath)
{
trackPath = null;
if (!TryGetSelectedTrack(out var item) || item.Metadata is not ResPath resPath)
return false;
trackPath = resPath;
return true;
}
private bool TryGetSelectedTrack([NotNullWhen(true)] out ItemList.Item? item)
{
return TrackList.GetSelected().TryFirstOrDefault(out item);
}
private void AddTrack(ResPath trackPath)
{
_loadedTracks.Add(trackPath);
FilterTrackList();
}
private void RemoveTrack(ResPath trackPath)
{
_loadedTracks.Remove(trackPath);
FilterTrackList();
}
private void ReloadTrackList()
{
_loadedTracks.Clear();
_loadedTracks.AddRange(_midiCollection.GetMidiFiles());
FilterTrackList();
}
private void ReplayCurrentTrack()
{
if (!TryGetSelectedTrack(out var track))
return;
StartPlaying(track);
}
/// <summary>
/// Selects the next MIDI track from the list according to shuffle and filter settings.
/// </summary>
/// <remarks>Selecting another track causes its playback if <see cref="IsPlaying" /> is true.</remarks>
public void SelectNextTrack()
{
// Only proceed if file panel is active.
if (!Enabled)
return;
// Only proceed if the play button is pressed.
if (!IsPlaying)
return;
// List is empty, return
if (TrackList.Count == 0)
return;
_selectedInternally = true;
try
{
if (IsShuffle)
{
var randomIndex = _random.Next(0, TrackList.Count);
var randomTrack = TrackList[randomIndex];
if (randomTrack.Selected)
{
// Try going one up or down if we roll the already selected track.
if (TrackList.Count - 1 > randomIndex)
TrackList[randomIndex + 1].Selected = true;
else if (randomIndex > 0)
TrackList[randomIndex - 1].Selected = true;
else
randomTrack.Selected = true;
}
else
{
randomTrack.Selected = true;
}
}
else
{
if (!TryGetSelectedTrack(out var item))
{
TrackList[0].Selected = true;
return;
}
for (var i = 0; i < TrackList.Count - 1; i++)
{
if (TrackList[i] != item)
continue;
TrackList[i + 1].Selected = true;
return;
}
TrackList[0].Selected = false;
TrackList[0].Selected = true;
}
}
finally
{
_selectedInternally = false;
}
}
}