@@ -120,12 +120,52 @@ public async Task ConnectAsync(Uri serverUri, CancellationToken cancellationToke
120120 // Connect WebSocket
121121 await _connection . ConnectAsync ( serverUri , cancellationToken ) ;
122122
123- // Prepare handshake completion
123+ // Perform handshake (send client hello, wait for server hello)
124+ await SendHandshakeAsync ( cancellationToken ) ;
125+ }
126+
127+ /// <summary>
128+ /// Sends the ClientHello message and waits for the ServerHello response.
129+ /// Used for both initial connection and reconnection handshakes.
130+ /// </summary>
131+ private async Task SendHandshakeAsync ( CancellationToken cancellationToken = default )
132+ {
124133 _handshakeTcs = new TaskCompletionSource < bool > ( ) ;
125134
126- // Send client hello with proper payload envelope
127- // Use audio formats from capabilities (order matters - server picks first supported)
128- var hello = ClientHelloMessage . Create (
135+ var hello = CreateClientHelloMessage ( ) ;
136+ var helloJson = MessageSerializer . Serialize ( hello ) ;
137+ _logger . LogInformation ( "Sending client/hello:\n {Json}" , helloJson ) ;
138+ await _connection . SendMessageAsync ( hello , cancellationToken ) ;
139+
140+ // Wait for server hello with timeout
141+ using var timeoutCts = new CancellationTokenSource ( TimeSpan . FromSeconds ( 10 ) ) ;
142+ using var linkedCts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken , timeoutCts . Token ) ;
143+
144+ try
145+ {
146+ var registration = linkedCts . Token . Register ( ( ) => _handshakeTcs . TrySetCanceled ( ) ) ;
147+ var success = await _handshakeTcs . Task ;
148+
149+ if ( success )
150+ {
151+ _logger . LogInformation ( "Handshake complete with server {ServerId} ({ServerName})" , ServerId , ServerName ) ;
152+ }
153+ }
154+ catch ( OperationCanceledException ) when ( timeoutCts . IsCancellationRequested )
155+ {
156+ _logger . LogError ( "Handshake timeout - no server/hello received" ) ;
157+ await _connection . DisconnectAsync ( "handshake_timeout" ) ;
158+ throw new TimeoutException ( "Server did not respond to handshake" ) ;
159+ }
160+ }
161+
162+ /// <summary>
163+ /// Creates the ClientHello message from current capabilities.
164+ /// Extracted for reuse between initial connection and reconnection handshakes.
165+ /// </summary>
166+ private ClientHelloMessage CreateClientHelloMessage ( )
167+ {
168+ return ClientHelloMessage . Create (
129169 clientId : _capabilities . ClientId ,
130170 name : _capabilities . ClientName ,
131171 supportedRoles : _capabilities . Roles ,
@@ -163,31 +203,36 @@ public async Task ConnectAsync(Uri serverUri, CancellationToken cancellationToke
163203 SoftwareVersion = _capabilities . SoftwareVersion
164204 }
165205 ) ;
206+ }
166207
167- // Log the full JSON being sent for debugging
168- var helloJson = MessageSerializer . Serialize ( hello ) ;
169- _logger . LogInformation ( "Sending client/hello:\n {Json}" , helloJson ) ;
170- await _connection . SendMessageAsync ( hello , cancellationToken ) ;
171-
172- // Wait for server hello with timeout
173- using var timeoutCts = new CancellationTokenSource ( TimeSpan . FromSeconds ( 10 ) ) ;
174- using var linkedCts = CancellationTokenSource . CreateLinkedTokenSource ( cancellationToken , timeoutCts . Token ) ;
208+ /// <summary>
209+ /// Performs handshake after the connection layer has successfully reconnected the WebSocket.
210+ /// Called from OnConnectionStateChanged when entering Handshaking state during reconnection.
211+ /// </summary>
212+ /// <remarks>
213+ /// Clock synchronizer is reset in HandleServerHello when the handshake completes,
214+ /// so we don't need to reset it here.
215+ /// </remarks>
216+ private async Task PerformReconnectHandshakeAsync ( CancellationToken cancellationToken = default )
217+ {
218+ _logger . LogInformation ( "WebSocket reconnected, performing handshake..." ) ;
175219
176220 try
177221 {
178- var registration = linkedCts . Token . Register ( ( ) => _handshakeTcs . TrySetCanceled ( ) ) ;
179- var success = await _handshakeTcs . Task ;
180-
181- if ( success )
182- {
183- _logger . LogInformation ( "Handshake complete with server {ServerId} ({ServerName})" , ServerId , ServerName ) ;
184- }
222+ await SendHandshakeAsync ( cancellationToken ) ;
185223 }
186- catch ( OperationCanceledException ) when ( timeoutCts . IsCancellationRequested )
224+ catch ( TimeoutException )
187225 {
188- _logger . LogError ( "Handshake timeout - no server/hello received" ) ;
189- await _connection . DisconnectAsync ( "handshake_timeout" ) ;
190- throw new TimeoutException ( "Server did not respond to handshake" ) ;
226+ _logger . LogWarning ( "Reconnect handshake timed out" ) ;
227+ }
228+ catch ( OperationCanceledException ) when ( cancellationToken . IsCancellationRequested )
229+ {
230+ _logger . LogDebug ( "Reconnect handshake cancelled" ) ;
231+ }
232+ catch ( Exception ex )
233+ {
234+ _logger . LogError ( ex , "Reconnect handshake failed" ) ;
235+ await _connection . DisconnectAsync ( "handshake_failed" ) ;
191236 }
192237 }
193238
@@ -266,15 +311,28 @@ private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEven
266311 // Forward the event
267312 ConnectionStateChanged ? . Invoke ( this , e ) ;
268313
269- // Handle disconnection
314+ // Stop time sync on any disconnection-related state to prevent
315+ // "WebSocket is not connected" spam from the time sync loop
316+ if ( e . NewState is ConnectionState . Disconnected or ConnectionState . Reconnecting )
317+ {
318+ StopTimeSyncLoop ( ) ;
319+ }
320+
321+ // Clean up client state on full disconnection
270322 if ( e . NewState == ConnectionState . Disconnected )
271323 {
272324 _handshakeTcs ? . TrySetResult ( false ) ;
273- StopTimeSyncLoop ( ) ;
274325 ServerId = null ;
275326 ServerName = null ;
276327 ConnectionReason = null ;
277328 }
329+
330+ // Re-handshake when WebSocket reconnects successfully
331+ // Use e.OldState instead of a separate field to avoid race conditions
332+ if ( e . NewState == ConnectionState . Handshaking && e . OldState == ConnectionState . Reconnecting )
333+ {
334+ PerformReconnectHandshakeAsync ( ) . SafeFireAndForget ( _logger ) ;
335+ }
278336 }
279337
280338 private void OnTextMessageReceived ( object ? sender , string json )
@@ -407,8 +465,8 @@ private void StartTimeSyncLoop()
407465
408466 _timeSyncCts = new CancellationTokenSource ( ) ;
409467
410- // Fire-and-forget: task runs until cancelled, no need to await
411- _ = TimeSyncLoopAsync ( _timeSyncCts . Token ) ;
468+ // Fire-and-forget with proper exception handling
469+ TimeSyncLoopAsync ( _timeSyncCts . Token ) . SafeFireAndForget ( _logger ) ;
412470
413471 _logger . LogDebug ( "Time sync loop started (adaptive intervals)" ) ;
414472 }
@@ -631,7 +689,10 @@ private void HandleGroupUpdate(string json)
631689 // Create group state if needed
632690 _currentGroup ??= new GroupState ( ) ;
633691
634- // Per spec, group/update only contains: group_id, group_name, playback_state
692+ var previousGroupId = _currentGroup . GroupId ;
693+ var previousName = _currentGroup . Name ;
694+
695+ // group/update contains: group_id, group_name, playback_state
635696 // Volume, mute, metadata come via server/state (handled in HandleServerState)
636697 if ( ! string . IsNullOrEmpty ( message . GroupId ) )
637698 _currentGroup . GroupId = message . GroupId ;
@@ -640,7 +701,22 @@ private void HandleGroupUpdate(string json)
640701 if ( message . PlaybackState . HasValue )
641702 _currentGroup . PlaybackState = message . PlaybackState . Value ;
642703
643- _logger . LogDebug ( "Group update: GroupId={GroupId}, Name={Name}, State={State}" ,
704+ // Log group ID changes (helps diagnose grouping issues)
705+ if ( previousGroupId != _currentGroup . GroupId && ! string . IsNullOrEmpty ( previousGroupId ) )
706+ {
707+ _logger . LogInformation ( "group/update [{Player}]: Group ID changed {OldId} -> {NewId}" ,
708+ _capabilities . ClientName , previousGroupId , _currentGroup . GroupId ) ;
709+ }
710+
711+ // Log group name changes
712+ if ( previousName != _currentGroup . Name && _currentGroup . Name is not null )
713+ {
714+ _logger . LogInformation ( "group/update [{Player}]: Group name changed '{OldName}' -> '{NewName}'" ,
715+ _capabilities . ClientName , previousName ?? "(none)" , _currentGroup . Name ) ;
716+ }
717+
718+ _logger . LogDebug ( "group/update [{Player}]: GroupId={GroupId}, Name={Name}, State={State}" ,
719+ _capabilities . ClientName ,
644720 _currentGroup . GroupId ,
645721 _currentGroup . Name ?? "(none)" ,
646722 _currentGroup . PlaybackState ) ;
@@ -701,7 +777,8 @@ private void HandleServerState(string json)
701777 _currentGroup . Muted = payload . Controller . Muted . Value ;
702778 }
703779
704- _logger . LogDebug ( "Server state update: Volume={Volume}, Muted={Muted}, Track={Track} by {Artist}" ,
780+ _logger . LogDebug ( "server/state [{Player}]: Volume={Volume}, Muted={Muted}, Track={Track} by {Artist}" ,
781+ _capabilities . ClientName ,
705782 _currentGroup . Volume ,
706783 _currentGroup . Muted ,
707784 _currentGroup . Metadata ? . Title ?? "unknown" ,
@@ -742,7 +819,8 @@ private void HandleServerCommand(string json)
742819 _playerState . Volume = player . Volume . Value ;
743820 _audioPipeline ? . SetVolume ( player . Volume . Value ) ;
744821 changed = true ;
745- _logger . LogInformation ( "server/command: Applied volume {Volume}" , player . Volume . Value ) ;
822+ _logger . LogInformation ( "server/command [{Player}]: Applied volume {Volume}" ,
823+ _capabilities . ClientName , player . Volume . Value ) ;
746824 }
747825
748826 // Apply mute change - update player state and audio pipeline
@@ -751,36 +829,27 @@ private void HandleServerCommand(string json)
751829 _playerState . Muted = player . Mute . Value ;
752830 _audioPipeline ? . SetMuted ( player . Mute . Value ) ;
753831 changed = true ;
754- _logger . LogInformation ( "server/command: Applied mute {Muted}" , player . Mute . Value ) ;
832+ _logger . LogInformation ( "server/command [{Player}]: Applied mute {Muted}" ,
833+ _capabilities . ClientName , player . Mute . Value ) ;
755834 }
756835
757836 if ( changed )
758837 {
759838 // Notify listeners of player state change
760839 PlayerStateChanged ? . Invoke ( this , _playerState ) ;
761840
762- // Send acknowledgement back to server per spec
763- // "State updates must be sent whenever any state changes, including changes
764- // triggered by server/command or device controls"
841+ // ACK: send client/state to confirm applied state back to server.
765842 SendPlayerStateAckAsync ( ) . SafeFireAndForget ( _logger ) ;
766843 }
767844 }
768845
769846 /// <summary>
770- /// Sends a player state acknowledgement after a server/command.
847+ /// Sends a client/state acknowledgement after applying a server/command.
848+ /// Reports current player volume and mute state back to the server.
771849 /// </summary>
772850 private async Task SendPlayerStateAckAsync ( )
773851 {
774- try
775- {
776- await SendPlayerStateAsync ( _playerState . Volume , _playerState . Muted ) ;
777- _logger . LogDebug ( "Sent player state ACK: Volume={Volume}, Muted={Muted}" ,
778- _playerState . Volume , _playerState . Muted ) ;
779- }
780- catch ( Exception ex )
781- {
782- _logger . LogWarning ( ex , "Failed to send player state ACK" ) ;
783- }
852+ await SendPlayerStateAsync ( _playerState . Volume , _playerState . Muted ) ;
784853 }
785854
786855 /// <summary>
0 commit comments