Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Reprise.Desktop/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -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")]
128 changes: 101 additions & 27 deletions src/Reprise.Desktop/PanelLyricsView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ public sealed class PanelLyricsView : Control
/// </summary>
private const double DimmedOpacity = 0.38;

/// <summary>
/// Angular frequency of the slide, over its normalised duration.
/// </summary>
/// <remarks>
/// SwiftUI's <c>spring(duration:bounce:)</c> defines the duration as one
/// period of the underlying spring, so a full turn of the circle spans
/// exactly <see cref="MoveDuration"/>. 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.
/// </remarks>
private const double SpringOmega = Math.Tau;

/// <summary>
/// Damping ratio of the slide, the macOS bounce of 0.24 subtracted from one.
/// </summary>
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);
Expand All @@ -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;

/// <summary>
Expand All @@ -94,6 +115,7 @@ public PanelLyricsView()
TimeSpan.FromMilliseconds(16),
DispatcherPriority.Render,
(_, _) => Tick());
_frameTimer.Stop();
ClipToBounds = true;
Height = ViewportHeight;
}
Expand Down Expand Up @@ -137,6 +159,8 @@ public SyncedLyrics? Lyrics
_textCache.Clear();
_focus = 0;
_previousFocus = 0;
_current = null;
_previousCurrent = null;
StopAnimation();
InvalidateVisual();
}
Expand All @@ -148,6 +172,14 @@ public SyncedLyrics? Lyrics
/// <remarks>
/// Called by the panel's progress timer. A change of line starts the
/// slide; an unchanged line costs nothing.
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="position">Current playback position.</param>
/// <example>
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -243,23 +294,36 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e
/// </summary>
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();
}

/// <summary>
/// Ends the slide and snaps rows to their final positions.
/// Ends both animations and snaps every row to its final state.
/// </summary>
private void StopAnimation()
{
_animating = false;
_moving = false;
_fading = false;
_previousFocus = _focus;
_previousCurrent = _current;
_frameTimer.Stop();
}

Expand All @@ -271,7 +335,7 @@ private void StopAnimation()
/// <returns>A fractional row index between the previous and current focus.</returns>
private double AnimatedFocus(DateTimeOffset now, int cascadeStep)
{
if (!_animating)
if (!_moving)
{
return _focus;
}
Expand All @@ -289,37 +353,47 @@ private double AnimatedFocus(DateTimeOffset now, int cascadeStep)
/// <returns>An opacity from <see cref="DimmedOpacity"/> to 1.</returns>
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);
}

/// <summary>
/// A lightly under-damped spring, standing in for SwiftUI's bouncy spring.
/// </summary>
/// <param name="progress">Normalised time from 0 to 1.</param>
/// <returns>Position from 0 to about 1, with a small overshoot.</returns>
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));
}

/// <summary>
/// The ease the highlight crossfades on.
/// </summary>
/// <remarks>
/// Stands in for SwiftUI's <c>easeInOut</c>. 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.
/// </remarks>
/// <param name="progress">Normalised time from 0 to 1.</param>
/// <returns>Eased position from 0 to 1.</returns>
internal static double EaseInOut(double progress) =>
progress * progress * (3 - 2 * progress);

/// <summary>
/// The mask that fades rows at the top and bottom edges.
/// </summary>
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Reprise.Desktop;
using Xunit;

namespace Reprise.Desktop.Tests;

/// <summary>
/// Pins the lyric sheet's animation curves to the macOS originals.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class PanelLyricsViewTests
{
/// <summary>
/// Tolerance on a curve value, loose enough for the reference figures
/// below to be quoted to three places.
/// </summary>
private const double Tolerance = 0.005;

/// <summary>
/// The slide follows SwiftUI's spring of duration 0.56 and bounce 0.24.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}

/// <summary>
/// The slide overshoots slightly and stays settled afterwards.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}

/// <summary>
/// The highlight crossfade eases in and out around a straight middle.
/// </summary>
[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);
}
}
Loading