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; } } }