Skip to content

Commit 401d738

Browse files
authored
Merge pull request #23 from chrisuthe/chrisuthe/task/hold-scheduled-artwork-and-metadata-until-their
Hold scheduled artwork and metadata until their timestamps
2 parents a39db11 + 9cb60f8 commit 401d738

11 files changed

Lines changed: 1054 additions & 34 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,37 @@ Still true on 9.3.2, checked rather than assumed: 9.3.0 reworked the filter's pr
230230
that stays noisy now falls back to the steady-state interval and withholds `IsClockSynced` — but the
231231
timeout string above is byte-identical in both assemblies.
232232

233+
### The SDK surfaces the artwork and metadata timestamps but holds nothing pending
234+
235+
Both roles carry a server-clock timestamp saying *when* the value takes effect, and the spec has a
236+
client keep a **current** value plus at most one **pending** one per channel: a future timestamp is
237+
held and applied when the local clock reaches it, a past one applies at once and discards the pending
238+
one, a new future one replaces it (`roles/artwork/v1.md`, `roles/metadata/v1.md`). The SDK exposes
239+
the timestamps — `ArtworkReceivedEventArgs.Timestamp`, `ArtworkClearedEventArgs.Timestamp`,
240+
`TrackMetadata.Timestamp` — and then applies everything on arrival: `GroupState.Metadata` is
241+
whatever came last, and there is no pending slot anywhere. With a queue that put the next track's
242+
cover and title on screen seconds before the audible change.
243+
244+
The pending model therefore lives here, in `Sendspin.Core.MediaSession.ScheduledValue<T>`: one per
245+
artwork channel and one for the track metadata, owned by `SendspinPlayerService`, which converts each
246+
timestamp with `IClockSynchronizer.ServerToClientTime` (the current best estimate — the spec says
247+
not to wait for convergence — and inclusive of this player's static delay, so the display changes
248+
when *this* player's audio does) and promotes on a `System.Threading.Timer`. Playback state, volume
249+
and the command set apply immediately; only the track metadata is scheduled. Position is the spec's
250+
formula run from the *current* metadata's timestamp (`MediaSessionMapper.ProjectPosition`), never
251+
from arrival time and never from a pending update. `PlayerServiceEventTests` drives the whole path
252+
with a known clock offset and a hand-cranked clock.
253+
254+
Two SDK facts that shape it. The SDK builds a **new `TrackMetadata` instance** for every
255+
`server/state` that carries a metadata object and leaves the instance alone for one that does not
256+
(it mutates the one `GroupState` in place), so reference identity is what distinguishes a metadata
257+
update from a volume change. And the SDK raises **no event for `stream/end`**, although the spec
258+
discards pending values there; they are discarded on disconnect, and otherwise survive a stream end
259+
until their timestamp — at most the 20 s the spec lets a server schedule ahead. The spec also changed
260+
artwork transfers on 2026-09-02 to an announce-and-parts sequence with an explicit cancel; 9.3.2
261+
still raises one event per complete image, and the scheduler is built on that event rather than on
262+
the wire shape, so the coming SDK bump changes only where "transfer complete" comes from.
263+
233264
### Other SDK API facts worth not rediscovering
234265

235266
- `IStaticDelayStore` is the SDK's persistence seam for `static_delay_ms`. It must be backed by the

docs/COMPLIANCE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ contradict what the plan originally assumed.
1616
|---|---|
1717
| `player@v1` | Implemented |
1818
| `controller@v1` | Implemented (transport commands, volume, mute, `switch`) |
19-
| `metadata@v1` | Implemented (title, artist, album, album artist, progress) |
20-
| `artwork@v1` | Implemented (one album channel, JPEG, 512×512) |
19+
| `metadata@v1` | Implemented (title, artist, album, album artist, progress; scheduled updates held until their timestamp and position projected from the current metadata's timestamp — `PlayerServiceEventTests`, `ScheduledValueTests`, `MediaSessionMapperTests.ProjectPosition_*`) |
20+
| `artwork@v1` | Implemented (one album channel, JPEG, 512×512; scheduled images and clears held until their timestamp per channel — `PlayerServiceEventTests.ScheduledArtworkAndMetadata_*`; the announce-and-parts transfer the spec adopted on 2026-09-02 awaits an SDK bump, see `docs/ARCHITECTURE.md`) |
2121
| `visualizer@v1` | Implemented (loudness and beat at up to 30 frames a second, 4 096-byte buffer; no spectrum) — drives the living backdrop |
2222
| `color@v1` | Implemented (the server's palette, picked per theme variant) — drives the living backdrop |
2323
| `management@v1` | **Not implemented** — see "Blocked on the SDK" below |

docs/NEXT_STEPS.md

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -239,19 +239,26 @@ dotnet run --project scripts/spike/ShellSpike -- clock
239239

240240
---
241241

242-
## 9. The artwork handler ignores the frame's channel and display timestamp
243-
244-
`SendspinPlayerService.OnArtworkReceived` and `OnArtworkCleared` read neither `Channel` nor
245-
`Timestamp` off the artwork frame. Harmless today: `client/hello` advertises exactly one artwork
246-
channel, so every frame is channel 0, and the server-clock timestamp — when the picture *should be
247-
shown* — is ignored in favour of showing it on arrival. Naming the cached file by its bytes fixed
248-
the stale-art race that ordering produced, so the residue is at worst a sub-second early cover
249-
when the next track's picture lands ahead of its metadata.
250-
251-
**First action:** none until a second channel (artist art) is advertised; a clear on channel 1 would
252-
then blank the album art. Honouring the timestamp means holding the publish until clock sync says
253-
server time has reached it, and is its own change — it does not replace per-picture paths, which are
254-
still needed at the boundary.
242+
## 9. Scheduled artwork and metadata are honoured; `stream/end` is not yet seen
243+
244+
`SendspinPlayerService` now holds a picture, a clear or a metadata update stamped for the future
245+
until its timestamp, per channel for artwork, and computes position from the current metadata's
246+
timestamp (`docs/ARCHITECTURE.md`, "The SDK surfaces the artwork and metadata timestamps but holds
247+
nothing pending"). What remains is upstream: SDK 9.3.2 raises no event for `stream/end`, where the
248+
spec discards pending values, so a pending value outlives a stream end until its own timestamp; and
249+
the announce-and-parts artwork transfer the spec adopted on 2026-09-02 is not in 9.3.2 at all.
250+
251+
Measured live on 2026-09-02 against Music Assistant (the Debug lines the service now writes per
252+
update): at a gapless queue boundary MA stamped the next picture 109 ms *in the past* and the next
253+
metadata 3 ms in the past, and sent the picture about 270 ms before the metadata. Past-stamped
254+
values apply at once by the spec's own rule, so with today's MA the cover still leads the title by
255+
that quarter second and both lead the audible change by the buffer. The fix for that is on the
256+
server: stamping both for the audible boundary, which this player now honours.
257+
258+
**First action:** none until the SDK bump that adds both. When it lands, discard pending values on
259+
the stream-end event and take "transfer complete" from the SDK — the scheduler is built on the
260+
complete-image event, so nothing else moves. The cross-fade the spec permits around a picture's
261+
timestamp is a UI follow-up that can be picked up independently.
255262

256263
---
257264

src/Sendspin.Core/MediaSession/MediaSessionMapper.cs

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,53 @@ public static class MediaSessionMapper
2626
/// </remarks>
2727
public const string TrackIdRoot = "/io/sendspin/client/track/";
2828

29+
/// <summary>The protocol's <c>playback_speed</c> for normal speed: a ×1000 integer.</summary>
30+
private const double NormalPlaybackSpeed = 1000;
31+
32+
/// <summary>
33+
/// Projects the server's authoritative group state into a media-session snapshot, taking the
34+
/// track metadata as it stands at its own timestamp.
35+
/// </summary>
36+
/// <remarks>
37+
/// The group's own <see cref="GroupState.Metadata"/> is whatever the server sent last, which
38+
/// may be a scheduled update that is not in effect yet. The service therefore does not call
39+
/// this; it schedules the metadata and calls
40+
/// <see cref="FromGroupState(GroupState?, TrackMetadata?, string?, long)"/> with the current
41+
/// one. This overload is the plain projection for callers with no clock in hand.
42+
/// </remarks>
43+
/// <param name="group">The group state, or null when disconnected.</param>
44+
/// <param name="artworkFilePath">
45+
/// Path to the artwork file for the current track, or null when there is none.
46+
/// </param>
47+
public static MediaSessionState FromGroupState(GroupState? group, string? artworkFilePath = null) =>
48+
FromGroupState(group, group?.Metadata, artworkFilePath, elapsedMicrosSinceMetadata: 0);
49+
2950
/// <summary>
3051
/// Projects the server's authoritative group state into a media-session snapshot.
3152
/// </summary>
3253
/// <param name="group">The group state, or null when disconnected.</param>
54+
/// <param name="metadata">
55+
/// The track metadata currently in effect — the scheduler's current value, not necessarily the
56+
/// group's latest — or null when there is none.
57+
/// </param>
3358
/// <param name="artworkFilePath">
3459
/// Path to the artwork file for the current track, or null when there is none.
3560
/// </param>
36-
public static MediaSessionState FromGroupState(GroupState? group, string? artworkFilePath = null)
61+
/// <param name="elapsedMicrosSinceMetadata">
62+
/// How long ago, on the local clock, <paramref name="metadata"/> took effect. The position is
63+
/// projected forward by this, per <see cref="ProjectPosition"/>.
64+
/// </param>
65+
public static MediaSessionState FromGroupState(
66+
GroupState? group,
67+
TrackMetadata? metadata,
68+
string? artworkFilePath,
69+
long elapsedMicrosSinceMetadata)
3770
{
3871
if (group is null)
3972
{
4073
return MediaSessionState.Idle;
4174
}
4275

43-
var metadata = group.Metadata;
4476
var duration = ToTimeSpan(metadata?.Duration);
4577
var identity = BuildTrackIdentity(metadata);
4678
var commands = group.SupportedCommands ?? [];
@@ -54,7 +86,7 @@ public static MediaSessionState FromGroupState(GroupState? group, string? artwor
5486
AlbumArtist = NullIfBlank(metadata?.AlbumArtist),
5587
ArtworkFilePath = artworkFilePath,
5688
Duration = duration,
57-
Position = ToTimeSpan(metadata?.Position) ?? TimeSpan.Zero,
89+
Position = ProjectPosition(metadata?.Progress, elapsedMicrosSinceMetadata),
5890
CanGoNext = Supports(commands, Commands.Next),
5991
CanGoPrevious = Supports(commands, Commands.Previous),
6092
// Always false. The player role has no seek command — position belongs to the server,
@@ -71,6 +103,48 @@ public static MediaSessionState FromGroupState(GroupState? group, string? artwor
71103
};
72104
}
73105

106+
/// <summary>
107+
/// The spec's track-position formula (<c>roles/metadata/v1.md</c>, "Calculating current track
108+
/// position"): where the track is <paramref name="elapsedMicros"/> after the metadata took
109+
/// effect.
110+
/// </summary>
111+
/// <remarks>
112+
/// <para>
113+
/// <c>progress = track_progress + elapsed × playback_speed / 1000</c>, in milliseconds, with
114+
/// <c>playback_speed</c> the spec's ×1000 integer (1000 is normal, 0 is paused). Clamped to the
115+
/// duration when one is known and never negative, exactly as the spec writes it.
116+
/// </para>
117+
/// <para>
118+
/// <paramref name="elapsedMicros"/> is measured from the metadata's own timestamp, converted to
119+
/// the local clock, never from when the message arrived: a server sends the next track's
120+
/// metadata ahead of the audible change, so arrival time would credit the new track with the
121+
/// seconds it had not yet played.
122+
/// </para>
123+
/// </remarks>
124+
/// <param name="progress">The metadata's progress object, or null when it has none.</param>
125+
/// <param name="elapsedMicros">
126+
/// Local microseconds since the metadata's timestamp. A negative value, which only a caller
127+
/// projecting a not-yet-current update could produce, counts as zero.
128+
/// </param>
129+
public static TimeSpan ProjectPosition(PlaybackProgress? progress, long elapsedMicros)
130+
{
131+
if (progress is null || progress.TrackProgress is not { } start || double.IsNaN(start))
132+
{
133+
return TimeSpan.Zero;
134+
}
135+
136+
var speed = progress.PlaybackSpeed ?? NormalPlaybackSpeed;
137+
var elapsedMilliseconds = Math.Max(elapsedMicros, 0) / 1000.0;
138+
var projected = start + elapsedMilliseconds * speed / NormalPlaybackSpeed;
139+
140+
if (progress.TrackDuration is > 0 and var duration)
141+
{
142+
projected = Math.Min(projected, duration);
143+
}
144+
145+
return TimeSpan.FromMilliseconds(Math.Max(projected, 0));
146+
}
147+
74148
/// <summary>
75149
/// Maps the SDK's playback state onto what a shell can display.
76150
/// </summary>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
namespace Sendspin.Core.MediaSession;
2+
3+
/// <summary>
4+
/// What <see cref="ScheduledValue{T}.Offer"/> did with a value.
5+
/// </summary>
6+
public enum ScheduledOffer
7+
{
8+
/// <summary>The value was due, so it became current at once.</summary>
9+
Applied,
10+
11+
/// <summary>The value is not due yet and is being held as the pending one.</summary>
12+
Held
13+
}
14+
15+
/// <summary>
16+
/// A value that takes effect at a scheduled time: what is current now, plus at most one pending
17+
/// value waiting for its moment.
18+
/// </summary>
19+
/// <remarks>
20+
/// <para>
21+
/// This is the spec's model for both scheduled artwork (<c>roles/artwork/v1.md</c>) and scheduled
22+
/// metadata (<c>roles/metadata/v1.md</c>), which state the same rules in different words. A server
23+
/// with a queue sends the next track's picture and metadata a few seconds before the audible track
24+
/// change, stamped with the server-clock time they should take effect; applying them on arrival is
25+
/// what made the cover flip early. The rules, with times already converted to this machine's clock:
26+
/// </para>
27+
/// <list type="bullet">
28+
/// <item>A value whose time is past or present applies at once and discards any pending value.</item>
29+
/// <item>A value whose time is in the future becomes the pending value, replacing the one held.</item>
30+
/// <item>The pending value becomes current once its time is reached. It is never dropped for
31+
/// lateness: a promotion that runs late still promotes.</item>
32+
/// <item>A cancel discards the pending value and leaves the current one alone.</item>
33+
/// <item>A clear is not a separate operation: the spec expresses it as an empty value that follows
34+
/// the same timing, so a future-stamped <see cref="Offer"/> of null schedules the clear.</item>
35+
/// </list>
36+
/// <para>
37+
/// Pure and clock-free on purpose: every method takes the local time as an argument, so the rules
38+
/// are testable without a timer and the service owning an instance decides when to ask. It is not
39+
/// thread-safe; the owner serialises access.
40+
/// </para>
41+
/// </remarks>
42+
/// <typeparam name="T">The scheduled value. Null is the empty value.</typeparam>
43+
public sealed class ScheduledValue<T>
44+
where T : class
45+
{
46+
private T? _pending;
47+
private long _pendingDue;
48+
private bool _hasPending;
49+
50+
/// <summary>Gets the value in effect now, or null when there is none.</summary>
51+
public T? Current { get; private set; }
52+
53+
/// <summary>
54+
/// Gets the local time, in microseconds, at which <see cref="Current"/> took effect. Zero until
55+
/// something has been applied.
56+
/// </summary>
57+
/// <remarks>
58+
/// For metadata this is the origin the spec's progress formula runs from; the position is
59+
/// projected from here, not from when the message arrived.
60+
/// </remarks>
61+
public long CurrentSince { get; private set; }
62+
63+
/// <summary>Gets whether a value is being held for a future time.</summary>
64+
public bool HasPending => _hasPending;
65+
66+
/// <summary>Gets the held value, or null when nothing is pending.</summary>
67+
public T? Pending => _hasPending ? _pending : null;
68+
69+
/// <summary>
70+
/// Gets the local time, in microseconds, at which the pending value is due, or null when nothing
71+
/// is pending. The owner arms its timer on this.
72+
/// </summary>
73+
public long? NextDue => _hasPending ? _pendingDue : null;
74+
75+
/// <summary>
76+
/// Offers a value that should take effect at <paramref name="dueLocalMicros"/>.
77+
/// </summary>
78+
/// <param name="value">The value, or null for the empty value (a scheduled clear).</param>
79+
/// <param name="dueLocalMicros">When it takes effect, on the local clock.</param>
80+
/// <param name="nowLocalMicros">The local clock now.</param>
81+
/// <returns>Whether the value was applied at once or is being held.</returns>
82+
public ScheduledOffer Offer(T? value, long dueLocalMicros, long nowLocalMicros)
83+
{
84+
if (dueLocalMicros <= nowLocalMicros)
85+
{
86+
// Past or present: applies now and, per the spec, cancels whatever was scheduled. This
87+
// is also how a server cancels a scheduled update — by re-sending the current state.
88+
Apply(value, dueLocalMicros);
89+
return ScheduledOffer.Applied;
90+
}
91+
92+
_pending = value;
93+
_pendingDue = dueLocalMicros;
94+
_hasPending = true;
95+
return ScheduledOffer.Held;
96+
}
97+
98+
/// <summary>
99+
/// Makes the pending value current if its time has come.
100+
/// </summary>
101+
/// <returns>True when something was promoted.</returns>
102+
public bool Promote(long nowLocalMicros)
103+
{
104+
if (!_hasPending || _pendingDue > nowLocalMicros)
105+
{
106+
return false;
107+
}
108+
109+
Apply(_pending, _pendingDue);
110+
return true;
111+
}
112+
113+
/// <summary>Discards the pending value. The current value is unaffected.</summary>
114+
public void Cancel()
115+
{
116+
_pending = null;
117+
_hasPending = false;
118+
}
119+
120+
/// <summary>
121+
/// Forgets both the current and the pending value, as on a stream end or a disconnect: there is
122+
/// no longer a stream for either to belong to.
123+
/// </summary>
124+
public void Reset()
125+
{
126+
Cancel();
127+
Current = null;
128+
CurrentSince = 0;
129+
}
130+
131+
private void Apply(T? value, long since)
132+
{
133+
Current = value;
134+
CurrentSince = since;
135+
Cancel();
136+
}
137+
}

0 commit comments

Comments
 (0)