Skip to content

fix: volume percentage briefly reverting after user change#192

Open
1knth wants to merge 1 commit intoLargeModGames:mainfrom
1knth:fix/volume-display-race
Open

fix: volume percentage briefly reverting after user change#192
1knth wants to merge 1 commit intoLargeModGames:mainfrom
1knth:fix/volume-display-race

Conversation

@1knth
Copy link
Copy Markdown

@1knth 1knth commented Mar 28, 2026

What

Fixes the volume percentage briefly flashing back to an old value after the user changes it, especially noticeable when spamming volume up/down.

Why

Spotify's API returns playback state on a polling interval. After a local volume change, the next poll response can still carry the old volume. Previously this would overwrite the user's intended value in the UI before the API caught up.

Before

UI read volume directly from the API response — no awareness of user intent:

// src/tui/ui/player.rs:539
current_playback_context.device.volume_percent.unwrap_or(0)

increase_volume calculated from the (potentially stale) API value:

// src/core/app.rs:1608
pub fn increase_volume(&mut self) {
    if let Some(context) = self.current_playback_context.clone() {
        let current_volume = context.device.volume_percent.unwrap_or(0) as u8;
        // ...
        self.dispatch(IoEvent::ChangeVolume(next_volume));
    }
}

change_volume just updated context directly — no coalescing or stale-response protection:

// src/infra/network/playback.rs:724
Ok(_) => {
    let mut app = self.app.lock().await;
    if let Some(ctx) = &mut app.current_playback_context {
        ctx.device.volume_percent = Some(volume.into());
    }
}

VolumeChanged handler blindly overwrote local state on every event:

// src/main.rs:1953
if let Some(ref mut ctx) = app.current_playback_context {
    ctx.device.volume_percent = Some(volume_percent as u32);
}
app.user_config.behavior.volume_percent = volume_percent.min(100);
let _ = app.user_config.save_config();
After
New desired_volume() returns user's intent, falling back to API:
pub fn desired_volume(&self) -> u32 {
    if let Some(pending) = self.pending_volume {
        return pending as u32;
    }
    self.current_playback_context.as_ref()
        .and_then(|c| c.device.volume_percent).unwrap_or(0)
}

UI and volume-adjust functions use desired_volume() — always shows user's last input:

// UI: src/tui/ui/player.rs
app.desired_volume()
// increase_volume / decrease_volume: src/core/app.rs
let current_volume = self.desired_volume() as u8;
self.pending_volume = Some(next_volume);

change_volume coalesces rapid presses and keeps pending_volume alive until the API confirms:

// src/infra/network/playback.rs
Ok(_) => {
    let mut app = self.app.lock().await;
    if let Some(ctx) = &mut app.current_playback_context {
        ctx.device.volume_percent = Some(volume.into());
    }
    app.is_volume_change_in_flight = false;
    app.last_dispatched_volume = Some(volume);
    // Keep pending_volume set — cleared when API confirms the value matches
}

API poll checks if Spotify caught up — stale responses are ignored:

// src/infra/network/playback.rs — get_current_playback
if let Some(pending) = app.pending_volume {
    let api_vol = c.device.volume_percent.unwrap_or(0) as u8;
    if api_vol == pending {
        app.pending_volume = None;
        app.last_dispatched_volume = None;
    } else {
        // API hasn't caught up yet — keep showing the user's intended value
        c.device.volume_percent = ctx.device.volume_percent;
    }
}

VolumeChanged handler skips overwrite when user has a pending change, still persists config:

// src/main.rs
if let Some(pending) = app.pending_volume {
    if volume_percent == pending {
        app.pending_volume = None;
        app.last_dispatched_volume = None;
    }
    app.user_config.behavior.volume_percent = volume_percent.min(100);
    let _ = app.user_config.save_config();
} else {
    if let Some(ref mut ctx) = app.current_playback_context {
        ctx.device.volume_percent = Some(volume_percent as u32);
    }
    app.user_config.behavior.volume_percent = volume_percent.min(100);
    let _ = app.user_config.save_config();
}
Files changed
- src/core/app.rs — pending_volume, last_dispatched_volume, is_volume_change_in_flight fields; desired_volume() helper; updated increase_volume/decrease_volume/flush_pending_volume
- src/infra/network/playback.rs — change_volume keeps pending_volume on success; get_current_playback confirms or rejects stale responses
- src/main.rs — VolumeChanged handler checks native confirmation while preserving config persistence
- src/tui/ui/player.rs — UI uses app.desired_volume()
- CHANGELOG.md — added unreleased fix entry

Testing:

  • cargo fmt --all ✓
  • cargo clippy --no-default-features --features telemetry -- -D warnings ✓
  • cargo test --no-default-features --features telemetry ✓ (147 tests)
  • Manual testing on macOS with native streaming — rapid volume clicks no longer glitch

The Spotify API can return stale playback state after a local volume
change. When the user presses volume up, the UI updates immediately,
but the next API poll might still return the old value — causing the
percentage to flash back before correcting.

Now we keep `pending_volume` set until the API confirms the new value,
so the UI always reflects the user's intent during and after a change.

Co-Authored-By: opencode <noreply@opencode.ai>
@1knth
Copy link
Copy Markdown
Author

1knth commented Mar 28, 2026

my agent hallucinated initial pr message. sorry!

@1knth 1knth closed this Mar 28, 2026
@1knth 1knth reopened this Mar 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant