diff --git a/README.md b/README.md index 5003881..07ba290 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,41 @@ ![alt text](https://s9.postimg.cc/n3e4vsb5b/logo.png "SoundBoard Logo") -# About SoundBoard +# SoundBoard -## Synopsis +## About -SoundBoard is a elegant, easy-to-use application to save and play your favorite sounds. +SoundBoard is an elegant, easy-to-use application to save and play your favorite sounds. -Features include... -* highly customizable (create your perfect soundboard with multiple pages and color-coded sounds) -* background saving/loading of sound configuration (so you don't lose your changes) -* instant searching and playback of sounds from the keyboard (find the perfect sound at a moment's notice) -* clean, modern UI -* and more... +* Create multiple tabs with sounds in any grid configuration +* Customize the look of each sound by changing the background color +* Set sounds to loop, adjust their volume, trigger other sounds, or stop all other sounds +* Activate sounds via hotkey +* Instantly search and playback sounds by typing their name ## Downloads Grab the latest version [here](https://github.com/micahmo/SoundBoard/releases/latest/download/SoundBoard.exe). This will download a portable SoundBoard.exe that can be run from anywhere. Use the Export Configuration function to bring your configuration to a different system. -## Screenshots +## Features -###### Create multiple pages with multiple sounds on each +##### Create multiple pages with multiple sounds on each ![alt text](https://i.postimg.cc/rwnDXNfN/2019-09-02-15-30-42-Glow-Window.png "Overview1") -###### Color-code and customize the number of sounds per page +##### Color-code and customize the number of sounds per page ![alt text](https://i.postimg.cc/SR6GDv7r/2019-09-02-15-33-07-Glow-Window.png "Overview2") -###### Quickly search and play a sound just by typing +##### Quickly search and play a sound just by typing ![alt text](https://i.postimg.cc/4NXvXwQD/2019-09-02-15-33-53-Glow-Window.png "Overview3") -###### View and control playback of each sound individually +##### View and control playback of each sound individually ![alt text](https://i.postimg.cc/4xPHBgCy/2019-09-02-15-39-46-Glow-Window.png "Overview") -###### Select audio output device; allows you to route audio to a device that is not selected as the default in Windows +##### Select audio output device; allows you to route audio to a device that is not selected as the default in Windows ![image](https://user-images.githubusercontent.com/7417301/147796828-5dacc1a5-9056-4437-8210-1154a5098920.png) -###### Select multiple audio output devices +##### Select multiple audio output devices ![image](https://user-images.githubusercontent.com/7417301/147796839-bb8d0abc-88a0-42e1-9c3a-b4b510c3e9d6.png) @@ -44,6 +43,20 @@ Grab the latest version [here](https://github.com/micahmo/SoundBoard/releases/la > > Note that audio playback to multiple output devices is not guaranteed to be 100% synchronized. This functionality is not officially supported by Windows or [NAudio](https://github.com/naudio/NAudio), so SoundBoard is creating separate audio streams to each device which have the potential to drift. +##### Pass through an audio input device + +You may also select an input to pipe to your output(s). This is essentially an audio passthrough, and should be roughly equivalent listen feature in the Windows sound properties. You may optionally tweak the desired latency in the configuration file. A too-low latency may result in choppy audio. + +##### Assign Hotkeys + +You may assign local and global hotkeys to sounds. Pressing a local hotkey will play the corresponding sound when the application is active. Pressing a global hotkey will play the sound regardless of the active window. + +* Some shortcuts may be reserved by other apps or by Windows itself. +* Using single letters/number/character hotkeys may conflict with the quick search feature. +* Using standard Windows shortcuts may also produce unintended behavior (e.g., Tab or Win). + +![image](https://user-images.githubusercontent.com/7417301/221188183-e1a320ff-9561-4722-887a-91e76560235a.png) + ## License This code is licenced under the [MIT License](https://opensource.org/licenses/MIT). diff --git a/SoundBoard/ButtonGridDialog.xaml.cs b/SoundBoard/ButtonGridDialog.xaml.cs index 9ddd1b0..9d6eedb 100644 --- a/SoundBoard/ButtonGridDialog.xaml.cs +++ b/SoundBoard/ButtonGridDialog.xaml.cs @@ -34,6 +34,12 @@ public ButtonGridDialog(int startingRowCount, int startingColumnCount) : this() ColumnCount = _startingColumnCount = startingColumnCount; } + public ButtonGridDialog(int startingRowCount, int startingColumnCount, string title, bool validate) : this(startingRowCount, startingColumnCount) + { + Title = title; + _validate = validate; + } + #endregion #region Public properties @@ -119,7 +125,7 @@ private void ColumnUpDown_ValueChanged(object sender, RoutedPropertyChangedEvent private void ShowHideWarningLabel() { - if (WarningLabel is null == false) + if (_validate && WarningLabel is null == false) { WarningLabel.Visibility = RowUpDown.Value < _startingRowCount || ColumnUpDown.Value < _startingColumnCount ? Visibility.Visible @@ -127,6 +133,8 @@ private void ShowHideWarningLabel() } } + private readonly bool _validate = true; + #endregion } } diff --git a/SoundBoard/Buttons.cs b/SoundBoard/Buttons.cs index 5f61d11..61c8187 100644 --- a/SoundBoard/Buttons.cs +++ b/SoundBoard/Buttons.cs @@ -16,9 +16,14 @@ using Microsoft.Win32; using System.Windows.Input; using Dsafa.WpfColorPicker; +using MahApps.Metro.SimpleChildWindow; using NAudio.Wave.SampleProviders; using Timer = System.Timers.Timer; using ControlPaint = System.Windows.Forms.ControlPaint; +using BondTech.HotKeyManagement.WPF._4; +using System.Media; +using System.Windows.Media.Animation; +using Humanizer; #endregion @@ -256,12 +261,14 @@ protected override void OnClick() { ParentButton.Pause(); _playing = false; + MainWindow.Instance.OnAnySoundStopped(ParentButton); Content = ImageHelper.GetImage(ImageHelper.PlayButtonPath, 11, 11, Mode == ColorMode.Dark); } else { ParentButton.Play(); _playing = true; + MainWindow.Instance.OnAnySoundStarted(ParentButton); Content = ImageHelper.GetImage(ImageHelper.PauseButtonPath, 11, 11, Mode == ColorMode.Dark); } } @@ -563,6 +570,108 @@ public void Update() #endregion } + internal sealed class HotkeyIndicatorButton : IconButtonBase + { + public HotkeyIndicatorButton(SoundButton parentButton) : base(parentButton) + { + VerticalAlignment = VerticalAlignment.Bottom; + HorizontalAlignment = HorizontalAlignment.Left; + Padding = new Thickness(Padding.Left + 20, Padding.Top, Padding.Right, Padding.Bottom); + + SetUpStyle(); + } + + protected override void SetUpStyle() + { + Content = ImageHelper.GetImage(ImageHelper.KeyboardIconPath, 16, 16, Mode == ColorMode.Dark); + Update(); + } + + public void Update() + { + if (ParentButton.LocalHotkey != null || ParentButton.GlobalHotkey != null) + { + Visibility = Visibility.Visible; + ToolTip = string.Format(Properties.Resources.HotkeyIndicatorToolTip, ParentButton.LocalHotkey?.ToString() ?? Properties.Resources.None, ParentButton.GlobalHotkey?.ToString() ?? Properties.Resources.None); + } + else + { + Visibility = Visibility.Collapsed; + ToolTip = default; + } + } + } + + #endregion + + #region StopAllSoundsIconButton class + + internal sealed class StopAllSoundsIconButton : IconButtonBase + { + public StopAllSoundsIconButton(SoundButton parentButton) : base(parentButton) + { + VerticalAlignment = VerticalAlignment.Bottom; + HorizontalAlignment = HorizontalAlignment.Left; + Padding = new Thickness(Padding.Left + 20, Padding.Top, Padding.Right, Padding.Bottom); + Margin = new Thickness(Margin.Left, Margin.Top, Margin.Right, Margin.Bottom + 20); + ToolTip = Properties.Resources.StopAllSoundsIcon; + + SetUpStyle(); + } + + protected override void SetUpStyle() + { + Content = ImageHelper.GetImage(ImageHelper.XIconPath, 16, 16, Mode == ColorMode.Dark); + Update(); + } + + public void Update() + { + Visibility = ParentButton.StopAllSounds ? Visibility.Visible : Visibility.Collapsed; + } + } + + #endregion + + #region NextSoundIconButton class + + internal sealed class NextSoundIconButton : IconButtonBase + { + public NextSoundIconButton(SoundButton parentButton) : base(parentButton) + { + VerticalAlignment = VerticalAlignment.Bottom; + HorizontalAlignment = HorizontalAlignment.Left; + Padding = new Thickness(Padding.Left + 20, Padding.Top, Padding.Right, Padding.Bottom); + Margin = new Thickness(Margin.Left, Margin.Top, Margin.Right, Margin.Bottom + 40); + + SetUpStyle(); + } + + protected override void SetUpStyle() + { + Content = ImageHelper.GetImage(ImageHelper.RightIconPath, 16, 16, Mode == ColorMode.Dark); + Update(); + } + + public void Update() + { + Visibility = Visibility.Collapsed; + + if (!string.IsNullOrEmpty(ParentButton.NextSound)) + { + SoundButton soundButton = MainWindow.Instance.GetSoundButtons().FirstOrDefault(sb => sb.Id == ParentButton.NextSound); + if (soundButton?.HasValidSound == true) + { + Visibility = Visibility.Visible; + + ToolTip = soundButton.ParentTab != ParentButton.ParentTab + ? string.Format(Properties.Resources.NextSoundTab, soundButton.ParentTab.HeaderText, soundButton.SoundName) + : string.Format(Properties.Resources.NextSoundName, soundButton.SoundName); + } + } + } + } + #endregion #region SoundProgressBar class @@ -604,7 +713,7 @@ internal sealed class SoundButton : Button, IUndoable /// Constructor /// public SoundButton(SoundButtonMode soundButtonMode = SoundButtonMode.Normal, - MetroTabItem parentTab = null, + MyMetroTabItem parentTab = null, (MetroTabItem SourceTab, SoundButton SourceButton) sourceTabAndButton = default) { Mode = soundButtonMode; @@ -632,10 +741,58 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) { if (ContextMenu?.Items.Contains(_loopMenuItem) == true) { - _loopMenuItem.Icon = Loop ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null; + if (IsSelected) + { + bool anyNotLooped = MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).Any(sb => !sb.Loop); + _loopMenuItem.Icon = !anyNotLooped ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null; + } + else + { + _loopMenuItem.Icon = Loop ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null; + } + } + + _loopMenuItem.IsEnabled = !IsPlaying; + + if (ContextMenu?.Items.Contains(_stopAllSoundsMenuItem) == true) + { + if (IsSelected) + { + bool anyNotStopped = MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).Any(sb => !sb.StopAllSounds); + _stopAllSoundsMenuItem.Icon = !anyNotStopped ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null; + } + else + { + _stopAllSoundsMenuItem.Icon = StopAllSounds ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null; + } } - _loopMenuItem.IsEnabled = _players.All(p => p.PlaybackState != PlaybackState.Playing); + // Verify that NextSound is valid + if (!MainWindow.Instance.GetSoundButtons().Any(sb => sb.Id == NextSound)) // Do not replace !Any with All + { + NextSound = default; + } + + if (ContextMenu?.Items.Contains(_nextSoundMenuItem) == true) + { + _nextSoundMenuItem.Icon = string.IsNullOrEmpty(NextSound) ? null : ImageHelper.GetImage(ImageHelper.CheckIconPath); + } + + // Make everything visible + ContextMenu?.Items.OfType().ToList().ForEach(i => i.Visibility = Visibility.Visible); + + // If there is a multi-selection in progress and this is one of the selected buttons, + // hide things that are not multi applicable. + if (IsSelected) + { + _chooseSoundMenuItem.Visibility = Visibility.Collapsed; + _renameMenuItem.Visibility = Visibility.Collapsed; + _viewSourceMenuItem.Visibility = Visibility.Collapsed; + _hotkeysMenuItem.Visibility = Visibility.Collapsed; + _nextSoundMenuItem.Visibility = Visibility.Collapsed; + + ContextMenu?.Items.OfType().ToList().ForEach(s => s.Visibility = Visibility.Collapsed); + } } private async void RenameMenuItem_Click(object sender, RoutedEventArgs e) @@ -658,17 +815,34 @@ private async void RenameMenuItem_Click(object sender, RoutedEventArgs e) private void ClearMenuItem_Click(object sender, RoutedEventArgs e) { - SoundButtonUndoState soundButtonUndoState = SaveState(); + if (IsSelected) + { + TabPageSoundsUndoState tabPageSoundsUndoState = (MainWindow.Instance as IUndoable).SaveState(); - // Set up our UndoAction - MainWindow.Instance.SetUndoAction(() => { LoadState(soundButtonUndoState); }); + // Set up our UndoAction + MainWindow.Instance.SetUndoAction(() => { MainWindow.Instance.LoadState(tabPageSoundsUndoState); }); - // Create and show a snackbar - string message = Properties.Resources.SoundWasCleared; - string truncatedSoundName = Utilities.Truncate(SoundName, MainWindow.Instance.SnackbarMessageFont, (int)MainWindow.Instance.Width - 50, message); - MainWindow.Instance.ShowUndoSnackbar(string.Format(message, truncatedSoundName)); + // Create and show a snackbar + string message = Properties.Resources.MultipleSoundsClearedFromTab; + string truncatedTabName = Utilities.Truncate(ParentTab.HeaderText, MainWindow.Instance.SnackbarMessageFont, (int)Width - 50, message); + MainWindow.Instance.ShowUndoSnackbar(string.Format(message, truncatedTabName)); - ClearButton(); + MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).ToList().ForEach(sb => sb.ClearButton()); + } + else + { + SoundButtonUndoState soundButtonUndoState = SaveState(); + + // Set up our UndoAction + MainWindow.Instance.SetUndoAction(() => { LoadState(soundButtonUndoState); }); + + // Create and show a snackbar + string message = Properties.Resources.SoundWasCleared; + string truncatedSoundName = Utilities.Truncate(SoundName, MainWindow.Instance.SnackbarMessageFont, (int)MainWindow.Instance.Width - 50, message); + MainWindow.Instance.ShowUndoSnackbar(string.Format(message, truncatedSoundName)); + + ClearButton(); + } } private void ChooseSoundMenuItem_Click(object sender, RoutedEventArgs e) @@ -754,7 +928,14 @@ private void SetColorMenuItem_Click(object sender, RoutedEventArgs e) if (colorPickerDialog.ShowDialog() == true) { - Color = colorPickerDialog.Color; + if (IsSelected) + { + MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).ToList().ForEach(sb => sb.Color = colorPickerDialog.Color); + } + else + { + Color = colorPickerDialog.Color; + } } } @@ -762,13 +943,29 @@ private void AdjustVolumeMenuItem_SubmenuOpened(object sender, RoutedEventArgs e { _adjustVolumeMenuItem.Items.Clear(); + // See if this is a multi-selection and if so, whether all selected sounds have the same volume + int? volume = null; + bool multiSelectSameVolume = true; + foreach (var sb in MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected)) + { + if (volume == null) + { + volume = sb.VolumeOffset; + } + else if (volume != sb.VolumeOffset) + { + multiSelectSameVolume = false; + break; + } + } + for (int i = -5; i <= 5; ++i) { string header = i.ToString(@"+#;-#;0"); MenuItem volumeAdjustmentMenuItem = new MenuItem {Header = header}; - if (i == VolumeOffset) + if (i == VolumeOffset && multiSelectSameVolume) { volumeAdjustmentMenuItem.Icon = ImageHelper.GetImage(ImageHelper.CheckIconPath); } @@ -779,41 +976,205 @@ private void AdjustVolumeMenuItem_SubmenuOpened(object sender, RoutedEventArgs e } int offset = i; // Copy i so we're not accessing modified closure - volumeAdjustmentMenuItem.Click += (_, __) => { VolumeOffset = offset; }; + volumeAdjustmentMenuItem.Click += (_, __) => + { + if (IsSelected) + { + MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).ToList().ForEach(sb => sb.VolumeOffset = offset); + } + else + { + VolumeOffset = offset; + } + }; _adjustVolumeMenuItem.Items.Add(volumeAdjustmentMenuItem); } } + private void NextSoundMenuItem_SubmenuOpened(object sender, RoutedEventArgs e) + { + _nextSoundMenuItem.Items.Clear(); + + List tabs = MainWindow.Instance.Tabs.Items.OfType().ToList(); + foreach (MyMetroTabItem metroTabItem in tabs) + { + MenuItem tabMenuItem = new MenuItem { Header = metroTabItem.HeaderText.Truncate(50), IsEnabled = false }; + + IEnumerable soundButtons = MainWindow.Instance.GetSoundButtons(metroTabItem).Where(sb => sb.HasValidSound).ToList(); + if (soundButtons.Any()) + { + _nextSoundMenuItem.Items.Add(tabMenuItem); + + foreach (SoundButton soundButton in soundButtons) + { + MenuItem soundButtonMenuItem = new MenuItem + { + Header = soundButton.SoundName.Truncate(50), + ToolTip = soundButton.SoundName, + StaysOpenOnClick = true, + Tag = soundButton.Id, + Icon = NextSound == soundButton.Id ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null, + IsEnabled = soundButton != this + }; + + soundButtonMenuItem.Click += NextSoundItem_Clicked; + + _nextSoundMenuItem.Items.Add(soundButtonMenuItem); + + if (soundButton == soundButtons.Last()) + { + _nextSoundMenuItem.Items.Add(new Separator()); + } + } + } + } + + if (_nextSoundMenuItem.Items.OfType().LastOrDefault() is Separator separator) + { + _nextSoundMenuItem.Items.Remove(separator); + } + } + + private void NextSoundItem_Clicked(object sender, RoutedEventArgs e) + { + if (sender is MenuItem menuItem && Guid.TryParse(menuItem.Tag?.ToString(), out Guid guid)) + { + string id = guid.ToString(); + NextSound = NextSound == id ? default : id; + + // Recalculate everything + foreach (MenuItem otherMenuItem in _nextSoundMenuItem.Items.OfType()) + { + if (Guid.TryParse(otherMenuItem.Tag?.ToString(), out Guid otherGuid)) + { + string otherId = otherGuid.ToString(); + otherMenuItem.Icon = NextSound == otherId ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null; + } + } + + // Recalculate the main menu item + _nextSoundMenuItem.Icon = string.IsNullOrEmpty(NextSound) ? null : ImageHelper.GetImage(ImageHelper.CheckIconPath); + } + } + private void LoopMenuItem_Click(object sender, RoutedEventArgs e) { - Loop = !Loop; + if (IsSelected) + { + bool anyNotLooped = MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).Any(sb => !sb.Loop); + + MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).ToList().ForEach(sb => sb.Loop = anyNotLooped); + } + else + { + Loop = !Loop; + } + } + + private void StopAllSoundsMenuItem_Click(object sender, RoutedEventArgs e) + { + if (IsSelected) + { + bool anyNotStopped = MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).Any(sb => !sb.StopAllSounds); + + MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).ToList().ForEach(sb => sb.StopAllSounds = anyNotStopped); + } + else + { + StopAllSounds = !StopAllSounds; + } + } + + private async void HotkeysMenuItemClick(object sender, RoutedEventArgs e) + { + MainWindow.Instance.IsHotkeyPickerOpen = true; + HotkeyDialog hotkeyDialog = new HotkeyDialog(this) + { + LocalHotkey = LocalHotkey, + GlobalHotkey = GlobalHotkey + }; + await MainWindow.Instance.ShowChildWindowAsync(hotkeyDialog); + MainWindow.Instance.IsHotkeyPickerOpen = false; } #endregion #region Overrides + protected override void OnPreviewMouseDown(MouseButtonEventArgs e) + { + if (Mode == SoundButtonMode.Normal && HasValidSound) + { + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + IsSelected = !IsSelected; + + LastSelected = this; + } + else if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) + { + IsSelected = true; + + // We also want to select everything between any prior selected button and this one. + + // Make sure LastSelected is still in the collection + if (LastSelected != null && LastSelected != this && LastSelected.IsSelected) + { + var buttons = MainWindow.Instance.GetSoundButtons(ParentTab).ToList(); + if (buttons.Contains(LastSelected)) + { + int indexOfThis = buttons.IndexOf(this); + int indexOfLastSelected = buttons.IndexOf(LastSelected); + for (int i = Math.Min(indexOfThis, indexOfLastSelected); i < Math.Max(indexOfThis, indexOfLastSelected); ++i) + { + if (buttons[i].HasValidSound) + { + buttons[i].IsSelected = true; + } + } + } + } + + LastSelected = this; + } + } + } + /// protected override void OnClick() { base.OnClick(); - if (string.IsNullOrEmpty(SoundPath)) + if (Keyboard.Modifiers.HasFlag(ModifierKeys.Control) || Keyboard.Modifiers.HasFlag(ModifierKeys.Shift)) { - // If this button doesn't have a sound yet, browse for it now - BrowseForSound(); + // Don't play the sound. This will be handled by OnPreviewMouseDown. } else { - if (Mode == SoundButtonMode.Normal) + if (string.IsNullOrEmpty(SoundPath)) { - StartSound(); + // If this button doesn't have a sound yet, browse for it now + BrowseForSound(); } - else if (Mode == SoundButtonMode.Search && - SourceTabAndButton.SourceButton is SoundButton sourceButton) + else { - sourceButton.StartSound(); + if (Mode == SoundButtonMode.Normal) + { + if (IsSelected) + { + MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).ToList().ForEach(sb => sb.StartSound()); + } + else + { + StartSound(); + } + } + else if (Mode == SoundButtonMode.Search && + SourceTabAndButton.SourceButton is SoundButton sourceButton) + { + sourceButton.StartSound(); + } } } } @@ -849,6 +1210,8 @@ protected override void OnMouseMove(MouseEventArgs e) Utilities.PointsArePastThreshold((Point)_mouseDownPosition, Mouse.GetPosition(this)) && Mode != SoundButtonMode.Search) { + MainWindow.Instance.GetSoundButtons(ParentTab).Where(sb => sb.IsSelected).ToList().ForEach(sb => sb.IsSelected = false); + _mouseDownPosition = Mouse.GetPosition(this); DragDrop.DoDragDrop(this, new SoundDragData(this), DragDropEffects.Link); } @@ -910,18 +1273,7 @@ protected override void OnDrop(DragEventArgs e) { // Get the dropped file(s) string[] files = (string[])e.Data.GetData(DataFormats.FileDrop); - - // Only care about the first file - string file = files?[0]; - - if (string.IsNullOrEmpty(file) == false) - { - // Stop any current playback - Stop(); - - // Set it - SetFile(file); - } + LoadFiles(files); } else { @@ -948,6 +1300,13 @@ protected override void OnDrop(DragEventArgs e) e.Handled = true; } + /// + public override string ToString() + { + // For debugging + return $"{ParentTab?.HeaderText} - {SoundName}"; + } + #endregion #region Public methods @@ -1031,6 +1390,14 @@ public async void StartSound() _players.Clear(); + if (StopAllSounds) + { + foreach (IWavePlayer player in MainWindow.Instance.SoundPlayers) + { + player.Stop(); + } + } + // Reinitialize the player bool addedDefaultDevice = false; GlobalSettings.GetOutputDeviceGuids().ForEach(d => @@ -1100,6 +1467,10 @@ public async void StartSound() // Aaaaand play Parallel.ForEach(_players, p => p.Play()); + CalculateTextMargin(); + + MainWindow.Instance.OnAnySoundStarted(this); + // Begin updating progress bar _progressBarCancellationToken?.Cancel(); _progressBarCancellationToken?.Dispose(); @@ -1125,16 +1496,13 @@ public bool BrowseForSound(string initialFileName = "") { // Set file type filters FileName = initialFileName, - Filter = $@"{Properties.Resources.AudioVideoFiles}|{Utilities.SupportedAudioFileTypes}|All files|*.*" + Filter = $@"{Properties.Resources.AudioVideoFiles}|{Utilities.SupportedAudioFileTypes}|All files|*.*", + Multiselect = true }; if (dialog.ShowDialog() == true) { - // Stop any current playback - Stop(); - - SetFile(dialog.FileName); - + LoadFiles(dialog.FileNames); return true; } @@ -1278,6 +1646,134 @@ public int GetColumn() return Grid.GetColumn(this); } + public void UnregisterLocalHotkey() + { + foreach (var existingLocalHotKey in MainWindow.Instance.HotKeyManager?.EnumerateLocalHotKeys.OfType() ?? Enumerable.Empty()) + { + if (existingLocalHotKey.Name == Utilities.SanitizeId(Id)) + { + try + { + MainWindow.Instance.HotKeyManager.RemoveLocalHotKey(existingLocalHotKey); + } + catch + { + // Swallow + } + + break; + } + } + } + + public void UnregisterGlobalHotkey() + { + foreach (var existingGlobalHotKey in MainWindow.Instance.HotKeyManager?.EnumerateGlobalHotKeys.OfType() ?? Enumerable.Empty()) + { + if (existingGlobalHotKey.Name == Utilities.SanitizeId(Id)) + { + try + { + MainWindow.Instance.HotKeyManager.RemoveGlobalHotKey(existingGlobalHotKey); + } + catch + { + // Swallow + } + + break; + } + } + } + + /// + /// Always wrap this in a try/catch + /// + public void ReregisterLocalHotkey() + { + if (LocalHotkey != null) + { + Keys mappedKey = Utilities.MapKey(LocalHotkey.Key); + + // The mapping failed + if (mappedKey == default) + { + throw new Exception(); + } + + LocalHotKey localHotKey = new LocalHotKey(Utilities.SanitizeId(Id), LocalHotkey.Modifiers, mappedKey, RaiseLocalEvent.OnKeyUp, true); + MainWindow.Instance.HotKeyManager?.AddLocalHotKey(localHotKey); + } + } + + /// + /// Always wrap this in a try/catch + /// + public void ReregisterGlobalHotkey() + { + if (GlobalHotkey != null) + { + Keys mappedKey = Utilities.MapKey(GlobalHotkey.Key); + + // The mapping failed + if (mappedKey == default) + { + throw new Exception(); + } + + GlobalHotKey globalHotKey = new GlobalHotKey(Utilities.SanitizeId(Id), GlobalHotkey.Modifiers, mappedKey, true); + MainWindow.Instance.HotKeyManager?.AddGlobalHotKey(globalHotKey); + } + } + + public void CalculateTextMargin() + { + if (_viewboxPanel != null && _textBlock != null) + { + if (AreTransportControlsVisible // We only shrink when playing + && _viewboxPanel.ActualHeight - _textBlock.ActualHeight < 50 // There's not enough room to comfortable display everything + && _targetViewboxMarginBottom < 30) // We haven't done this yet + { + _textMarginStoryboard.Stop(); + _textMarginStoryboard.Children.Clear(); + + ThicknessAnimation animation = new ThicknessAnimation + { + From = new Thickness(30, 0, 30, _viewboxPanel.Margin.Bottom), + To = new Thickness(30, 0, 30, 30), + Duration = TimeSpan.FromSeconds(.1) + }; + + _targetViewboxMarginBottom = 30; + + Storyboard.SetTarget(animation, _viewboxPanel); + Storyboard.SetTargetProperty(animation, new PropertyPath(MarginProperty)); + _textMarginStoryboard.Children.Add(animation); + _textMarginStoryboard.Begin(); + } + else if (!AreTransportControlsVisible // Always reset when not playing + || (_targetViewboxMarginBottom > 0 && ((_viewboxPanel.ActualHeight + _targetViewboxMarginBottom) - _textBlock.ActualHeight) >= 50)) // The bottom margin is set, but the height that it would be without it set is sufficient + { + _textMarginStoryboard.Stop(); + _textMarginStoryboard.Children.Clear(); + + ThicknessAnimation animation = new ThicknessAnimation + { + From = new Thickness(30, 0, 30, _viewboxPanel.Margin.Bottom), + To = new Thickness(30, 0, 30, 0), + Duration = TimeSpan.FromSeconds(.1) + }; + + _targetViewboxMarginBottom = 0; + + Storyboard.SetTarget(animation, _viewboxPanel); + Storyboard.SetTargetProperty(animation, new PropertyPath(MarginProperty)); + _textMarginStoryboard.Children.Add(animation); + _textMarginStoryboard.Begin(); + } + } + } + #endregion #region Private methods @@ -1290,6 +1786,17 @@ private void SetDefaultText() Color = null; VolumeOffset = 0; Loop = false; + StopAllSounds = false; + LocalHotkey = null; + GlobalHotkey = null; + NextSound = default; + + // Clear any hotkeys + UnregisterLocalHotkey(); + UnregisterGlobalHotkey(); + + Id = Guid.NewGuid().ToString(); + IsSelected = false; SetUpStyle(); SetUpContextMenu(); @@ -1391,10 +1898,15 @@ private void SetUpContextMenu() _loopMenuItem.Click += LoopMenuItem_Click; } + if (_stopAllSoundsMenuItem is null) + { + _stopAllSoundsMenuItem = new MenuItem { Header = Properties.Resources.StopAllSounds, ToolTip = Properties.Resources.StopAllSoundsToolTip }; + _stopAllSoundsMenuItem.Click += StopAllSoundsMenuItem_Click; + } + if (_adjustVolumeMenuItem is null) { _adjustVolumeMenuItem = new MenuItem {Header = Properties.Resources.AdjustVolume}; - _adjustVolumeMenuItem.SetSeparator(true); // Add a dummy item so that this item becomes a parent with a sub-menu // The real items will be populated every time at run-time in the SubmenuOpened handler @@ -1403,6 +1915,20 @@ private void SetUpContextMenu() _adjustVolumeMenuItem.SubmenuOpened += AdjustVolumeMenuItem_SubmenuOpened; } + if (_hotkeysMenuItem is null) + { + _hotkeysMenuItem = new MenuItem { Header = Properties.Resources.SetHotkeys }; + _hotkeysMenuItem.Click += HotkeysMenuItemClick; + } + + if (_nextSoundMenuItem is null) + { + _nextSoundMenuItem = new MenuItem { Header = Properties.Resources.NextSound }; + _nextSoundMenuItem.Items.Add(new MenuItem()); // Placeholder for submenu + _nextSoundMenuItem.SetSeparator(true); + _nextSoundMenuItem.SubmenuOpened += NextSoundMenuItem_SubmenuOpened; + } + // If the "Source" menu item is null, create it and hook up its handler if (_viewSourceMenuItem is null) { @@ -1452,10 +1978,25 @@ private void SetUpContextMenu() ContextMenu.Items.Add(_loopMenuItem); } + if (ContextMenu.Items.Contains(_stopAllSoundsMenuItem) == false) + { + ContextMenu.Items.Add(_stopAllSoundsMenuItem); + } + if (ContextMenu.Items.Contains(_adjustVolumeMenuItem) == false) { ContextMenu.Items.Add(_adjustVolumeMenuItem); } + + if (ContextMenu.Items.Contains(_hotkeysMenuItem) == false) + { + ContextMenu.Items.Add(_hotkeysMenuItem); + } + + if (ContextMenu.Items.Contains(_nextSoundMenuItem) == false) + { + ContextMenu.Items.Add(_nextSoundMenuItem); + } } } else if (Mode == SoundButtonMode.Search) @@ -1499,6 +2040,11 @@ private void SetUpContextMenu() ContextMenu.Items.Remove(_loopMenuItem); } + if (ContextMenu.Items.Contains(_stopAllSoundsMenuItem)) + { + ContextMenu.Items.Remove(_stopAllSoundsMenuItem); + } + if (ContextMenu.Items.Contains(_adjustVolumeMenuItem)) { ContextMenu.Items.Remove(_adjustVolumeMenuItem); @@ -1508,6 +2054,16 @@ private void SetUpContextMenu() { ContextMenu.Items.Remove(_viewSourceMenuItem); } + + if (ContextMenu.Items.Contains(_hotkeysMenuItem)) + { + ContextMenu.Items.Remove(_hotkeysMenuItem); + } + + if (ContextMenu.Items.Contains(_nextSoundMenuItem)) + { + ContextMenu.Items.Remove(_nextSoundMenuItem); + } } ContextMenu.AddSeparators(); @@ -1557,6 +2113,18 @@ private void SetUpStyle() style.Triggers.Add(trigger); } + // Add focused colors + if (Mode == SoundButtonMode.Search) + { + Trigger focusTrigger = new Trigger { Property = IsFocusedProperty, Value = true }; + focusTrigger.Setters.Add(new Setter(BorderThicknessProperty, new Thickness(5))); + focusTrigger.Setters.Add(new Setter(BorderBrushProperty, new SolidColorBrush(Colors.SlateGray))); + style.Triggers.Add(focusTrigger); + } + + // Don't show the ugly dotted line around focused elements + FocusVisualStyle = null; + // Assign the style! Style = style; @@ -1581,11 +2149,13 @@ private void HandleSoundStopped(IWavePlayer player) { _progressBarCancellationToken?.Cancel(); + // Indicates that the sound finished playing on its own + bool finished = false; + if (_audioFileReaders.TryGetValue(player, out var audioFileReader) && audioFileReader != null) { - { - audioFileReader.Position = 0; - } + finished = audioFileReader.Position >= audioFileReader.Length; + audioFileReader.Position = 0; } // Hide the additional buttons @@ -1595,26 +2165,42 @@ private void HandleSoundStopped(IWavePlayer player) { hideableButton.Hide(); } + + CalculateTextMargin(); + + MainWindow.Instance.OnAnySoundStopped(this); + + if (finished && !string.IsNullOrEmpty(NextSound) + && MainWindow.Instance.GetSoundButtons().Where(sb => sb.HasValidSound).FirstOrDefault(sb => sb.Id == NextSound) is SoundButton nextSoundButton) + { + // If the next sound isn't on the current tab, focus that tab. + if (nextSoundButton.ParentTab != MainWindow.Instance.SelectedTab) + { + nextSoundButton.ParentTab.Focus(); + } + + nextSoundButton.StartSound(); + } } private void SetContent(string text) { if (Mode == SoundButtonMode.Normal) { - TextBlock textBlock = new TextBlock + _textBlock = new TextBlock { Text = text, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap }; - ViewboxPanel viewboxPanel = new ViewboxPanel + _viewboxPanel = new ViewboxPanel { - Margin = new Thickness(30) + Margin = new Thickness(30, 0, 30, 0) }; - viewboxPanel.Children.Add(textBlock); + _viewboxPanel.Children.Add(_textBlock); - Content = viewboxPanel; + Content = _viewboxPanel; } else if (Mode == SoundButtonMode.Search) { @@ -1635,11 +2221,93 @@ private void SetContent(string text) } } + private void LoadFiles(params string[] files) + { + List multiFileDrop = new List(); + + if (files?.Length > 1) + { + multiFileDrop.AddRange(files); + } + else if (!string.IsNullOrEmpty(files?[0]) && Directory.Exists(files[0])) + { + multiFileDrop.AddRange(Directory.GetFiles(files[0])); + } + + if (multiFileDrop.Any()) + { + // This is a multi-file drop! + + // Since this is a big operation, make it undoable + ConfigUndoState configUndoState = (MainWindow.Instance as IUndoable).SaveState(); + MainWindow.Instance.SetUndoAction(() => { MainWindow.Instance.LoadState(configUndoState); }); + + // Set our grid size to exactly match the number + int rows = ParentTab.GetRows(); + int columns = ParentTab.GetColumns(); + bool? lastOperation = false; // False means added column, true means added first row, null means added second row + while (rows * columns < multiFileDrop.Count) + { + if (lastOperation == false) + { + ++rows; + lastOperation = true; + } + else if (lastOperation == true) + { + ++rows; + lastOperation = null; + } + else if (lastOperation == null) + { + ++columns; + lastOperation = false; + } + } + + // Get starting index before potentially changing grid, since that recreates all buttons + var startingIndex = MainWindow.Instance.GetSoundButtons(ParentTab).ToList().IndexOf(this); + + if (rows != ParentTab.GetRows() || columns != ParentTab.GetColumns()) + { + MainWindow.Instance.ChangeButtonGrid(rows, columns); + } + + // Start populating the buttons + var buttons = MainWindow.Instance.GetSoundButtons(MainWindow.Instance.SelectedTab).ToList(); + buttons = buttons.GetRange(startingIndex, buttons.Count - startingIndex).Concat(buttons.GetRange(0, startingIndex)).ToList(); + for (int i = 0; i < multiFileDrop.Count; ++i) + { + buttons[i].Stop(); + buttons[i].SetFile(multiFileDrop[i]); + } + + // Finally, make it undoable + string message = Properties.Resources.MultipleSoundsAdded; + string truncatedMessage = Utilities.Truncate(message, MainWindow.Instance.SnackbarMessageFont, (int)Width - 50); + MainWindow.Instance.ShowUndoSnackbar(truncatedMessage); + } + else + { + // Only care about the first file + string file = files?[0]; + + if (string.IsNullOrEmpty(file) == false) + { + // Stop any current playback + Stop(); + + // Set it + SetFile(file); + } + } + } + #endregion #region Private properties - private bool HasValidSound => string.IsNullOrEmpty(SoundPath) == false; + internal bool HasValidSound => string.IsNullOrEmpty(SoundPath) == false; internal SoundButtonStyle SoundButtonStyle { @@ -1651,7 +2319,7 @@ internal SoundButtonStyle SoundButtonStyle if (HasValidSound == false) { // Not a valid sound yet, use a "placeholder" color - soundButtonStyle.ForegroundColor = Colors.Gray; + soundButtonStyle.ForegroundColor = System.Windows.Media.Color.FromRgb(168, 168, 168); } else { @@ -1721,7 +2389,16 @@ private set /// /// Defines the name of the sound file as displayed on the button /// - public string SoundName { get; private set; } = string.Empty; + public string SoundName + { + get => _soundName; + private set + { + _soundName = value; + MainWindow.Instance.OnAnySoundRenamed(); + } + } + private string _soundName; /// /// Defines the background color of the button @@ -1771,6 +2448,41 @@ private set private bool _loop; // Backing field + public bool StopAllSounds + { + get => _stopAllSounds; + set + { + _stopAllSounds = value; + ChildButtons.OfType().FirstOrDefault()?.Update(); + } + } + private bool _stopAllSounds; + + public string Id { get; set; } + + public Hotkey LocalHotkey + { + get => _localHotkey; + set + { + _localHotkey = value; + ChildButtons.OfType().FirstOrDefault()?.Update(); + } + } + private Hotkey _localHotkey; + + public Hotkey GlobalHotkey + { + get => _globalHotkey; + set + { + _globalHotkey = value; + ChildButtons.OfType().FirstOrDefault()?.Update(); + } + } + private Hotkey _globalHotkey; + /// /// Contains a list of child buttons /// @@ -1785,7 +2497,49 @@ private set /// /// Specifies the on which this sound lives. Will be null when in . /// - public MetroTabItem ParentTab { get; } + public MyMetroTabItem ParentTab { get; } + + public bool IsSelected + { + get => _isSelected; + set + { + _isSelected = value; + + if (_isSelected) + { + BorderThickness = new Thickness(5); + BorderBrush = new SolidColorBrush(Colors.SlateGray); + } + else + { + BorderThickness = new Thickness(2); + BorderBrush = new SolidColorBrush(Colors.Black); + } + } + } + private bool _isSelected; + + public bool IsPlaying => !_players.All(p => p.PlaybackState != PlaybackState.Playing); // Do not let ReSharper refactor this as !All is different than Any + + public bool AreTransportControlsVisible => ChildButtons.OfType().Where(b => b.ShowHideAutomatically).Any(b => b.Visibility == Visibility.Visible); + + public string NextSound + { + get => _nextSound; + set + { + _nextSound = value; + ChildButtons.OfType().FirstOrDefault()?.Update(); + } + } + private string _nextSound; + + #endregion + + #region Public static properties + + public static SoundButton LastSelected { get; set; } #endregion @@ -1809,11 +2563,20 @@ private set private MenuItem _setColorMenuItem; private MenuItem _adjustVolumeMenuItem; private MenuItem _loopMenuItem; + private MenuItem _stopAllSoundsMenuItem; + private MenuItem _hotkeysMenuItem; + private MenuItem _nextSoundMenuItem; private Point? _mouseDownPosition; private CancellationTokenSource _progressBarCancellationToken; + // Related to text resizing + private ViewboxPanel _viewboxPanel; + private int _targetViewboxMarginBottom; + private TextBlock _textBlock; + private readonly Storyboard _textMarginStoryboard = new Storyboard(); + #endregion #region Private consts @@ -1836,7 +2599,12 @@ public SoundButtonUndoState SaveState() SoundName = SoundName, Color = Color, VolumeOffset = VolumeOffset, - Loop = Loop + Loop = Loop, + StopAllSounds = StopAllSounds, + NextSound = NextSound, + Id = Id, + LocalHotkey = LocalHotkey, + GlobalHotkey = GlobalHotkey }; } @@ -1844,11 +2612,39 @@ public void LoadState(SoundButtonUndoState undoState) { if (string.IsNullOrEmpty(undoState.SoundPath) == false) { + if (!string.IsNullOrEmpty(undoState.Id)) + { + Id = undoState.Id; + } + SetFile(undoState.SoundPath); SetContent(SoundName = undoState.SoundName); Color = undoState.Color; VolumeOffset = undoState.VolumeOffset; Loop = undoState.Loop; + StopAllSounds = undoState.StopAllSounds; + NextSound = undoState.NextSound; + + LocalHotkey = undoState.LocalHotkey; + GlobalHotkey = undoState.GlobalHotkey; + + try + { + ReregisterLocalHotkey(); + } + catch + { + // Swallow + } + + try + { + ReregisterGlobalHotkey(); + } + catch + { + // Swallow + } } else { diff --git a/SoundBoard/Extensions.cs b/SoundBoard/Extensions.cs index a2e4b61..bd7ac35 100644 --- a/SoundBoard/Extensions.cs +++ b/SoundBoard/Extensions.cs @@ -143,7 +143,8 @@ public static int GetRows(this TabItem tabItem) { return result; } - else return GetDefaultRows(tabItem); + + return GlobalSettings.NewPageDefaultRows; } /// @@ -156,15 +157,6 @@ public static void SetRows(this TabItem tabItem, int value) _rows[tabItem] = value; } - /// - /// Get the default value for the Rows property - /// - /// - public static int GetDefaultRows(this TabItem tabItem) - { - return 5; - } - private static readonly Dictionary _rows = new Dictionary(); #endregion @@ -182,7 +174,8 @@ public static int GetColumns(this TabItem tabItem) { return result; } - else return GetDefaultColumns(tabItem); + + return GlobalSettings.NewPageDefaultColumns; } /// @@ -195,16 +188,6 @@ public static void SetColumns(this TabItem tabItem, int value) _columns[tabItem] = value; } - /// - /// Get the default value for the Columns property - /// - /// - /// - public static int GetDefaultColumns(this TabItem tabItem) - { - return 2; - } - private static readonly Dictionary _columns = new Dictionary(); #endregion diff --git a/SoundBoard/GlobalSettings.cs b/SoundBoard/GlobalSettings.cs index e48b8a0..717b903 100644 --- a/SoundBoard/GlobalSettings.cs +++ b/SoundBoard/GlobalSettings.cs @@ -39,6 +39,7 @@ public static class GlobalSettings /// but there is no property with that name any more. /// public static string OutputDeviceGuidSettingName = "OutputDeviceGuid"; + /// /// Defines the ID(s) of the audio output device(s) to use when playing sounds /// @@ -48,5 +49,57 @@ public static class GlobalSettings private static HashSet OutputDeviceGuids { get; } = new HashSet(); #endregion + + #region Input device + + /// + /// Add an input device to the current list + /// + public static void AddInputDeviceGuid(Guid guid) => InputDeviceGuids.Add(guid); + + /// + /// Remove an input device from the current list + /// + public static void RemoveInputDeviceGuid(Guid guid) => InputDeviceGuids.Remove(guid); + + /// + /// Removes all current input devices + /// + public static void RemoveAllInputDeviceGuids() => InputDeviceGuids.Clear(); + + /// + /// Get the current list of input devices + /// + public static List GetInputDeviceGuids() => (InputDeviceGuids.Any() ? InputDeviceGuids : Enumerable.Empty()).ToList(); + + /// + /// The name of the InputDeviceGuid setting name. + /// + public static string InputDeviceGuidSettingName = "InputDeviceGuid"; + + /// + /// Defines the ID(s) of the audio input device(s) to use when playing sounds + /// + /// + /// HashSet to prevent duplicate GUIDs. + /// + private static HashSet InputDeviceGuids { get; } = new HashSet(); + + /// + /// The number of button columns to use by default for new pages + /// + public static int NewPageDefaultColumns { get; set; } = 2; + + /// + /// The number of button rows to use by default for new pages + /// + public static int NewPageDefaultRows { get; set; } = 5; + + #endregion + + /// + /// The latency to use when chaining input to outputs + /// + public static int AudioPassthroughLatency { get; set; } = 10; } } diff --git a/SoundBoard/HotkeyDialog.xaml b/SoundBoard/HotkeyDialog.xaml new file mode 100644 index 0000000..84786f1 --- /dev/null +++ b/SoundBoard/HotkeyDialog.xaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + private void LoadSettings(string configFilePath) { + if (!File.Exists(configFilePath) && Tabs.Items.Count == 1) + { + // Populate content for "welcome" + CreateHelpContent((MyMetroTabItem)Tabs.Items[0]); + return; + } + + // If we get here, we can remove the default tab. + if (Tabs.Items.Count == 1) + { + Tabs.Items.RemoveAt(0); + } + try { // try to load settings @@ -198,6 +361,35 @@ private void LoadSettings(string configFilePath) } }); } + + if (globalSettings.Attributes?[GlobalSettings.InputDeviceGuidSettingName] is XmlAttribute inputDeviceGuidAttribute) + { + inputDeviceGuidAttribute.Value.Split(',').ToList().ForEach(guid => + { + if (Guid.TryParse(guid, out var inputDeviceGuid)) + { + GlobalSettings.AddInputDeviceGuid(inputDeviceGuid); + } + }); + } + + if (globalSettings.Attributes?[nameof(GlobalSettings.AudioPassthroughLatency)] is XmlAttribute audioPassthroughLatencyAttribute + && int.TryParse(audioPassthroughLatencyAttribute.Value, out int audioPassthroughLatency)) + { + GlobalSettings.AudioPassthroughLatency = audioPassthroughLatency; + } + + if (globalSettings.Attributes?[nameof(GlobalSettings.NewPageDefaultRows)] is XmlAttribute newPageDefaultRowsAttribute + && int.TryParse(newPageDefaultRowsAttribute.Value, out int newPageDefaultRows)) + { + GlobalSettings.NewPageDefaultRows = newPageDefaultRows; + } + + if (globalSettings.Attributes?[nameof(GlobalSettings.NewPageDefaultColumns)] is XmlAttribute newPageDefaultColumnsAttribute + && int.TryParse(newPageDefaultColumnsAttribute.Value, out int newPageDefaultColumns)) + { + GlobalSettings.NewPageDefaultColumns = newPageDefaultColumns; + } } // Get tabs @@ -214,7 +406,7 @@ private void LoadSettings(string configFilePath) { string name = node["name"]?.InnerText; - MetroTabItem tab = new MyMetroTabItem {Header = name}; + MyMetroTabItem tab = new MyMetroTabItem {HeaderText = name}; Tabs.Items.Add(tab); if (node.Attributes?["focused"]?.Value is string focusedString && @@ -267,6 +459,36 @@ private void LoadSettings(string configFilePath) soundButtonUndoState.Loop = loop; } + if (node["button" + i]?.Attributes["stopAllSounds"]?.Value is string stopAllSoundsString && + string.IsNullOrEmpty(stopAllSoundsString) == false && bool.TryParse(stopAllSoundsString, out bool stopAllSounds)) + { + soundButtonUndoState.StopAllSounds = stopAllSounds; + } + + if (node["button" + i]?.Attributes["nextSound"]?.Value is string nextSound && + string.IsNullOrEmpty(nextSound) == false) + { + soundButtonUndoState.NextSound = nextSound; + } + + if (node["button" + i]?.Attributes["id"]?.Value is string id && + string.IsNullOrEmpty(id) == false) + { + soundButtonUndoState.Id = id; + } + + if (node["button" + i]?.Attributes["localHotkey"]?.Value is string localHotKeyStr + && !string.IsNullOrEmpty(localHotKeyStr)) + { + soundButtonUndoState.LocalHotkey = Hotkey.FromString(localHotKeyStr); + } + + if (node["button" + i]?.Attributes["globalHotkey"]?.Value is string globalHotKeyStr + && !string.IsNullOrEmpty(globalHotKeyStr)) + { + soundButtonUndoState.GlobalHotkey = Hotkey.FromString(globalHotKeyStr); + } + int buttonRow = rowIndex; if (node["button" + i]?.Attributes["row"]?.Value is string rowString && string.IsNullOrEmpty(rowString) == false && int.TryParse(rowString, out int row)) @@ -297,11 +519,35 @@ private void LoadSettings(string configFilePath) } } } - // If anything failed, add the startup page - catch + catch (Exception ex) { - // Populate content for "welcome" - CreateHelpContent((MetroTabItem)Tabs.Items[0]); + // Immediately back up the config + File.Copy(ConfigFilePath, TempConfigFilePath, overwrite: true); + + // Do better error handling + Dispatcher.Invoke(async () => + { + var res = await this.ShowMessageAsync(Properties.Resources.Error, + string.Join(Environment.NewLine, string.Format(Properties.Resources.ConfigLoadError, TempConfigFilePath), string.Empty, ex.Message), + MessageDialogStyle.AffirmativeAndNegative, new MetroDialogSettings + { + AffirmativeButtonText = Properties.Resources.CopyDetails, + NegativeButtonText = Properties.Resources.OK + }); + + if (res == MessageDialogResult.Affirmative) + { + Clipboard.SetText(string.Join(Environment.NewLine, TempConfigFilePath, string.Empty, ex.ToString())); + } + }); + } + + // If there are no tabs after we load, show the help screen + if (Tabs.Items.Count == 0) + { + ButtonAutomationPeer peer = new ButtonAutomationPeer(Help); + IInvokeProvider invokeProv = peer.GetPattern(PatternInterface.Invoke) as IInvokeProvider; + invokeProv?.Invoke(); } } @@ -356,7 +602,7 @@ private void CreateTabContextMenus() } } - private void CreatePageContent(MetroTabItem tab, TwoDimensionalList buttons = null) + private void CreatePageContent(MyMetroTabItem tab, TwoDimensionalList buttons = null) { Grid parentGrid = new Grid(); @@ -375,9 +621,9 @@ private void CreatePageContent(MetroTabItem tab, TwoDimensionalList textWriter.WriteAttributeString(GlobalSettings.OutputDeviceGuidSettingName, string.Join(@",", GlobalSettings.GetOutputDeviceGuids())); + textWriter.WriteAttributeString(GlobalSettings.InputDeviceGuidSettingName, string.Join(@",", GlobalSettings.GetInputDeviceGuids())); + textWriter.WriteAttributeString(nameof(GlobalSettings.AudioPassthroughLatency), GlobalSettings.AudioPassthroughLatency.ToString()); + textWriter.WriteAttributeString(nameof(GlobalSettings.NewPageDefaultRows), GlobalSettings.NewPageDefaultRows.ToString()); + textWriter.WriteAttributeString(nameof(GlobalSettings.NewPageDefaultColumns), GlobalSettings.NewPageDefaultColumns.ToString()); textWriter.WriteEndElement(); // - foreach (MetroTabItem tab in Tabs.Items.OfType()) + foreach (MyMetroTabItem tab in Tabs.Items.OfType()) { if (tab.Content is Grid grid) { - string name = tab.Header.ToString(); + string name = tab.HeaderText; textWriter.WriteStartElement("tab"); textWriter.WriteAttributeString("focused", tab.IsSelectedItem().ToString()); textWriter.WriteAttributeString("rows", tab.GetRows().ToString()); @@ -594,6 +872,11 @@ private void SaveSettings(string configFilePath) textWriter.WriteAttributeString("color", button.Color.ToString()); textWriter.WriteAttributeString("volumeOffset", button.VolumeOffset.ToString()); textWriter.WriteAttributeString("loop", button.Loop.ToString()); + textWriter.WriteAttributeString("stopAllSounds", button.StopAllSounds.ToString()); + textWriter.WriteAttributeString("nextSound", button.NextSound); + textWriter.WriteAttributeString("id", button.Id); + textWriter.WriteAttributeString("localHotkey", button.LocalHotkey?.ToString() ?? string.Empty); + textWriter.WriteAttributeString("globalHotkey", button.GlobalHotkey?.ToString() ?? string.Empty); textWriter.WriteAttributeString("row", button.GetRow().ToString()); textWriter.WriteAttributeString("column", button.GetColumn().ToString()); textWriter.WriteEndElement(); @@ -698,7 +981,7 @@ private void RemoveMenuItem_Click(object sender, EventArgs e) private void ClearAllSoundsMenuItem_Click(object sender, RoutedEventArgs e) { - if (Tabs.SelectedItem is MetroTabItem metroTabItem) + if (Tabs.SelectedItem is MyMetroTabItem metroTabItem) { TabPageSoundsUndoState tabPageSoundsUndoState = (this as IUndoable).SaveState(); @@ -707,7 +990,7 @@ private void ClearAllSoundsMenuItem_Click(object sender, RoutedEventArgs e) // Create and show a snackbar string message = Properties.Resources.AllSoundsClearedFromTab; - string truncatedTabName = Utilities.Truncate(metroTabItem.Header.ToString(), SnackbarMessageFont, (int)Width - 50, message); + string truncatedTabName = Utilities.Truncate(metroTabItem.HeaderText, SnackbarMessageFont, (int)Width - 50, message); ShowUndoSnackbar(string.Format(message, truncatedTabName)); foreach (SoundButton soundButton in GetSoundButtons(metroTabItem)) @@ -734,31 +1017,41 @@ private async void ChangeButtonGridMenuItem_Click(object sender, EventArgs e) if (proceed) { - using (new WaitCursor()) - { - // Stop all sounds - foreach (SoundButton soundButton in GetSoundButtons()) - { - soundButton.Stop(); - } + ChangeButtonGrid(buttonGridDialog.RowCount, buttonGridDialog.ColumnCount); + } + } + } - ConfigUndoState configUndoState = (this as IUndoable).SaveState(); + /// + /// Change the button grid + /// + public void ChangeButtonGrid(int rowCount, int columnCount) + { + using (new WaitCursor()) + { + // Stop all sounds + foreach (SoundButton soundButton in GetSoundButtons()) + { + soundButton.Stop(); + soundButton.UnregisterLocalHotkey(); + soundButton.UnregisterGlobalHotkey(); + } - // Set up our UndoAction - SetUndoAction(() => { LoadState(configUndoState); }); + ConfigUndoState configUndoState = (this as IUndoable).SaveState(); - // Create and show a snackbar - string message = Properties.Resources.ButtonLayoutWasChanged; - string truncatedMessage = Utilities.Truncate(message, SnackbarMessageFont, (int)Width - 50); - ShowUndoSnackbar(truncatedMessage); + // Set up our UndoAction + SetUndoAction(() => { LoadState(configUndoState); }); - // Do the change - SelectedTab.SetRows(buttonGridDialog.RowCount); - SelectedTab.SetColumns(buttonGridDialog.ColumnCount); - SaveSettings(); - LoadSettings(); - } - } + // Create and show a snackbar + string message = Properties.Resources.ButtonLayoutWasChanged; + string truncatedMessage = Utilities.Truncate(message, SnackbarMessageFont, (int)Width - 50); + ShowUndoSnackbar(truncatedMessage); + + // Do the change + SelectedTab.SetRows(rowCount); + SelectedTab.SetColumns(columnCount); + SaveSettings(); + LoadSettings(); } } @@ -770,6 +1063,12 @@ private void RoutedKeyDownHandler(object sender, RoutedEventArgs args) { Mouse.Capture(null); + if (e.Key == Key.A && Keyboard.Modifiers.HasFlag(ModifierKeys.Control)) + { + GetSoundButtons(SelectedTab).Where(sb => sb.HasValidSound).ToList().ForEach(sb => sb.IsSelected = true); + return; + } + char c = GetCharFromKey(e.Key); if (char.IsLetter(c) || char.IsPunctuation(c) || char.IsNumber(c)) { @@ -790,6 +1089,11 @@ private void RoutedKeyDownHandler(object sender, RoutedEventArgs args) { CloseSearch(); } + // If there are any selected sounds, unselect them + else if (GetSoundButtons().Any(sb => sb.IsSelected)) + { + GetSoundButtons().ToList().ForEach(sb => sb.IsSelected = false); + } // Otherwise, stop any playing sounds else { @@ -801,65 +1105,14 @@ private void RoutedKeyDownHandler(object sender, RoutedEventArgs args) } else if (e.Key == Key.Down) { - bool foundFocused = false; - - // Loop through the buttons and focus the next one - foreach (var child in ResultsPanel.Children) - { - if (child is SoundButton soundButton) - { - if (foundFocused) - { - // We found the last focused button, so focus this one - soundButton.Focus(); - _focusedButton = soundButton; - break; - } - if (soundButton.IsFocused) - { - // We found the focused button! focus the next one - foundFocused = true; - } - } - } - return; - } - else if (e.Key == Key.Up) - { - SoundButton previousButton = null; - - // Loop through the buttons and focus the previous one - foreach (var child in ResultsPanel.Children) - { - if (child is SoundButton soundButton) - { - if (soundButton.IsFocused) - { - // Focus the previous one! - if (previousButton != null) - { - previousButton.Focus(); - _focusedButton = previousButton; - break; - } - } - previousButton = soundButton; - } - } - return; - } - else if (e.Key == Key.Enter) - { - // Play the sound! - foreach (var child in ResultsPanel.Children) + // If the texbox is focused, we want to focus the first button. + // Then we'll let Windows handle the navigation and pressing + + if (Query.IsFocused) { - if (child is SoundButton soundButton && soundButton.IsFocused) - { - ButtonAutomationPeer peer = new ButtonAutomationPeer(soundButton); - IInvokeProvider invokeProv = peer.GetPattern(PatternInterface.Invoke) as IInvokeProvider; - invokeProv?.Invoke(); - } + ResultsPanel.Children.OfType().FirstOrDefault()?.Focus(); } + return; } else @@ -898,8 +1151,7 @@ private void RoutedKeyDownHandler(object sender, RoutedEventArgs args) // If we've added at least one button, focus the first one if (ResultsPanel.Children.Count > 0) { - (ResultsPanel.Children[0] as SoundButton)?.Focus(); - _focusedButton = ResultsPanel.Children[0] as SoundButton; + Dispatcher.BeginInvoke(new Action(() => ResultsPanel.Children.OfType().FirstOrDefault()?.Focus()), DispatcherPriority.ApplicationIdle); } } } @@ -908,8 +1160,6 @@ private void RoutedKeyDownHandler(object sender, RoutedEventArgs args) private void RoutedKeyUpHandler(object sender, RoutedEventArgs args) { - // If a focus gets messed up, re-focus it here - _focusedButton?.Focus(); } private void FlyoutCloseHandler(object sender, RoutedEventArgs e) @@ -927,7 +1177,7 @@ private void silence_Click(object sender, EventArgs e) private void help_Click(object sender, RoutedEventArgs e) { - MetroTabItem tab = new MyMetroTabItem {Header = Properties.Resources.Welcome}; + MyMetroTabItem tab = new MyMetroTabItem(); CreateHelpContent(tab); Tabs.Items.Add(tab); tab.Focus(); @@ -959,6 +1209,17 @@ private void overflow_Click(object sender, RoutedEventArgs e) clearConfig.SetSeparator(true); clearConfig.Click += ClearConfig_Click; + _newPageDefaultMenu = new MenuItem + { + Header = Properties.Resources.NewPageDefaultGrid, + ToolTip = Properties.Resources.NewPageDefaultGridToolTip + }; + _newPageDefaultMenu.Click += NewPageDefault_Click; + _newPageDefaultMenu.SetSeparator(true); + + _inputDeviceMenu = new MenuItem { Header = Properties.Resources.InputDevice }; + _inputDeviceMenu.SubmenuOpened += InputDeviceMenuOpened; + _outputDeviceMenu = new MenuItem {Header = Properties.Resources.OutputDevice}; _outputDeviceMenu.SubmenuOpened += OutputDeviceMenuOpened; @@ -967,9 +1228,16 @@ private void overflow_Click(object sender, RoutedEventArgs e) MenuItem placeholder = new MenuItem(); _outputDeviceMenu.Items.Add(placeholder); + // Add a placeholder menu item so that "Input device" will have a submenu + // even before we have evaluated the audio devices to add to the menu + placeholder = new MenuItem(); + _inputDeviceMenu.Items.Add(placeholder); + overflowMenu.Items.Add(importConfig); overflowMenu.Items.Add(exportConfig); overflowMenu.Items.Add(clearConfig); + overflowMenu.Items.Add(_newPageDefaultMenu); + overflowMenu.Items.Add(_inputDeviceMenu); overflowMenu.Items.Add(_outputDeviceMenu); overflowMenu.AddSeparators(); @@ -982,7 +1250,7 @@ private void overflow_Click(object sender, RoutedEventArgs e) private void addPage_Click(object sender, RoutedEventArgs e) { - MetroTabItem tab = new MyMetroTabItem {Header = Properties.Resources.NewPage}; + MyMetroTabItem tab = new MyMetroTabItem {HeaderText = Properties.Resources.NewPage}; CreatePageContent(tab); Tabs.Items.Add(tab); tab.Focus(); @@ -995,14 +1263,14 @@ private async void renamePage_Click(object sender, RoutedEventArgs e) { RemoveHandler(KeyDownEvent, KeyDownHandler); - if (Tabs.SelectedItem is MetroTabItem tab) + if (Tabs.SelectedItem is MyMetroTabItem tab) { string result = await this.ShowInputAsync(Properties.Resources.Rename, Properties.Resources.WhatDoYouWantToCallIt, - new MetroDialogSettings {DefaultText = tab.Header.ToString()}); + new MetroDialogSettings {DefaultText = tab.HeaderText}); if (string.IsNullOrEmpty(result) == false) { - tab.Header = result; + tab.HeaderText = result; } } @@ -1011,7 +1279,7 @@ private async void renamePage_Click(object sender, RoutedEventArgs e) private void removePage_Click(object sender, RoutedEventArgs e) { - if (Tabs.SelectedItem is MetroTabItem metroTabItem) + if (Tabs.SelectedItem is MyMetroTabItem metroTabItem) { TabPageUndoState tabPageUndoState = SaveState(); @@ -1020,13 +1288,15 @@ private void removePage_Click(object sender, RoutedEventArgs e) // Create and show a snackbar string message = Properties.Resources.TabWasRemoved; - string truncatedTabName = Utilities.Truncate(metroTabItem.Header.ToString(), SnackbarMessageFont, (int)Width - 50, message); + string truncatedTabName = Utilities.Truncate(metroTabItem.HeaderText, SnackbarMessageFont, (int)Width - 50, message); ShowUndoSnackbar(string.Format(message, truncatedTabName)); // Stop all sounds on this page foreach (SoundButton soundButton in GetSoundButtons(metroTabItem)) { soundButton.Stop(); + soundButton.UnregisterLocalHotkey(); + soundButton.UnregisterGlobalHotkey(); } // Remove the page @@ -1081,6 +1351,8 @@ private async void ImportConfig_Click(object sender, RoutedEventArgs e) foreach (SoundButton soundButton in GetSoundButtons()) { soundButton.Stop(); + soundButton.UnregisterLocalHotkey(); + soundButton.UnregisterGlobalHotkey(); } ConfigUndoState configUndoState = (this as IUndoable).SaveState(); @@ -1115,6 +1387,8 @@ private async void ClearConfig_Click(object sender, RoutedEventArgs e) foreach (SoundButton soundButton in GetSoundButtons()) { soundButton.Stop(); + soundButton.UnregisterLocalHotkey(); + soundButton.UnregisterGlobalHotkey(); } ConfigUndoState configUndoState = (this as IUndoable).SaveState(); @@ -1137,6 +1411,71 @@ await this.ShowMessageAsync(Properties.Resources.Error, } } + private async void NewPageDefault_Click(object sender, RoutedEventArgs e) + { + ButtonGridDialog buttonGridDialog = new ButtonGridDialog(GlobalSettings.NewPageDefaultRows, GlobalSettings.NewPageDefaultColumns, Properties.Resources.ChangeDefaultButtonGrid, validate: false); + await this.ShowChildWindowAsync(buttonGridDialog); + + if (buttonGridDialog.DialogResult == System.Windows.Forms.DialogResult.OK) + { + GlobalSettings.NewPageDefaultColumns = buttonGridDialog.ColumnCount; + GlobalSettings.NewPageDefaultRows = buttonGridDialog.RowCount; + } + } + + private void InputDeviceMenuOpened(object sender, RoutedEventArgs e) + { + if (sender is MenuItem inputDeviceMenu) + { + // Clear the current items, whether they are the placeholder + // or the previously evaluated audio devices. + // We're marking them for removal instead of removing them immediately so that the + // menu doesn't resize and decide to close because our mouse is no longer over it. + // Instead we'll add all the new items, then remove the old ones at the very end. + // Use Control istead of MenuItem to capture the Separator. + List itemsToRemove = inputDeviceMenu.Items.OfType().ToList(); + + // Create a menu item for each output device + using (MMDeviceEnumerator deviceEnumerator = new MMDeviceEnumerator()) + { + // Note: We're going in reverse order to preserve the separator and "Close" item at the bottom + + // Now add the rest + foreach (MMDevice device in deviceEnumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).Reverse()) + { + MenuItem menuItem = new MenuItem + { + Header = string.Format(Properties.Resources.SingleSpecifier, device.FriendlyName), + Icon = GlobalSettings.GetInputDeviceGuids().Contains(device.GetGuid()) ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null, + StaysOpenOnClick = true + }; + menuItem.PreviewMouseUp += (_, args) => HandleInputDeviceSelection(device.GetGuid()); + inputDeviceMenu.Items.Insert(0, menuItem); + } + + MenuItem closeDeviceMenuMenuItem = new MenuItem + { + Header = Properties.Resources.Close + }; + inputDeviceMenu.Items.Add(new Separator()); + inputDeviceMenu.Items.Add(closeDeviceMenuMenuItem); + } + + // Finally, remove the items marked for removal + foreach (Control control in itemsToRemove) + { + inputDeviceMenu.Items.Remove(control); + } + + // We only have close and separator, which looks funny, so remove the separator + if (inputDeviceMenu.Items.Count == 2 + && inputDeviceMenu.Items.OfType().FirstOrDefault() is Separator separator) + { + inputDeviceMenu.Items.Remove(separator); + } + } + } + private void OutputDeviceMenuOpened(object sender, RoutedEventArgs e) { // Re-evaluate the audio devices every time this sub-menu is opened @@ -1164,7 +1503,7 @@ private void OutputDeviceMenuOpened(object sender, RoutedEventArgs e) Icon = GlobalSettings.GetOutputDeviceGuids().Contains(device.GetGuid()) ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null, StaysOpenOnClick = true }; - menuItem.PreviewMouseUp += (_, args) => HandleDeviceSelection(device.GetGuid(), args.ChangedButton); + menuItem.PreviewMouseUp += (_, args) => HandleOutputDeviceSelection(device.GetGuid(), args.ChangedButton); outputDeviceMenuItem.Items.Insert(0, menuItem); } @@ -1176,7 +1515,7 @@ private void OutputDeviceMenuOpened(object sender, RoutedEventArgs e) Icon = GlobalSettings.GetOutputDeviceGuids().Contains(Guid.Empty) ? ImageHelper.GetImage(ImageHelper.CheckIconPath) : null, StaysOpenOnClick = true }; - defaultDeviceMenuItem.PreviewMouseUp += (_, args) => HandleDeviceSelection(Guid.Empty, args.ChangedButton); + defaultDeviceMenuItem.PreviewMouseUp += (_, args) => HandleOutputDeviceSelection(Guid.Empty, args.ChangedButton); outputDeviceMenuItem.Items.Insert(0, defaultDeviceMenuItem); MenuItem closeDeviceMenuMenuItem = new MenuItem @@ -1198,10 +1537,129 @@ private void OutputDeviceMenuOpened(object sender, RoutedEventArgs e) { outputDeviceMenuItem.Items.Remove(control); } + + // We only have close and separator, which looks funny, so remove the separator + if (outputDeviceMenuItem.Items.Count == 2 + && outputDeviceMenuItem.Items.OfType().FirstOrDefault() is Separator separator) + { + outputDeviceMenuItem.Items.Remove(separator); + } + } + } + + // This behavior is different from the output devices in that we can only select one. + // However, all of the pieces are in place to allow multiple selection if needed. + private void HandleInputDeviceSelection(Guid deviceGuid) + { + if (GlobalSettings.GetInputDeviceGuids().Contains(deviceGuid)) + { + // Toggle it off + GlobalSettings.RemoveInputDeviceGuid(deviceGuid); + } + else + { + // Toggle it on and remove others + GlobalSettings.RemoveAllInputDeviceGuids(); + GlobalSettings.AddInputDeviceGuid(deviceGuid); + } + + // Refresh the menu + InputDeviceMenuOpened(_inputDeviceMenu, new RoutedEventArgs()); + + HandleInputOutputChange(); + } + + private void HandleInputOutputChange() + { + // Clear any existing chaining + CleanUpAudioPassthrough(); + + if (GlobalSettings.GetInputDeviceGuids().Any()) + { + foreach (var outputDeviceId in GlobalSettings.GetOutputDeviceGuids()) + { + // Create the input + Guid inputDeviceId = GlobalSettings.GetInputDeviceGuids().First(); + MMDevice inputDevice = Utilities.GetDevice(inputDeviceId, DataFlow.Capture); + WasapiCapture inputCapture = new WasapiCapture(inputDevice); + inputCapture.RecordingStopped += HandleRecordingStopped; + _inputCaptures.Add(inputCapture); + + // Create the buffer + BufferedWaveProvider bufferedWaveProvider = new BufferedWaveProvider(inputDevice.AudioClient.MixFormat) + { + DiscardOnBufferOverflow = true + }; + _bufferedWaveProviders.Add(bufferedWaveProvider); + + inputCapture.DataAvailable += (_, args) => + { + bufferedWaveProvider.AddSamples(args.Buffer, 0, args.BytesRecorded); + }; + + // Create the outputs + WasapiOut output = new WasapiOut(Utilities.GetDevice(outputDeviceId, DataFlow.Render), AudioClientShareMode.Shared, true, GlobalSettings.AudioPassthroughLatency); + _outputCaptures.Add(output); + + output.Init(bufferedWaveProvider); + output.Play(); + + inputCapture.StartRecording(); + } } } - private void HandleDeviceSelection(Guid deviceGuid, MouseButton mouseButton) + private void HandleRecordingStopped(object sender, StoppedEventArgs args) + { + // Handle the device being disabled/disconnected/etc. + GlobalSettings.RemoveAllInputDeviceGuids(); + CleanUpAudioPassthrough(); + } + + private void CleanUpAudioPassthrough() + { + try + { + _inputCaptures.ForEach(ic => + { + ic.RecordingStopped -= HandleRecordingStopped; + ic.StopRecording(); + ic.Dispose(); + }); + } + finally + { + _inputCaptures.Clear(); + } + + try + { + _outputCaptures.ForEach(oc => + { + oc.Stop(); + oc.Dispose(); + }); + } + finally + { + _outputCaptures.Clear(); + } + + try + { + _bufferedWaveProviders.ForEach(bwp => bwp.ClearBuffer()); + } + finally + { + _bufferedWaveProviders.Clear(); + } + } + + private readonly List _inputCaptures = new List(); + private readonly List _outputCaptures = new List(); + private readonly List _bufferedWaveProviders = new List(); + + private void HandleOutputDeviceSelection(Guid deviceGuid, MouseButton mouseButton) { if (mouseButton == MouseButton.Right) { @@ -1233,6 +1691,8 @@ private void HandleDeviceSelection(Guid deviceGuid, MouseButton mouseButton) // Refresh the menu OutputDeviceMenuOpened(_outputDeviceMenu, new RoutedEventArgs()); + + HandleInputOutputChange(); } private void CloseSnackbarButton_Click(object sender, RoutedEventArgs e) @@ -1274,6 +1734,8 @@ protected override void OnClosed(EventArgs e) _globalMouseEvents.MouseDown -= Global_MouseDown; _globalMouseEvents.Dispose(); + CleanUpAudioPassthrough(); + base.OnClosed(e); } @@ -1306,6 +1768,21 @@ protected override void OnClosed(EventArgs e) /// public Font SnackbarMessageFont => new Font(SnackbarMessage.FontFamily.ToString(), (float) SnackbarMessage.FontSize); + /// + /// The hot key manager for the application + /// + public HotKeyManager HotKeyManager { get; private set; } + + /// + /// Whether or not any instance of the hotkey picker dialog is open + /// + public bool IsHotkeyPickerOpen { get; set; } + + /// + /// The currently selected tab + /// + public MetroTabItem SelectedTab => Tabs.SelectedItem as MetroTabItem; + #endregion #region Public methods @@ -1341,16 +1818,46 @@ public void ShowUndoSnackbar(string message = "", int timeout = 5000) Snackbar.IsOpen = true; } + /// + /// Invoked when any sound starts + /// + internal void OnAnySoundStarted(SoundButton soundButton) + { + soundButton.ParentTab.IndicateSoundPlaying(); + } + + /// + /// Invoked when any sound stop + /// + internal void OnAnySoundStopped(SoundButton soundButton) + { + if (!IsAnySoundPlayingOnTab(soundButton.ParentTab)) + { + soundButton.ParentTab.RemoveSoundPlaying(); + } + } + + internal bool IsAnySoundPlayingOnTab(MyMetroTabItem myMetroTabItem) + { + return GetSoundButtons(myMetroTabItem).Any(sb => sb.IsPlaying); + } + + internal void OnAnySoundRenamed() + { + GetSoundButtons().SelectMany(sb => sb.ChildButtons.OfType()).ToList().ForEach(ns => ns.Update()); + } + #endregion #region Private fields private readonly IKeyboardMouseEvents _globalMouseEvents; private string _searchString = string.Empty; - private SoundButton _focusedButton; private Action _undoAction; private readonly Dictionary _tabContextMenus = new Dictionary(); private readonly WpfUpdateChecker _updateChecker; + private MenuItem _newPageDefaultMenu; + private MenuItem _inputDeviceMenu; private MenuItem _outputDeviceMenu; #endregion @@ -1365,8 +1872,6 @@ public void ShowUndoSnackbar(string message = "", int timeout = 5000) private string ApplicationName => @"SoundBoard"; - private MetroTabItem SelectedTab => Tabs.SelectedItem as MetroTabItem; - #endregion #region Consts @@ -1392,6 +1897,30 @@ public void LoadState(TabPageUndoState undoState) { Tabs.Items.Insert(undoState.Index, undoState.MetroTabItem); Tabs.SelectedIndex = undoState.Index; + + if (Tabs.SelectedItem is MetroTabItem metroTabItem) + { + foreach (SoundButton soundButton in GetSoundButtons(metroTabItem)) + { + try + { + soundButton.ReregisterLocalHotkey(); + } + catch + { + // Swallow + } + + try + { + soundButton.ReregisterGlobalHotkey(); + } + catch + { + // Swallow + } + } + } } @@ -1410,6 +1939,8 @@ public void LoadState(ConfigUndoState undoState) foreach (SoundButton soundButton in GetSoundButtons()) { soundButton.Stop(); + soundButton.UnregisterLocalHotkey(); + soundButton.UnregisterGlobalHotkey(); } LoadSettings(undoState.SavedConfigStatePath); diff --git a/SoundBoard/MyMetroTabItem.cs b/SoundBoard/MyMetroTabItem.cs index 50d241b..4f65839 100644 --- a/SoundBoard/MyMetroTabItem.cs +++ b/SoundBoard/MyMetroTabItem.cs @@ -133,6 +133,31 @@ protected override void OnLostFocus(RoutedEventArgs e) #endregion + #region Public properties + + public string HeaderText + { + get => _headerText; + set + { + _headerText = value; + Header = MainWindow.Instance.IsAnySoundPlayingOnTab(this) ? $"{_headerText}\uD83D\uDD69" : _headerText; + } + } + private string _headerText; + + internal void IndicateSoundPlaying() + { + Header = $"{_headerText}\uD83D\uDD69"; + } + + internal void RemoveSoundPlaying() + { + Header = _headerText; + } + + #endregion + #region Private properties private TabControl ParentTabControl => _parentTabControl ?? diff --git a/SoundBoard/Properties/AssemblyInfo.cs b/SoundBoard/Properties/AssemblyInfo.cs index 2523fe5..8f7a60d 100644 --- a/SoundBoard/Properties/AssemblyInfo.cs +++ b/SoundBoard/Properties/AssemblyInfo.cs @@ -51,5 +51,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.9.0.2")] -[assembly: AssemblyFileVersion("1.9.0.2")] +[assembly: AssemblyVersion("1.10.0.2")] +[assembly: AssemblyFileVersion("1.10.0.2")] diff --git a/SoundBoard/Properties/Resources.Designer.cs b/SoundBoard/Properties/Resources.Designer.cs index 9e6eae7..97bdda5 100644 --- a/SoundBoard/Properties/Resources.Designer.cs +++ b/SoundBoard/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace SoundBoard.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -123,6 +123,15 @@ internal static string ChangeButtonGrid { } } + /// + /// Looks up a localized string similar to Change Default Button Grid. + /// + internal static string ChangeDefaultButtonGrid { + get { + return ResourceManager.GetString("ChangeDefaultButtonGrid", resourceCulture); + } + } + /// /// Looks up a localized string similar to Check for updates. /// @@ -177,6 +186,15 @@ internal static string Close { } } + /// + /// Looks up a localized string similar to There was an error loading the SoundBoard configuration. A backup has been made at '{0}'. + /// + internal static string ConfigLoadError { + get { + return ResourceManager.GetString("ConfigLoadError", resourceCulture); + } + } + /// /// Looks up a localized string similar to Configuration Files. /// @@ -303,6 +321,15 @@ internal static string FixLinksMessage { } } + /// + /// Looks up a localized string similar to Global hotkey '{0}' already in use by sound '{1}'.. + /// + internal static string GlobalHotkeyInuse { + get { + return ResourceManager.GetString("GlobalHotkeyInuse", resourceCulture); + } + } + /// /// Looks up a localized string similar to Go to sound. /// @@ -312,6 +339,36 @@ internal static string GoToSound { } } + /// + /// Looks up a localized string similar to Local Hotkey: {0} + ///Global Hotkey: {1}. + /// + internal static string HotkeyIndicatorToolTip { + get { + return ResourceManager.GetString("HotkeyIndicatorToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The hotkey '{0}' could not be registered. It may be in use by another application or it may be reserved by Windows.. + /// + internal static string HotkeyRegistrationFailed { + get { + return ResourceManager.GetString("HotkeyRegistrationFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The following hotkeys could not be registered. They may be in use by other applications. + /// + ///{0}. + /// + internal static string HotkeyRegistrationFailedOnLoad { + get { + return ResourceManager.GetString("HotkeyRegistrationFailedOnLoad", resourceCulture); + } + } + /// /// Looks up a localized string similar to How does it work?. /// @@ -321,6 +378,15 @@ internal static string HowDoesItWork { } } + /// + /// Looks up a localized string similar to Local and global hotkey cannot be identical. . + /// + internal static string IdenticalHotkeyWarning { + get { + return ResourceManager.GetString("IdenticalHotkeyWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Import configuration. /// @@ -330,6 +396,15 @@ internal static string ImportConfiguration { } } + /// + /// Looks up a localized string similar to Input device. + /// + internal static string InputDevice { + get { + return ResourceManager.GetString("InputDevice", resourceCulture); + } + } + /// /// Looks up a localized string similar to A large button grid could cause the application to become slow or unresponsive. Are you sure you want to continue?. /// @@ -339,6 +414,15 @@ internal static string LargeButtonCountWarning { } } + /// + /// Looks up a localized string similar to Local hotkey '{0}' already in use by sound '{1}'. . + /// + internal static string LocalHotkeyInUse { + get { + return ResourceManager.GetString("LocalHotkeyInUse", resourceCulture); + } + } + /// /// Looks up a localized string similar to Loop. /// @@ -348,6 +432,24 @@ internal static string Loop { } } + /// + /// Looks up a localized string similar to Multiple sounds were added.. + /// + internal static string MultipleSoundsAdded { + get { + return ResourceManager.GetString("MultipleSoundsAdded", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Selected sound(s) were cleared from '{0}'.. + /// + internal static string MultipleSoundsClearedFromTab { + get { + return ResourceManager.GetString("MultipleSoundsClearedFromTab", resourceCulture); + } + } + /// /// Looks up a localized string similar to .NET Framework Error. /// @@ -366,6 +468,51 @@ internal static string NewPage { } } + /// + /// Looks up a localized string similar to Default button grid. + /// + internal static string NewPageDefaultGrid { + get { + return ResourceManager.GetString("NewPageDefaultGrid", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set the button grid that will be used for new pages. + /// + internal static string NewPageDefaultGridToolTip { + get { + return ResourceManager.GetString("NewPageDefaultGridToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Next sound. + /// + internal static string NextSound { + get { + return ResourceManager.GetString("NextSound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Next sound: {0}. + /// + internal static string NextSoundName { + get { + return ResourceManager.GetString("NextSoundName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Next sound: {0} - {1}. + /// + internal static string NextSoundTab { + get { + return ResourceManager.GetString("NextSoundTab", resourceCulture); + } + } + /// /// Looks up a localized string similar to No. /// @@ -384,6 +531,15 @@ internal static string NoAudioTrackWarning { } } + /// + /// Looks up a localized string similar to none. + /// + internal static string None { + get { + return ResourceManager.GetString("None", resourceCulture); + } + } + /// /// Looks up a localized string similar to OK. /// @@ -429,6 +585,15 @@ internal static string SetColor { } } + /// + /// Looks up a localized string similar to Set Hotkeys. + /// + internal static string SetHotkeys { + get { + return ResourceManager.GetString("SetHotkeys", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0}. /// @@ -519,6 +684,33 @@ internal static string Source { } } + /// + /// Looks up a localized string similar to Stop other sounds. + /// + internal static string StopAllSounds { + get { + return ResourceManager.GetString("StopAllSounds", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This sound will stop all other sounds. + /// + internal static string StopAllSoundsIcon { + get { + return ResourceManager.GetString("StopAllSoundsIcon", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to When this sound is played, all others will be stopped.. + /// + internal static string StopAllSoundsToolTip { + get { + return ResourceManager.GetString("StopAllSoundsToolTip", resourceCulture); + } + } + /// /// Looks up a localized string similar to Page '{0}' was removed.. /// diff --git a/SoundBoard/Properties/Resources.resx b/SoundBoard/Properties/Resources.resx index 042bf97..0c42fbf 100644 --- a/SoundBoard/Properties/Resources.resx +++ b/SoundBoard/Properties/Resources.resx @@ -230,6 +230,10 @@ All sounds cleared from '{0}'. {0} is the name of a tab page + + Selected sound(s) were cleared from '{0}'. + {0} is the name of a tab page + Set color @@ -264,6 +268,9 @@ OK + + Input device + Output device @@ -313,4 +320,64 @@ {0} additional missing sounds were found in the selected directory. Relink them? + + Local Hotkey: {0} +Global Hotkey: {1} + + + none + + + Set Hotkeys + + + Local and global hotkey cannot be identical. + + + Local hotkey '{0}' already in use by sound '{1}'. + + + Global hotkey '{0}' already in use by sound '{1}'. + + + The hotkey '{0}' could not be registered. It may be in use by another application or it may be reserved by Windows. + + + The following hotkeys could not be registered. They may be in use by other applications. + +{0} + + + Multiple sounds were added. + + + Stop other sounds + + + When this sound is played, all others will be stopped. + + + There was an error loading the SoundBoard configuration. A backup has been made at '{0}' + + + Next sound + + + This sound will stop all other sounds + + + Next sound: {0} - {1} + + + Next sound: {0} + + + Default button grid + + + Set the button grid that will be used for new pages + + + Change Default Button Grid + \ No newline at end of file diff --git a/SoundBoard/SoundBoard.csproj b/SoundBoard/SoundBoard.csproj index 48a6199..4cad5f7 100644 --- a/SoundBoard/SoundBoard.csproj +++ b/SoundBoard/SoundBoard.csproj @@ -59,12 +59,22 @@ ..\packages\MouseKeyHook.5.6.0\lib\net40\Gma.System.MouseKeyHook.dll + + lib\HotKeyManagement.WPF.4.dll + + + ..\packages\Humanizer.Core.2.14.1\lib\netstandard2.0\Humanizer.dll + ..\packages\MahApps.Metro.1.6.5\lib\net47\MahApps.Metro.dll ..\packages\MahApps.Metro.SimpleChildWindow.1.5.0\lib\net45\MahApps.Metro.SimpleChildWindow.dll + + + ..\packages\TaskScheduler.2.8.7\lib\net452\Microsoft.Win32.TaskScheduler.dll + ..\packages\MimeMapping.1.0.1.37\lib\netstandard2.0\MimeMapping.dll @@ -78,6 +88,17 @@ + + + ..\packages\System.Reactive.5.0.0\lib\net472\System.Reactive.dll + + + ..\packages\System.Runtime.CompilerServices.Unsafe.4.5.3\lib\net461\System.Runtime.CompilerServices.Unsafe.dll + + + ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll + + ..\packages\ControlzEx.3.0.2.4\lib\net462\System.Windows.Interactivity.dll @@ -90,6 +111,9 @@ 4.0 + + ..\packages\UACHelper.1.3.0.5\lib\net40\UACHelper.dll + @@ -117,6 +141,15 @@ + + HotkeyDialog.xaml + + + MSBuild:Compile + + + HotkeyEditorControl.xaml + @@ -131,6 +164,14 @@ + + Designer + MSBuild:Compile + + + Designer + MSBuild:Compile + MSBuild:Compile Designer @@ -227,6 +268,18 @@ + + + + + + + + + + + + diff --git a/SoundBoard/UndoClasses.cs b/SoundBoard/UndoClasses.cs index a39afa1..a14eaf3 100644 --- a/SoundBoard/UndoClasses.cs +++ b/SoundBoard/UndoClasses.cs @@ -56,6 +56,31 @@ public class SoundButtonUndoState : UndoStateBase /// Loop /// public bool Loop { get; set; } + + /// + /// StopAllSounds + /// + public bool StopAllSounds { get; set; } + + /// + /// NextSound + /// + public string NextSound { get; set; } + + /// + /// Id + /// + public string Id { get; set; } + + /// + /// LocalHotkey + /// + public Hotkey LocalHotkey { get; set; } + + /// + /// GlobalHotkey + /// + public Hotkey GlobalHotkey { get; set; } } /// diff --git a/SoundBoard/Utilities.cs b/SoundBoard/Utilities.cs index c9e116c..240fa24 100644 --- a/SoundBoard/Utilities.cs +++ b/SoundBoard/Utilities.cs @@ -9,6 +9,8 @@ using NAudio.CoreAudioApi; using NAudio.Wave; using Point = System.Windows.Point; +using WpfKey = System.Windows.Input.Key; +using HotKey = BondTech.HotKeyManagement.WPF._4.Keys; #endregion @@ -41,6 +43,8 @@ public static string Truncate(string input, System.Drawing.Font font, int maxWid return input + (truncated ? ELLIPSES : string.Empty); } + #region Audio utilities + /// /// Unmute the system audio device(s), with optional parameters to specify the and . /// @@ -99,6 +103,23 @@ public static MMDevice GetDefaultDevice(DataFlow dataFlow = DataFlow.Render, Rol } } + public static MMDevice GetDevice(Guid deviceId, DataFlow dataFlow, Role role = Role.Multimedia) + { + if (deviceId == Guid.Empty) + { + return (GetDefaultDevice(dataFlow, role)); + } + else + { + using (MMDeviceEnumerator deviceEnumerator = new MMDeviceEnumerator()) + { + return deviceEnumerator.EnumerateAudioEndPoints(dataFlow, DeviceState.Active).FirstOrDefault(d => d.GetGuid() == deviceId); + } + } + } + + #endregion + /// /// Returns true if the distance (absolute value) between both .X and .X /// and .Y and .Y is greater than . @@ -194,6 +215,31 @@ internal struct Win32Point #endregion + #region Hotkey stuff + + /// + /// This method removes leading digits from GUIDs, which are invalid identifiers for the hotkey manager + /// + public static string SanitizeId(string id) + { + if (!string.IsNullOrEmpty(id)) + { + return new string(id.SkipWhile(char.IsDigit).ToArray()); + } + + return id; + } + + /// + /// Maps a key from the hotkey manager to the built-in key enum + /// + public static HotKey MapKey(WpfKey key) + { + return Enum.TryParse(Enum.GetName(typeof(WpfKey), key), true, out HotKey hotKey) ? hotKey : HotKey.None; + } + + #endregion + #region Private consts private const string ELLIPSES = @"..."; diff --git a/SoundBoard/VersionInfo.xml b/SoundBoard/VersionInfo.xml index ff7fdea..872443f 100644 --- a/SoundBoard/VersionInfo.xml +++ b/SoundBoard/VersionInfo.xml @@ -1,25 +1,29 @@ - 1.9.0.2 - 2022-01-08 + 1.10.0.2 + 2023-03-10 - https://github.com/micahmo/SoundBoard/releases/download/v1.9/SoundBoard.exe + https://github.com/micahmo/SoundBoard/releases/download/v1.10.0/SoundBoard.exe SoundBoard.exe - https://github.com/micahmo/SoundBoard/releases/download/v1.9/SoundBoard.exe + https://github.com/micahmo/SoundBoard/releases/download/v1.10.0/SoundBoard.exe SoundBoard.exe - - Allow updates to be skipped - - Handle long sound paths and names - - Expand list of supported audio/video file types - - Add missing/unsupported file warnings - - Allow restoring broken file links - - General error handling and bug fixes + - Audio recording device can be passed through to an audio playback device (#18) + - Hotkeys can be assigned to sounds + - Multiple sounds or a whole folder can be added at once + - Select sounds with Control- or Shift-Click to update or play multiple at once + - Sounds can be configured to stop all other sounds when played + - Sound names are more readable at different sizes (#17) + - Tabs with currently playing sounds will now be clearly indicated + - Sounds can trigger other sounds, allowing chaining + - The default grid layout for new pages can be configured + - General bug fixes and improvements \ No newline at end of file diff --git a/SoundBoard/lib/HotKeyManagement.WPF.4.dll b/SoundBoard/lib/HotKeyManagement.WPF.4.dll new file mode 100644 index 0000000..90a73d0 Binary files /dev/null and b/SoundBoard/lib/HotKeyManagement.WPF.4.dll differ diff --git a/SoundBoard/packages.config b/SoundBoard/packages.config index 069b2c4..edfa82a 100644 --- a/SoundBoard/packages.config +++ b/SoundBoard/packages.config @@ -5,10 +5,18 @@ + + + + + + + + \ No newline at end of file