From 2f9891a2991055c9207fc95f02d26c6ce28be8c1 Mon Sep 17 00:00:00 2001 From: OhMyCode Date: Thu, 12 Mar 2026 12:17:09 +0100 Subject: [PATCH 1/5] feat: Add custom Acrylic backdrop with inline settings in flyout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Acrylic personnalisé" option to the Backdrop section that replaces the flyout content with sliders for tint opacity, luminosity, tint/fallback colors, and Base/Thin style. Defaults to max tint opacity and min luminosity. Co-Authored-By: Claude Opus 4.6 --- Audiomatic/MainWindow.xaml.cs | 307 +++++++++++++++++++++++++++++++++- Audiomatic/SettingsManager.cs | 4 +- 2 files changed, 304 insertions(+), 7 deletions(-) diff --git a/Audiomatic/MainWindow.xaml.cs b/Audiomatic/MainWindow.xaml.cs index 358617f..e1527bb 100644 --- a/Audiomatic/MainWindow.xaml.cs +++ b/Audiomatic/MainWindow.xaml.cs @@ -15,6 +15,7 @@ using Windows.Media.Control; using Windows.Storage; using Windows.Storage.Pickers; +using WinRT; using WinRT.Interop; namespace Audiomatic; @@ -50,6 +51,10 @@ private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Visualizer private readonly Dictionary _mediaSessionPanels = new(); private DispatcherTimer? _mediaTickTimer; + // Custom acrylic + private DesktopAcrylicController? _acrylicController; + private SystemBackdropConfiguration? _configSource; + // Collapse animation private enum CollapseState { Expanded, Compact, Mini } private CollapseState _collapseState = CollapseState.Expanded; @@ -2187,6 +2192,22 @@ void AddBackdropOption(string type, string label) } AddBackdropOption("acrylic", "Acrylic"); + + // Custom acrylic — replaces flyout content with sliders + { + var isActive = currentBackdrop == "acrylic_custom"; + panel.Children.Add(ActionPanel.CreateButton( + isActive ? "\uE73E" : "\uE8D7", "Acrylic personnalisé", [], () => + { + var bd = SettingsManager.LoadBackdrop(); + if (bd.Type != "acrylic_custom") + bd = bd with { Type = "acrylic_custom" }; + SettingsManager.SaveBackdrop(bd); + ApplyBackdrop(bd); + ShowAcrylicSettingsInFlyout(flyout, anchor); + }, isActive: isActive)); + } + AddBackdropOption("mica", "Mica"); AddBackdropOption("mica_alt", "Mica Alt"); AddBackdropOption("none", "None"); @@ -2288,12 +2309,288 @@ private async void ScanAllFoldersAsync() private void ApplyBackdrop(BackdropSettings settings) { - SystemBackdrop = settings.Type switch + _acrylicController?.Dispose(); + _acrylicController = null; + + if (settings.Type == "acrylic_custom") + { + SystemBackdrop = null; + + _configSource = new SystemBackdropConfiguration { IsInputActive = true }; + if (Content is FrameworkElement fe) + _configSource.Theme = (SystemBackdropTheme)fe.ActualTheme; + + _acrylicController = new DesktopAcrylicController + { + TintOpacity = (float)settings.TintOpacity, + LuminosityOpacity = (float)settings.LuminosityOpacity, + TintColor = ParseColor(settings.TintColor), + FallbackColor = ParseColor(settings.FallbackColor), + Kind = settings.Kind == "Thin" + ? DesktopAcrylicKind.Thin + : DesktopAcrylicKind.Base, + }; + + _acrylicController.AddSystemBackdropTarget( + this.As()); + _acrylicController.SetSystemBackdropConfiguration(_configSource); + } + else + { + _configSource = null; + SystemBackdrop = settings.Type switch + { + "mica" => new MicaBackdrop(), + "mica_alt" => new MicaBackdrop { Kind = MicaKind.BaseAlt }, + "none" => null, + _ => new DesktopAcrylicBackdrop() + }; + } + } + + private void ShowAcrylicSettingsInFlyout(Flyout flyout, FrameworkElement anchor) + { + var settings = SettingsManager.LoadBackdrop(); + var suppressChanges = true; + var currentKind = settings.Kind; + + var panel = new StackPanel { Spacing = 0, Width = 260 }; + + // Back button + panel.Children.Add(ActionPanel.CreateButton("\uE72B", "Backdrop", [], () => + { + flyout.Hide(); + ShowSettingsFlyout(anchor); + })); + panel.Children.Add(ActionPanel.CreateSeparator()); + + // Tint Opacity + var tintOpacityValue = new TextBlock + { + FontSize = 12, + HorizontalAlignment = HorizontalAlignment.Right, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Text = settings.TintOpacity.ToString("F2") + }; + var tintOpacitySlider = new Slider + { + Minimum = 0, Maximum = 1, StepFrequency = 0.01, + Value = settings.TintOpacity, + Margin = new Thickness(0, -2, 0, 0) + }; + + var tintOpacityHeader = new Grid { Margin = new Thickness(8, 8, 8, 0) }; + tintOpacityHeader.Children.Add(new TextBlock + { + Text = "Opacité de teinte", FontSize = 12, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush") + }); + tintOpacityHeader.Children.Add(tintOpacityValue); + panel.Children.Add(tintOpacityHeader); + panel.Children.Add(new StackPanel + { + Margin = new Thickness(4, 0, 4, 0), + Children = { tintOpacitySlider } + }); + + // Luminosity + var luminosityValue = new TextBlock + { + FontSize = 12, + HorizontalAlignment = HorizontalAlignment.Right, + Foreground = ThemeHelper.Brush("TextFillColorTertiaryBrush"), + Text = settings.LuminosityOpacity.ToString("F2") + }; + var luminositySlider = new Slider + { + Minimum = 0, Maximum = 1, StepFrequency = 0.01, + Value = settings.LuminosityOpacity, + Margin = new Thickness(0, -2, 0, 0) + }; + + var luminosityHeader = new Grid { Margin = new Thickness(8, 8, 8, 0) }; + luminosityHeader.Children.Add(new TextBlock + { + Text = "Luminosité", FontSize = 12, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush") + }); + luminosityHeader.Children.Add(luminosityValue); + panel.Children.Add(luminosityHeader); + panel.Children.Add(new StackPanel + { + Margin = new Thickness(4, 0, 4, 0), + Children = { luminositySlider } + }); + + // Tint Color + var tintColorPreview = new Border + { + Width = 28, Height = 28, CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1), + BorderBrush = ThemeHelper.Brush("ControlStrokeColorDefaultBrush"), + Background = new SolidColorBrush(ParseColor(settings.TintColor)) + }; + var tintColorBox = new TextBox + { + Text = settings.TintColor, FontSize = 12, MaxLength = 7, + MinWidth = 0 + }; + + var tintColorGrid = new Grid + { + ColumnSpacing = 8, + Margin = new Thickness(8, 8, 8, 0) + }; + tintColorGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + tintColorGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + tintColorGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var tintColorLabel = new TextBlock + { + Text = "Teinte", FontSize = 12, VerticalAlignment = VerticalAlignment.Center, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush") + }; + Grid.SetColumn(tintColorLabel, 0); + Grid.SetColumn(tintColorBox, 1); + Grid.SetColumn(tintColorPreview, 2); + tintColorGrid.Children.Add(tintColorLabel); + tintColorGrid.Children.Add(tintColorBox); + tintColorGrid.Children.Add(tintColorPreview); + panel.Children.Add(tintColorGrid); + + // Fallback Color + var fallbackColorPreview = new Border + { + Width = 28, Height = 28, CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1), + BorderBrush = ThemeHelper.Brush("ControlStrokeColorDefaultBrush"), + Background = new SolidColorBrush(ParseColor(settings.FallbackColor)) + }; + var fallbackColorBox = new TextBox + { + Text = settings.FallbackColor, FontSize = 12, MaxLength = 7, + MinWidth = 0 + }; + + var fallbackColorGrid = new Grid + { + ColumnSpacing = 8, + Margin = new Thickness(8, 8, 8, 0) + }; + fallbackColorGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + fallbackColorGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + fallbackColorGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var fallbackColorLabel = new TextBlock + { + Text = "Repli", FontSize = 12, VerticalAlignment = VerticalAlignment.Center, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush") + }; + Grid.SetColumn(fallbackColorLabel, 0); + Grid.SetColumn(fallbackColorBox, 1); + Grid.SetColumn(fallbackColorPreview, 2); + fallbackColorGrid.Children.Add(fallbackColorLabel); + fallbackColorGrid.Children.Add(fallbackColorBox); + fallbackColorGrid.Children.Add(fallbackColorPreview); + panel.Children.Add(fallbackColorGrid); + + // Kind (Base / Thin) + var baseBtn = new Button + { + Content = "Base", HorizontalAlignment = HorizontalAlignment.Stretch, + CornerRadius = new CornerRadius(4), Tag = "Base" + }; + var thinBtn = new Button + { + Content = "Thin", HorizontalAlignment = HorizontalAlignment.Stretch, + CornerRadius = new CornerRadius(4), Tag = "Thin" + }; + + void UpdateKindButtons() + { + var selected = (Brush)Application.Current.Resources["AccentFillColorDefaultBrush"]; + var normal = new SolidColorBrush(Microsoft.UI.Colors.Transparent); + baseBtn.Background = currentKind == "Base" ? selected : normal; + thinBtn.Background = currentKind == "Thin" ? selected : normal; + } + + UpdateKindButtons(); + + var kindGrid = new Grid { ColumnSpacing = 6, Margin = new Thickness(8, 10, 8, 8) }; + kindGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + kindGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + kindGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var kindLabel = new TextBlock + { + Text = "Style", FontSize = 12, VerticalAlignment = VerticalAlignment.Center, + Foreground = ThemeHelper.Brush("TextFillColorSecondaryBrush") + }; + Grid.SetColumn(kindLabel, 0); + Grid.SetColumn(baseBtn, 1); + Grid.SetColumn(thinBtn, 2); + kindGrid.Children.Add(kindLabel); + kindGrid.Children.Add(baseBtn); + kindGrid.Children.Add(thinBtn); + panel.Children.Add(kindGrid); + + // Apply changes helper + void ApplyChanges() + { + if (suppressChanges) return; + var bd = new BackdropSettings( + Type: "acrylic_custom", + TintOpacity: tintOpacitySlider.Value, + LuminosityOpacity: luminositySlider.Value, + TintColor: tintColorBox.Text, + FallbackColor: fallbackColorBox.Text, + Kind: currentKind); + SettingsManager.SaveBackdrop(bd); + ApplyBackdrop(bd); + } + + // Wire events + tintOpacitySlider.ValueChanged += (_, _) => + { + tintOpacityValue.Text = tintOpacitySlider.Value.ToString("F2"); + ApplyChanges(); + }; + luminositySlider.ValueChanged += (_, _) => + { + luminosityValue.Text = luminositySlider.Value.ToString("F2"); + ApplyChanges(); + }; + tintColorBox.TextChanged += (_, _) => + { + try { tintColorPreview.Background = new SolidColorBrush(ParseColor(tintColorBox.Text)); } catch { } + if (tintColorBox.Text.StartsWith('#') && tintColorBox.Text.Length == 7) + ApplyChanges(); + }; + fallbackColorBox.TextChanged += (_, _) => + { + try { fallbackColorPreview.Background = new SolidColorBrush(ParseColor(fallbackColorBox.Text)); } catch { } + if (fallbackColorBox.Text.StartsWith('#') && fallbackColorBox.Text.Length == 7) + ApplyChanges(); + }; + baseBtn.Click += (_, _) => { currentKind = "Base"; UpdateKindButtons(); ApplyChanges(); }; + thinBtn.Click += (_, _) => { currentKind = "Thin"; UpdateKindButtons(); ApplyChanges(); }; + + suppressChanges = false; + + flyout.Content = panel; + } + + private static Windows.UI.Color ParseColor(string hex) + { + hex = hex.TrimStart('#'); + if (hex.Length != 6) + return new Windows.UI.Color { A = 255, R = 0, G = 0, B = 0 }; + return new Windows.UI.Color { - "mica" => new MicaBackdrop(), - "mica_alt" => new MicaBackdrop { Kind = MicaKind.BaseAlt }, - "none" => null, - _ => new DesktopAcrylicBackdrop() + A = 255, + R = Convert.ToByte(hex[..2], 16), + G = Convert.ToByte(hex[2..4], 16), + B = Convert.ToByte(hex[4..6], 16) }; } diff --git a/Audiomatic/SettingsManager.cs b/Audiomatic/SettingsManager.cs index 7c4348c..185cc7e 100644 --- a/Audiomatic/SettingsManager.cs +++ b/Audiomatic/SettingsManager.cs @@ -4,8 +4,8 @@ namespace Audiomatic; public record BackdropSettings( string Type = "acrylic", - double TintOpacity = 0.8, - double LuminosityOpacity = 0.9, + double TintOpacity = 1.0, + double LuminosityOpacity = 0.0, string TintColor = "#000000", string FallbackColor = "#1E1E1E", string Kind = "Base"); From 2f325f45ccbe3ca03d3e0b024403e9c2d7250bd1 Mon Sep 17 00:00:00 2001 From: OhMyCode Date: Thu, 12 Mar 2026 15:00:25 +0100 Subject: [PATCH 2/5] docs: Add Win2D 3D visualizer design spec Co-Authored-By: Claude Opus 4.6 --- .../2026-03-12-win2d-3d-visualizer-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-12-win2d-3d-visualizer-design.md diff --git a/docs/superpowers/specs/2026-03-12-win2d-3d-visualizer-design.md b/docs/superpowers/specs/2026-03-12-win2d-3d-visualizer-design.md new file mode 100644 index 0000000..cbb4dc9 --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-win2d-3d-visualizer-design.md @@ -0,0 +1,107 @@ +# Win2D 3D Audio Visualizer + +## Summary + +Replace the current XAML Rectangle-based spectrum visualizer with a GPU-accelerated Win2D renderer supporting 4 modes: Classic (existing), Bars (perspective), Circle (radial), and Wave (surface grid). Includes configurable color, glow, reflection, background blur, and dark overlay. + +## Architecture + +- `CanvasAnimatedControl` (Win2D) replaces direct Rectangle manipulation for the 3 new modes +- `SpectrumAnalyzer` remains unchanged — only the rendering layer changes +- `DispatcherTimer` kept for Classic mode only; Win2D modes use their own GPU draw loop + +### Components + +- **`IVisualizerMode`** — common interface for all 4 modes +- **`ClassicMode`** — wraps existing Rectangle/Canvas rendering (no Win2D) +- **`BarsMode`** — perspective bars with depth rows (3-4 echo rows) +- **`CircleMode`** — radial bar layout (360°) with slow rotation +- **`WaveMode`** — grid surface (64x16) with wireframe perspective view +- **`VisualizerRenderer`** — orchestrates canvas, mode switching, effects pipeline + +### Data Flow + +``` +CanvasAnimatedControl.Draw (60fps native) + → _spectrum.GetSpectrum(position, bandCount) + → activeMode.Render(session, bands, settings) + → draw shapes + effects (glow, reflection, blur) +``` + +## Visualization Modes + +### Classic (existing) +- Current dual-bar Rectangle rendering via DispatcherTimer + WaveformCanvas +- Respects 30/60 FPS setting +- No Win2D dependency + +### Bars (perspective) +- Rows of bars viewed at ~30° tilt angle +- Front row = current spectrum, back rows = previous frames (echo/trail) +- Perspective: back rows smaller and more transparent +- 3-4 depth rows + +### Circle +- Bars arranged in a 360° circle, pointing outward +- Base radius proportional to container size +- Bar height = frequency magnitude +- Slow continuous rotation + +### Wave +- Point grid (e.g., 64x16) forming a surface +- Y height of each point = frequency band magnitude +- Depth columns = previous frames (like Bars) +- Connected lines or wireframe mesh, perspective view + +## Visual Effects (common to Bars/Circle/Wave) + +- **Glow**: `GaussianBlurEffect` on shape copy, additive blending, intensity proportional to magnitude +- **Reflection**: inverted copy below center line, 30% opacity, slight blur +- **Background blur**: light `GaussianBlurEffect` on full canvas when enabled +- **Dark background**: semi-transparent black rectangle (opacity ~0.3) behind everything +- **Color**: user-configurable hex color (default: system accent) + +## UI: Mode Selector + +Positioned at the top of the visualizer area: + +- 4 horizontal text buttons: **Classic · Bars · Circle · Wave** +- Active mode uses accent color, others use secondary text color +- Style matches existing SelectorBar pattern + +Right side of selector: +- Toggle glow (icon button) +- Toggle dark background (icon button) +- Color: small colored square that opens hex input (like acrylic custom) + +## Settings (persisted in AppSettings) + +New fields added to `AppSettings`: +- `VisualizerMode`: `"classic"` | `"bars"` | `"circle"` | `"wave"` (default: `"classic"`) +- `VisualizerColor`: hex string (default: system accent color) +- `VisualizerGlow`: bool (default: `true`) +- `VisualizerDarkBg`: bool (default: `false`) + +## Files + +### New files +- `Audiomatic/Visualizer/IVisualizerMode.cs` +- `Audiomatic/Visualizer/ClassicMode.cs` +- `Audiomatic/Visualizer/BarsMode.cs` +- `Audiomatic/Visualizer/CircleMode.cs` +- `Audiomatic/Visualizer/WaveMode.cs` +- `Audiomatic/Visualizer/VisualizerRenderer.cs` + +### Modified files +- `Audiomatic/MainWindow.xaml` — add CanvasAnimatedControl + mode selector +- `Audiomatic/MainWindow.xaml.cs` — delegate to VisualizerRenderer, keep DispatcherTimer for Classic only +- `Audiomatic/SettingsManager.cs` — add visualizer fields to AppSettings +- `Audiomatic/Audiomatic.csproj` — add Win2D NuGet reference + +### Unchanged +- `Audiomatic/Services/SpectrumAnalyzer.cs` +- `Audiomatic/Services/AudioPlayerService.cs` + +## Dependencies + +- **NuGet**: `Microsoft.Graphics.Win2D` (only addition) From 11e8c09c471d24438e7e27b46afc3986f3b5038e Mon Sep 17 00:00:00 2001 From: OhMyCode Date: Thu, 12 Mar 2026 15:03:22 +0100 Subject: [PATCH 3/5] docs: Add Win2D 3D visualizer implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-12-win2d-3d-visualizer.md | 1020 +++++++++++++++++ 1 file changed, 1020 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-12-win2d-3d-visualizer.md diff --git a/docs/superpowers/plans/2026-03-12-win2d-3d-visualizer.md b/docs/superpowers/plans/2026-03-12-win2d-3d-visualizer.md new file mode 100644 index 0000000..2f5eeca --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-win2d-3d-visualizer.md @@ -0,0 +1,1020 @@ +# Win2D 3D Audio Visualizer Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the XAML Rectangle visualizer with a Win2D GPU-accelerated renderer supporting 4 modes (Classic, Bars, Circle, Wave) with configurable color, glow, reflection, and dark background. + +**Architecture:** A `VisualizerRenderer` orchestrates mode switching between `IVisualizerMode` implementations. Classic mode reuses the existing DispatcherTimer+Canvas approach. The 3 new modes render via `CanvasAnimatedControl` (Win2D) with shared effects pipeline (glow, reflection, background blur). A mode selector bar sits above the canvas. + +**Tech Stack:** Win2D (`Microsoft.Graphics.Win2D`), WinUI 3, NAudio (existing SpectrumAnalyzer) + +--- + +## File Structure + +### New files +| File | Responsibility | +|------|---------------| +| `Audiomatic/Visualizer/IVisualizerMode.cs` | Interface + VisualizerSettings record | +| `Audiomatic/Visualizer/EffectsHelper.cs` | Shared Win2D effects (glow, reflection, blur) | +| `Audiomatic/Visualizer/BarsMode.cs` | Perspective bars with depth echo rows | +| `Audiomatic/Visualizer/CircleMode.cs` | Radial 360° bar layout with rotation | +| `Audiomatic/Visualizer/WaveMode.cs` | Wireframe surface grid | +| `Audiomatic/Visualizer/VisualizerRenderer.cs` | Orchestrates canvas, mode switching, selector UI | + +### Modified files +| File | Changes | +|------|---------| +| `Audiomatic/Audiomatic.csproj` | Add Win2D NuGet reference | +| `Audiomatic/SettingsManager.cs` | Add visualizer fields to AppSettings | +| `Audiomatic/MainWindow.xaml` | Add `CanvasAnimatedControl` + selector row in WaveformContainer | +| `Audiomatic/MainWindow.xaml.cs` | Delegate to VisualizerRenderer, keep Classic mode logic in place | + +### Unchanged +| File | Reason | +|------|--------| +| `Audiomatic/Services/SpectrumAnalyzer.cs` | Data source stays the same | +| `Audiomatic/Services/AudioPlayerService.cs` | Playback logic unchanged | + +--- + +## Chunk 1: Foundation (Tasks 1-3) + +### Task 1: Add Win2D dependency and visualizer settings + +**Files:** +- Modify: `Audiomatic/Audiomatic.csproj` +- Modify: `Audiomatic/SettingsManager.cs` + +- [ ] **Step 1: Add Win2D NuGet package** + +```xml + + +``` + +- [ ] **Step 2: Add visualizer fields to AppSettings** + +In `SettingsManager.cs`, update the `AppSettings` record: + +```csharp +public record AppSettings( + BackdropSettings Backdrop, + double Volume, + bool ShuffleEnabled, + string RepeatMode, + string SortBy, + bool SortAscending, + string Language, + string Theme = "system", + int VisualizerFps = 30, + string VisualizerMode = "classic", // "classic", "bars", "circle", "wave" + string VisualizerColor = "", // hex string, empty = system accent + bool VisualizerGlow = true, + bool VisualizerDarkBg = false, + int? WindowX = null, + int? WindowY = null); +``` + +Add helper methods: + +```csharp +public static string LoadVisualizerMode() => Load().VisualizerMode ?? "classic"; +public static void SaveVisualizerMode(string mode) +{ + var current = Load(); + Save(current with { VisualizerMode = mode }); +} +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 4: Commit** + +```bash +git add Audiomatic/Audiomatic.csproj Audiomatic/SettingsManager.cs +git commit -m "feat: Add Win2D dependency and visualizer settings fields" +``` + +--- + +### Task 2: Create IVisualizerMode interface and EffectsHelper + +**Files:** +- Create: `Audiomatic/Visualizer/IVisualizerMode.cs` +- Create: `Audiomatic/Visualizer/EffectsHelper.cs` + +- [ ] **Step 1: Create IVisualizerMode.cs** + +```csharp +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.UI.Xaml; + +namespace Audiomatic.Visualizer; + +public record VisualizerSettings( + string Color, // hex string, empty = accent + bool GlowEnabled, + bool DarkBackground); + +public interface IVisualizerMode +{ + /// Band count this mode wants for the given canvas size. + int GetBandCount(float width, float height); + + /// Render one frame. Called from CanvasAnimatedControl.Draw. + void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings); +} +``` + +- [ ] **Step 2: Create EffectsHelper.cs** + +```csharp +using System.Numerics; +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Effects; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +internal static class EffectsHelper +{ + /// Parse hex color string to Windows.UI.Color. Falls back to accent-like blue. + internal static Color ParseColor(string hex) + { + hex = (hex ?? "").TrimStart('#'); + if (hex.Length != 6) + return Color.FromArgb(255, 96, 165, 250); // fallback blue + return Color.FromArgb(255, + Convert.ToByte(hex[..2], 16), + Convert.ToByte(hex[2..4], 16), + Convert.ToByte(hex[4..6], 16)); + } + + /// Get the render color — user color or system accent. + internal static Color GetRenderColor(VisualizerSettings settings) + { + if (!string.IsNullOrEmpty(settings.Color)) + return ParseColor(settings.Color); + // Fallback: use a bright accent blue (system accent not accessible from non-UI thread) + return Color.FromArgb(255, 96, 165, 250); + } + + /// Draw dark semi-transparent background overlay. + internal static void DrawDarkBackground(CanvasDrawingSession session, float width, float height) + { + session.FillRectangle(0, 0, width, height, Color.FromArgb(76, 0, 0, 0)); + } + + /// Apply glow effect by drawing a blurred copy of the command list. + internal static void DrawGlow(CanvasDrawingSession session, CanvasCommandList commandList, float blurAmount = 12f) + { + var blur = new GaussianBlurEffect + { + Source = commandList, + BlurAmount = blurAmount, + BorderMode = EffectBorderMode.Soft + }; + session.DrawImage(blur); + } + + /// Draw reflection: flipped, faded, blurred copy below centerY. + internal static void DrawReflection(CanvasDrawingSession session, CanvasCommandList commandList, + float width, float height, float centerY, float opacity = 0.3f) + { + var transform = new Transform2DEffect + { + Source = commandList, + TransformMatrix = Matrix3x2.CreateScale(1, -1, new Vector2(0, centerY)) + }; + var blur = new GaussianBlurEffect + { + Source = transform, + BlurAmount = 4f + }; + var old = session.Transform; + session.DrawImage(blur, 0, 0, new Windows.Foundation.Rect(0, centerY, width, height - centerY), opacity); + session.Transform = old; + } +} +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 4: Commit** + +```bash +git add Audiomatic/Visualizer/ +git commit -m "feat: Add IVisualizerMode interface and EffectsHelper" +``` + +--- + +### Task 3: Implement BarsMode + +**Files:** +- Create: `Audiomatic/Visualizer/BarsMode.cs` + +- [ ] **Step 1: Create BarsMode.cs** + +```csharp +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Geometry; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +public sealed class BarsMode : IVisualizerMode +{ + private const int DepthRows = 4; + private const float BarWidth = 5f; + private const float BarGap = 2f; + private const float TiltAngle = 0.25f; // perspective tilt factor + private const float DepthSpacing = 14f; + + // Ring buffer for previous frames + private readonly float[][] _history = new float[DepthRows][]; + private int _historyIndex; + private int _frameCount; + + public int GetBandCount(float width, float height) + { + return Math.Max(1, (int)(width / (BarWidth + BarGap))); + } + + public void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings) + { + var baseColor = EffectsHelper.GetRenderColor(settings); + float centerY = height * 0.55f; + float halfMax = height * 0.40f; + int bandCount = bands.Length; + + // Store current frame in history + if (_history[0] == null || _history[0].Length != bandCount) + { + for (int i = 0; i < DepthRows; i++) + _history[i] = new float[bandCount]; + } + Array.Copy(bands, _history[_historyIndex], bandCount); + _historyIndex = (_historyIndex + 1) % DepthRows; + _frameCount++; + + if (settings.DarkBackground) + EffectsHelper.DrawDarkBackground(session, width, height); + + using var glowLayer = settings.GlowEnabled ? new CanvasCommandList(session) : null; + var drawSession = settings.GlowEnabled ? glowLayer!.CreateDrawingSession() : session; + + float totalWidth = bandCount * (BarWidth + BarGap); + float offsetX = (width - totalWidth) / 2f; + + // Draw back rows first (painter's order) + for (int row = DepthRows - 1; row >= 0; row--) + { + int histIdx = ((_historyIndex - 1 - row) % DepthRows + DepthRows) % DepthRows; + var rowBands = _history[histIdx]; + if (_frameCount <= row) continue; + + float depthFactor = 1f - row * 0.18f; // scale down with depth + float rowAlpha = (byte)(255 * (1f - row * 0.22f)); + float yOffset = row * DepthSpacing * TiltAngle; + + var color = Color.FromArgb((byte)rowAlpha, baseColor.R, baseColor.G, baseColor.B); + + for (int i = 0; i < bandCount && i < rowBands.Length; i++) + { + float x = offsetX + i * (BarWidth + BarGap); + float barH = Math.Max(2f, rowBands[i] * halfMax * depthFactor); + float y = centerY - barH - yOffset; + + drawSession.FillRoundedRectangle(x, y, BarWidth * depthFactor, barH, 2, 2, color); + } + } + + if (settings.GlowEnabled && glowLayer != null) + { + drawSession.Dispose(); + // Draw the glow (blurred copy) first, then the sharp shapes on top + EffectsHelper.DrawGlow(session, glowLayer); + session.DrawImage(glowLayer); + + // Reflection + EffectsHelper.DrawReflection(session, glowLayer, width, height, centerY); + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add Audiomatic/Visualizer/BarsMode.cs +git commit -m "feat: Implement BarsMode with perspective depth rows" +``` + +--- + +## Chunk 2: Circle and Wave Modes (Tasks 4-5) + +### Task 4: Implement CircleMode + +**Files:** +- Create: `Audiomatic/Visualizer/CircleMode.cs` + +- [ ] **Step 1: Create CircleMode.cs** + +```csharp +using System.Numerics; +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Geometry; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +public sealed class CircleMode : IVisualizerMode +{ + private const int BandCount = 64; + private const float MinRadius = 0.15f; // fraction of min(w,h)/2 + private const float MaxBarLength = 0.30f; // fraction of min(w,h)/2 + private const float BarWidth = 3f; + + private float _rotation; + + public int GetBandCount(float width, float height) => BandCount; + + public void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings) + { + var baseColor = EffectsHelper.GetRenderColor(settings); + float cx = width / 2f; + float cy = height / 2f; + float radius = MathF.Min(width, height) / 2f; + float innerR = radius * MinRadius; + float maxLen = radius * MaxBarLength; + + _rotation += 0.003f; // slow continuous rotation + + if (settings.DarkBackground) + EffectsHelper.DrawDarkBackground(session, width, height); + + using var glowLayer = settings.GlowEnabled ? new CanvasCommandList(session) : null; + var drawSession = settings.GlowEnabled ? glowLayer!.CreateDrawingSession() : session; + + int count = bands.Length; + float angleStep = MathF.Tau / count; + + for (int i = 0; i < count; i++) + { + float angle = i * angleStep + _rotation; + float barLen = Math.Max(2f, bands[i] * maxLen); + float alpha = 0.5f + bands[i] * 0.5f; + + float cos = MathF.Cos(angle); + float sin = MathF.Sin(angle); + + float x1 = cx + cos * innerR; + float y1 = cy + sin * innerR; + float x2 = cx + cos * (innerR + barLen); + float y2 = cy + sin * (innerR + barLen); + + var color = Color.FromArgb((byte)(255 * alpha), baseColor.R, baseColor.G, baseColor.B); + drawSession.DrawLine(x1, y1, x2, y2, color, BarWidth); + } + + if (settings.GlowEnabled && glowLayer != null) + { + drawSession.Dispose(); + EffectsHelper.DrawGlow(session, glowLayer, 16f); + session.DrawImage(glowLayer); + + EffectsHelper.DrawReflection(session, glowLayer, width, height, cy); + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add Audiomatic/Visualizer/CircleMode.cs +git commit -m "feat: Implement CircleMode with radial bar layout" +``` + +--- + +### Task 5: Implement WaveMode + +**Files:** +- Create: `Audiomatic/Visualizer/WaveMode.cs` + +- [ ] **Step 1: Create WaveMode.cs** + +```csharp +using System.Numerics; +using Microsoft.Graphics.Canvas; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +public sealed class WaveMode : IVisualizerMode +{ + private const int Cols = 64; // frequency bands + private const int Rows = 16; // depth (time history) + private const float Perspective = 0.6f; // depth shrink factor + private const float TiltY = 0.35f; // vertical tilt for 3D look + + private readonly float[][] _history = new float[Rows][]; + private int _historyIndex; + private int _frameCount; + + public int GetBandCount(float width, float height) => Cols; + + public void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings) + { + var baseColor = EffectsHelper.GetRenderColor(settings); + int bandCount = Math.Min(bands.Length, Cols); + + // Store current frame + if (_history[0] == null || _history[0].Length != bandCount) + { + for (int i = 0; i < Rows; i++) + _history[i] = new float[bandCount]; + } + Array.Copy(bands, 0, _history[_historyIndex], 0, bandCount); + _historyIndex = (_historyIndex + 1) % Rows; + _frameCount++; + + if (settings.DarkBackground) + EffectsHelper.DrawDarkBackground(session, width, height); + + using var glowLayer = settings.GlowEnabled ? new CanvasCommandList(session) : null; + var drawSession = settings.GlowEnabled ? glowLayer!.CreateDrawingSession() : session; + + float baseY = height * 0.7f; + float maxH = height * 0.4f; + float totalWidth = width * 0.8f; + + // Draw rows from back to front + for (int row = Rows - 1; row >= 0; row--) + { + int histIdx = ((_historyIndex - 1 - row) % Rows + Rows) % Rows; + var rowBands = _history[histIdx]; + if (_frameCount <= row) continue; + + float depthT = row / (float)(Rows - 1); // 0=front, 1=back + float scale = 1f - depthT * Perspective; + float rowY = baseY - row * (height * 0.025f); + float alpha = 1f - depthT * 0.7f; + var color = Color.FromArgb((byte)(255 * alpha), baseColor.R, baseColor.G, baseColor.B); + + float rowWidth = totalWidth * scale; + float offsetX = (width - rowWidth) / 2f; + float step = rowWidth / bandCount; + + // Draw connected line segments for this row + Vector2? prev = null; + for (int i = 0; i < bandCount && i < rowBands.Length; i++) + { + float x = offsetX + i * step; + float barH = rowBands[i] * maxH * scale; + float y = rowY - barH; + + var point = new Vector2(x, y); + if (prev.HasValue) + drawSession.DrawLine(prev.Value, point, color, 1.5f * scale); + prev = point; + } + } + + if (settings.GlowEnabled && glowLayer != null) + { + drawSession.Dispose(); + EffectsHelper.DrawGlow(session, glowLayer, 10f); + session.DrawImage(glowLayer); + + EffectsHelper.DrawReflection(session, glowLayer, width, height, baseY); + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add Audiomatic/Visualizer/WaveMode.cs +git commit -m "feat: Implement WaveMode with wireframe surface grid" +``` + +--- + +## Chunk 3: Renderer and UI Integration (Tasks 6-8) + +### Task 6: Create VisualizerRenderer + +**Files:** +- Create: `Audiomatic/Visualizer/VisualizerRenderer.cs` + +- [ ] **Step 1: Create VisualizerRenderer.cs** + +The renderer manages the `CanvasAnimatedControl`, mode switching, and the selector bar. + +```csharp +using Microsoft.Graphics.Canvas.UI.Xaml; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Audiomatic.Services; + +namespace Audiomatic.Visualizer; + +public sealed class VisualizerRenderer +{ + private readonly SpectrumAnalyzer _spectrum; + private readonly Func _getPosition; + private readonly Func _hasTrack; + + private CanvasAnimatedControl? _canvas; + private IVisualizerMode _currentMode; + private string _currentModeName; + private VisualizerSettings _settings; + + // Selector buttons for highlighting + private readonly Dictionary _modeButtons = new(); + + private static readonly Dictionary> ModeFactories = new() + { + ["bars"] = () => new BarsMode(), + ["circle"] = () => new CircleMode(), + ["wave"] = () => new WaveMode(), + }; + + public VisualizerRenderer(SpectrumAnalyzer spectrum, Func getPosition, Func hasTrack) + { + _spectrum = spectrum; + _getPosition = getPosition; + _hasTrack = hasTrack; + + var s = SettingsManager.Load(); + _currentModeName = s.VisualizerMode ?? "classic"; + _settings = new VisualizerSettings( + Color: s.VisualizerColor ?? "", + GlowEnabled: s.VisualizerGlow, + DarkBackground: s.VisualizerDarkBg); + + _currentMode = ModeFactories.TryGetValue(_currentModeName, out var factory) + ? factory() + : new BarsMode(); // won't be used in classic mode + } + + /// Whether the current mode is "classic" (handled by MainWindow's existing code). + public bool IsClassicMode => _currentModeName == "classic"; + + /// Build the selector bar + CanvasAnimatedControl. Returns the root element to add to the container. + public StackPanel BuildUI() + { + var root = new StackPanel { Spacing = 0 }; + + // Mode selector row + var selector = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Spacing = 0, + Margin = new Thickness(0, 4, 0, 4) + }; + + void AddModeButton(string mode, string label) + { + var btn = new Button + { + Content = new TextBlock { Text = label, FontSize = 11 }, + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0), + Padding = new Thickness(10, 4, 10, 4), + CornerRadius = new CornerRadius(4), + MinHeight = 0, MinWidth = 0 + }; + btn.Click += (_, _) => SwitchMode(mode); + _modeButtons[mode] = btn; + selector.Children.Add(btn); + } + + AddModeButton("classic", "Classic"); + AddModeButton("bars", "Bars"); + AddModeButton("circle", "Circle"); + AddModeButton("wave", "Wave"); + + // Spacer + selector.Children.Add(new Border { Width = 12 }); + + // Glow toggle + var glowBtn = new Button + { + Content = new FontIcon { Glyph = "\uE706", FontSize = 12 }, // brightness + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0), + Padding = new Thickness(6, 4, 6, 4), + CornerRadius = new CornerRadius(4), + MinHeight = 0, MinWidth = 0, + Tag = "glow" + }; + glowBtn.Click += (_, _) => + { + _settings = _settings with { GlowEnabled = !_settings.GlowEnabled }; + SaveVisualizerSettings(); + UpdateToggleButtons(glowBtn, null); + }; + selector.Children.Add(glowBtn); + + // Dark bg toggle + var darkBgBtn = new Button + { + Content = new FontIcon { Glyph = "\uE708", FontSize = 12 }, // contrast + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0), + Padding = new Thickness(6, 4, 6, 4), + CornerRadius = new CornerRadius(4), + MinHeight = 0, MinWidth = 0, + Tag = "darkbg" + }; + darkBgBtn.Click += (_, _) => + { + _settings = _settings with { DarkBackground = !_settings.DarkBackground }; + SaveVisualizerSettings(); + UpdateToggleButtons(glowBtn, darkBgBtn); + }; + selector.Children.Add(darkBgBtn); + + // Color box + var colorPreview = new Border + { + Width = 20, Height = 20, CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1), + BorderBrush = ThemeHelper.Brush("ControlStrokeColorDefaultBrush"), + Background = new SolidColorBrush(EffectsHelper.GetRenderColor(_settings)), + Margin = new Thickness(4, 0, 0, 0) + }; + var colorBox = new TextBox + { + Text = _settings.Color, FontSize = 11, MaxLength = 7, + PlaceholderText = "Accent", Width = 72, MinHeight = 0, + Padding = new Thickness(4, 2, 4, 2), + Margin = new Thickness(4, 0, 0, 0) + }; + colorBox.TextChanged += (_, _) => + { + _settings = _settings with { Color = colorBox.Text }; + try { colorPreview.Background = new SolidColorBrush(EffectsHelper.GetRenderColor(_settings)); } catch { } + if (string.IsNullOrEmpty(colorBox.Text) || (colorBox.Text.StartsWith('#') && colorBox.Text.Length == 7)) + SaveVisualizerSettings(); + }; + selector.Children.Add(colorPreview); + selector.Children.Add(colorBox); + + root.Children.Add(selector); + UpdateModeHighlight(); + UpdateToggleButtons(glowBtn, darkBgBtn); + + // Win2D canvas — fills remaining space + _canvas = new CanvasAnimatedControl + { + ClearColor = Windows.UI.Color.FromArgb(0, 0, 0, 0), // transparent + IsFixedTimeStep = true, + TargetElapsedTime = TimeSpan.FromMilliseconds(16), // 60fps + }; + _canvas.Draw += Canvas_Draw; + + root.Children.Add(_canvas); + + return root; + } + + public void Start() + { + if (_canvas != null && !IsClassicMode) + _canvas.Paused = false; + } + + public void Stop() + { + if (_canvas != null) + _canvas.Paused = true; + } + + public void SetCanvasVisibility(bool win2dVisible) + { + if (_canvas != null) + _canvas.Visibility = win2dVisible ? Visibility.Visible : Visibility.Collapsed; + } + + private void Canvas_Draw(ICanvasAnimatedControl sender, CanvasAnimatedDrawEventArgs args) + { + if (IsClassicMode) return; + + float w = (float)sender.Size.Width; + float h = (float)sender.Size.Height; + if (w <= 0 || h <= 0) return; + + int bandCount = _currentMode.GetBandCount(w, h); + float[] bands; + + if (_hasTrack()) + bands = _spectrum.GetSpectrum(_getPosition(), bandCount); + else + bands = _spectrum.GetSpectrum(TimeSpan.Zero, bandCount); // will decay + + _currentMode.Render(args.DrawingSession, bands, w, h, _settings); + } + + private void SwitchMode(string mode) + { + _currentModeName = mode; + + if (ModeFactories.TryGetValue(mode, out var factory)) + _currentMode = factory(); + + SettingsManager.Save(SettingsManager.Load() with { VisualizerMode = mode }); + UpdateModeHighlight(); + + // Toggle canvas vs classic + SetCanvasVisibility(!IsClassicMode); + if (IsClassicMode) + Stop(); + else + Start(); + } + + private void UpdateModeHighlight() + { + var accent = ThemeHelper.Brush("AccentTextFillColorPrimaryBrush"); + var normal = ThemeHelper.Brush("TextFillColorSecondaryBrush"); + + foreach (var (mode, btn) in _modeButtons) + { + if (btn.Content is TextBlock tb) + tb.Foreground = mode == _currentModeName ? accent : normal; + } + } + + private void UpdateToggleButtons(Button glowBtn, Button? darkBgBtn) + { + var accent = ThemeHelper.Brush("AccentTextFillColorPrimaryBrush"); + var normal = ThemeHelper.Brush("TextFillColorSecondaryBrush"); + if (glowBtn.Content is FontIcon gi) + gi.Foreground = _settings.GlowEnabled ? accent : normal; + if (darkBgBtn?.Content is FontIcon di) + di.Foreground = _settings.DarkBackground ? accent : normal; + } + + private void SaveVisualizerSettings() + { + var s = SettingsManager.Load(); + SettingsManager.Save(s with + { + VisualizerColor = _settings.Color, + VisualizerGlow = _settings.GlowEnabled, + VisualizerDarkBg = _settings.DarkBackground + }); + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add Audiomatic/Visualizer/VisualizerRenderer.cs +git commit -m "feat: Add VisualizerRenderer with mode switching and selector UI" +``` + +--- + +### Task 7: Update MainWindow XAML + +**Files:** +- Modify: `Audiomatic/MainWindow.xaml:339-346` + +- [ ] **Step 1: Replace WaveformContainer content** + +Replace the current WaveformContainer (lines 339-346): + +```xml + + + + + + + + + + + +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add Audiomatic/MainWindow.xaml +git commit -m "feat: Add VisualizerHost and row definitions to WaveformContainer" +``` + +--- + +### Task 8: Integrate VisualizerRenderer into MainWindow.xaml.cs + +**Files:** +- Modify: `Audiomatic/MainWindow.xaml.cs` + +- [ ] **Step 1: Add field and using** + +Add at the top of the file (imports section): +```csharp +using Audiomatic.Visualizer; +``` + +Add field near the other visualizer fields (~line 39): +```csharp +private VisualizerRenderer? _vizRenderer; +``` + +- [ ] **Step 2: Initialize renderer** + +In the constructor or in `UpdateSpectrumTimer()`, initialize the renderer the first time the Visualizer view is shown. Modify `UpdateSpectrumTimer()` (currently lines 1726-1748): + +```csharp +private void UpdateSpectrumTimer() +{ + bool needsViz = _viewMode == ViewMode.Visualizer; + + if (needsViz) + { + PrepareSpectrumForCurrentTrack(); + + // Initialize renderer once + if (_vizRenderer == null) + { + _vizRenderer = new VisualizerRenderer( + _spectrum, + () => _player.Position, + () => _player.CurrentTrack != null); + var ui = _vizRenderer.BuildUI(); + VisualizerHost.Children.Clear(); + VisualizerHost.Children.Add(ui); + } + + if (_vizRenderer.IsClassicMode) + { + // Classic mode: use DispatcherTimer + WaveformCanvas + WaveformCanvas.Visibility = Visibility.Visible; + _vizRenderer.SetCanvasVisibility(false); + _vizRenderer.Stop(); + + if (_spectrumTimer == null) + { + int ms = _vizFps >= 60 ? 16 : 33; + _spectrumTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(ms) }; + _spectrumTimer.Tick += (_, _) => + { + if (_viewMode == ViewMode.Visualizer && (_vizRenderer?.IsClassicMode ?? true)) + DrawVisualization(); + }; + _spectrumTimer.Start(); + } + } + else + { + // Win2D mode: hide classic canvas, start renderer + WaveformCanvas.Visibility = Visibility.Collapsed; + _vizRenderer.SetCanvasVisibility(true); + _vizRenderer.Start(); + + // Stop classic timer if running + if (_spectrumTimer != null) + { + _spectrumTimer.Stop(); + _spectrumTimer = null; + } + } + } + else + { + // Not in visualizer view — stop everything + if (_spectrumTimer != null) + { + _spectrumTimer.Stop(); + _spectrumTimer = null; + _vizBandCount = 0; + _vizNoTrackText = null; + } + _vizRenderer?.Stop(); + } +} +``` + +- [ ] **Step 3: Update mode switch callback** + +The `VisualizerRenderer.SwitchMode` already saves settings. We need to hook the mode switch to restart the timer logic. Add to the `VisualizerRenderer` a callback or make `SwitchMode` call back to MainWindow. + +Simpler approach: add a public `Action? OnModeChanged` callback to `VisualizerRenderer`: + +In `VisualizerRenderer.cs`, add field: +```csharp +public Action? OnModeChanged { get; set; } +``` + +In `SwitchMode()`, at the end add: +```csharp +OnModeChanged?.Invoke(); +``` + +In `MainWindow.xaml.cs`, after creating the renderer: +```csharp +_vizRenderer.OnModeChanged = () => UpdateSpectrumTimer(); +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build` +Expected: 0 errors + +- [ ] **Step 5: Commit** + +```bash +git add Audiomatic/MainWindow.xaml.cs Audiomatic/Visualizer/VisualizerRenderer.cs +git commit -m "feat: Integrate VisualizerRenderer into MainWindow with mode switching" +``` + +--- + +## Chunk 4: Final Integration and Polish (Task 9) + +### Task 9: End-to-end test and polish + +- [ ] **Step 1: Run the application** + +Run: `dotnet run` + +Manual test checklist: +- Navigate to Visualizer view +- Verify Classic mode works as before (rectangle bars) +- Switch to Bars mode — verify perspective depth bars render +- Switch to Circle mode — verify radial bars render with rotation +- Switch to Wave mode — verify wireframe surface renders +- Toggle glow on/off — verify blur effect appears/disappears +- Toggle dark background — verify overlay +- Change color hex — verify bars change color +- Clear color field — verify fallback to accent blue +- Switch back to Classic — verify original rendering resumes +- Close and reopen app — verify mode/settings persisted + +- [ ] **Step 2: Fix any rendering issues found during testing** + +Common issues to watch for: +- Canvas not sizing correctly (may need `VerticalAlignment="Stretch"` on CanvasAnimatedControl) +- Transparency not working (check `ClearColor` is transparent) +- Classic mode bars not showing (check WaveformCanvas visibility) + +- [ ] **Step 3: Final commit** + +```bash +git add -A +git commit -m "feat: Win2D 3D audio visualizer with 4 modes" +``` From d8d031b9e004692f2363c561f0ff1eaeccd0bee7 Mon Sep 17 00:00:00 2001 From: OhMyCode Date: Thu, 12 Mar 2026 16:00:52 +0100 Subject: [PATCH 4/5] feat: Add Win2D visualizer --- Audiomatic/Audiomatic.csproj | 1 + Audiomatic/MainWindow.xaml | 8 +- Audiomatic/MainWindow.xaml.cs | 76 +++++- Audiomatic/SettingsManager.cs | 4 + Audiomatic/Visualizer/BarsMode.cs | 77 ++++++ Audiomatic/Visualizer/CircleMode.cs | 64 +++++ Audiomatic/Visualizer/EffectsHelper.cs | 59 +++++ Audiomatic/Visualizer/IVisualizerMode.cs | 16 ++ Audiomatic/Visualizer/VisualizerRenderer.cs | 268 ++++++++++++++++++++ Audiomatic/Visualizer/WaveMode.cs | 82 ++++++ 10 files changed, 640 insertions(+), 15 deletions(-) create mode 100644 Audiomatic/Visualizer/BarsMode.cs create mode 100644 Audiomatic/Visualizer/CircleMode.cs create mode 100644 Audiomatic/Visualizer/EffectsHelper.cs create mode 100644 Audiomatic/Visualizer/IVisualizerMode.cs create mode 100644 Audiomatic/Visualizer/VisualizerRenderer.cs create mode 100644 Audiomatic/Visualizer/WaveMode.cs diff --git a/Audiomatic/Audiomatic.csproj b/Audiomatic/Audiomatic.csproj index cb9cb39..ef89e0c 100644 --- a/Audiomatic/Audiomatic.csproj +++ b/Audiomatic/Audiomatic.csproj @@ -29,5 +29,6 @@ + diff --git a/Audiomatic/MainWindow.xaml b/Audiomatic/MainWindow.xaml index a2b11aa..bc2f921 100644 --- a/Audiomatic/MainWindow.xaml +++ b/Audiomatic/MainWindow.xaml @@ -342,7 +342,13 @@ Background="Transparent" SizeChanged="WaveformContainer_SizeChanged" PointerPressed="WaveformCanvas_PointerPressed"> - + + + + + + + diff --git a/Audiomatic/MainWindow.xaml.cs b/Audiomatic/MainWindow.xaml.cs index e1527bb..31d59a5 100644 --- a/Audiomatic/MainWindow.xaml.cs +++ b/Audiomatic/MainWindow.xaml.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using Audiomatic.Models; using Audiomatic.Services; +using Audiomatic.Visualizer; using Microsoft.UI; using Microsoft.UI.Composition.SystemBackdrops; using Microsoft.UI.Windowing; @@ -42,6 +43,7 @@ private enum ViewMode { Library, PlaylistList, PlaylistDetail, Queue, Visualizer private int _vizFps = 30; private int _vizBandCount; // tracks current bar count for reuse private TextBlock? _vizNoTrackText; + private VisualizerRenderer? _vizRenderer; // View transition animation private bool _isViewTransitioning; @@ -1725,25 +1727,71 @@ private void UpdateMediaTimer() private void UpdateSpectrumTimer() { - bool needsTimer = _viewMode == ViewMode.Visualizer; - if (needsTimer && _spectrumTimer == null) + bool needsViz = _viewMode == ViewMode.Visualizer; + + if (needsViz) { PrepareSpectrumForCurrentTrack(); - int ms = _vizFps >= 60 ? 16 : 33; - _spectrumTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(ms) }; - _spectrumTimer.Tick += (_, _) => + + // Initialize renderer once + if (_vizRenderer == null) + { + _vizRenderer = new VisualizerRenderer( + _spectrum, + () => _player.Position, + () => _player.CurrentTrack != null); + _vizRenderer.OnModeChanged = () => UpdateSpectrumTimer(); + var selector = _vizRenderer.BuildSelector(); + VisualizerSelector.Children.Clear(); + VisualizerSelector.Children.Add(selector); + // Place Win2D canvas in the star-sized grid row + if (_vizRenderer.Canvas != null) + VisualizerCanvasHost.Children.Add(_vizRenderer.Canvas); + } + + if (_vizRenderer.IsClassicMode) { - if (_viewMode == ViewMode.Visualizer) - DrawVisualization(); - }; - _spectrumTimer.Start(); + // Classic mode: DispatcherTimer + WaveformCanvas + WaveformCanvas.Visibility = Visibility.Visible; + _vizRenderer.SetCanvasVisibility(false); + _vizRenderer.Stop(); + + if (_spectrumTimer == null) + { + int ms = _vizFps >= 60 ? 16 : 33; + _spectrumTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(ms) }; + _spectrumTimer.Tick += (_, _) => + { + if (_viewMode == ViewMode.Visualizer && (_vizRenderer?.IsClassicMode ?? true)) + DrawVisualization(); + }; + _spectrumTimer.Start(); + } + } + else + { + // Win2D mode + WaveformCanvas.Visibility = Visibility.Collapsed; + _vizRenderer.SetCanvasVisibility(true); + _vizRenderer.Start(); + + if (_spectrumTimer != null) + { + _spectrumTimer.Stop(); + _spectrumTimer = null; + } + } } - else if (!needsTimer && _spectrumTimer != null) + else { - _spectrumTimer.Stop(); - _spectrumTimer = null; - _vizBandCount = 0; - _vizNoTrackText = null; + if (_spectrumTimer != null) + { + _spectrumTimer.Stop(); + _spectrumTimer = null; + _vizBandCount = 0; + _vizNoTrackText = null; + } + _vizRenderer?.Stop(); } } diff --git a/Audiomatic/SettingsManager.cs b/Audiomatic/SettingsManager.cs index 185cc7e..062cdd4 100644 --- a/Audiomatic/SettingsManager.cs +++ b/Audiomatic/SettingsManager.cs @@ -20,6 +20,10 @@ public record AppSettings( string Language, // "fr", "en" string Theme = "system", // "system", "light", "dark" int VisualizerFps = 30, + string VisualizerMode = "classic", // "classic", "bars", "circle", "wave" + string VisualizerColor = "", // hex string, empty = system accent + bool VisualizerGlow = true, + bool VisualizerDarkBg = false, int? WindowX = null, int? WindowY = null); diff --git a/Audiomatic/Visualizer/BarsMode.cs b/Audiomatic/Visualizer/BarsMode.cs new file mode 100644 index 0000000..6b409c3 --- /dev/null +++ b/Audiomatic/Visualizer/BarsMode.cs @@ -0,0 +1,77 @@ +using Microsoft.Graphics.Canvas; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +public sealed class BarsMode : IVisualizerMode +{ + private const int DepthRows = 4; + private const float BarWidth = 5f; + private const float BarGap = 2f; + private const float TiltAngle = 0.25f; + private const float DepthSpacing = 14f; + + private readonly float[][] _history = new float[DepthRows][]; + private int _historyIndex; + private int _frameCount; + + public int GetBandCount(float width, float height) + => Math.Max(1, (int)(width / (BarWidth + BarGap))); + + public void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings) + { + var baseColor = EffectsHelper.GetRenderColor(settings); + float centerY = height * 0.55f; + float halfMax = height * 0.40f; + int bandCount = bands.Length; + + if (_history[0] == null || _history[0].Length != bandCount) + { + for (int i = 0; i < DepthRows; i++) + _history[i] = new float[bandCount]; + } + Array.Copy(bands, _history[_historyIndex], bandCount); + _historyIndex = (_historyIndex + 1) % DepthRows; + _frameCount++; + + if (settings.DarkBackground) + EffectsHelper.DrawDarkBackground(session, width, height); + + using var glowLayer = settings.GlowEnabled ? new CanvasCommandList(session) : null; + using var drawSession = settings.GlowEnabled ? glowLayer!.CreateDrawingSession() : null; + var ds = drawSession ?? session; + + float totalWidth = bandCount * (BarWidth + BarGap); + float offsetX = (width - totalWidth) / 2f; + + for (int row = DepthRows - 1; row >= 0; row--) + { + int histIdx = ((_historyIndex - 1 - row) % DepthRows + DepthRows) % DepthRows; + var rowBands = _history[histIdx]; + if (_frameCount <= row) continue; + + float depthFactor = 1f - row * 0.18f; + byte rowAlpha = (byte)(255 * (1f - row * 0.22f)); + float yOffset = row * DepthSpacing * TiltAngle; + + var color = Color.FromArgb(rowAlpha, baseColor.R, baseColor.G, baseColor.B); + + for (int i = 0; i < bandCount && i < rowBands.Length; i++) + { + float x = offsetX + i * (BarWidth + BarGap); + float barH = Math.Max(2f, rowBands[i] * halfMax * depthFactor); + float y = centerY - barH - yOffset; + + ds.FillRoundedRectangle(x, y, BarWidth * depthFactor, barH, 2, 2, color); + } + } + + if (settings.GlowEnabled && glowLayer != null) + { + EffectsHelper.DrawGlow(session, glowLayer); + session.DrawImage(glowLayer); + EffectsHelper.DrawReflection(session, glowLayer, width, height, centerY); + } + } +} diff --git a/Audiomatic/Visualizer/CircleMode.cs b/Audiomatic/Visualizer/CircleMode.cs new file mode 100644 index 0000000..59929c2 --- /dev/null +++ b/Audiomatic/Visualizer/CircleMode.cs @@ -0,0 +1,64 @@ +using Microsoft.Graphics.Canvas; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +public sealed class CircleMode : IVisualizerMode +{ + private const int BandCount = 64; + private const float MinRadius = 0.15f; + private const float MaxBarLength = 0.30f; + private const float BarWidth = 3f; + + private float _rotation; + + public int GetBandCount(float width, float height) => BandCount; + + public void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings) + { + var baseColor = EffectsHelper.GetRenderColor(settings); + float cx = width / 2f; + float cy = height / 2f; + float radius = MathF.Min(width, height) / 2f; + float innerR = radius * MinRadius; + float maxLen = radius * MaxBarLength; + + _rotation += 0.003f; + + if (settings.DarkBackground) + EffectsHelper.DrawDarkBackground(session, width, height); + + using var glowLayer = settings.GlowEnabled ? new CanvasCommandList(session) : null; + using var drawSession = settings.GlowEnabled ? glowLayer!.CreateDrawingSession() : null; + var ds = drawSession ?? session; + + int count = bands.Length; + float angleStep = MathF.Tau / count; + + for (int i = 0; i < count; i++) + { + float angle = i * angleStep + _rotation; + float barLen = Math.Max(2f, bands[i] * maxLen); + float alpha = 0.5f + bands[i] * 0.5f; + + float cos = MathF.Cos(angle); + float sin = MathF.Sin(angle); + + float x1 = cx + cos * innerR; + float y1 = cy + sin * innerR; + float x2 = cx + cos * (innerR + barLen); + float y2 = cy + sin * (innerR + barLen); + + var color = Color.FromArgb((byte)(255 * alpha), baseColor.R, baseColor.G, baseColor.B); + ds.DrawLine(x1, y1, x2, y2, color, BarWidth); + } + + if (settings.GlowEnabled && glowLayer != null) + { + EffectsHelper.DrawGlow(session, glowLayer, 16f); + session.DrawImage(glowLayer); + EffectsHelper.DrawReflection(session, glowLayer, width, height, cy); + } + } +} diff --git a/Audiomatic/Visualizer/EffectsHelper.cs b/Audiomatic/Visualizer/EffectsHelper.cs new file mode 100644 index 0000000..b60cd8c --- /dev/null +++ b/Audiomatic/Visualizer/EffectsHelper.cs @@ -0,0 +1,59 @@ +using System.Numerics; +using Microsoft.Graphics.Canvas; +using Microsoft.Graphics.Canvas.Effects; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +internal static class EffectsHelper +{ + internal static Color ParseColor(string hex) + { + hex = (hex ?? "").TrimStart('#'); + if (hex.Length != 6) + return Color.FromArgb(255, 96, 165, 250); + return Color.FromArgb(255, + Convert.ToByte(hex[..2], 16), + Convert.ToByte(hex[2..4], 16), + Convert.ToByte(hex[4..6], 16)); + } + + internal static Color GetRenderColor(VisualizerSettings settings) + { + if (!string.IsNullOrEmpty(settings.Color) && settings.Color.StartsWith('#') && settings.Color.Length == 7) + return ParseColor(settings.Color); + return Color.FromArgb(255, 96, 165, 250); + } + + internal static void DrawDarkBackground(CanvasDrawingSession session, float width, float height) + { + session.FillRectangle(0, 0, width, height, Color.FromArgb(76, 0, 0, 0)); + } + + internal static void DrawGlow(CanvasDrawingSession session, CanvasCommandList commandList, float blurAmount = 12f) + { + var blur = new GaussianBlurEffect + { + Source = commandList, + BlurAmount = blurAmount, + BorderMode = EffectBorderMode.Soft + }; + session.DrawImage(blur); + } + + internal static void DrawReflection(CanvasDrawingSession session, CanvasCommandList commandList, + float width, float height, float centerY, float opacity = 0.3f) + { + var transform = new Transform2DEffect + { + Source = commandList, + TransformMatrix = Matrix3x2.CreateScale(1, -1, new Vector2(0, centerY)) + }; + var blur = new GaussianBlurEffect + { + Source = transform, + BlurAmount = 4f + }; + session.DrawImage(blur, 0, 0, new Windows.Foundation.Rect(0, centerY, width, height - centerY), opacity); + } +} diff --git a/Audiomatic/Visualizer/IVisualizerMode.cs b/Audiomatic/Visualizer/IVisualizerMode.cs new file mode 100644 index 0000000..e3bad2e --- /dev/null +++ b/Audiomatic/Visualizer/IVisualizerMode.cs @@ -0,0 +1,16 @@ +using Microsoft.Graphics.Canvas; + +namespace Audiomatic.Visualizer; + +public record VisualizerSettings( + string Color, + bool GlowEnabled, + bool DarkBackground); + +public interface IVisualizerMode +{ + int GetBandCount(float width, float height); + + void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings); +} diff --git a/Audiomatic/Visualizer/VisualizerRenderer.cs b/Audiomatic/Visualizer/VisualizerRenderer.cs new file mode 100644 index 0000000..f78cd1f --- /dev/null +++ b/Audiomatic/Visualizer/VisualizerRenderer.cs @@ -0,0 +1,268 @@ +using Audiomatic.Services; +using Microsoft.Graphics.Canvas.UI.Xaml; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; + +namespace Audiomatic.Visualizer; + +public sealed class VisualizerRenderer +{ + private readonly SpectrumAnalyzer _spectrum; + private readonly Func _getPosition; + private readonly Func _hasTrack; + + private CanvasAnimatedControl? _canvas; + private IVisualizerMode _currentMode; + private string _currentModeName; + private VisualizerSettings _settings; + + private readonly Dictionary _modeButtons = new(); + + public Action? OnModeChanged { get; set; } + + public CanvasAnimatedControl? Canvas => _canvas; + + private static readonly Dictionary> ModeFactories = new() + { + ["bars"] = () => new BarsMode(), + ["circle"] = () => new CircleMode(), + ["wave"] = () => new WaveMode(), + }; + + public VisualizerRenderer(SpectrumAnalyzer spectrum, Func getPosition, Func hasTrack) + { + _spectrum = spectrum; + _getPosition = getPosition; + _hasTrack = hasTrack; + + var s = SettingsManager.Load(); + _currentModeName = s.VisualizerMode ?? "classic"; + _settings = new VisualizerSettings( + Color: s.VisualizerColor ?? "", + GlowEnabled: s.VisualizerGlow, + DarkBackground: false); + + _currentMode = ModeFactories.TryGetValue(_currentModeName, out var factory) + ? factory() + : new BarsMode(); + } + + public bool IsClassicMode => _currentModeName == "classic"; + + public StackPanel BuildSelector() + { + var selector = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Spacing = 0, + Margin = new Thickness(0, 4, 0, 4) + }; + + void AddModeButton(string mode, string label) + { + var btn = new Button + { + Content = new TextBlock { Text = label, FontSize = 11 }, + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0), + Padding = new Thickness(10, 4, 10, 4), + CornerRadius = new CornerRadius(4), + MinHeight = 0, MinWidth = 0 + }; + btn.Click += (_, _) => SwitchMode(mode); + _modeButtons[mode] = btn; + selector.Children.Add(btn); + } + + AddModeButton("classic", "Classic"); + AddModeButton("bars", "Bars"); + AddModeButton("circle", "Circle"); + AddModeButton("wave", "Wave"); + + selector.Children.Add(new Border { Width = 12 }); + + // Glow toggle + var glowBtn = new Button + { + Content = new FontIcon { Glyph = "\uE706", FontSize = 12 }, + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0), + Padding = new Thickness(6, 4, 6, 4), + CornerRadius = new CornerRadius(4), + MinHeight = 0, MinWidth = 0 + }; + + glowBtn.Click += (_, _) => + { + _settings = _settings with { GlowEnabled = !_settings.GlowEnabled }; + SaveVisualizerSettings(); + UpdateGlowButton(glowBtn); + }; + + selector.Children.Add(glowBtn); + + // Color picker button + var colorPreview = new Border + { + Width = 22, Height = 22, CornerRadius = new CornerRadius(4), + BorderThickness = new Thickness(1), + BorderBrush = ThemeHelper.Brush("ControlStrokeColorDefaultBrush"), + Background = new SolidColorBrush(EffectsHelper.GetRenderColor(_settings)), + Margin = new Thickness(6, 0, 0, 0) + }; + + var colorPicker = new ColorPicker + { + IsColorSliderVisible = false, + IsColorChannelTextInputVisible = false, + IsHexInputVisible = true, + IsAlphaEnabled = false, + Color = EffectsHelper.GetRenderColor(_settings), + }; + + colorPicker.ColorChanged += (_, args) => + { + var c = args.NewColor; + var hex = $"#{c.R:X2}{c.G:X2}{c.B:X2}"; + _settings = _settings with { Color = hex }; + colorPreview.Background = new SolidColorBrush(c); + SaveVisualizerSettings(); + }; + + var defaultColor = EffectsHelper.ParseColor(""); + var resetBtn = new Button + { + Content = new TextBlock { Text = "Reset", FontSize = 11 }, + HorizontalAlignment = HorizontalAlignment.Stretch, + Margin = new Thickness(0, 8, 0, 0), + }; + resetBtn.Click += (_, _) => + { + colorPicker.Color = defaultColor; + _settings = _settings with { Color = "" }; + colorPreview.Background = new SolidColorBrush(defaultColor); + SaveVisualizerSettings(); + }; + + var pickerPanel = new StackPanel { Children = { colorPicker, resetBtn } }; + + var pickerFlyout = new Flyout + { + Content = pickerPanel, + Placement = FlyoutPlacementMode.Bottom + }; + + var colorBtn = new Button + { + Content = colorPreview, + Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + CornerRadius = new CornerRadius(4), + MinHeight = 0, MinWidth = 0, + Flyout = pickerFlyout + }; + + selector.Children.Add(colorBtn); + + UpdateModeHighlight(); + UpdateGlowButton(glowBtn); + + // Create canvas + _canvas = new CanvasAnimatedControl + { + ClearColor = Windows.UI.Color.FromArgb(0, 0, 0, 0), + IsFixedTimeStep = true, + TargetElapsedTime = TimeSpan.FromMilliseconds(16), + }; + _canvas.Draw += Canvas_Draw; + _canvas.Visibility = IsClassicMode ? Visibility.Collapsed : Visibility.Visible; + _canvas.Paused = IsClassicMode; + + return selector; + } + + public void Start() + { + if (_canvas != null && !IsClassicMode) + _canvas.Paused = false; + } + + public void Stop() + { + if (_canvas != null) + _canvas.Paused = true; + } + + public void SetCanvasVisibility(bool win2dVisible) + { + if (_canvas != null) + _canvas.Visibility = win2dVisible ? Visibility.Visible : Visibility.Collapsed; + } + + private void Canvas_Draw(ICanvasAnimatedControl sender, CanvasAnimatedDrawEventArgs args) + { + if (IsClassicMode) return; + + float w = (float)sender.Size.Width; + float h = (float)sender.Size.Height; + if (w <= 0 || h <= 0) return; + + int bandCount = _currentMode.GetBandCount(w, h); + var bands = _spectrum.GetSpectrum(_hasTrack() ? _getPosition() : TimeSpan.Zero, bandCount); + + _currentMode.Render(args.DrawingSession, bands, w, h, _settings); + } + + private void SwitchMode(string mode) + { + _currentModeName = mode; + + if (ModeFactories.TryGetValue(mode, out var factory)) + _currentMode = factory(); + + SettingsManager.Save(SettingsManager.Load() with { VisualizerMode = mode }); + UpdateModeHighlight(); + + SetCanvasVisibility(!IsClassicMode); + if (IsClassicMode) + Stop(); + else + Start(); + + OnModeChanged?.Invoke(); + } + + private void UpdateModeHighlight() + { + var accent = ThemeHelper.Brush("AccentTextFillColorPrimaryBrush"); + var normal = ThemeHelper.Brush("TextFillColorSecondaryBrush"); + + foreach (var (mode, btn) in _modeButtons) + { + if (btn.Content is TextBlock tb) + tb.Foreground = mode == _currentModeName ? accent : normal; + } + } + + private void UpdateGlowButton(Button glowBtn) + { + var accent = ThemeHelper.Brush("AccentTextFillColorPrimaryBrush"); + var normal = ThemeHelper.Brush("TextFillColorSecondaryBrush"); + if (glowBtn.Content is FontIcon gi) + gi.Foreground = _settings.GlowEnabled ? accent : normal; + } + + private void SaveVisualizerSettings() + { + var s = SettingsManager.Load(); + SettingsManager.Save(s with + { + VisualizerColor = _settings.Color, + VisualizerGlow = _settings.GlowEnabled, + }); + } +} diff --git a/Audiomatic/Visualizer/WaveMode.cs b/Audiomatic/Visualizer/WaveMode.cs new file mode 100644 index 0000000..35137cb --- /dev/null +++ b/Audiomatic/Visualizer/WaveMode.cs @@ -0,0 +1,82 @@ +using System.Numerics; +using Microsoft.Graphics.Canvas; +using Windows.UI; + +namespace Audiomatic.Visualizer; + +public sealed class WaveMode : IVisualizerMode +{ + private const int Cols = 64; + private const int Rows = 16; + private const float Perspective = 0.6f; + + private readonly float[][] _history = new float[Rows][]; + private int _historyIndex; + private int _frameCount; + + public int GetBandCount(float width, float height) => Cols; + + public void Render(CanvasDrawingSession session, float[] bands, float width, float height, + VisualizerSettings settings) + { + var baseColor = EffectsHelper.GetRenderColor(settings); + int bandCount = Math.Min(bands.Length, Cols); + + if (_history[0] == null || _history[0].Length != bandCount) + { + for (int i = 0; i < Rows; i++) + _history[i] = new float[bandCount]; + } + Array.Copy(bands, 0, _history[_historyIndex], 0, bandCount); + _historyIndex = (_historyIndex + 1) % Rows; + _frameCount++; + + if (settings.DarkBackground) + EffectsHelper.DrawDarkBackground(session, width, height); + + using var glowLayer = settings.GlowEnabled ? new CanvasCommandList(session) : null; + using var drawSession = settings.GlowEnabled ? glowLayer!.CreateDrawingSession() : null; + var ds = drawSession ?? session; + + float baseY = height * 0.7f; + float maxH = height * 0.4f; + float totalWidth = width * 0.8f; + + for (int row = Rows - 1; row >= 0; row--) + { + int histIdx = ((_historyIndex - 1 - row) % Rows + Rows) % Rows; + var rowBands = _history[histIdx]; + if (_frameCount <= row) continue; + + float depthT = row / (float)(Rows - 1); + float scale = 1f - depthT * Perspective; + float rowY = baseY - row * (height * 0.025f); + float alpha = 1f - depthT * 0.7f; + var color = Color.FromArgb((byte)(255 * alpha), baseColor.R, baseColor.G, baseColor.B); + + float rowWidth = totalWidth * scale; + float offsetX = (width - rowWidth) / 2f; + float step = rowWidth / bandCount; + + Vector2? prev = null; + for (int i = 0; i < bandCount && i < rowBands.Length; i++) + { + float x = offsetX + i * step; + float barH = rowBands[i] * maxH * scale; + float y = rowY - barH; + + var point = new Vector2(x, y); + if (prev.HasValue) + ds.DrawLine(prev.Value, point, color, 1.5f * scale); + prev = point; + } + } + + if (settings.GlowEnabled && glowLayer != null) + { + EffectsHelper.DrawGlow(session, glowLayer, 10f); + session.DrawImage(glowLayer); + EffectsHelper.DrawReflection(session, glowLayer, width, height, baseY); + } + } +} From 7ecdb1925b2a176dc68acb2af25290b3569f3c3d Mon Sep 17 00:00:00 2001 From: OhMyCode Date: Thu, 12 Mar 2026 16:19:07 +0100 Subject: [PATCH 5/5] fix: Translate --- Audiomatic/MainWindow.xaml.cs | 2 +- installer.iss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Audiomatic/MainWindow.xaml.cs b/Audiomatic/MainWindow.xaml.cs index 31d59a5..5d74608 100644 --- a/Audiomatic/MainWindow.xaml.cs +++ b/Audiomatic/MainWindow.xaml.cs @@ -2245,7 +2245,7 @@ void AddBackdropOption(string type, string label) { var isActive = currentBackdrop == "acrylic_custom"; panel.Children.Add(ActionPanel.CreateButton( - isActive ? "\uE73E" : "\uE8D7", "Acrylic personnalisé", [], () => + isActive ? "\uE73E" : "\uE8D7", "Custom Acrylic", [], () => { var bd = SettingsManager.LoadBackdrop(); if (bd.Type != "acrylic_custom") diff --git a/installer.iss b/installer.iss index 058268e..504a86c 100644 --- a/installer.iss +++ b/installer.iss @@ -1,6 +1,6 @@ [Setup] AppName=Audiomatic -AppVersion=0.0.2 +AppVersion=0.0.3 AppPublisher=OhMyCode DefaultDirName={localappdata}\Programs\Audiomatic DefaultGroupName=Audiomatic