diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3a05c0c..b670334 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,8 +22,8 @@ env: BOM_FILE: sbom.cdx.json # NOTE: GitHub Actions does not allow expressions in `uses:` refs, so the # pipeline-tools pin is repeated in each `uses:` line below. Bump in lockstep. - # Current pin: hoobio/pipeline-tools @ v1.5.0 (adds Backstage hierarchy bootstrap - # and channel routing for Dependency-Track SBOM uploads). + # Current pin: hoobio/pipeline-tools @ v2.2.1 (v2 split the Dependency-Track + # channel input into channel + sub-channel; see the SBOM job below). jobs: release-please: @@ -286,9 +286,13 @@ jobs: with: dotnet-version: ${{ env.DOTNET_SDK_VERSION }} - # Channel routing for the Backstage hierarchy in Dependency-Track: - # release event -> channel=release, mark-latest=true, no prune - # push / dispatch / PR -> channel=ci/,mark-latest=false, prune + # Channel routing for the Backstage hierarchy in Dependency-Track. v2 of + # pipeline-tools takes channel + sub-channel as two separate inputs (the + # old single `ci/` string is a v1 shape that the bootstrap now + # treats as a legacy and migrates out from under us). + # release event -> channel=release, sub-channel='', mark-latest=true, no prune + # push to default branch -> channel=main, sub-channel='', mark-latest=false, prune + # push to feature / PR -> channel=ci, sub-channel=, mark-latest=false, prune - name: Resolve DT channel id: dt shell: pwsh @@ -297,9 +301,11 @@ jobs: REF_NAME: ${{ github.ref_name }} HEAD_REF: ${{ github.head_ref }} SHA: ${{ github.sha }} + DEFAULT_BRANCH: main run: | if ($env:EVENT_NAME -eq 'release') { $channel = 'release' + $subChannel = '' $projectVersion = $env:REF_NAME $markLatest = 'true' $prune = 'false' @@ -307,20 +313,36 @@ jobs: else { $branchSource = if ($env:EVENT_NAME -eq 'pull_request') { $env:HEAD_REF } else { $env:REF_NAME } $sanitized = ($branchSource -replace '[^A-Za-z0-9._/-]', '-') - $channel = "ci/$sanitized" + if ($sanitized -eq $env:DEFAULT_BRANCH) { + # Default branch sits at top-level alongside release, not nested under ci. + $channel = $sanitized + $subChannel = '' + } + else { + $channel = 'ci' + $subChannel = $sanitized + } $projectVersion = $env:SHA $markLatest = 'false' $prune = 'true' } - "channel=$channel" | Out-File -Append $env:GITHUB_OUTPUT - "project_version=$projectVersion" | Out-File -Append $env:GITHUB_OUTPUT - "mark_latest=$markLatest" | Out-File -Append $env:GITHUB_OUTPUT - "prune=$prune" | Out-File -Append $env:GITHUB_OUTPUT - Write-Host "Channel: $channel" - Write-Host "ProjectVersion: $projectVersion" - Write-Host "MarkLatest: $markLatest" - Write-Host "Prune: $prune" + # Effective parent name used by the upload step. Used for the tag value + # so DT filters match the new v2 hierarchy. + $effectiveChannel = if ($subChannel) { $subChannel } else { $channel } + + "channel=$channel" | Out-File -Append $env:GITHUB_OUTPUT + "sub_channel=$subChannel" | Out-File -Append $env:GITHUB_OUTPUT + "effective_channel=$effectiveChannel" | Out-File -Append $env:GITHUB_OUTPUT + "project_version=$projectVersion" | Out-File -Append $env:GITHUB_OUTPUT + "mark_latest=$markLatest" | Out-File -Append $env:GITHUB_OUTPUT + "prune=$prune" | Out-File -Append $env:GITHUB_OUTPUT + Write-Host "Channel: $channel" + Write-Host "SubChannel: $subChannel" + Write-Host "EffectiveChannel: $effectiveChannel" + Write-Host "ProjectVersion: $projectVersion" + Write-Host "MarkLatest: $markLatest" + Write-Host "Prune: $prune" - name: Generate CycloneDX SBOM uses: hoobio/pipeline-tools/pipeline/github/step/cyclonedx-sbom-dotnet@v2.2.1 @@ -347,8 +369,10 @@ jobs: system: ${{ env.DT_SYSTEM }} component: ${{ env.DT_COMPONENT }} channel: ${{ steps.dt.outputs.channel }} + sub-channel: ${{ steps.dt.outputs.sub_channel }} + default-branch: main project-version: ${{ steps.dt.outputs.project_version }} - project-tags: channel=${{ steps.dt.outputs.channel }},repo=${{ github.repository }},commit=${{ github.sha }},run_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + project-tags: channel=${{ steps.dt.outputs.effective_channel }},repo=${{ github.repository }},commit=${{ github.sha }},run_url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} mark-latest: ${{ steps.dt.outputs.mark_latest }} prune-stale-children: ${{ steps.dt.outputs.prune }} keep: ${{ github.event_name == 'workflow_dispatch' && '1' || '10' }} diff --git a/CLAUDE.md b/AGENTS.md similarity index 96% rename from CLAUDE.md rename to AGENTS.md index 6b37498..596e1ba 100644 --- a/CLAUDE.md +++ b/AGENTS.md @@ -90,6 +90,10 @@ Rules apply in **list order** - top of the list wins. There is no priority field - `RuleRow` is the per-rule view-model. It owns its own debounced `SaveAsync`. When the underlying rules list changes, `RulesViewModel.OnRulesChanged` calls `SyncFromRule` on each existing row in place (preserving expanded state) when the order is unchanged; only on add/delete/reorder does it rebuild `Items`. - Tray: `H.NotifyIcon.WinUI`. `WindowChromeManager` subclasses the window with `SetWindowSubclass` to intercept `WM_SYSCOMMAND/SC_MINIMIZE` for "minimize to tray", and handles `Closed` for "close to tray". +## Reactivity preferences + +- **Event-driven first, polling as fallback.** When the OS exposes a change notification (e.g. `IMMNotificationClient`, `AudioEndpointVolume.OnVolumeNotification`, `IAudioSessionEvents`), prefer subscribing to that and reconciling on the event. Polling is acceptable as a safety net (drift recovery, restart correctness), but the primary path for "external state changed -> reconcile our state" must not depend on a tick interval. New code that adds reactivity should plumb the event path first and only fall back to polling when the OS has no usable notification. + ## Common gotchas - **Don't use `[InterfaceType(InterfaceIsIInspectable)]`** in COM interop. Modern .NET doesn't marshal it. Use `IUnknown` with reserved vtable slots. diff --git a/README.md b/README.md index 5ef1340..44c3504 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Per-app audio routing for Windows, driven by regex rules. Pin a browser to your [![GitHub Issues](https://img.shields.io/github/issues/hoobio/earmark)](https://github.com/hoobio/earmark/issues) [![Last Commit](https://img.shields.io/github/last-commit/hoobio/earmark)](https://github.com/hoobio/earmark/commits/main) -![Earmark](application.png) +![Devices](ss_application.png) ![Rules Engine](ss_rules.png) ## Features diff --git a/application.png b/application.png deleted file mode 100644 index 7c7eb69..0000000 Binary files a/application.png and /dev/null differ diff --git a/src/Earmark.App/App.xaml b/src/Earmark.App/App.xaml index 1e35fc6..7a2de26 100644 --- a/src/Earmark.App/App.xaml +++ b/src/Earmark.App/App.xaml @@ -32,6 +32,9 @@ + + + 4 @@ -63,16 +66,14 @@ @@ -84,8 +85,7 @@ @@ -103,6 +103,17 @@ + + + diff --git a/src/Earmark.App/App.xaml.cs b/src/Earmark.App/App.xaml.cs index 9f8b670..a9b03fa 100644 --- a/src/Earmark.App/App.xaml.cs +++ b/src/Earmark.App/App.xaml.cs @@ -52,6 +52,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) { await _host.Services.GetRequiredService().LoadAsync(); _host.Services.GetRequiredService().Start(); + _host.Services.GetRequiredService().Register(); _logger.LogInformation("Settings loaded"); // Heavy init (audio endpoint + session enumeration via COM, rules load, routing diff --git a/src/Earmark.App/Controls/WrapByRowLayout.cs b/src/Earmark.App/Controls/WrapByRowLayout.cs new file mode 100644 index 0000000..be57fd6 --- /dev/null +++ b/src/Earmark.App/Controls/WrapByRowLayout.cs @@ -0,0 +1,190 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +using Windows.Foundation; + +namespace Earmark.App.Controls; + +/// +/// Wrap-style virtualised layout. Arranges items into rows of equal-width columns; each row's +/// height is sized to the tallest card in that row alone, independent of other rows. Unlike +/// (which makes all items the same height), one expanded card +/// only grows the row it sits in - it does not balloon every card on the page. +/// +public sealed class WrapByRowLayout : VirtualizingLayout +{ + /// + /// Attached property. When true on an item, the item keeps its own desired height + /// instead of stretching to the row baseline - lets one user-expanded card grow on its + /// own while sibling cards in the row stay aligned to each other. + /// + public static readonly DependencyProperty IsCustomSizedProperty = DependencyProperty.RegisterAttached( + "IsCustomSized", typeof(bool), typeof(WrapByRowLayout), + new PropertyMetadata(false, OnIsCustomSizedChanged)); + + public static bool GetIsCustomSized(DependencyObject element) => + (bool)element.GetValue(IsCustomSizedProperty); + + public static void SetIsCustomSized(DependencyObject element, bool value) => + element.SetValue(IsCustomSizedProperty, value); + + private static void OnIsCustomSizedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is UIElement element) + { + // Force the parent ItemsRepeater to remeasure when a card flips between + // baseline-sized and custom-sized. + element.InvalidateMeasure(); + } + } + + public static readonly DependencyProperty MinItemWidthProperty = DependencyProperty.Register( + nameof(MinItemWidth), typeof(double), typeof(WrapByRowLayout), + new PropertyMetadata(320.0, OnLayoutPropertyChanged)); + + public static readonly DependencyProperty ColumnSpacingProperty = DependencyProperty.Register( + nameof(ColumnSpacing), typeof(double), typeof(WrapByRowLayout), + new PropertyMetadata(12.0, OnLayoutPropertyChanged)); + + public static readonly DependencyProperty RowSpacingProperty = DependencyProperty.Register( + nameof(RowSpacing), typeof(double), typeof(WrapByRowLayout), + new PropertyMetadata(12.0, OnLayoutPropertyChanged)); + + public double MinItemWidth + { + get => (double)GetValue(MinItemWidthProperty); + set => SetValue(MinItemWidthProperty, value); + } + + public double ColumnSpacing + { + get => (double)GetValue(ColumnSpacingProperty); + set => SetValue(ColumnSpacingProperty, value); + } + + public double RowSpacing + { + get => (double)GetValue(RowSpacingProperty); + set => SetValue(RowSpacingProperty, value); + } + + private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is WrapByRowLayout layout) + { + layout.InvalidateMeasure(); + } + } + + protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + if (context.ItemCount == 0) + { + return new Size(availableSize.Width, 0); + } + + var columnCount = ComputeColumnCount(availableSize.Width); + var columnWidth = ComputeColumnWidth(availableSize.Width, columnCount); + + var totalHeight = 0.0; + var rowMax = 0.0; + var inRow = 0; + + for (var i = 0; i < context.ItemCount; i++) + { + var element = context.GetOrCreateElementAt(i); + element.Measure(new Size(columnWidth, double.PositiveInfinity)); + + if (element.DesiredSize.Height > rowMax) + { + rowMax = element.DesiredSize.Height; + } + inRow++; + + if (inRow == columnCount) + { + totalHeight += rowMax; + if (i < context.ItemCount - 1) totalHeight += RowSpacing; + rowMax = 0; + inRow = 0; + } + } + + if (inRow > 0) + { + totalHeight += rowMax; + } + + return new Size(availableSize.Width, totalHeight); + } + + protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + if (context.ItemCount == 0) + { + return new Size(finalSize.Width, 0); + } + + var columnCount = ComputeColumnCount(finalSize.Width); + var columnWidth = ComputeColumnWidth(finalSize.Width, columnCount); + + var y = 0.0; + + for (var rowStart = 0; rowStart < context.ItemCount; rowStart += columnCount) + { + var rowEnd = Math.Min(rowStart + columnCount, context.ItemCount); + + // Pass 1: baseline = tallest non-custom card. Custom-sized cards (e.g. a card with + // its rules-chevron expanded by the user) are excluded so they don't drag everyone + // else up. Also track overall row height for next-row positioning. + var baseline = 0.0; + var rowTotal = 0.0; + for (var i = rowStart; i < rowEnd; i++) + { + var element = context.GetOrCreateElementAt(i); + var h = element.DesiredSize.Height; + if (h > rowTotal) rowTotal = h; + if (!GetIsCustomSized((DependencyObject)element) && h > baseline) + { + baseline = h; + } + } + if (baseline == 0) baseline = rowTotal; // all custom-sized? fall back to true max + + // Pass 2: non-custom cards stretch to the baseline so siblings stay aligned. + // Custom-sized cards keep their own (taller) desired height. + for (var i = rowStart; i < rowEnd; i++) + { + var element = context.GetOrCreateElementAt(i); + var col = i - rowStart; + var x = col * (columnWidth + ColumnSpacing); + var h = GetIsCustomSized((DependencyObject)element) + ? element.DesiredSize.Height + : baseline; + element.Arrange(new Rect(x, y, columnWidth, h)); + } + + y += rowTotal; + if (rowEnd < context.ItemCount) + { + y += RowSpacing; + } + } + + return new Size(finalSize.Width, y); + } + + private int ComputeColumnCount(double availableWidth) + { + if (availableWidth <= 0 || MinItemWidth <= 0) return 1; + var candidate = (availableWidth + ColumnSpacing) / (MinItemWidth + ColumnSpacing); + return Math.Max(1, (int)Math.Floor(candidate)); + } + + private double ComputeColumnWidth(double availableWidth, int columnCount) + { + if (columnCount <= 0) return availableWidth; + var totalSpacing = ColumnSpacing * (columnCount - 1); + return Math.Max(0, (availableWidth - totalSpacing) / columnCount); + } +} diff --git a/src/Earmark.App/Converters/Converters.cs b/src/Earmark.App/Converters/Converters.cs index d69a161..67341fc 100644 --- a/src/Earmark.App/Converters/Converters.cs +++ b/src/Earmark.App/Converters/Converters.cs @@ -112,6 +112,72 @@ public object ConvertBack(object value, Type targetType, object parameter, strin throw new NotSupportedException(); } +public sealed class PeakLevelToBrushConverter : IValueConverter +{ + // Thresholds picked to mirror conventional pro-audio meters: + // < 0.25 (~ -12 dBFS): below the speech / vocal sweet spot - keep it green. + // 0.25 - 0.5 (-12 to -6 dBFS): the speech / vocal sweet spot - amber. + // >= 0.5 (~ -6 dBFS and up): hot, approaching 0 dBFS clip - red. + public object Convert(object value, Type targetType, object parameter, string language) + { + var level = value is float f ? f : 0f; + var key = level switch + { + >= 0.5f => "SystemFillColorCriticalBrush", + >= 0.25f => "SystemFillColorCautionBrush", + _ => "SystemFillColorSuccessBrush", + }; + if (Application.Current.Resources.TryGetValue(key, out var brush) && brush is Brush b) + { + return b; + } + return new SolidColorBrush(Microsoft.UI.Colors.Gray); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); +} + +public sealed class MuteLockedBackgroundConverter : IValueConverter +{ + // When the mute is rule-locked, the icon button shouldn't read as "clickable" - drop its + // chip background to transparent. Unlocked state uses the normal subtle fill. + public object Convert(object value, Type targetType, object parameter, string language) + { + var locked = value is bool b && b; + if (locked) + { + return new SolidColorBrush(Microsoft.UI.Colors.Transparent); + } + if (Application.Current.Resources.TryGetValue("SubtleFillColorSecondaryBrush", out var brush) && brush is Brush b2) + { + return b2; + } + return new SolidColorBrush(Microsoft.UI.Colors.Transparent); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); +} + +public sealed class MutedBrushConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var key = value is bool muted && muted + ? "SystemFillColorCriticalBrush" + : "AccentTextFillColorPrimaryBrush"; + if (Application.Current.Resources.TryGetValue(key, out var brush) && brush is Brush b) + { + return b; + } + return new SolidColorBrush(Microsoft.UI.Colors.Gray); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => + throw new NotSupportedException(); +} + public sealed class VolumeFloatToPercentConverter : IValueConverter { // Slider values are double; the rule action stores Volume as float in [0,1]. diff --git a/src/Earmark.App/Hosting/HostBuilderExtensions.cs b/src/Earmark.App/Hosting/HostBuilderExtensions.cs index de1ae44..8cb7607 100644 --- a/src/Earmark.App/Hosting/HostBuilderExtensions.cs +++ b/src/Earmark.App/Hosting/HostBuilderExtensions.cs @@ -43,14 +43,17 @@ public static HostApplicationBuilder ConfigureEarmark(this HostApplicationBuilde builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Earmark.App/MainWindow.xaml b/src/Earmark.App/MainWindow.xaml index 844f31e..fe55c32 100644 --- a/src/Earmark.App/MainWindow.xaml +++ b/src/Earmark.App/MainWindow.xaml @@ -19,27 +19,46 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/src/Earmark.App/MainWindow.xaml.cs b/src/Earmark.App/MainWindow.xaml.cs index ac9d2e4..bdcbbef 100644 --- a/src/Earmark.App/MainWindow.xaml.cs +++ b/src/Earmark.App/MainWindow.xaml.cs @@ -11,6 +11,7 @@ public sealed partial class MainWindow : Window { private readonly INavigationService _navigation; private readonly IDispatcherQueueProvider _dispatcher; + private bool _initialNavComplete; public MainWindow(INavigationService navigation, IDispatcherQueueProvider dispatcher) { @@ -25,32 +26,63 @@ public MainWindow(INavigationService navigation, IDispatcherQueueProvider dispat _dispatcher.Register(DispatcherQueue); _navigation.Register(ContentFrame); - NavView.Loaded += async (_, _) => + NavView.Loaded += (_, _) => _ = EnsureInitialNavigationAsync(); + // Belt-and-suspenders: NavView.Loaded can race or skip when the window starts hidden + // (Launch-to-tray) and the visual tree isn't realised until the user opens it. + // Activated fires every time the window is shown, so the first activation also + // triggers initial nav if it hasn't happened yet. + Activated += (_, _) => _ = EnsureInitialNavigationAsync(); + } + + private async Task EnsureInitialNavigationAsync() + { + if (_initialNavComplete) return; + + // Wait for background init (audio service construction, rules load) before the + // first navigation. RulesViewModel depends on the audio singletons; navigating + // before they're ready would resolve them on the UI thread and freeze startup. + if (InitializationTask is { } init) { - // Wait for background init (audio service construction, rules load) before the - // first navigation. RulesViewModel depends on the audio singletons; navigating - // before they're ready would resolve them on the UI thread and freeze startup. - if (InitializationTask is { } init) - { - try - { - await init; - } - catch - { - // Failures are already logged by App.OnLaunched; navigate anyway so the - // user sees a (degraded) UI rather than a blank window. - } - } - - var first = (NavigationViewItem)NavView.MenuItems[0]; - NavigateTo(first); - NavView.SelectedItem = first; - }; + try { await init; } + catch { /* logged by App.OnLaunched; navigate anyway to a degraded UI */ } + } + + if (_initialNavComplete) return; + if (NavView.MenuItems.Count == 0) return; + + _initialNavComplete = true; + var first = (NavigationViewItem)NavView.MenuItems[0]; + NavigateTo(first); + NavView.SelectedItem = first; } public Task? InitializationTask { get; set; } + /// + /// Switches the navigation pane (and therefore the active page) to the menu item with + /// the given Tag. Used for cross-page navigation, e.g. clicking a rule chip on + /// the Devices page jumps the user to the Rules page with that rule expanded. + /// + public void NavigateByTag(string tag) + { + ArgumentException.ThrowIfNullOrEmpty(tag); + + var target = NavView.MenuItems.Concat(NavView.FooterMenuItems) + .OfType() + .FirstOrDefault(i => (i.Tag as string) == tag); + if (target is null) return; + + if (!ReferenceEquals(NavView.SelectedItem, target)) + { + NavView.SelectedItem = target; + } + else + { + // Already selected: SelectionChanged won't fire, so trigger the nav directly. + NavigateTo(target); + } + } + private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) { if (args.SelectedItem is NavigationViewItem item) @@ -73,6 +105,7 @@ private void NavigateTo(NavigationViewItem item) var pageType = tag switch { + "Home" => typeof(HomePage), "Rules" => typeof(RulesPage), "Sessions" => typeof(SessionsPage), "Settings" => typeof(SettingsPage), diff --git a/src/Earmark.App/Services/INotificationService.cs b/src/Earmark.App/Services/INotificationService.cs new file mode 100644 index 0000000..8939b30 --- /dev/null +++ b/src/Earmark.App/Services/INotificationService.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Windows.AppNotifications; +using Microsoft.Windows.AppNotifications.Builder; + +namespace Earmark.App.Services; + +public interface INotificationService +{ + /// Registers the app with the Windows notification platform. Safe to call multiple + /// times; subsequent calls are no-ops. + void Register(); + + /// Shows a simple two-line toast. + void Show(string title, string body); +} + +internal sealed class NotificationService : INotificationService, IDisposable +{ + private readonly ILogger _logger; + private bool _registered; + + public NotificationService(ILogger logger) + { + _logger = logger; + } + + public void Register() + { + if (_registered) return; + try + { + AppNotificationManager.Default.Register(); + _registered = true; + _logger.LogInformation("AppNotificationManager registered"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "AppNotificationManager.Register failed"); + } + } + + public void Show(string title, string body) + { + ArgumentException.ThrowIfNullOrEmpty(title); + ArgumentNullException.ThrowIfNull(body); + if (!_registered) + { + Register(); + if (!_registered) return; + } + + try + { + var notification = new AppNotificationBuilder() + .AddText(title) + .AddText(body) + .BuildNotification(); + AppNotificationManager.Default.Show(notification); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "AppNotificationManager.Show failed"); + } + } + + public void Dispose() + { + if (!_registered) return; + try + { + AppNotificationManager.Default.Unregister(); + } + catch + { + // Unregister can race with COM shutdown; swallow. + } + } +} diff --git a/src/Earmark.App/Services/IRoutingApplier.cs b/src/Earmark.App/Services/IRoutingApplier.cs index f66f6cf..2605e0d 100644 --- a/src/Earmark.App/Services/IRoutingApplier.cs +++ b/src/Earmark.App/Services/IRoutingApplier.cs @@ -142,11 +142,12 @@ await Task.Run(() => { ArgumentNullException.ThrowIfNull(session); + var sessions = _sessions.GetSessions(); var renderEndpoints = _endpoints.GetEndpoints(EndpointFlow.Render); var captureEndpoints = _endpoints.GetEndpoints(EndpointFlow.Capture); - ApplyAppForFlow(session, EndpointFlow.Render, renderEndpoints); - ApplyAppForFlow(session, EndpointFlow.Capture, captureEndpoints); + ApplyAppForFlow(session, EndpointFlow.Render, renderEndpoints, sessions); + ApplyAppForFlow(session, EndpointFlow.Capture, captureEndpoints, sessions); return Task.FromResult(null); } @@ -165,14 +166,14 @@ private void ApplyApplicationsToSessions() foreach (var session in sessions) { - ApplyAppForFlow(session, EndpointFlow.Render, renderEndpoints); - ApplyAppForFlow(session, EndpointFlow.Capture, captureEndpoints); + ApplyAppForFlow(session, EndpointFlow.Render, renderEndpoints, sessions); + ApplyAppForFlow(session, EndpointFlow.Capture, captureEndpoints, sessions); } } - private void ApplyAppForFlow(AudioSession session, EndpointFlow flow, IReadOnlyList endpoints) + private void ApplyAppForFlow(AudioSession session, EndpointFlow flow, IReadOnlyList endpoints, IReadOnlyList sessions) { - var match = _matcher.FindAppRoute(session, flow, _rules.Rules, endpoints); + var match = _matcher.FindAppRoute(session, flow, _rules.Rules, endpoints, sessions); if (match is null) { return; @@ -240,23 +241,25 @@ private void ApplyDefaultDevices() } } + var sessions = _sessions.GetSessions(); + if (hasOutputDefault) { - ApplyDefaultFlow(EndpointFlow.Render, _endpoints.GetEndpoints(EndpointFlow.Render)); + ApplyDefaultFlow(EndpointFlow.Render, _endpoints.GetEndpoints(EndpointFlow.Render), sessions); } if (hasInputDefault) { - ApplyDefaultFlow(EndpointFlow.Capture, _endpoints.GetEndpoints(EndpointFlow.Capture)); + ApplyDefaultFlow(EndpointFlow.Capture, _endpoints.GetEndpoints(EndpointFlow.Capture), sessions); } } - private void ApplyDefaultFlow(EndpointFlow flow, IReadOnlyList endpoints) + private void ApplyDefaultFlow(EndpointFlow flow, IReadOnlyList endpoints, IReadOnlyList sessions) { - ApplyDefaultRole(flow, DefaultRoleKind.Default, RoleScope.Default, endpoints, + ApplyDefaultRole(flow, DefaultRoleKind.Default, RoleScope.Default, endpoints, sessions, current => endpoints.FirstOrDefault(e => e.Flow == flow && e.IsDefault)); - ApplyDefaultRole(flow, DefaultRoleKind.Communications, RoleScope.Communications, endpoints, + ApplyDefaultRole(flow, DefaultRoleKind.Communications, RoleScope.Communications, endpoints, sessions, current => endpoints.FirstOrDefault(e => e.Flow == flow && e.IsDefaultCommunications)); } @@ -265,9 +268,10 @@ private void ApplyDefaultRole( DefaultRoleKind roleKind, RoleScope scope, IReadOnlyList endpoints, + IReadOnlyList sessions, Func currentResolver) { - var match = _matcher.FindDefaultDevice(flow, roleKind, _rules.Rules, endpoints); + var match = _matcher.FindDefaultDevice(flow, roleKind, _rules.Rules, endpoints, sessions); if (match is null) { return; @@ -386,6 +390,8 @@ private void ApplyVolumeAndMuteRules() return; } + var sessions = _sessions.GetSessions(); + foreach (var endpoint in allEndpoints) { float? targetVolume = null; @@ -397,7 +403,7 @@ private void ApplyVolumeAndMuteRules() { if (targetVolume.HasValue && targetMuted.HasValue) break; if (!rule.Enabled) continue; - if (!_matcher.ConditionsMet(rule, renderEndpoints)) continue; + if (!_matcher.ConditionsMet(rule, renderEndpoints, sessions)) continue; var ruleLabel = string.IsNullOrEmpty(rule.Name) ? rule.Id.ToString() : rule.Name; @@ -523,11 +529,12 @@ private Dictionary BuildWaveLinkClaims(WaveLinkSnapshot s var claims = new Dictionary(StringComparer.Ordinal); var setOwnedMixes = new HashSet(StringComparer.Ordinal); var renderEndpoints = _endpoints.GetEndpoints(EndpointFlow.Render); + var sessions = _sessions.GetSessions(); foreach (var rule in _rules.Rules) { if (!rule.Enabled) continue; - if (!_matcher.ConditionsMet(rule, renderEndpoints)) continue; + if (!_matcher.ConditionsMet(rule, renderEndpoints, sessions)) continue; var ruleLabel = string.IsNullOrEmpty(rule.Name) ? rule.Id.ToString() : rule.Name; diff --git a/src/Earmark.App/Services/WindowChromeManager.cs b/src/Earmark.App/Services/WindowChromeManager.cs index b405f9c..fcf1c27 100644 --- a/src/Earmark.App/Services/WindowChromeManager.cs +++ b/src/Earmark.App/Services/WindowChromeManager.cs @@ -24,6 +24,9 @@ internal sealed class WindowChromeManager : IWindowChromeManager, IDisposable { private const uint WM_SYSCOMMAND = 0x0112; private const uint SC_MINIMIZE = 0xF020; + private const uint WM_GETMINMAXINFO = 0x0024; + private const int MinWindowWidthDip = 440; + private const int MinWindowHeightDip = 340; private readonly ISettingsService _settings; private TaskbarIcon? _trayIcon; @@ -51,12 +54,50 @@ public void Attach(Window window) if (_appWindow is not null) { _appWindow.Closing += OnAppWindowClosing; + RestoreSavedSize(); } InstallSubclass(); SyncTrayIcon(); } + private void RestoreSavedSize() + { + if (_appWindow is null) return; + var s = _settings.Current; + if (s.WindowWidth is int w && s.WindowHeight is int h && w > 0 && h > 0) + { + try + { + _appWindow.Resize(new Windows.Graphics.SizeInt32(w, h)); + } + catch + { + // Resize can fail if dimensions are larger than any monitor; let the + // default size stand. + } + } + } + + private void SaveCurrentSize() + { + if (_appWindow is null) return; + try + { + var size = _appWindow.Size; + if (size.Width <= 0 || size.Height <= 0) return; + if (_settings.Current.WindowWidth == size.Width && + _settings.Current.WindowHeight == size.Height) return; + _settings.Current.WindowWidth = size.Width; + _settings.Current.WindowHeight = size.Height; + _ = _settings.SaveAsync(); + } + catch + { + // Non-fatal; we just won't remember this resize. + } + } + public void RestoreWindow() { if (_window is null || _appWindow is null) @@ -84,6 +125,7 @@ public void HideToTray() public void RequestExit() { _exitRequested = true; + SaveCurrentSize(); _trayIcon?.Dispose(); _trayIcon = null; @@ -183,6 +225,10 @@ private void EnsureTrayIcon() // not cancel anything, so route through here instead. private void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args) { + // Save size every time the window closes (or hides to tray) so we restore the + // user's preferred dimensions next launch. + SaveCurrentSize(); + if (_exitRequested) { return; @@ -217,6 +263,17 @@ private nint WindowSubclassProc(nint hWnd, uint uMsg, nint wParam, nint lParam, } } + if (uMsg == WM_GETMINMAXINFO && lParam != 0) + { + var dpi = GetDpiForWindow(hWnd); + var scale = dpi == 0 ? 1.0 : dpi / 96.0; + var mmi = Marshal.PtrToStructure(lParam); + mmi.ptMinTrackSize.x = (int)Math.Round(MinWindowWidthDip * scale); + mmi.ptMinTrackSize.y = (int)Math.Round(MinWindowHeightDip * scale); + Marshal.StructureToPtr(mmi, lParam, fDeleteOld: false); + return 0; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); } @@ -250,6 +307,26 @@ public void Dispose() [DllImport("Comctl32.dll", CharSet = CharSet.Unicode)] private static extern nint DefSubclassProc(nint hWnd, uint uMsg, nint wParam, nint lParam); + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(nint hWnd); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int x; + public int y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MINMAXINFO + { + public POINT ptReserved; + public POINT ptMaxSize; + public POINT ptMaxPosition; + public POINT ptMinTrackSize; + public POINT ptMaxTrackSize; + } + private sealed class RelayCommandSimple : System.Windows.Input.ICommand { private readonly Action _execute; diff --git a/src/Earmark.App/Settings/AppSettings.cs b/src/Earmark.App/Settings/AppSettings.cs index 642fa7e..7fc6a8a 100644 --- a/src/Earmark.App/Settings/AppSettings.cs +++ b/src/Earmark.App/Settings/AppSettings.cs @@ -15,4 +15,18 @@ public sealed class AppSettings public bool VerboseLogging { get; set; } public bool EnableWaveLink { get; set; } + + public List HiddenDeviceIds { get; set; } = new(); + + /// + /// Devices the user has explicitly chosen to keep visible. Overrides the auto-hide rule + /// that hides non-default devices with no rules. still wins + /// if the same device somehow ends up in both lists. + /// + public List PinnedDeviceIds { get; set; } = new(); + + /// Persisted window size in physical pixels. Null until the user has resized + /// at least once (so first launch picks the WinUI default). + public int? WindowWidth { get; set; } + public int? WindowHeight { get; set; } } diff --git a/src/Earmark.App/ViewModels/DeviceCard.cs b/src/Earmark.App/ViewModels/DeviceCard.cs new file mode 100644 index 0000000..a2aa68e --- /dev/null +++ b/src/Earmark.App/ViewModels/DeviceCard.cs @@ -0,0 +1,528 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using Earmark.Core.Audio; +using Earmark.Core.Models; + +using Microsoft.UI.Xaml; + +namespace Earmark.App.ViewModels; + +/// +/// Per-device card view-model for the Home page. Owns its volume / mute / peak-meter / +/// hide state and routes user actions to . +/// +public partial class DeviceCard : ObservableObject +{ + private const double PeakHoldSeconds = 1.5; + private const float PeakHoldDecayPerSecond = 0.55f; + + private readonly IAudioEndpointService _endpoints; + private readonly Action _onVisibilityToggled; + private bool _suppressVolumeWrite; + private bool _showHidden; + private DateTime _peakHoldExpiry = DateTime.MinValue; + + /// Snapshot of the two user-visibility flags. Used to capture pre-toggle state + /// for undo. + public readonly record struct VisibilityState(bool IsHidden, bool IsPinned); + + public DeviceCard( + IAudioEndpointService endpoints, + AudioEndpoint endpoint, + float volume, + bool isMuted, + bool isVolumeLockedByRule, + bool isMuteLockedByRule, + bool? ruleMutedTarget, + string? ruleMutedSource, + IReadOnlyList rules, + bool isHiddenByUser, + bool isPinnedByUser, + bool showHidden, + Action onUserVisibilityToggled) + { + _endpoints = endpoints; + _onVisibilityToggled = onUserVisibilityToggled; + Endpoint = endpoint; + + _suppressVolumeWrite = true; + Volume = Math.Clamp(volume, 0f, 1f); + _suppressVolumeWrite = false; + + IsMuted = isMuted; + IsVolumeLockedByRule = isVolumeLockedByRule; + IsMuteLockedByRule = isMuteLockedByRule; + RuleMutedTarget = ruleMutedTarget; + RuleMutedSource = ruleMutedSource; + Rules = rules; + + _showHidden = showHidden; + IsHiddenByUser = isHiddenByUser; + IsPinnedByUser = isPinnedByUser; + } + + public AudioEndpoint Endpoint { get; } + public IReadOnlyList Rules { get; } + + public string DisplayName => Endpoint.FriendlyName; + public string Subtitle => Endpoint.DeviceDescription; + public bool IsRender => Endpoint.Flow == EndpointFlow.Render; + public bool IsCapture => Endpoint.Flow == EndpointFlow.Capture; + public string FlowLabel => IsRender ? "Output" : "Input"; + public bool IsDefault => Endpoint.IsDefault; + public bool IsDefaultCommunications => Endpoint.IsDefaultCommunications; + + /// The "Input" / "Output" label is redundant when a Default-* pill already + /// names the flow, so we hide it whenever either default pill is showing. + public bool ShowFlowLabel => !IsDefault && !IsDefaultCommunications; + + public string DefaultPillText => IsRender ? "Default Output" : "Default Input"; + public string CommunicationsPillText => IsRender ? "Communications Output" : "Communications Input"; + public bool HasRules => Rules.Count > 0; + public bool HasNoRules => Rules.Count == 0; + public bool HasMultipleRules => Rules.Count > 1; + + // The slider is editable unless: + // - a volume rule pins the level, or + // - a mute rule forces the device MUTED (volume is irrelevant when silenced). + // A rule that forces UNMUTE still lets the user change the volume - they just can't + // mute it back themselves. + public bool IsVolumeEditable => + !IsVolumeLockedByRule && !(IsMuteLockedByRule && RuleMutedTarget == true); + + /// If a rule is currently pinning this device's mute state, this is the target + /// value (true = forced muted, false = forced unmuted). Null when no rule applies. + public bool? RuleMutedTarget { get; private set; } + + /// The display name of the rule currently pinning the mute state, used by the + /// reconciliation toast to tell the user which rule overrode their change. + public string? RuleMutedSource { get; private set; } + + // ---- Persistence-bound state ---- + + /// User has explicitly hidden this card. Wins over auto / pin. + [ObservableProperty] + public partial bool IsHiddenByUser { get; set; } + + /// User has explicitly pinned this card visible. Overrides the auto-hide-no-rules rule + /// but is itself overridden by . + [ObservableProperty] + public partial bool IsPinnedByUser { get; set; } + + /// + /// Resolves visibility per the spec: + /// - User-hidden -> hidden (force) + /// - User-pinned -> shown (force) + /// - Default device -> shown (never auto-hidden) + /// - No rules -> hidden (auto) + /// - Otherwise -> shown + /// + public bool IsEffectivelyHidden + { + get + { + if (IsHiddenByUser) return true; + if (IsPinnedByUser) return false; + if (IsDefault || IsDefaultCommunications) return false; + return HasNoRules; + } + } + + /// True when the card should render in the grid (visible-or-show-hidden). + public bool IsListed => _showHidden || !IsEffectivelyHidden; + + /// Reduced when shown via "show hidden" toggle. + public double CardOpacity => IsListed && IsEffectivelyHidden ? 0.5 : 1.0; + + // ---- Volume ---- + + /// Slider exposes 0-100 to match common UI; underlying API is 0-1. + public double VolumePercent + { + get => Math.Round(Volume * 100.0); + set + { + var asFloat = (float)Math.Clamp(value / 100.0, 0.0, 1.0); + Volume = asFloat; + } + } + + public string VolumePercentText => IsMuted + ? "Muted" + : $"{(int)Math.Round(Volume * 100.0)}%"; + + /// Greys out the slider track + peak meter when the device is muted. Hit-testing + /// still works so the user can drag to unmute. + public double VolumeAreaOpacity => IsMuted ? 0.2 : 1.0; + + [ObservableProperty] + public partial float Volume { get; set; } + + [ObservableProperty] + public partial bool IsMuted { get; set; } + + [ObservableProperty] + public partial bool IsVolumeLockedByRule { get; set; } + + /// True when an active MuteDevice or UnmuteDevice rule pins this device's mute + /// state. The mute icon becomes non-interactive and slider drags don't auto-toggle mute. + [ObservableProperty] + public partial bool IsMuteLockedByRule { get; set; } + + public bool IsMuteToggleEnabled => !IsMuteLockedByRule; + + /// + /// When true, the rules panel renders all rule chips up to ; + /// otherwise it caps at one rule (~52 dip) and shows a chevron-down to expand. Toggled + /// only when there are 2+ rules. + /// + [ObservableProperty] + public partial bool IsRulesExpanded { get; set; } + + /// + /// When there's only one rule, no cap (so the chip never gets a stray scrollbar). With + /// 2+ rules: collapsed caps to one chip's worth of height with chevron-down to expand; + /// expanded grows to a generous ceiling. + /// + public double RulesPanelMaxHeight + { + get + { + if (!HasMultipleRules) return RulesPanelMaxHeightExpanded; + return IsRulesExpanded ? RulesPanelMaxHeightExpanded : RulesPanelMaxHeightCollapsed; + } + } + + public string RulesExpandGlyph => IsRulesExpanded + ? new string((char)0xE70E, 1) // ChevronUp + : new string((char)0xE70D, 1); // ChevronDown + + public string RulesExpandTooltip => IsRulesExpanded ? "Collapse rules" : "Show all rules"; + + // Chip body = 10 (top padding) + 20 (BodyStrong line) + 2 (spacing) + 16 (Caption line) + // + 10 (bottom padding) = 58 dip (no borders); plus 2 dip top margin between chips. + // 60 fits one chip + its leading margin so the second chip starts past the viewport. + private const double RulesPanelMaxHeightCollapsed = 60; + private const double RulesPanelMaxHeightExpanded = 320; + + // ---- Peak meter (sectioned + hold) ---- + // + // The meter is log-scaled so it matches how pro-audio VU meters render dB. Linear + // amplitude is converted to dBFS (-60 to 0 dB display range), then mapped to bar + // position [0..1]. Bar position is then bounded on the right by the volume thumb + // (final width = barPosition(peak) * volume). + // + // Colour thresholds are fixed dBFS values; the log-scale maps them to bar positions. + // Each boundary is wrapped in a narrow ±BlendHalf gradient band so the transition is + // smooth but the dB-accurate centre stays at the threshold itself: + // -inf ... -12 dBFS -> bar 0 ... 0.78 : green + // ±BlendHalf around -12 dBFS : green->yellow blend + // -12 ... -6 dBFS -> bar 0.82 ... 0.88 : amber (speech / vocal sweet spot) + // ±BlendHalf around -6 dBFS : yellow->red blend + // -6 ... 0 dBFS -> bar 0.92 ... 1.00 : red (approaching clip) + private const float MinDb = -60f; + private const double YellowCentre = 0.80; // = (-12 - MinDb) / -MinDb + private const double RedCentre = 0.90; // = (-6 - MinDb) / -MinDb + private const double BlendHalf = 0.02; // ±2% on either side of each threshold + + private const double GreenEnd = YellowCentre - BlendHalf; // 0.78 + private const double YellowStart = YellowCentre + BlendHalf; // 0.82 + private const double YellowEnd = RedCentre - BlendHalf; // 0.88 + private const double RedStart = RedCentre + BlendHalf; // 0.92 + + /// Current audio peak level (0..1 linear amplitude), pushed by . + [ObservableProperty] + public partial float PeakLevel { get; set; } + + /// Latched peak hold (0..1 linear amplitude): rises instantly with new highs, holds, then decays. + [ObservableProperty] + public partial float PeakHoldLevel { get; set; } + + public double PeakLevelPercent => Math.Clamp(PeakLevel * 100.0, 0.0, 100.0); + + private static GridLength Star(double value) => + new GridLength(Math.Max(value, 0.0001), GridUnitType.Star); + + /// Linear amplitude (0..1) -> bar position (0..1), log-scaled across [MinDb, 0] dBFS. + private static double DbBar(float amplitude) + { + if (amplitude <= 0f) return 0; + var db = 20.0 * Math.Log10(amplitude); + if (db <= MinDb) return 0; + return Math.Clamp((db - MinDb) / -MinDb, 0.0, 1.0); + } + + public GridLength GreenStars => + Star(Math.Min(DbBar(PeakLevel), GreenEnd) * Volume); + + public GridLength GreenYellowBlendStars => + Star(Math.Max(0.0, Math.Min(DbBar(PeakLevel), YellowStart) - GreenEnd) * Volume); + + public GridLength YellowStars => + Star(Math.Max(0.0, Math.Min(DbBar(PeakLevel), YellowEnd) - YellowStart) * Volume); + + public GridLength YellowRedBlendStars => + Star(Math.Max(0.0, Math.Min(DbBar(PeakLevel), RedStart) - YellowEnd) * Volume); + + public GridLength RedStars => + Star(Math.Max(0.0, DbBar(PeakLevel) - RedStart) * Volume); + + public GridLength MeterRemainderStars => + Star(1.0 - Math.Clamp(DbBar(PeakLevel) * Volume, 0.0, 1.0)); + + public GridLength PeakHoldLeftStars => + Star(Math.Clamp(DbBar(PeakHoldLevel) * Volume, 0.0, 1.0)); + + public GridLength PeakHoldRightStars => + Star(1.0 - Math.Clamp(DbBar(PeakHoldLevel) * Volume, 0.0, 1.0)); + + public Visibility PeakHoldVisibility => + PeakHoldLevel > 0.001f ? Visibility.Visible : Visibility.Collapsed; + + // ---- Icon visuals ---- + + public string Glyph => (IsRender, IsMuted) switch + { + (true, false) => new string((char)0xE15D, 1), // Volume / speaker + (true, true) => new string((char)0xE74F, 1), // Volume Mute + (false, false) => new string((char)0xE720, 1), // Microphone + (false, true) => new string((char)0xF781, 1), // MicOff + }; + + public string MuteTooltip + { + get + { + if (IsMuteLockedByRule) + { + return IsMuted ? "Mute locked by rule" : "Unmute locked by rule"; + } + return IsMuted + ? (IsRender ? "Unmute output" : "Unmute input") + : (IsRender ? "Mute output" : "Mute input"); + } + } + + public string MuteIconForegroundResource => IsMuted + ? "SystemFillColorCriticalBrush" + : "AccentTextFillColorPrimaryBrush"; + + public string HideToggleGlyph => IsEffectivelyHidden + ? new string((char)0xE7B3, 1) // View + : new string((char)0xED1A, 1); // Hide + + public string HideToggleTooltip => IsEffectivelyHidden ? "Show this device" : "Hide this device"; + + // ---- Commands & sync entry points ---- + + /// Called by the page-level toggle so cards repaint visibility/opacity. + public void RefreshListed(bool showHidden) + { + _showHidden = showHidden; + OnPropertyChanged(nameof(IsListed)); + OnPropertyChanged(nameof(CardOpacity)); + } + + [RelayCommand] + public void ToggleUserVisibility() + { + // Capture pre-toggle state so the host (HomeViewModel) can push an undo entry. + var prev = new VisibilityState(IsHiddenByUser, IsPinnedByUser); + + // Flip between "force hide" and "force show" based on the card's current effective + // visibility. This lets a single eye button switch back and forth without exposing + // a tri-state UI. + if (IsEffectivelyHidden) + { + IsHiddenByUser = false; + IsPinnedByUser = true; + } + else + { + IsHiddenByUser = true; + IsPinnedByUser = false; + } + + _onVisibilityToggled?.Invoke(this, prev); + } + + /// + /// Restores explicit visibility state without invoking the toggle callback. Used by the + /// undo path so reversing a hide/show doesn't push another entry onto the undo stack. + /// + public void SetUserVisibility(bool isHidden, bool isPinned) + { + IsHiddenByUser = isHidden; + IsPinnedByUser = isPinned; + } + + /// + /// Restores volume and mute together (used by undo). Writes to the device and bypasses + /// the slider's auto-mute-on-zero / auto-unmute-on-drag side effects. + /// + public void SetVolumeAndMute(float volume, bool muted) + { + _suppressVolumeWrite = true; + try + { + Volume = Math.Clamp(volume, 0f, 1f); + } + finally + { + _suppressVolumeWrite = false; + } + IsMuted = muted; + _endpoints.SetVolume(Endpoint.Id, Volume); + _endpoints.SetMuted(Endpoint.Id, muted); + } + + [RelayCommand] + public void ToggleMute() + { + if (IsMuteLockedByRule) return; + var target = !IsMuted; + _endpoints.SetMuted(Endpoint.Id, target); + IsMuted = _endpoints.GetMuted(Endpoint.Id) ?? target; + } + + /// Updates only when it differs, so the change-notification + /// path runs only when the OS actually drifted from our cached state. + public void SyncMutedFromDevice(bool muted) + { + if (IsMuted != muted) + { + IsMuted = muted; + } + } + + /// Pushes a new peak sample. latches at new highs, + /// holds for , then decays linearly toward the current peak. + public void UpdatePeak(float peak, TimeSpan tickInterval) + { + PeakLevel = peak; + + var now = DateTime.UtcNow; + if (peak >= PeakHoldLevel) + { + PeakHoldLevel = peak; + _peakHoldExpiry = now + TimeSpan.FromSeconds(PeakHoldSeconds); + return; + } + + if (now > _peakHoldExpiry) + { + var step = PeakHoldDecayPerSecond * (float)tickInterval.TotalSeconds; + var next = MathF.Max(peak, PeakHoldLevel - step); + if (Math.Abs(next - PeakHoldLevel) > 0.0005f) + { + PeakHoldLevel = next; + } + } + } + + /// Plays a brief test tone through this device. No-op on capture devices. + public void PlayPing() + { + if (!IsRender) return; + _endpoints.PlayTestPing(Endpoint.Id); + } + + // ---- Property change handlers ---- + + partial void OnVolumeChanged(float value) + { + OnPropertyChanged(nameof(VolumePercent)); + OnPropertyChanged(nameof(VolumePercentText)); + NotifyMeterChanged(); + if (_suppressVolumeWrite || IsVolumeLockedByRule) return; + + // User-initiated slider change: keep mute state coherent with the value. Dragging + // off 0 unmutes, dragging back to 0 auto-mutes. Skipped when an active rule has + // pinned the mute state. + if (!IsMuteLockedByRule) + { + var shouldBeMuted = value <= 0.001f; + if (IsMuted != shouldBeMuted) + { + _endpoints.SetMuted(Endpoint.Id, shouldBeMuted); + IsMuted = shouldBeMuted; + } + } + + _endpoints.SetVolume(Endpoint.Id, value); + } + + private void NotifyMeterChanged() + { + OnPropertyChanged(nameof(GreenStars)); + OnPropertyChanged(nameof(GreenYellowBlendStars)); + OnPropertyChanged(nameof(YellowStars)); + OnPropertyChanged(nameof(YellowRedBlendStars)); + OnPropertyChanged(nameof(RedStars)); + OnPropertyChanged(nameof(MeterRemainderStars)); + OnPropertyChanged(nameof(PeakHoldLeftStars)); + OnPropertyChanged(nameof(PeakHoldRightStars)); + OnPropertyChanged(nameof(PeakHoldVisibility)); + } + + partial void OnIsMutedChanged(bool value) + { + OnPropertyChanged(nameof(Glyph)); + OnPropertyChanged(nameof(MuteTooltip)); + OnPropertyChanged(nameof(MuteIconForegroundResource)); + OnPropertyChanged(nameof(VolumePercentText)); + OnPropertyChanged(nameof(VolumeAreaOpacity)); + } + + partial void OnIsVolumeLockedByRuleChanged(bool value) => + OnPropertyChanged(nameof(IsVolumeEditable)); + + partial void OnIsMuteLockedByRuleChanged(bool value) + { + OnPropertyChanged(nameof(IsMuteToggleEnabled)); + OnPropertyChanged(nameof(MuteTooltip)); + OnPropertyChanged(nameof(IsVolumeEditable)); + } + + partial void OnIsRulesExpandedChanged(bool value) + { + OnPropertyChanged(nameof(RulesPanelMaxHeight)); + OnPropertyChanged(nameof(RulesExpandGlyph)); + OnPropertyChanged(nameof(RulesExpandTooltip)); + } + + partial void OnPeakLevelChanged(float value) + { + OnPropertyChanged(nameof(PeakLevelPercent)); + NotifyMeterChanged(); + } + + partial void OnPeakHoldLevelChanged(float value) + { + OnPropertyChanged(nameof(PeakHoldLeftStars)); + OnPropertyChanged(nameof(PeakHoldRightStars)); + OnPropertyChanged(nameof(PeakHoldVisibility)); + } + + // OnIsHiddenByUserChanged / OnIsPinnedByUserChanged do not fire the visibility callback + // here: ToggleUserVisibility / SetUserVisibility own the callback so they can pass the + // pre-toggle state along (needed for undo). These partials only refresh derived UI props. + partial void OnIsHiddenByUserChanged(bool value) + { + OnPropertyChanged(nameof(IsEffectivelyHidden)); + OnPropertyChanged(nameof(IsListed)); + OnPropertyChanged(nameof(CardOpacity)); + OnPropertyChanged(nameof(HideToggleGlyph)); + OnPropertyChanged(nameof(HideToggleTooltip)); + } + + partial void OnIsPinnedByUserChanged(bool value) + { + OnPropertyChanged(nameof(IsEffectivelyHidden)); + OnPropertyChanged(nameof(IsListed)); + OnPropertyChanged(nameof(CardOpacity)); + OnPropertyChanged(nameof(HideToggleGlyph)); + OnPropertyChanged(nameof(HideToggleTooltip)); + } +} diff --git a/src/Earmark.App/ViewModels/DeviceRulesSummary.cs b/src/Earmark.App/ViewModels/DeviceRulesSummary.cs new file mode 100644 index 0000000..17141cd --- /dev/null +++ b/src/Earmark.App/ViewModels/DeviceRulesSummary.cs @@ -0,0 +1,186 @@ +using Earmark.Core.Models; +using Earmark.Core.Routing; + +namespace Earmark.App.ViewModels; + +/// +/// Compact per-rule data shown on a . Mirrors the summary the +/// Rules page renders for each rule row (name + evaluator status + counts), so the +/// device card stays in sync with the rules editor and we don't duplicate UI layout. +/// +public sealed record RuleSummary( + Guid RuleId, + string Name, + RuleStatus Status, + string StatusMessage, + string MatchSummary) +{ + public bool HasMatchSummary => !string.IsNullOrEmpty(MatchSummary); + + /// Same dim-out logic the Rules page uses for non-active rules. + public double Opacity => Status switch + { + RuleStatus.Active => 1.0, + _ => 0.55, + }; +} + +/// +/// Pure function over rules + endpoints + sessions: given an audio endpoint, returns +/// the list of enabled rules with at least one action targeting it, together with each +/// rule's current evaluator status and a "x apps / y devices" match summary. +/// +internal static class DeviceRulesSummary +{ + public readonly record struct Result( + IReadOnlyList Rules, + bool VolumeLocked, + bool MuteLocked, + bool? RuleMutedTarget, + string? RuleMutedSource); + + public static Result For( + AudioEndpoint endpoint, + IReadOnlyList rules, + IReadOnlyList renderEndpoints, + IReadOnlyList captureEndpoints, + IReadOnlyList sessions, + IRuleMatcher matcher, + IRuleEvaluator evaluator) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(rules); + ArgumentNullException.ThrowIfNull(renderEndpoints); + ArgumentNullException.ThrowIfNull(captureEndpoints); + ArgumentNullException.ThrowIfNull(sessions); + ArgumentNullException.ThrowIfNull(matcher); + ArgumentNullException.ThrowIfNull(evaluator); + + var summaries = new List(); + var combinedEndpoints = renderEndpoints.Concat(captureEndpoints).ToList(); + var volumeLocked = false; + var muteLocked = false; + bool? ruleMutedTarget = null; + string? ruleMutedSource = null; + + foreach (var rule in rules) + { + if (!RuleTargetsEndpoint(rule, endpoint)) continue; + + var name = string.IsNullOrWhiteSpace(rule.Name) ? "Unnamed rule" : rule.Name; + var evaluation = evaluator.Evaluate(rule, rules, sessions, combinedEndpoints); + var matchSummary = BuildMatchSummary(rule, sessions, combinedEndpoints); + + summaries.Add(new RuleSummary( + rule.Id, name, evaluation.Status, evaluation.Message, matchSummary)); + + // Only Active rules can lock controls. Highest-priority (first-listed) active rule + // wins the mute target; subsequent rules don't override it. + if (rule.Enabled && evaluation.Status == RuleStatus.Active) + { + foreach (var action in rule.Actions) + { + if (!action.IsValid || !ActionTargetsEndpoint(action, endpoint)) continue; + + if (action.Type == ActionType.SetDeviceVolume) volumeLocked = true; + if (action.Type is ActionType.MuteDevice or ActionType.UnmuteDevice) + { + muteLocked = true; + if (ruleMutedTarget is null) + { + ruleMutedTarget = action.Type == ActionType.MuteDevice; + ruleMutedSource = name; + } + } + } + } + } + + // Active rules float to the top; everything else keeps its original priority order + // (LINQ's OrderBy is stable, so ties preserve the input sequence). + var ordered = summaries + .OrderByDescending(s => s.Status == RuleStatus.Active) + .ToList(); + + return new Result(ordered, volumeLocked, muteLocked, ruleMutedTarget, ruleMutedSource); + } + + private static bool RuleTargetsEndpoint(RoutingRule rule, AudioEndpoint endpoint) + { + foreach (var action in rule.Actions) + { + if (!action.IsValid || action.IsWaveLinkAction) continue; + if (ActionTargetsEndpoint(action, endpoint)) return true; + } + return false; + } + + private static bool ActionTargetsEndpoint(RuleAction action, AudioEndpoint endpoint) + { + var pattern = action.DevicePattern; + if (string.IsNullOrWhiteSpace(pattern)) return false; + + var flowOk = action.Type switch + { + ActionType.SetApplicationOutput or ActionType.SetDefaultOutput => endpoint.Flow == EndpointFlow.Render, + ActionType.SetApplicationInput or ActionType.SetDefaultInput => endpoint.Flow == EndpointFlow.Capture, + ActionType.SetDeviceVolume or ActionType.MuteDevice or ActionType.UnmuteDevice => true, + _ => false, + }; + if (!flowOk) return false; + + return RuleRow.MatchOrExact(pattern, endpoint.FriendlyName) + || RuleRow.MatchOrExact(pattern, endpoint.DisplayName); + } + + /// + /// Replicates the Rules page's "X apps / Y devices" line for this rule. + /// Counts unique PIDs across all SetApplication* actions, and unique endpoints across + /// actions that target a device. + /// + private static string BuildMatchSummary( + RoutingRule rule, + IReadOnlyList sessions, + IReadOnlyList endpoints) + { + var seenPids = new HashSet(); + var deviceMatchActions = 0; + + foreach (var action in rule.Actions) + { + if (!action.IsValid) continue; + + if (action.Type is ActionType.SetApplicationOutput or ActionType.SetApplicationInput && + !string.IsNullOrWhiteSpace(action.AppPattern)) + { + foreach (var session in sessions) + { + if (RuleRow.MatchOrExact(action.AppPattern, session.ProcessName) || + RuleRow.MatchOrExact(action.AppPattern, session.ExecutablePath)) + { + seenPids.Add(session.ProcessId); + } + } + } + + if (!action.IsWaveLinkAction && !string.IsNullOrWhiteSpace(action.DevicePattern)) + { + var hits = endpoints.Any(e => e.State == EndpointState.Active && + (RuleRow.MatchOrExact(action.DevicePattern, e.FriendlyName) || + RuleRow.MatchOrExact(action.DevicePattern, e.DisplayName))); + if (hits) deviceMatchActions++; + } + } + + var parts = new List(); + if (seenPids.Count > 0) + { + parts.Add(seenPids.Count == 1 ? "1 app" : $"{seenPids.Count} apps"); + } + if (deviceMatchActions > 0) + { + parts.Add(deviceMatchActions == 1 ? "1 device" : $"{deviceMatchActions} devices"); + } + return string.Join(" / ", parts); + } +} diff --git a/src/Earmark.App/ViewModels/DeviceUndoStack.cs b/src/Earmark.App/ViewModels/DeviceUndoStack.cs new file mode 100644 index 0000000..a3aa629 --- /dev/null +++ b/src/Earmark.App/ViewModels/DeviceUndoStack.cs @@ -0,0 +1,50 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Earmark.App.ViewModels; + +/// +/// Capped LIFO of reversible Devices-page actions. Entries reference a device by id (not by +/// instance) so the undo trail survives rebuilds of the card list. +/// When the cap is hit the oldest entry is dropped in O(1) via . +/// +internal sealed class DeviceUndoStack +{ + private const int Limit = 32; + private readonly LinkedList _entries = new(); + + /// Base type for reversible actions. Each variant carries the prior state. + public abstract record UndoAction(string DeviceId); + + public sealed record VisibilityUndo(string DeviceId, bool PrevHidden, bool PrevPinned) + : UndoAction(DeviceId); + + public sealed record VolumeMuteUndo(string DeviceId, float PrevVolume, bool PrevMuted) + : UndoAction(DeviceId); + + public void PushVisibility(string deviceId, bool prevHidden, bool prevPinned) => + Push(new VisibilityUndo(deviceId, prevHidden, prevPinned)); + + public void PushVolumeMute(string deviceId, float prevVolume, bool prevMuted) => + Push(new VolumeMuteUndo(deviceId, prevVolume, prevMuted)); + + private void Push(UndoAction action) + { + _entries.AddLast(action); + if (_entries.Count > Limit) + { + _entries.RemoveFirst(); + } + } + + public bool TryPop([NotNullWhen(true)] out UndoAction? action) + { + if (_entries.Last is null) + { + action = null; + return false; + } + action = _entries.Last.Value; + _entries.RemoveLast(); + return true; + } +} diff --git a/src/Earmark.App/ViewModels/HomeViewModel.cs b/src/Earmark.App/ViewModels/HomeViewModel.cs new file mode 100644 index 0000000..4e418bd --- /dev/null +++ b/src/Earmark.App/ViewModels/HomeViewModel.cs @@ -0,0 +1,447 @@ +using System.Collections.ObjectModel; + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using Earmark.App.Services; +using Earmark.App.Settings; +using Earmark.Core.Audio; +using Earmark.Core.Models; +using Earmark.Core.Routing; +using Earmark.Core.Services; +using Earmark.Core.WaveLink; + +using Microsoft.UI.Xaml; + +namespace Earmark.App.ViewModels; + +/// +/// Orchestrates the Home page: discovers active audio endpoints, builds +/// instances from them (rule summary + initial volume/mute state), filters the visible set +/// against user-hidden / no-rules state, and polls peak + mute on a single timer. +/// +public partial class HomeViewModel : ObservableObject, IDisposable +{ + private static readonly TimeSpan DebounceWindow = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan PeakTickInterval = TimeSpan.FromMilliseconds(50); + private const int MutePollEveryNthTick = 5; + + private readonly IRulesService _rules; + private readonly IAudioEndpointService _endpoints; + private readonly IAudioSessionService _sessions; + private readonly IRuleMatcher _matcher; + private readonly IRuleEvaluator _evaluator; + private readonly ISettingsService _settings; + private readonly IWaveLinkService _waveLink; + private readonly IDispatcherQueueProvider _dispatcher; + private readonly INotificationService _notifications; + private readonly Dictionary _lastReconcileToast = new(StringComparer.OrdinalIgnoreCase); + private static readonly TimeSpan ToastRateLimit = TimeSpan.FromSeconds(15); + private readonly Lock _gate = new(); + private readonly List _allCards = new(); + private readonly DeviceUndoStack _undoStack = new(); + private CancellationTokenSource? _refreshCts; + private CancellationTokenSource? _settingsSaveCts; + private DispatcherTimer? _peakTimer; + private int _muteTickCounter; + + public HomeViewModel( + IRulesService rules, + IAudioEndpointService endpoints, + IAudioSessionService sessions, + IRuleMatcher matcher, + IRuleEvaluator evaluator, + ISettingsService settings, + IWaveLinkService waveLink, + INotificationService notifications, + IDispatcherQueueProvider dispatcher) + { + _rules = rules ?? throw new ArgumentNullException(nameof(rules)); + _endpoints = endpoints ?? throw new ArgumentNullException(nameof(endpoints)); + _sessions = sessions ?? throw new ArgumentNullException(nameof(sessions)); + _matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); + _evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _waveLink = waveLink ?? throw new ArgumentNullException(nameof(waveLink)); + _notifications = notifications ?? throw new ArgumentNullException(nameof(notifications)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + // Show-hidden is session-only: defaults to off on every launch. + + _rules.RulesChanged += OnAnythingChanged; + _endpoints.EndpointsChanged += OnAnythingChanged; + _endpoints.DefaultsChanged += OnAnythingChanged; + _endpoints.ExternalMuteChanged += OnExternalMuteChanged; + _sessions.SessionsChanged += OnAnythingChanged; + _waveLink.SnapshotChanged += OnAnythingChanged; + _waveLink.StateChanged += OnAnythingChanged; + + QueueRefresh(); + StartPeakPolling(); + } + + public ObservableCollection Devices { get; } = new(); + + public bool HasItems => Devices.Count > 0; + public bool IsEmpty => Devices.Count == 0; + + [ObservableProperty] + public partial bool ShowHiddenDevices { get; set; } + + partial void OnShowHiddenDevicesChanged(bool value) + { + foreach (var card in _allCards) + { + card.RefreshListed(value); + } + SyncVisibleDevices(); + } + + /// + /// Debounces settings writes so rapid toggling of doesn't + /// queue a backlog of file writes (each with its 5-attempt retry loop) that could lag the + /// displayed state. The latest in-memory value wins. + /// + private void QueueSettingsSave() + { + _settingsSaveCts?.Cancel(); + _settingsSaveCts = new CancellationTokenSource(); + var token = _settingsSaveCts.Token; + _ = Task.Run(async () => + { + try { await Task.Delay(TimeSpan.FromMilliseconds(200), token).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + try { await _settings.SaveAsync(token).ConfigureAwait(false); } + catch { /* SettingsService logs internally */ } + }, token); + } + + // -------- Periodic peak + mute polling -------- + + private void StartPeakPolling() + { + _peakTimer = new DispatcherTimer { Interval = PeakTickInterval }; + _peakTimer.Tick += OnPeakTick; + _peakTimer.Start(); + } + + private void OnPeakTick(object? sender, object e) + { + // Event-driven mute notifications (AudioEndpointService.ExternalMuteChanged) handle + // the fast path; this poll is the fallback safety net for any miss. Runs every ~250ms. + var pollMute = ++_muteTickCounter % MutePollEveryNthTick == 0; + foreach (var card in Devices) + { + var level = _endpoints.GetPeakLevel(card.Endpoint.Id) ?? 0f; + card.UpdatePeak(level, PeakTickInterval); + + if (pollMute) + { + var muted = _endpoints.GetMuted(card.Endpoint.Id); + if (muted.HasValue) + { + ApplyExternalMute(card, muted.Value); + } + } + } + } + + private void OnExternalMuteChanged(object? sender, EndpointMuteChangedEventArgs e) + { + // Callback arrives on a COM thread; marshal to the UI thread before touching VMs. + var deviceId = e.DeviceId; + var muted = e.Muted; + _dispatcher.Enqueue(() => + { + var card = _allCards.FirstOrDefault(c => + string.Equals(c.Endpoint.Id, deviceId, StringComparison.OrdinalIgnoreCase)); + if (card is null) return; + ApplyExternalMute(card, muted); + }); + } + + /// + /// Updates the card's cached mute state to match what the OS reports, then snaps it back + /// to the rule-pinned target if that target disagrees (rule wins over external changes). + /// Surfaces a Windows toast naming the rule so the user understands why their external + /// change didn't stick. Rate-limited per device to avoid spam. + /// + private void ApplyExternalMute(DeviceCard card, bool actualMuted) + { + card.SyncMutedFromDevice(actualMuted); + + if (card.RuleMutedTarget is not bool target || actualMuted == target) return; + + _endpoints.SetMuted(card.Endpoint.Id, target); + card.SyncMutedFromDevice(target); + NotifyReconciled(card, target); + } + + private void NotifyReconciled(DeviceCard card, bool restoredToMuted) + { + var now = DateTime.UtcNow; + if (_lastReconcileToast.TryGetValue(card.Endpoint.Id, out var last) && + now - last < ToastRateLimit) + { + return; + } + _lastReconcileToast[card.Endpoint.Id] = now; + + var ruleName = card.RuleMutedSource ?? "an active rule"; + var verb = restoredToMuted ? "muted" : "unmuted"; + _notifications.Show( + $"Earmark kept '{card.Endpoint.FriendlyName}' {verb}", + $"Rule \"{ruleName}\" pins this device, so the external change was reverted."); + } + + // -------- Rebuild + visibility filtering -------- + + private void OnAnythingChanged(object? sender, EventArgs e) => QueueRefresh(); + + private void QueueRefresh() + { + CancellationToken token; + lock (_gate) + { + _refreshCts?.Cancel(); + _refreshCts = new CancellationTokenSource(); + token = _refreshCts.Token; + } + + _ = RebuildAsync(token); + } + + private async Task RebuildAsync(CancellationToken ct) + { + try + { + await Task.Delay(DebounceWindow, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + var hiddenIds = new HashSet(_settings.Current.HiddenDeviceIds, StringComparer.OrdinalIgnoreCase); + var pinnedIds = new HashSet(_settings.Current.PinnedDeviceIds, StringComparer.OrdinalIgnoreCase); + var showHidden = ShowHiddenDevices; + + var built = await Task.Run(() => BuildCards(hiddenIds, pinnedIds, showHidden), ct).ConfigureAwait(false); + + if (ct.IsCancellationRequested) return; + + _dispatcher.Enqueue(() => + { + if (ct.IsCancellationRequested) return; + _allCards.Clear(); + _allCards.AddRange(built); + SyncVisibleDevices(); + }); + } + + private List BuildCards(HashSet hiddenIds, HashSet pinnedIds, bool showHidden) + { + var renderEndpoints = _endpoints.GetEndpoints(EndpointFlow.Render); + var captureEndpoints = _endpoints.GetEndpoints(EndpointFlow.Capture); + var sessions = _sessions.GetSessions(); + var rules = _rules.Rules; + var waveLinkOutputDeviceNames = BuildWaveLinkDeviceNameSet(_waveLink.LastSnapshot); + + // Sort tiers (top -> bottom): + // 1. System default render (output before input within defaults) + // 2. System default capture + // 3. System default-communications render (only relevant when distinct from #1) + // 4. System default-communications capture + // 5. Wave Link mix targets (physical "listening" endpoints) + // 6. Wave Link virtual channels (Elgato Virtual Audio pairs) + // 7. Everything else + // Within each tier: render before capture, then alphabetical. + var ordered = renderEndpoints.Concat(captureEndpoints) + .Where(e => e.State == EndpointState.Active) + .OrderByDescending(e => e.IsDefault && e.Flow == EndpointFlow.Render) + .ThenByDescending(e => e.IsDefault) + .ThenByDescending(e => e.IsDefaultCommunications && e.Flow == EndpointFlow.Render) + .ThenByDescending(e => e.IsDefaultCommunications) + .ThenByDescending(e => IsWaveLinkMixTarget(e, waveLinkOutputDeviceNames)) + .ThenByDescending(e => IsWaveLinkVirtualChannel(e)) + .ThenBy(e => e.Flow) + .ThenBy(e => e.FriendlyName, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var cards = new List(ordered.Count); + foreach (var endpoint in ordered) + { + var summary = DeviceRulesSummary.For(endpoint, rules, renderEndpoints, captureEndpoints, sessions, _matcher, _evaluator); + var volume = _endpoints.GetVolume(endpoint.Id) ?? 0f; + var muted = _endpoints.GetMuted(endpoint.Id) ?? false; + var hiddenByUser = hiddenIds.Contains(endpoint.Id); + var pinnedByUser = pinnedIds.Contains(endpoint.Id); + + cards.Add(new DeviceCard( + _endpoints, + endpoint, + volume, + muted, + summary.VolumeLocked, + summary.MuteLocked, + summary.RuleMutedTarget, + summary.RuleMutedSource, + summary.Rules, + hiddenByUser, + pinnedByUser, + showHidden, + OnCardVisibilityToggled)); + } + return cards; + } + + /// + /// Pulls the list of physical playback endpoint names Wave Link is currently routing + /// mixed audio to ("Headphones", "Speakers", etc.). These rank above the Elgato virtual + /// channels in the device grid. + /// + private static HashSet BuildWaveLinkDeviceNameSet(WaveLinkSnapshot? snapshot) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + if (snapshot is null) return names; + foreach (var output in snapshot.OutputDevices) + { + if (!string.IsNullOrEmpty(output.DeviceName)) + { + names.Add(output.DeviceName); + } + } + return names; + } + + /// A physical playback endpoint Wave Link sends mixed audio to. + private static bool IsWaveLinkMixTarget(AudioEndpoint endpoint, HashSet waveLinkOutputDeviceNames) + => waveLinkOutputDeviceNames.Contains(endpoint.FriendlyName); + + /// An Elgato virtual channel pair (Game, Comms, Media, etc.). + private static bool IsWaveLinkVirtualChannel(AudioEndpoint endpoint) + => !string.IsNullOrEmpty(endpoint.DeviceDescription) + && endpoint.DeviceDescription.Contains("Elgato Virtual Audio", StringComparison.OrdinalIgnoreCase); + + /// + /// Diffs against the visible collection. + /// Preserves instance identity for cards that stay visible so the UI doesn't churn + /// (avoids tearing down peak meters and slider bindings on every event). + /// + private void SyncVisibleDevices() + { + var listed = _allCards.Where(c => c.IsListed).ToList(); + + for (var i = Devices.Count - 1; i >= 0; i--) + { + if (!listed.Contains(Devices[i])) + { + Devices.RemoveAt(i); + } + } + + for (var i = 0; i < listed.Count; i++) + { + var card = listed[i]; + var currentIndex = Devices.IndexOf(card); + if (currentIndex < 0) + { + Devices.Insert(i, card); + } + else if (currentIndex != i) + { + Devices.Move(currentIndex, i); + } + } + + OnPropertyChanged(nameof(HasItems)); + OnPropertyChanged(nameof(IsEmpty)); + } + + private void OnCardVisibilityToggled(DeviceCard card, DeviceCard.VisibilityState prev) + { + _undoStack.PushVisibility(card.Endpoint.Id, prev.IsHidden, prev.IsPinned); + PersistAndResync(card); + } + + /// Records a volume / mute change as a single undo entry. Called by the page + /// when a slider drag or mute icon click completes. + public void RecordVolumeMuteUndo(DeviceCard card, float prevVolume, bool prevMuted) + { + // Skip no-ops. + if (Math.Abs(card.Volume - prevVolume) < 0.001f && card.IsMuted == prevMuted) + { + return; + } + _undoStack.PushVolumeMute(card.Endpoint.Id, prevVolume, prevMuted); + } + + /// Reverts the most recent reversible action (hide/show, volume drag, mute toggle). + /// Bound to Ctrl+Z on the page. + [RelayCommand] + public void UndoVisibilityChange() + { + if (!_undoStack.TryPop(out var action)) return; + + var card = _allCards.FirstOrDefault(c => + string.Equals(c.Endpoint.Id, action.DeviceId, StringComparison.OrdinalIgnoreCase)); + if (card is null) return; + + switch (action) + { + case DeviceUndoStack.VisibilityUndo v: + card.SetUserVisibility(v.PrevHidden, v.PrevPinned); + PersistAndResync(card); + break; + case DeviceUndoStack.VolumeMuteUndo vm: + card.SetVolumeAndMute(vm.PrevVolume, vm.PrevMuted); + break; + } + } + + private void PersistAndResync(DeviceCard card) + { + var hiddenList = _settings.Current.HiddenDeviceIds ??= new(); + var pinnedList = _settings.Current.PinnedDeviceIds ??= new(); + SyncIdInList(hiddenList, card.Endpoint.Id, include: card.IsHiddenByUser); + SyncIdInList(pinnedList, card.Endpoint.Id, include: card.IsPinnedByUser); + QueueSettingsSave(); + SyncVisibleDevices(); + } + + private static void SyncIdInList(List list, string id, bool include) + { + if (include) + { + if (!list.Any(existing => string.Equals(existing, id, StringComparison.OrdinalIgnoreCase))) + { + list.Add(id); + } + } + else + { + list.RemoveAll(existing => string.Equals(existing, id, StringComparison.OrdinalIgnoreCase)); + } + } + + public void Dispose() + { + _rules.RulesChanged -= OnAnythingChanged; + _endpoints.EndpointsChanged -= OnAnythingChanged; + _endpoints.DefaultsChanged -= OnAnythingChanged; + _endpoints.ExternalMuteChanged -= OnExternalMuteChanged; + _sessions.SessionsChanged -= OnAnythingChanged; + _waveLink.SnapshotChanged -= OnAnythingChanged; + _waveLink.StateChanged -= OnAnythingChanged; + if (_peakTimer is not null) + { + _peakTimer.Tick -= OnPeakTick; + _peakTimer.Stop(); + _peakTimer = null; + } + _refreshCts?.Cancel(); + _refreshCts?.Dispose(); + _settingsSaveCts?.Cancel(); + _settingsSaveCts?.Dispose(); + } +} diff --git a/src/Earmark.App/ViewModels/RuleRow.cs b/src/Earmark.App/ViewModels/RuleRow.cs index a6ce281..407a61d 100644 --- a/src/Earmark.App/ViewModels/RuleRow.cs +++ b/src/Earmark.App/ViewModels/RuleRow.cs @@ -156,7 +156,7 @@ public void Recompute( { foreach (var c in Conditions) { - c.Recompute(endpoints); + c.Recompute(endpoints, sessions); } foreach (var a in Actions) { @@ -850,6 +850,8 @@ public ConditionRow(RuleCondition condition, Action notifyParent) { new ConditionTypeOption(ConditionType.DevicePresent, "Device present"), new ConditionTypeOption(ConditionType.DeviceMissing, "Device missing"), + new ConditionTypeOption(ConditionType.ApplicationRunning, "Application running"), + new ConditionTypeOption(ConditionType.ApplicationNotRunning, "Application not running"), }; public static IReadOnlyList FlowOptions { get; } = new[] @@ -873,13 +875,21 @@ public ConditionRow(RuleCondition condition, Action notifyParent) [ObservableProperty] public partial string DevicePattern { get; set; } = string.Empty; + [ObservableProperty] + public partial string AppPattern { get; set; } = string.Empty; + [ObservableProperty] public partial bool IsSatisfied { get; set; } + public bool IsApplicationCondition => Type is ConditionType.ApplicationRunning or ConditionType.ApplicationNotRunning; + public bool IsDeviceCondition => !IsApplicationCondition; + public string TypeLabel => Type switch { ConditionType.DevicePresent => "Device present", ConditionType.DeviceMissing => "Device missing", + ConditionType.ApplicationRunning => "Application running", + ConditionType.ApplicationNotRunning => "Application not running", _ => Type.ToString(), }; @@ -912,6 +922,7 @@ public ConditionFlowOption SelectedFlowOption Type = Type, Flow = Flow, DevicePattern = DevicePattern, + AppPattern = AppPattern, }; public void SyncFromModel(RuleCondition condition) @@ -923,6 +934,7 @@ public void SyncFromModel(RuleCondition condition) Type = condition.Type; Flow = condition.Flow; DevicePattern = condition.DevicePattern; + AppPattern = condition.AppPattern; } finally { @@ -930,8 +942,24 @@ public void SyncFromModel(RuleCondition condition) } } - public void Recompute(IReadOnlyList endpoints) + public void Recompute(IReadOnlyList endpoints, IReadOnlyList sessions) { + if (IsApplicationCondition) + { + if (string.IsNullOrWhiteSpace(AppPattern)) + { + IsSatisfied = Type == ConditionType.ApplicationNotRunning; + return; + } + + var anyRunning = sessions.Any(s => + RuleRow.MatchOrExact(AppPattern, s.ProcessName) || + RuleRow.MatchOrExact(AppPattern, s.ExecutablePath)); + + IsSatisfied = Type == ConditionType.ApplicationRunning ? anyRunning : !anyRunning; + return; + } + if (string.IsNullOrWhiteSpace(DevicePattern) || !RuleRow.TryCompile(DevicePattern, out var regex) || regex is null) { IsSatisfied = Type == ConditionType.DeviceMissing; @@ -954,6 +982,8 @@ partial void OnTypeChanged(ConditionType value) { OnPropertyChanged(nameof(TypeLabel)); OnPropertyChanged(nameof(SelectedTypeOption)); + OnPropertyChanged(nameof(IsApplicationCondition)); + OnPropertyChanged(nameof(IsDeviceCondition)); Notify(); } partial void OnFlowChanged(ConditionFlow value) @@ -962,6 +992,7 @@ partial void OnFlowChanged(ConditionFlow value) Notify(); } partial void OnDevicePatternChanged(string value) => Notify(); + partial void OnAppPatternChanged(string value) => Notify(); private void Notify() { diff --git a/src/Earmark.App/ViewModels/RulesViewModel.cs b/src/Earmark.App/ViewModels/RulesViewModel.cs index bff7541..26a3025 100644 --- a/src/Earmark.App/ViewModels/RulesViewModel.cs +++ b/src/Earmark.App/ViewModels/RulesViewModel.cs @@ -71,6 +71,38 @@ public RulesViewModel( [ObservableProperty] public partial RuleRow? Selected { get; set; } + /// + /// Set by other pages (e.g. Devices) to ask the Rules page to expand a specific rule + /// and scroll it into view. Cleared back to null by the page after it handles the focus. + /// + [ObservableProperty] + public partial Guid? PendingFocusRuleId { get; set; } + + /// Marks the given rule as the next focus target; collapses every other row so + /// only the focused rule's editor is open when the user lands on the Rules page. + public void RequestFocusRule(Guid ruleId) + { + RuleRow? target = null; + foreach (var row in Items) + { + if (row.Id == ruleId) + { + target = row; + row.IsExpanded = true; + } + else + { + row.IsExpanded = false; + } + } + + if (target is not null) + { + Selected = target; + } + PendingFocusRuleId = ruleId; + } + private RuleRow BuildRow(RoutingRule rule) => new(rule, r => _rules.UpsertAsync(r)); [RelayCommand] diff --git a/src/Earmark.App/Views/HomePage.xaml b/src/Earmark.App/Views/HomePage.xaml new file mode 100644 index 0000000..eecf85a --- /dev/null +++ b/src/Earmark.App/Views/HomePage.xaml @@ -0,0 +1,348 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Earmark.App/Views/HomePage.xaml.cs b/src/Earmark.App/Views/HomePage.xaml.cs new file mode 100644 index 0000000..03c485d --- /dev/null +++ b/src/Earmark.App/Views/HomePage.xaml.cs @@ -0,0 +1,176 @@ +using Earmark.App.ViewModels; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; + +using Windows.System; + +namespace Earmark.App.Views; + +public sealed partial class HomePage : Page +{ + private readonly ILogger? _logger; + private readonly RulesViewModel _rulesViewModel; + private readonly MainWindow _mainWindow; + + /// + /// Pre-drag volume / mute captured per slider. Indexed by the Slider instance because + /// the same DeviceCard could theoretically host concurrent interactions; in practice this + /// also dodges any "card replaced mid-drag" edge cases by keying off the live control. + /// + private readonly Dictionary _sliderDragStart = new(); + + public HomePage(HomeViewModel viewModel, RulesViewModel rulesViewModel, MainWindow mainWindow) + { + ViewModel = viewModel; + _rulesViewModel = rulesViewModel; + _mainWindow = mainWindow; + InitializeComponent(); + _logger = App.Current.Services.GetService>(); + } + + public HomeViewModel ViewModel { get; } + + private void OnUndoInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + ViewModel.UndoVisibilityChangeCommand.Execute(null); + args.Handled = true; + } + + private void OnMuteToggleClicked(object sender, RoutedEventArgs e) + { + // ItemsRepeater doesn't propagate DataContext to x:Bind templates - the button + // carries the DeviceCard via Tag="{x:Bind}" instead. + if (sender is not FrameworkElement { Tag: DeviceCard card }) return; + + var prevVolume = card.Volume; + var prevMuted = card.IsMuted; + card.ToggleMuteCommand.Execute(null); + // Mute icon clicks only change IsMuted; carry the unchanged volume so Ctrl+Z + // restores both together as one entry. + 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"); + } + + private void OnRulesExpandToggle(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement element) return; + // ItemsRepeater doesn't propagate DataContext into x:Bind templates, so the button + // carries the DeviceCard reference via Tag="{x:Bind}" instead. + if (element.Tag is not DeviceCard card) return; + + var collapsing = card.IsRulesExpanded; + card.IsRulesExpanded = !card.IsRulesExpanded; + + if (collapsing) + { + // Was expanded, now collapsing: snap the scroll back to the top of the list. + var scrollViewer = FindAncestorScrollViewer(element); + scrollViewer?.ChangeView(null, 0, null, disableAnimation: false); + } + } + + /// Walks up the visual tree to the first ancestor StackPanel that contains a + /// ScrollViewer named RulesScroll and returns it. + private static ScrollViewer? FindAncestorScrollViewer(DependencyObject start) + { + var current = VisualTreeHelper.GetParent(start); + while (current is not null) + { + if (current is StackPanel panel) + { + foreach (var child in panel.Children) + { + if (child is ScrollViewer sv && sv.Name == "RulesScroll") + { + return sv; + } + } + } + current = VisualTreeHelper.GetParent(current); + } + return null; + } + + // CA1822 suppressed: XAML event hookup requires instance methods even when the body + // doesn't touch instance state. +#pragma warning disable CA1822 + + 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) + { + // Belt-and-suspenders: if focus moves away mid-interaction (e.g. window deactivated), + // commit whatever change we have so the undo entry isn't lost. + 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(); + } + } +#pragma warning restore CA1822 +} diff --git a/src/Earmark.App/Views/RulesPage.xaml b/src/Earmark.App/Views/RulesPage.xaml index cb1eaaa..5aeefa3 100644 --- a/src/Earmark.App/Views/RulesPage.xaml +++ b/src/Earmark.App/Views/RulesPage.xaml @@ -24,10 +24,12 @@ - + - +