Skip to content

Refactor: Implement Optimistic UI for Spotify Controls to Resolve State Contention and Enhance Responsiveness #9295

Description

@arii
Original Issue Body

Establishing a high-fidelity real-time control loop for Spotify playback in an HRM context requires tight synchronization between local optimistic state and the server's authoritative polling. The current implementation suffers from state contention: the UI is fighting against the asynchronous SPOTIFY_UPDATE messages arriving from the server's 204 response delay.

1. Architectural Analysis of the "Snap-Back" Issue

The volume "snap-back" occurs because SpotifyControls.tsx relies on a simple useEffect to sync the local volume state with the devices array from the WebSocket.

  • The Conflict: When a user slides the volume, sendVolumeCommand is triggered (debounced). However, the server-side polling (SpotifyPolling.ts) may broadcast an old volume state before the Spotify API has fully processed the change and updated the device list.
  • The Result: The UI receives a SPOTIFY_UPDATE, sees the old volume in the devices array, and calls setVolume(activeDevice.volume_percent), causing the slider to jump back until the next poll cycle.

2. Implementation Strategy: Optimistic Locks & Debounced Sync

To fix this, we must implement an Optimistic UI pattern with a "lock" that prevents server updates from overwriting user input for a specific duration after an interaction.

Refactoring SpotifyControls.tsx

Replace the current volume sync logic with a "last user interaction" timestamp check to ignore incoming server state during the transition.

// app/client/control/components/SpotifyControls.tsx refinement

const SYNC_LOCK_DURATION = 2000; // 2s lock to allow API propagation
const lastUserInteractionRef = useRef<number>(0);

// ... inside SpotifyControls ...

useEffect(() => {
  const activeDevice = devices.find((d) => d.is_active);
  if (!activeDevice || typeof activeDevice.volume_percent !== 'number') return;

  const isLocked = Date.now() - lastUserInteractionRef.current < SYNC_LOCK_DURATION;

  // Only sync if we aren't currently "locked" by a recent user adjustment
  if (!isLocked && activeDevice.volume_percent !== volume) {
    setVolume(activeDevice.volume_percent);
  }
}, [devices, volume, setVolume]);

const handleVolumeAdjust = (newVolume: number) => {
  lastUserInteractionRef.current = Date.now();
  setVolume(newVolume); // Immediate UI update (Optimistic)
};

// Update VolumeSlider to use handleVolumeAdjust
<VolumeSlider
  volume={volume}
  onVolumeChange={handleVolumeAdjust}
  // ...
/>

3. Improving Playback Responsiveness

The delay in Play/Pause visibility is caused by the 500ms setTimeout in SpotifyPolling.ts before re-polling. In a high-performance HRM environment, we should utilize Server-Sent Optimistic State Updates.

Backend Optimization (services/spotifyPolling.ts)

Modify handleCommand to broadcast the intended state change immediately to all clients before waiting for the Spotify API to confirm.

// services/spotifyPolling.ts enhancement

public async handleCommand(command: SpotifyCommand, params: SpotifyCommandParameters): Promise<void> {
  // 1. Immediate Optimistic Broadcast for Play/Pause
  if (command === 'PLAY' || command === 'PAUSE') {
    this.state.isPlaying = command === 'PLAY';
    this.broadcastUpdate({ type: 'SPOTIFY_UPDATE', payload: this.getState() });
  }

  try {
    await this.executeSpotifyCommand(command, params);
    // 2. Reduce delay for the authoritative poll
    setTimeout(() => this.getCurrentlyPlaying(), 300); 
  } catch (error) {
    await logSpotifyCommandError(command, error);
    // 3. Revert state on failure
    await this.getCurrentlyPlaying();
  }
}

4. Accessibility (WCAG) & Responsiveness Improvements

  • Touch Targets: Increase IconButton and Slider thumb dimensions for gym/active environments where fine motor control is reduced.
  • ARIA Live Regions: The VolumeSlider should use aria-valuetext to announce volume levels to screen readers during adjustments.
  • Throttling vs Debouncing: Use throttle for the volume API calls (e.g., every 200ms) instead of a 300ms debounce to make the volume change feel "live" rather than "delayed-then-jumpy".

