@@ -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>
0 commit comments