diff --git a/src/Earmark.App/App.xaml b/src/Earmark.App/App.xaml
index 51f8592..1c7a79b 100644
--- a/src/Earmark.App/App.xaml
+++ b/src/Earmark.App/App.xaml
@@ -25,7 +25,6 @@
-
diff --git a/src/Earmark.App/Controls/DeviceCardView.xaml b/src/Earmark.App/Controls/DeviceCardView.xaml
new file mode 100644
index 0000000..0eeb573
--- /dev/null
+++ b/src/Earmark.App/Controls/DeviceCardView.xaml
@@ -0,0 +1,654 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 14
+ 14
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Earmark.App/Controls/DeviceCardView.xaml.cs b/src/Earmark.App/Controls/DeviceCardView.xaml.cs
new file mode 100644
index 0000000..33d688e
--- /dev/null
+++ b/src/Earmark.App/Controls/DeviceCardView.xaml.cs
@@ -0,0 +1,295 @@
+using Earmark.App.ViewModels;
+using Earmark.App.Views;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.UI.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Hosting;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+
+using Windows.ApplicationModel.DataTransfer;
+using Windows.System;
+using Windows.UI.Core;
+
+namespace Earmark.App.Controls;
+
+// XAML event handlers and x:Bind function bindings must be instance members even when the body
+// doesn't touch instance state.
+#pragma warning disable CA1822
+
+///
+/// The reusable device card body: tile/name/chips, volume + peak meter, rules, and app chips. Hosted
+/// by the Devices page (inside a drag/drop ) and by the Quick Controls flyout. The
+/// page-level gestures (block reorder, app-chip drop target) live on the host's wrapping Border; this
+/// control owns the per-card interactions and reaches the singleton directly.
+///
+public sealed partial class DeviceCardView : UserControl
+{
+ private readonly HomeViewModel _viewModel;
+ private readonly RulesViewModel _rulesViewModel;
+ private readonly MainWindow _mainWindow;
+ private readonly ILogger? _logger;
+ private readonly Dictionary _sliderDragStart = new();
+
+ public DeviceCardView()
+ {
+ var services = App.Current.Services;
+ _viewModel = services.GetRequiredService();
+ _rulesViewModel = services.GetRequiredService();
+ _mainWindow = services.GetRequiredService();
+ _logger = services.GetService>();
+ InitializeComponent();
+ }
+
+ public static readonly DependencyProperty CardProperty = DependencyProperty.Register(
+ nameof(Card), typeof(DeviceCard), typeof(DeviceCardView), new PropertyMetadata(null));
+
+ public DeviceCard? Card
+ {
+ get => (DeviceCard?)GetValue(CardProperty);
+ set => SetValue(CardProperty, value);
+ }
+
+ /// When false (the Quick Controls flyout), the rules divider / section / "no rules"
+ /// message never render. Constant per host.
+ public static readonly DependencyProperty ShowRulesProperty = DependencyProperty.Register(
+ nameof(ShowRules), typeof(bool), typeof(DeviceCardView), new PropertyMetadata(true));
+
+ public bool ShowRules
+ {
+ get => (bool)GetValue(ShowRulesProperty);
+ set => SetValue(ShowRulesProperty, value);
+ }
+
+ /// x:Bind function: a rule element shows only when rules are enabled for this host AND the
+ /// card wants it. Re-evaluates when either argument changes, replacing the old collapse watchdog.
+ public Visibility RuleVis(bool showRules, bool cardWants) =>
+ showRules && cardWants ? Visibility.Visible : Visibility.Collapsed;
+
+ /// Raised when "Rename group" is picked from a card/app-chip menu, so the host can focus
+ /// the group's title editor (which lives in the page's tree, not here).
+ public event EventHandler? RenameGroupRequested;
+
+ private DeviceGroupCard? FindGroupOf(DeviceCard card)
+ {
+ foreach (var block in _viewModel.Blocks)
+ {
+ if (block is DeviceGroupCard group && group.Members.Contains(card)) return group;
+ }
+ return null;
+ }
+
+ private void OnMuteToggleClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement { Tag: DeviceCard card }) return;
+ var prevVolume = card.Volume;
+ var prevMuted = card.IsMuted;
+ card.ToggleMuteCommand.Execute(null);
+ _viewModel.RecordVolumeMuteUndo(card, prevVolume, prevMuted);
+ }
+
+ private void OnRuleChipClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement { DataContext: RuleSummary summary }) return;
+ _rulesViewModel.RequestFocusRule(summary.RuleId);
+ _mainWindow.NavigateByTag("Rules");
+ }
+
+ // ---- Volume slider interaction (captures pre-drag state for a single Ctrl+Z entry) ----
+
+ private void OnSliderPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is Slider { Tag: DeviceCard card } slider)
+ {
+ _sliderDragStart[slider] = (card.Volume, card.IsMuted);
+ }
+ }
+
+ private void OnSliderReleased(object sender, PointerRoutedEventArgs e)
+ {
+ if (sender is not Slider { Tag: DeviceCard card } slider) return;
+ FinaliseSliderInteraction(slider, card);
+ card.PlayPing();
+ }
+
+ private void OnSliderKeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (!IsSliderNudgeKey(e.Key)) return;
+ if (sender is Slider { Tag: DeviceCard card } slider && !_sliderDragStart.ContainsKey(slider))
+ {
+ _sliderDragStart[slider] = (card.Volume, card.IsMuted);
+ }
+ }
+
+ private void OnSliderKeyUp(object sender, KeyRoutedEventArgs e)
+ {
+ if (!IsSliderNudgeKey(e.Key)) return;
+ if (sender is not Slider { Tag: DeviceCard card } slider) return;
+ FinaliseSliderInteraction(slider, card);
+ card.PlayPing();
+ }
+
+ private void OnSliderLostFocus(object sender, RoutedEventArgs e)
+ {
+ if (sender is Slider { Tag: DeviceCard card } slider) FinaliseSliderInteraction(slider, card);
+ }
+
+ private void FinaliseSliderInteraction(Slider slider, DeviceCard card)
+ {
+ if (!_sliderDragStart.TryGetValue(slider, out var start)) return;
+ _sliderDragStart.Remove(slider);
+ _viewModel.RecordVolumeMuteUndo(card, start.Volume, start.Muted);
+ }
+
+ private static bool IsSliderNudgeKey(VirtualKey key) =>
+ key is VirtualKey.Left or VirtualKey.Right
+ or VirtualKey.Up or VirtualKey.Down
+ or VirtualKey.PageUp or VirtualKey.PageDown
+ or VirtualKey.Home or VirtualKey.End;
+
+ private void OnLockedSliderTapped(object sender, TappedRoutedEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: DeviceCard card }) card.PlayPing();
+ }
+
+ private void OnLockedSliderPointerPressed(object sender, PointerRoutedEventArgs e) =>
+ (sender as UIElement)?.CapturePointer(e.Pointer);
+
+ private void OnLockedSliderPointerReleased(object sender, PointerRoutedEventArgs e) =>
+ (sender as UIElement)?.ReleasePointerCapture(e.Pointer);
+
+ // ---- App chip drag source + animation ----
+
+ private const string DragPayloadPrefix = "earmark:chip:";
+
+ private void OnAppChipLoaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement border) return;
+ if (VisualTreeHelper.GetParent(border) is not UIElement container) return;
+
+ var visual = ElementCompositionPreview.GetElementVisual(container);
+ var compositor = visual.Compositor;
+ var offset = compositor.CreateVector3KeyFrameAnimation();
+ offset.Target = "Offset";
+ offset.InsertExpressionKeyFrame(1.0f, "this.FinalValue");
+ offset.Duration = TimeSpan.FromMilliseconds(220);
+ var implicits = compositor.CreateImplicitAnimationCollection();
+ implicits["Offset"] = offset;
+ visual.ImplicitAnimations = implicits;
+ }
+
+ private void OnAppChipDragStarting(UIElement sender, DragStartingEventArgs args)
+ {
+ if (sender is not FrameworkElement { Tag: AppChip chip }) return;
+ if (!chip.CanDrag)
+ {
+ args.Cancel = true;
+ return;
+ }
+
+ args.Data.SetText($"{DragPayloadPrefix}{chip.ProcessId}|{chip.SourceEndpointId}");
+ args.Data.RequestedOperation = DataPackageOperation.Move;
+ SetDragInProgress(true);
+ }
+
+ private void OnAppChipDropCompleted(UIElement sender, DropCompletedEventArgs args) => SetDragInProgress(false);
+
+ /// Reveals every group container's dotted outline while a drag is in flight.
+ private void SetDragInProgress(bool active)
+ {
+ foreach (var block in _viewModel.Blocks)
+ {
+ if (block is DeviceGroupCard group) group.ShowOutline = active;
+ }
+ }
+
+ /// Reveals the chip's "Terminate this app" item only while Shift is held as the menu opens.
+ private void OnAppChipFlyoutOpening(object sender, object e)
+ {
+ if (sender is not MenuFlyout flyout) return;
+ var shiftDown = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift)
+ .HasFlag(CoreVirtualKeyStates.Down);
+ foreach (var item in flyout.Items)
+ {
+ if (item is MenuFlyoutItem { Tag: AppChip chip } terminate)
+ {
+ terminate.Visibility = shiftDown && chip.ShowProcessActions
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+ }
+ }
+ }
+
+ // ---- Context-menu actions (the device + app-chip menus share these) ----
+
+ private async void OnDeviceVisibilityClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement { Tag: DeviceCard card }) return;
+ if (!card.IsEffectivelyHidden && card.IsQuickPinned)
+ {
+ var dialog = new ContentDialog
+ {
+ XamlRoot = XamlRoot,
+ Title = "Hide pinned device?",
+ Content = "This device is pinned to Quick Controls. Hiding it will remove it from Quick Controls.",
+ PrimaryButtonText = "Hide and unpin",
+ CloseButtonText = "Cancel",
+ DefaultButton = ContentDialogButton.Close,
+ };
+ if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
+ HomeViewModel.HideAndUnpin(card);
+ return;
+ }
+
+ card.ToggleUserVisibilityCommand.Execute(null);
+ }
+
+ private void OnForgetDeviceClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement { Tag: DeviceCard card }) _viewModel.ForgetDevice(card);
+ }
+
+ private void OnUngroupDeviceClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement { Tag: DeviceCard card }) _viewModel.UngroupDevice(card.DeviceKey);
+ }
+
+ private void OnUngroupAllClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement { Tag: DeviceCard card } && FindGroupOf(card) is { } group)
+ {
+ _viewModel.UngroupAll(group.Id);
+ }
+ }
+
+ private void OnRenameGroupClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement { Tag: DeviceCard card } && FindGroupOf(card) is { } group)
+ {
+ group.IsEditingTitle = true;
+ RenameGroupRequested?.Invoke(this, group);
+ }
+ }
+
+ private void OnCustomiseClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is not FrameworkElement { Tag: DeviceCard card }) return;
+ // Defer so the context MenuFlyout finishes dismissing before the dialog opens.
+ DispatcherQueue.TryEnqueue(async () =>
+ {
+ try
+ {
+ var dialog = HomePage.BuildCustomiseDialog(card);
+ dialog.XamlRoot = XamlRoot;
+ await dialog.ShowAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger?.LogError(ex, "Customise: dialog threw");
+ }
+ });
+ }
+}
diff --git a/src/Earmark.App/Converters/Converters.cs b/src/Earmark.App/Converters/Converters.cs
index ac32c89..8b99106 100644
--- a/src/Earmark.App/Converters/Converters.cs
+++ b/src/Earmark.App/Converters/Converters.cs
@@ -40,13 +40,6 @@ public object ConvertBack(object value, Type targetType, object parameter, strin
throw new NotSupportedException();
}
-public sealed class EnumToStringConverter : IValueConverter
-{
- public object Convert(object value, Type targetType, object parameter, string language) => value?.ToString() ?? string.Empty;
-
- public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotSupportedException();
-}
-
public sealed class SatisfiedGlyphConverter : IValueConverter
{
// Segoe MDL2 Assets: CheckMark (E73E), Cancel (E711).
diff --git a/src/Earmark.App/Hosting/HostBuilderExtensions.cs b/src/Earmark.App/Hosting/HostBuilderExtensions.cs
index 696efa8..36e45ce 100644
--- a/src/Earmark.App/Hosting/HostBuilderExtensions.cs
+++ b/src/Earmark.App/Hosting/HostBuilderExtensions.cs
@@ -57,7 +57,6 @@ public static HostApplicationBuilder ConfigureEarmark(this HostApplicationBuilde
builder.Services.AddSingleton();
builder.Services.AddSingleton();
- builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/src/Earmark.App/Services/DeviceDefaultsService.cs b/src/Earmark.App/Services/DeviceDefaultsService.cs
index a9c5c4b..0473f0f 100644
--- a/src/Earmark.App/Services/DeviceDefaultsService.cs
+++ b/src/Earmark.App/Services/DeviceDefaultsService.cs
@@ -119,16 +119,9 @@ private List ApplyDefaultDeviceLayout()
}
var orderHead = new List();
- var defaultGroup = AddGroup(s, orderHead, DefaultDevicesGroupTitle, group0Members);
+ AddGroup(s, orderHead, DefaultDevicesGroupTitle, group0Members);
PinAll(s, group0Members);
- if (defaultGroup is not null)
- {
- defaultGroup.PinnedToQuickControls = true;
- }
- else
- {
- QuickPinAll(s, group0Members);
- }
+ QuickPinAll(s, group0Members);
// ---- Group 1: Wave Link channels (render/output-only virtual channels), minus group 0 ----
var channelMap = WaveLinkChannelMap.Build(snapshot, combined);
@@ -171,9 +164,9 @@ private List ApplyDefaultDeviceLayout()
/// Adds a group for (>=2 members) and pushes its id onto the
/// block-order head. Fewer than two members can't render as a group, so it's skipped (members are
/// still pinned separately).
- private static DeviceGroup? AddGroup(AppSettings s, List orderHead, string title, List memberIds)
+ private static void AddGroup(AppSettings s, List orderHead, string title, List memberIds)
{
- if (memberIds.Count < 2) return null;
+ if (memberIds.Count < 2) return;
var group = new DeviceGroup
{
Id = Guid.NewGuid().ToString("N"),
@@ -182,7 +175,6 @@ private List ApplyDefaultDeviceLayout()
};
s.DeviceGroups.Add(group);
orderHead.Add(group.Id);
- return group;
}
private static void PinAll(AppSettings s, IEnumerable ids)
diff --git a/src/Earmark.App/Services/QuickControlsService.cs b/src/Earmark.App/Services/QuickControlsService.cs
index fda66f5..6bc89e7 100644
--- a/src/Earmark.App/Services/QuickControlsService.cs
+++ b/src/Earmark.App/Services/QuickControlsService.cs
@@ -25,30 +25,15 @@ internal sealed class QuickControlsService : IQuickControlsService
private const int OverlayGap = 8;
private const int MinimumOverflowHeight = 220;
private const int WarmWindowPoolSize = 6;
- private const int WhKeyboardLl = 13;
- private const int WhMouseLl = 14;
- private const int WmKeydown = 0x0100;
- private const int WmSyskeydown = 0x0104;
- private const int WmLbuttondown = 0x0201;
- private const int WmRbuttondown = 0x0204;
- private const int WmMbuttondown = 0x0207;
- private const int WmXbuttondown = 0x020B;
- private const uint VkEscape = 0x1B;
private readonly IGlobalHotkeyService _hotkey;
private readonly HomeViewModel _viewModel;
- private readonly HomePage _homePage;
private readonly ISettingsService _settings;
private readonly ILoggerFactory _loggerFactory;
- private readonly ILogger _logger;
private readonly IDispatcherQueueProvider _dispatcher;
private readonly List _windows = new();
private readonly Dictionary _windowsByBlockKey = new(StringComparer.OrdinalIgnoreCase);
- private readonly List<(string Key, IReadOnlyList