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);
+ }
+}