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!;
///
/// Raised when MIDI data should be played.
///
public event Action? StartPlayingRequest;
///
/// Raised when any currently playing MIDI track should be stopped.
///
public event Action? StopPlayingRequest;
///
/// Raised when the track position on the currently playing MIDI track should change.
///
public event Action? TrackPositionChangeRequest;
///
/// Raised when looping MIDI tracks is toggled.
///
public event Action? LoopingToggled;
private readonly List _loadedTracks = [];
private bool _isMidiFileDialogueWindowOpen;
private bool _selectedInternally;
private float _timeSinceLastRecoverAttempt;
///
/// The Instrument playing the tracks. This is needed for the UI to read out current track time.
/// on .
///
public Entity Instrument;
private bool IsShuffle => ShuffleButton.Pressed;
private string CurrentFilter => FilterBar.Text;
///
public override string ButtonName => Loc.GetString("instruments-component-menu-file-midi-source-button");
///
/// Controls if MIDI should currently be played. This is connected to the play button.
///
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);
}
///
/// Selects the next MIDI track from the list according to shuffle and filter settings.
///
/// Selecting another track causes its playback if is true.
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;
}
}
}