Would you like me to generate a specific Playwright test suite to validate the "snap-back" prevention logic under simulated network latency?

Establishing a high-fidelity real-time control loop for Spotify playback in an HRM context requires tight synchronization between local optimistic state and the server's authoritative polling. The current implementation suffers from state contention: the UI is fighting against the asynchronous SPOTIFY_UPDATE messages arriving from the server's 204 response delay.

1. Architectural Analysis of the "Snap-Back" Issue

The volume "snap-back" occurs because SpotifyControls.tsx relies on a simple useEffect to sync the local volume state with the devices array from the WebSocket.

  • The Conflict: When a user slides the volume, sendVolumeCommand is triggered (debounced). However, the server-side polling (SpotifyPolling.ts) may broadcast an old volume state before the Spotify API has fully processed the change and updated the device list.
  • The Result: The UI receives a SPOTIFY_UPDATE, sees the old volume in the devices array, and calls setVolume(activeDevice.volume_percent), causing the slider to jump back until the next poll cycle.

2. Implementation Strategy: Optimistic Locks & Debounced Sync

To fix this, we must implement an Optimistic UI pattern with a "lock" that prevents server updates from overwriting user input for a specific duration after an interaction.

Refactoring SpotifyControls.tsx

Replace the current volume sync logic with a "last user interaction" timestamp check to ignore incoming server state during the transition.

// app/client/control/components/SpotifyControls.tsx refinement

const SYNC_LOCK_DURATION = 2000; // 2s lock to allow API propagation
const lastUserInteractionRef = useRef<number>(0);

// ... inside SpotifyControls ...

useEffect(() => {
  const activeDevice = devices.find((d) => d.is_active);
  if (!activeDevice || typeof activeDevice.volume_percent !== 'number') return;

  const isLocked = Date.now() - lastUserInteractionRef.current < SYNC_LOCK_DURATION;

  // Only sync if we aren't currently "locked" by a recent user adjustment
  if (!isLocked && activeDevice.volume_percent !== volume) {
    setVolume(activeDevice.volume_percent);
  }
}, [devices, volume, setVolume]);

const handleVolumeAdjust = (newVolume: number) => {
  lastUserInteractionRef.current = Date.now();
  setVolume(newVolume); // Immediate UI update (Optimistic)
};

// Update VolumeSlider to use handleVolumeAdjust
<VolumeSlider
  volume={volume}
  onVolumeChange={handleVolumeAdjust}
  // ...
/>

3. Improving Playback Responsiveness

The delay in Play/Pause visibility is caused by the 500ms setTimeout in SpotifyPolling.ts before re-polling. In a high-performance HRM environment, we should utilize Server-Sent Optimistic State Updates.

Backend Optimization (services/spotifyPolling.ts)

Modify handleCommand to broadcast the intended state change immediately to all clients before waiting for the Spotify API to confirm.

// services/spotifyPolling.ts enhancement

public async handleCommand(command: SpotifyCommand, params: SpotifyCommandParameters): Promise<void> {
  // 1. Immediate Optimistic Broadcast for Play/Pause
  if (command === 'PLAY' || command === 'PAUSE') {
    this.state.isPlaying = command === 'PLAY';
    this.broadcastUpdate({ type: 'SPOTIFY_UPDATE', payload: this.getState() });
  }

  try {
    await this.executeSpotifyCommand(command, params);
    // 2. Reduce delay for the authoritative poll
    setTimeout(() => this.getCurrentlyPlaying(), 300); 
  } catch (error) {
    await logSpotifyCommandError(command, error);
    // 3. Revert state on failure
    await this.getCurrentlyPlaying();
  }
}

4. Accessibility (WCAG) & Responsiveness Improvements

  • Touch Targets: Increase IconButton and Slider thumb dimensions for gym/active environments where fine motor control is reduced.
  • ARIA Live Regions: The VolumeSlider should use aria-valuetext to announce volume levels to screen readers during adjustments.
  • Throttling vs Debouncing: Use throttle for the volume API calls (e.g., every 200ms) instead of a 300ms debounce to make the volume change feel "live" rather than "delayed-then-jumpy".

Would you like me to generate a specific Playwright test suite to validate the "snap-back" prevention logic under simulated network latency?

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status
    In Progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions