From 2b0ad8e104cb7267de796c1263d798769eb941bf Mon Sep 17 00:00:00 2001 From: aodjo Date: Sun, 6 Sep 2026 22:37:09 +0900 Subject: [PATCH] fix(desktop): slide the lyric sheet on the macOS spring Claude-Session: https://claude.ai/code/session_0164R7Zuagx737iKYbuuSjnA --- src/Reprise.Desktop/AssemblyInfo.cs | 6 + src/Reprise.Desktop/PanelLyricsView.cs | 128 ++++++++++++++---- .../PanelLyricsViewTests.cs | 75 ++++++++++ 3 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 src/Reprise.Desktop/AssemblyInfo.cs create mode 100644 tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs diff --git a/src/Reprise.Desktop/AssemblyInfo.cs b/src/Reprise.Desktop/AssemblyInfo.cs new file mode 100644 index 0000000..ae63b48 --- /dev/null +++ b/src/Reprise.Desktop/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// The panel's animation curves are internal: they are shape, not API, and +// nothing outside the drawing code calls them. They are also the pieces that +// have to match the macOS app frame for frame, so they are worth pinning. +[assembly: InternalsVisibleTo("Reprise.Desktop.Tests")] diff --git a/src/Reprise.Desktop/PanelLyricsView.cs b/src/Reprise.Desktop/PanelLyricsView.cs index 9f3c0a8..8752e0b 100644 --- a/src/Reprise.Desktop/PanelLyricsView.cs +++ b/src/Reprise.Desktop/PanelLyricsView.cs @@ -51,6 +51,23 @@ public sealed class PanelLyricsView : Control /// private const double DimmedOpacity = 0.38; + /// + /// Angular frequency of the slide, over its normalised duration. + /// + /// + /// SwiftUI's spring(duration:bounce:) defines the duration as one + /// period of the underlying spring, so a full turn of the circle spans + /// exactly . Getting this wrong is what makes + /// a spring feel like a snap: at twice this frequency the row has + /// travelled 96% of the way in a quarter of the time it was given. + /// + private const double SpringOmega = Math.Tau; + + /// + /// Damping ratio of the slide, the macOS bounce of 0.24 subtracted from one. + /// + private const double SpringDamping = 0.76; + private static readonly TimeSpan CascadeDelay = TimeSpan.FromMilliseconds(45); private static readonly TimeSpan MoveDuration = TimeSpan.FromMilliseconds(560); private static readonly TimeSpan FadeDuration = TimeSpan.FromMilliseconds(220); @@ -74,7 +91,11 @@ public sealed class PanelLyricsView : Control private int _focus; private double _previousFocus; private DateTimeOffset _moveStartedAt; - private bool _animating; + private bool _moving; + private int? _current; + private int? _previousCurrent; + private DateTimeOffset _fadeStartedAt; + private bool _fading; private double _cachedWidth; /// @@ -94,6 +115,7 @@ public PanelLyricsView() TimeSpan.FromMilliseconds(16), DispatcherPriority.Render, (_, _) => Tick()); + _frameTimer.Stop(); ClipToBounds = true; Height = ViewportHeight; } @@ -137,6 +159,8 @@ public SyncedLyrics? Lyrics _textCache.Clear(); _focus = 0; _previousFocus = 0; + _current = null; + _previousCurrent = null; StopAnimation(); InvalidateVisual(); } @@ -148,6 +172,14 @@ public SyncedLyrics? Lyrics /// /// Called by the panel's progress timer. A change of line starts the /// slide; an unchanged line costs nothing. + /// + /// Two indices are tracked, and the distinction matters. The focused + /// line is what the rows are positioned around and always exists once + /// playback has begun; the current line is the one actually being sung, + /// and is absent through an instrumental gap. So the sheet holds its + /// place while the highlight fades away, rather than jumping whenever + /// a line runs out. + /// /// /// Current playback position. /// @@ -162,16 +194,31 @@ public void Advance(TimeSpan position) return; } + var now = DateTimeOffset.UtcNow; var focus = _lyrics.FocusedLineIndex(position) ?? 0; - if (focus == _focus) + var current = _lyrics.LineIndex(position); + + if (focus != _focus) + { + _previousFocus = _moving ? AnimatedFocus(now, 0) : _focus; + _focus = focus; + _moveStartedAt = now; + _moving = true; + } + + if (current != _current) + { + _previousCurrent = _current; + _current = current; + _fadeStartedAt = now; + _fading = true; + } + + if (!_moving && !_fading) { return; } - _previousFocus = _animating ? AnimatedFocus(DateTimeOffset.UtcNow, 0) : _focus; - _focus = focus; - _moveStartedAt = DateTimeOffset.UtcNow; - _animating = true; _frameTimer.Start(); InvalidateVisual(); } @@ -201,8 +248,12 @@ public override void Render(DrawingContext context) var now = DateTimeOffset.UtcNow; using var mask = context.PushOpacityMask(CreateFadeMask(), bounds); - var firstVisible = Math.Max(0, _focus - 3); - var lastVisible = Math.Min(lyrics.Lines.Count - 1, _focus + 6); + // Span both ends of a slide, or the rows the sheet is travelling + // from would pop into place instead of moving out of view. + var lowest = (int)Math.Floor(Math.Min(_previousFocus, _focus)); + var highest = (int)Math.Ceiling(Math.Max(_previousFocus, _focus)); + var firstVisible = Math.Max(0, lowest - 3); + var lastVisible = Math.Min(lyrics.Lines.Count - 1, highest + 6); for (var index = firstVisible; index <= lastVisible; index++) { var relative = index - _focus; @@ -243,23 +294,36 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e /// private void Tick() { - var elapsed = DateTimeOffset.UtcNow - _moveStartedAt; - var total = MoveDuration + CascadeDelay * MaximumCascadeStep + FadeDuration; - if (elapsed >= total) + var now = DateTimeOffset.UtcNow; + if (_moving && now - _moveStartedAt >= MoveDuration + CascadeDelay * MaximumCascadeStep) { - StopAnimation(); + _moving = false; + _previousFocus = _focus; + } + + if (_fading && now - _fadeStartedAt >= FadeDuration) + { + _fading = false; + _previousCurrent = _current; + } + + if (!_moving && !_fading) + { + _frameTimer.Stop(); } InvalidateVisual(); } /// - /// Ends the slide and snaps rows to their final positions. + /// Ends both animations and snaps every row to its final state. /// private void StopAnimation() { - _animating = false; + _moving = false; + _fading = false; _previousFocus = _focus; + _previousCurrent = _current; _frameTimer.Stop(); } @@ -271,7 +335,7 @@ private void StopAnimation() /// A fractional row index between the previous and current focus. private double AnimatedFocus(DateTimeOffset now, int cascadeStep) { - if (!_animating) + if (!_moving) { return _focus; } @@ -289,15 +353,15 @@ private double AnimatedFocus(DateTimeOffset now, int cascadeStep) /// An opacity from to 1. private double AnimatedOpacity(DateTimeOffset now, int index) { - var target = index == _focus ? 1 : DimmedOpacity; - if (!_animating) + var target = index == _current ? 1 : DimmedOpacity; + if (!_fading) { return target; } - var previous = index == (int)Math.Round(_previousFocus) ? 1 : DimmedOpacity; - var progress = Math.Clamp((now - _moveStartedAt).TotalMilliseconds / FadeDuration.TotalMilliseconds, 0, 1); - return previous + (target - previous) * progress; + var previous = index == _previousCurrent ? 1 : DimmedOpacity; + var progress = Math.Clamp((now - _fadeStartedAt).TotalMilliseconds / FadeDuration.TotalMilliseconds, 0, 1); + return previous + (target - previous) * EaseInOut(progress); } /// @@ -305,21 +369,31 @@ private double AnimatedOpacity(DateTimeOffset now, int index) /// /// Normalised time from 0 to 1. /// Position from 0 to about 1, with a small overshoot. - private static double Spring(double progress) + internal static double Spring(double progress) { if (progress >= 1) { return 1; } - const double omega = 11.5; - const double damping = 0.68; - var t = progress; - var damped = omega * Math.Sqrt(1 - damping * damping); - return 1 - Math.Exp(-damping * omega * t) - * (Math.Cos(damped * t) + damping * omega / damped * Math.Sin(damped * t)); + var damped = SpringOmega * Math.Sqrt(1 - SpringDamping * SpringDamping); + return 1 - Math.Exp(-SpringDamping * SpringOmega * progress) + * (Math.Cos(damped * progress) + SpringDamping * SpringOmega / damped * Math.Sin(damped * progress)); } + /// + /// The ease the highlight crossfades on. + /// + /// + /// Stands in for SwiftUI's easeInOut. A straight ramp makes the + /// handover between two lines read as a dip in brightness, because both + /// sit at middling opacity for the whole of the middle of the fade. + /// + /// Normalised time from 0 to 1. + /// Eased position from 0 to 1. + internal static double EaseInOut(double progress) => + progress * progress * (3 - 2 * progress); + /// /// The mask that fades rows at the top and bottom edges. /// diff --git a/tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs b/tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs new file mode 100644 index 0000000..05b20a6 --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs @@ -0,0 +1,75 @@ +using Reprise.Desktop; +using Xunit; + +namespace Reprise.Desktop.Tests; + +/// +/// Pins the lyric sheet's animation curves to the macOS originals. +/// +/// +/// The scrolling lyrics are the one part of the panel judged purely by how +/// it moves, and a wrong constant here does not fail anything else: the +/// rows still arrive in the right places, just far too quickly and tearing +/// apart on the way. So the curve itself is the thing worth asserting. +/// +public sealed class PanelLyricsViewTests +{ + /// + /// Tolerance on a curve value, loose enough for the reference figures + /// below to be quoted to three places. + /// + private const double Tolerance = 0.005; + + /// + /// The slide follows SwiftUI's spring of duration 0.56 and bounce 0.24. + /// + /// + /// Values are the analytic response of a spring with a damping ratio of + /// 0.76 whose period is the full duration, sampled at tenths of the way + /// through. An earlier curve reached 0.96 by the quarter mark, which is + /// what made the sheet jump rather than glide. + /// + [Fact] + public void SlideMatchesTheMacOsSpring() + { + Assert.Equal(0, PanelLyricsView.Spring(0), Tolerance); + Assert.Equal(0.143, PanelLyricsView.Spring(0.1), Tolerance); + Assert.Equal(0.539, PanelLyricsView.Spring(0.25), Tolerance); + Assert.Equal(0.946, PanelLyricsView.Spring(0.5), Tolerance); + Assert.Equal(1, PanelLyricsView.Spring(1), Tolerance); + } + + /// + /// The slide overshoots slightly and stays settled afterwards. + /// + /// + /// The bounce is what gives the sheet the weight of a physical scroll, + /// so a curve that never passes 1 has lost it; one that passes it by + /// much would visibly rebound. + /// + [Fact] + public void SlideOvershootsOnceAndSettles() + { + var peak = 0.0; + for (var step = 0; step <= 100; step++) + { + peak = Math.Max(peak, PanelLyricsView.Spring(step / 100.0)); + } + + Assert.InRange(peak, 1.01, 1.05); + Assert.Equal(1, PanelLyricsView.Spring(1.5), Tolerance); + } + + /// + /// The highlight crossfade eases in and out around a straight middle. + /// + [Fact] + public void CrossfadeEasesAtBothEnds() + { + Assert.Equal(0, PanelLyricsView.EaseInOut(0), Tolerance); + Assert.Equal(0.5, PanelLyricsView.EaseInOut(0.5), Tolerance); + Assert.Equal(1, PanelLyricsView.EaseInOut(1), Tolerance); + Assert.True(PanelLyricsView.EaseInOut(0.1) < 0.1); + Assert.True(PanelLyricsView.EaseInOut(0.9) > 0.9); + } +}