diff --git a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs index bc8e53c2db..bf08646315 100644 --- a/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs +++ b/src/ui/Controls/AudioVisualizerControl/AudioVisualizer.cs @@ -129,6 +129,7 @@ public Color WaveformColor set { _paintWaveform = new Pen(new SolidColorBrush(value), 1); + ResetFancyColorCaches(); SetValue(WaveformColorProperty, value); } } @@ -149,6 +150,7 @@ public Color WaveformSelectedColor set { _paintPenSelected = new Pen(new SolidColorBrush(value), 1); + ResetFancyColorCaches(); SetValue(WaveformSelectedColorProperty, value); } } @@ -217,7 +219,17 @@ public Color WaveformParagraphRightColor // "center video position" mode can turn into a seek that keeps the play-head centered // (#12864). Hosts without a video player (or that never set this) keep plain scrolling. public Func? GetIsVideoPlaying { get; set; } - public Color WaveformFancyHighColor { get; set; } = Colors.Orange; + private Color _waveformFancyHighColor = Colors.Orange; + + public Color WaveformFancyHighColor + { + get => _waveformFancyHighColor; + set + { + _waveformFancyHighColor = value; + ResetFancyColorCaches(); + } + } private Color _paragraphBackground = Color.FromArgb(140, 70, 70, 70); @@ -2597,6 +2609,26 @@ private void DrawClassicSelectionOverlay(DrawingContext context, ref RenderConte // Pooled buffer for DrawClassicSelectionOverlay's visible selected regions. private readonly List<(double Left, double Right)> _selectionOverlayIntervals = new(16); + /// + /// Drops every fancy-style cache that has a waveform color baked into it. The pen/gradient/glow + /// caches - and the pooled per-color-key batches, which keep a pen of their own - are keyed on + /// the quantized amplitude bucket, not on the color, so a color change leaves them holding pens + /// painted in the old color. Missing the batches here is what made a new waveform/selected/fancy + /// high color only show up after a restart (#13897). + /// + private void ResetFancyColorCaches() + { + _fancyWaveformPenCache.Clear(); + _fancyWaveformGlowPenCache.Clear(); + _fancyWaveformGradientCache.Clear(); + _fancyBatches.Clear(); + _fancyBatchKeysInUse.Clear(); + + // The color properties are not AffectsRender, so ask for the repaint that shows the new + // color instead of waiting for whatever moves the waveform next. + InvalidateVisual(); + } + private Pen GetCachedFancyWaveformPen(int colorKey, Color color) { if (!_fancyWaveformPenCache.TryGetValue(colorKey, out var pen)) @@ -4229,9 +4261,7 @@ internal WaveformDisplayMode GetDisplayMode() internal void ResetCache() { - _fancyWaveformPenCache.Clear(); - _fancyWaveformGlowPenCache.Clear(); - _fancyWaveformGradientCache.Clear(); + ResetFancyColorCaches(); _timeLineTextCache.Clear(); _paragraphFormattedTextCache.Clear(); _paragraphTextCache.Clear(); diff --git a/tests/UI/Controls/AudioVisualizerColorTests.cs b/tests/UI/Controls/AudioVisualizerColorTests.cs new file mode 100644 index 0000000000..4984a4ebcd --- /dev/null +++ b/tests/UI/Controls/AudioVisualizerColorTests.cs @@ -0,0 +1,170 @@ +using Avalonia; +using Avalonia.Headless.XUnit; +using Avalonia.Media; +using Nikse.SubtitleEdit.Controls.AudioVisualizerControl; +using Nikse.SubtitleEdit.Features.Main; +using Nikse.SubtitleEdit.Logic.Media; +using System; +using System.Collections.Generic; +using System.Reflection; +using Xunit; + +namespace UITests.Controls; + +/// +/// Waveform color changes must take effect without restarting SE (#13897). +/// +/// The fancy draw style batches columns by a quantized amplitude bucket and caches a pen per +/// bucket - both in the pen/gradient caches and in the pooled batch dictionary. None of those keys +/// carry the color, so every one of them has to be dropped when a waveform color changes, +/// otherwise the rebuilt geometry is stroked with pens still painted in the old color and only a +/// new control (i.e. a restart) shows the new one. +/// +public class AudioVisualizerColorTests +{ + private const int SampleRate = 126; // Se.Settings.Waveform.WaveformMinimumSampleRate default + private const double WidthPx = 800; + private const double HeightPx = 200; + + private static readonly Color OldColor = Color.FromRgb(0, 70, 0); + private static readonly Color NewColor = Color.FromRgb(255, 0, 255); + + /// Mostly quiet peaks (drawn in the base waveform color) with an occasional loud one + /// so the highest peak - and with it the low/medium/high color thresholds - is well above the + /// quiet columns. + private static WavePeakData2 MakePeaks(int seconds) + { + var peaks = new WavePeak2[SampleRate * seconds]; + for (var i = 0; i < peaks.Length; i++) + { + var v = i % 500 == 0 ? (short)8000 : (short)500; + peaks[i] = new WavePeak2(v, (short)-v); + } + + return new WavePeakData2(SampleRate, peaks); + } + + private static AudioVisualizer MakeMeasuredFancyVisualizer() + { + var av = new AudioVisualizer + { + WaveformDrawStyle = WaveformDrawStyle.Fancy, + WavePeaks = MakePeaks(60), + WaveformColor = OldColor, + WaveformSelectedColor = Color.FromArgb(150, 0, 120, 255), + WaveformFancyHighColor = Colors.Orange, + }; + av.Measure(new Size(WidthPx, HeightPx)); + av.Arrange(new Rect(0, 0, WidthPx, HeightPx)); + av.SetPosition(0, new List(), 0, 0, new List()); + return av; + } + + private static void RenderFrame(AudioVisualizer av) + { + var drawingGroup = new DrawingGroup(); + using var context = drawingGroup.Open(); + av.Render(context); + } + + /// Every color the cached waveform draw ops would actually paint with. + private static List CachedPenColors(AudioVisualizer av) + { + var field = typeof(AudioVisualizer).GetField("_waveformCacheDraws", BindingFlags.NonPublic | BindingFlags.Instance)!; + var list = (System.Collections.IList)field.GetValue(av)!; + var colors = new List(); + foreach (var draw in list) + { + var (pen, _) = (ValueTuple)draw!; + switch (pen.Brush) + { + case IGradientBrush gradient: + foreach (var stop in gradient.GradientStops) + { + colors.Add(stop.Color); + } + + break; + case ISolidColorBrush solid: + colors.Add(solid.Color); + break; + } + } + + return colors; + } + + [AvaloniaFact] + public void FancyWaveform_UsesNewWaveformColor_AfterSettingsApply_Issue13897() + { + var av = MakeMeasuredFancyVisualizer(); + RenderFrame(av); + Assert.Contains(OldColor, CachedPenColors(av)); + + // What Settings -> OK does: push the new colors, then reset the caches. + av.WaveformColor = NewColor; + av.ResetCache(); + RenderFrame(av); + + var colors = CachedPenColors(av); + Assert.DoesNotContain(OldColor, colors); + Assert.Contains(NewColor, colors); + } + + [AvaloniaFact] + public void FancyWaveform_UsesNewWaveformColor_WithoutResetCache_Issue13897() + { + // The setter alone must be enough - no caller should have to remember ResetCache(). + var av = MakeMeasuredFancyVisualizer(); + RenderFrame(av); + Assert.Contains(OldColor, CachedPenColors(av)); + + av.WaveformColor = NewColor; + RenderFrame(av); + + var colors = CachedPenColors(av); + Assert.DoesNotContain(OldColor, colors); + Assert.Contains(NewColor, colors); + } + + [AvaloniaFact] + public void FancyWaveform_UsesNewSelectedColor_Issue13897() + { + var av = MakeMeasuredFancyVisualizer(); + var selected = new SubtitleLineViewModel + { + Text = "text", + StartTime = TimeSpan.Zero, + EndTime = TimeSpan.FromSeconds(5), + }; + var lines = new List { selected }; + av.SetPosition(0, lines, 0, 0, lines); + + var oldSelectedColor = Color.FromRgb(0, 120, 255); + av.WaveformSelectedColor = oldSelectedColor; + RenderFrame(av); + Assert.Contains(oldSelectedColor, CachedPenColors(av)); + + av.WaveformSelectedColor = NewColor; + RenderFrame(av); + + var colors = CachedPenColors(av); + Assert.DoesNotContain(oldSelectedColor, colors); + Assert.Contains(NewColor, colors); + } + + [AvaloniaFact] + public void FancyWaveform_UsesNewFancyHighColor_Issue13897() + { + var av = MakeMeasuredFancyVisualizer(); + RenderFrame(av); + Assert.Contains(Colors.Orange, CachedPenColors(av)); + + av.WaveformFancyHighColor = NewColor; + RenderFrame(av); + + var colors = CachedPenColors(av); + Assert.DoesNotContain(Colors.Orange, colors); + Assert.Contains(NewColor, colors); + } +}