Repository: aome510/spotify-player (language: rust)

Issue:
Support handling CLI commands without a running `spotify_player` instance
**Is your feature request related to a problem? Please describe.**
`spotify_player playback …` requires a running instance of `spotify_player`. However, when playing via `Spotify Connect`, you do not always really need a daemon or the TUI running, often something like `spotify_player playback next` is enough. 

**Describe the solution you'd like**
A way to send playback commands to a Spotify Connect device. `spotify-tui` allows you to do this via `spt playback --next` – no daemon, no running TUI, affecting the Spotify Connect Device.

**Describe alternatives you've considered**
Running `spotifiy_player` in the background all the time, even if not needed.

**Additional context**
Looking to replace the unmaintained `spotify-tui`. Basically, this is the only feature missing that I need before being able to switch.

Thanks for the cli, its UI is much better than of other players I tried.


Discussion / hints:
The design for CLI support between `spotify_player` and `spotify-tui` are different. `spotify-tui` treats CLI as a separate application, while `spotify_player` treats CLI as a tool to interact with the main application.

Because CLI is a separate application, each time `spotify-tui` want to handle a CLI command, e.g `player next`, it will need to initialize a Spotify client from scratch, which includes fetching credentials. The credential fetching part can make handling the CLI command slow. `spotify_player` CLI handles those commands _instantly_. Of course, this is a trade-off between flexibility and performance. 

Currently, the `spotify_player` CLI architecture is implemented based on the assumption that it's a tool to communicate with the main application. Will need to find a good way to abstract the codes to support both modes.
I used to use `spotify_tui` in neovim with the toggle term plugin.  It was simple to open a floating window to control spotify.  I don't really want to have an application running, I would really like to dispatch commands to a CLI when neaded.

Fix patch:
diff --git a/README.md b/README.md
index 6f74b733..8579a7f6 100644
--- a/README.md
+++ b/README.md
@@ -314,11 +314,7 @@ You can run the application as a daemon by specifying the `-d` or `--daemon` opt
 
 ### CLI Commands
 
-`spotify_player` offers several CLI commands to interact with **a running `spotify_player` instance**.
-
-Under the hood, the application handles a CLI command by sending requests to a `spotify_player` instance's client socket running on the `client_port` port, a general application configuration with a default value `8080`.
-
-Lists of CLI commands:
+`spotify_player` offers several CLI commands to interact with Spotify:
 
 - `get`: Get Spotify data (playlist/album/artist data, user's data, etc)
 - `playback`: Interact with the playback (start a playback, play-pause, next, etc)
@@ -329,6 +325,11 @@ Lists of CLI commands:
 
 For more details, run `spotify_player -h` or `spotify_player {command} -h`, in which `{command}` is a CLI command.
 
+**Notes**
+
+- When using the CLI for the first time, you'll need to run `spotify_player authenticate` to authenticate the application beforehand.
+- Under the hood, CLI command is handled by sending requests to a `spotify_player` client socket running on port `client_port`, [a general application configuration](https://github.com/aome510/spotify-player/blob/master/docs/config.md#general) with a default value of `8080`. If there is no running application's instance, a new client will be created upon handling the CLI commands, which increases the latency of the command.
+
 ## Commands
 
 To open a shortcut help popup, press `?` or `C-h` (default shortcuts for `OpenCommandHelp` command).
diff --git a/spotify_player/src/cli/client.rs b/spotify_player/src/cli/client.rs
index 749e33e4..3692fbf9 100644
--- a/spotify_player/src/cli/client.rs
+++ b/spotify_player/src/cli/client.rs
@@ -15,7 +15,7 @@ use crate::{
     client::Client,
     config::get_cache_folder_path,
     event::PlayerRequest,
-    state::{Context, ContextId, Playback, SharedState},
+    state::{Context, ContextId, Playback, SharedState, SimplifiedPlayback},
 };
 use rspotify::{
     model::*,
@@ -24,12 +24,7 @@ use rspotify::{
 
 use super::*;
 
-pub async fn start_socket(client: Client, state: SharedState) -> Result<()> {
-    let port = state.configs.app_config.client_port;
-    tracing::info!("Starting a client socket at 127.0.0.1:{port}");
-
-    let socket = UdpSocket::bind(("127.0.0.1", port)).await?;
-
+pub async fn start_socket(client: Client, socket: UdpSocket, state: Option<SharedState>) {
     // initialize the receive buffer to be 4096 bytes
     let mut buf = [0; MAX_REQUEST_SIZE];
     loop {
@@ -92,14 +87,31 @@ async fn send_response(
     Ok(())
 }
 
+async fn current_playback(
+    client: &Client,
+    state: &Option<SharedState>,
+) -> Result<Option<CurrentPlaybackContext>> {
+    // get current playback from the application's state, if exists, or by making an API request
+    match state {
+        Some(ref state) => Ok(state.player.read().current_playback()),
+        None => client
+            .spotify
+            .current_playback(None, None::<Vec<_>>)
+            .await
+            .context("get current playback"),
+    }
+}
+
 async fn handle_socket_request(
     client: &Client,
-    state: &SharedState,
+    state: &Option<SharedState>,
     request: super::Request,
 ) -> Result<Vec<u8>> {
     if client.spotify.session().await.is_invalid() {
-        tracing::info!("Spotify client's session is invalid, re-creating a new session...");
-        client.new_session(state).await?;
+        if let Some(state) = state {
+            tracing::info!("Spotify client's session is invalid, re-creating a new session...");
+            client.new_session(state).await?;
+        }
     }
 
     match request {
@@ -133,13 +145,18 @@ async fn handle_socket_request(
             Ok(Vec::new())
         }
         Request::Like { unlike } => {
-            let id = state
-                .player
-                .read()
-                .current_playing_track()
-                .and_then(|t| t.id.to_owned());
+            let playback = current_playback(client, state).await?;
+
+            // get currently playing track from the playback
+            let track = match playback {
+                None => None,
+                Some(ref playback) => match playback.item {
+                    Some(rspotify::model::PlayableItem::Track(ref track)) => Some(track),
+                    _ => None,
+                },
+            };
 
-            if let Some(id) = id {
+            if let Some(id) = track.and_then(|t| t.id.to_owned()) {
                 if unlike {
                     client
                         .spotify
@@ -153,16 +170,20 @@ async fn handle_socket_request(
             Ok(Vec::new())
         }
         Request::Playlist(command) => {
-            let resp = handle_playlist_request(client, state, command).await?;
+            let resp = handle_playlist_request(client, command).await?;
             Ok(resp.into_bytes())
         }
     }
 }
 
-async fn handle_get_key_request(client: &Client, state: &SharedState, key: Key) -> Result<Vec<u8>> {
+async fn handle_get_key_request(
+    client: &Client,
+    state: &Option<SharedState>,
+    key: Key,
+) -> Result<Vec<u8>> {
     Ok(match key {
         Key::Playback => {
-            let playback = state.player.read().current_playback();
+            let playback = current_playback(client, state).await?;
             serde_json::to_vec(&playback)?
         }
         Key::Devices => {
@@ -299,9 +320,20 @@ async fn handle_get_item_request(
 
 async fn handle_playback_request(
     client: &Client,
-    state: &SharedState,
+    state: &Option<SharedState>,
     command: Command,
 ) -> Result<()> {
+    let playback = match state {
+        Some(state) => state.player.read().buffered_playback.clone(),
+        None => {
+            let playback = client
+                .spotify
+                .current_playback(None, None::<Vec<_>>)
+                .await?;
+            playback.as_ref().map(SimplifiedPlayback::from_playback)
+        }
+    };
+
     let player_request = match command {
         Command::StartRadio(item_type, id_or_name) => {
             let sid = get_spotify_id(client, item_type, id_or_name).await?;
@@ -313,20 +345,31 @@ async fn handle_playback_request(
             )
         }
         Command::StartLikedTracks { limit, random } => {
-            let mut tracks = client.current_user_saved_tracks().await?;
+            // get a list of liked tracks' ids
+            let mut ids: Vec<_> = if let Some(ref state) = state {
+                state
+                    .data
+                    .read()
+                    .user_data
+                    .saved_tracks
+                    .values()
+                    .map(|t| t.id.to_owned())
+                    .collect()
+            } else {
+                client
+                    .current_user_saved_tracks()
+                    .await?
+                    .into_iter()
+                    .map(|t| t.id)
+                    .collect()
+            };
 
             if random {
                 let mut rng = rand::thread_rng();
-                tracks.shuffle(&mut rng)
+                ids.shuffle(&mut rng)
             }
 
-            let ids = if tracks.len() > limit {
-                tracks[0..limit].iter()
-            } else {
-                tracks.iter()
-            }
-            .map(|t| t.id.to_owned())
-            .collect();
+            ids.truncate(limit);
 
             PlayerRequest::StartPlayback(Playback::URIs(ids, None), None)
         }
@@ -350,52 +393,68 @@ async fn handle_playback_request(
         Command::Previous => PlayerRequest::PreviousTrack,
         Command::Shuffle => PlayerRequest::Shuffle,
         Command::Repeat => PlayerRequest::Repeat,
-        Command::Volume { percent, is_offset } => match state.player.read().buffered_playback {
-            Some(ref playback) => {
-                let percent = if is_offset {
-                    std::cmp::max(0, (playback.volume.unwrap_or_default() as i8) + percent)
-                } else {
-                    percent
-                };
-                PlayerRequest::Volume(percent.try_into()?)
-            }
-            None => anyhow::bail!("No playback found!"),
-        },
-        Command::Seek(position_offset_ms) => {
-            let progress = match state.player.read().playback_progress() {
-                Some(progress) => progress,
-                None => {
-                    anyhow::bail!("Playback has no progress!");
-                }
+        Command::Volume { percent, is_offset } => {
+            let volume = playback
+                .as_ref()
+                .context("no active playback found!")?
+                .volume
+                .context("playback has no volume!")?;
+            let percent = if is_offset {
+                std::cmp::max(0, (volume as i8) + percent)
+            } else {
+                percent
             };
+            PlayerRequest::Volume(percent.try_into()?)
+        }
+        Command::Seek(position_offset_ms) => {
+            // Playback's progress cannot be computed trivially without knowing the `playback` variable in
+            // the function scope is from the application's state (cached) or the `current_playback` API.
+            // Therefore, we need to make an additional API request to get the playback's progress.
+            let progress = client
+                .spotify
+                .current_playback(None, None::<Vec<_>>)
+                .await?
+                .context("no active playback found!")?
+                .progress
+                .context("playback has no progress!")?;
             PlayerRequest::SeekTrack(progress + chrono::Duration::milliseconds(position_offset_ms))
         }
     };
 
-    tokio::task::spawn({
-        let client = client.clone();
-        let state = state.clone();
-        async move {
-            match client.handle_player_request(&state, player_request).await {
-                Ok(()) => {
-                    client.update_playback(&state);
-                }
-                Err(err) => {
-                    tracing::warn!(
-                        "Failed to handle a player request for playback CLI command: {err:#}"
-                    );
+    if let Some(ref state) = state {
+        // A non-null application's state indicates there is a running application instance.
+        // To reduce the latency of the CLI command, the player request is handled asynchronously
+        // knowing that the application will outlive the asynchronous task.
+        tokio::task::spawn({
+            let client = client.clone();
+            let state = state.clone();
+            async move {
+                match client.handle_player_request(player_request, playback).await {
+                    Ok(playback) => {
+                        // update application's states
+                        state.player.write().buffered_playback = playback;
+                        client.update_playback(&state);
+                    }
+                    Err(err) => {
+                        tracing::warn!(
+                            "Failed to handle a player request for playback CLI command: {err:#}"
+                        );
+                    }
                 }
             }
-        }
-    });
+        });
+    } else {
+        // Handles the player request synchronously
+        client
+            .handle_player_request(player_request, playback)
+            .await?;
+    }
     Ok(())
 }
 
-async fn handle_playlist_request(
-    client: &Client,
-    state: &SharedState,
-    command: PlaylistCommand,
-) -> Result<String> {
+async fn handle_playlist_request(client: &Client, command: PlaylistCommand) -> Result<String> {
+    let uid = client.spotify.current_user().await?.id;
+
     match command {
         PlaylistCommand::New {
             name,
@@ -403,13 +462,10 @@ async fn handle_playlist_request(
             collab,
             description,
         } => {
-            let user = state.data.read().user_data.user.to_owned().unwrap();
-            let id = user.id;
-
             let resp = client
                 .spotify
                 .user_playlist_create(
-                    id,
+                    uid,
                     name.as_str(),
                     Some(public),
                     Some(collab),
@@ -422,9 +478,6 @@ async fn handle_playlist_request(
             ))
         }
         PlaylistCommand::Delete { id } => {
-            let user = state.data.read().user_data.user.to_owned().unwrap();
-            let uid = user.id;
-
             let following = client
                 .spotify
                 .playlist_check_follow(id.to_owned(), &[uid])
@@ -460,9 +513,6 @@ async fn handle_playlist_request(
             delete,
         } => playlist_import(client, import_from, import_to, delete).await,
         PlaylistCommand::Fork { id } => {
-            let user = state.data.read().user_data.user.to_owned().unwrap();
-            let uid = user.id;
-
             let from = client
                 .spotify
                 .playlist(id.to_owned(), None, None)
@@ -493,9 +543,6 @@ async fn handle_playlist_request(
             Ok(result)
         }
         PlaylistCommand::Sync { id, delete } => {
-            let user = state.data.read().user_data.user.to_owned().unwrap();
-            let uid = user.id;
-
             // Get import dir/file
             let imports_dir = get_cache_folder_path()?.join("imports");
 
diff --git a/spotify_player/src/cli/handlers.rs b/spotify_player/src/cli/handlers.rs
index 67410014..b1c8be44 100644
--- a/spotify_player/src/cli/handlers.rs
+++ b/spotify_player/src/cli/handlers.rs
@@ -1,10 +1,10 @@
 use crate::{
-    auth::{new_session_with_new_creds, AuthConfig},
-    state,
+    auth::{new_session, new_session_with_new_creds, AuthConfig},
+    client, event, state,
 };
 
 use super::*;
-use anyhow::Result;
+use anyhow::{Context, Result};
 use clap::{ArgMatches, Id};
 use std::net::UdpSocket;
 
@@ -134,21 +134,54 @@ fn handle_playback_subcommand(args: &ArgMatches) -> Result<Request> {
     Ok(Request::Playback(command))
 }
 
-pub fn handle_cli_subcommand(cmd: &str, args: &ArgMatches, configs: &state::Configs) -> Result<()> {
-    let socket = UdpSocket::bind("127.0.0.1:0")?;
-    socket.connect(("127.0.0.1", configs.app_config.client_port))?;
+/// Tries to connect to a running client, if exists, by sending a connection request
+/// to the client via a UDP socket.
+/// If no running client found, create a new client running in a separate thread to
+/// handle the socket request.
+fn try_connect_to_client(socket: &UdpSocket, configs: &state::Configs) -> Result<()> {
+    let port = configs.app_config.client_port;
+    socket.connect(("127.0.0.1", port))?;
 
     // send an empty buffer as a connection request to the client
     socket.send(&[])?;
     if let Err(err) = socket.recv(&mut [0; 1]) {
         if let std::io::ErrorKind::ConnectionRefused = err.kind() {
-            eprintln!("Error: {err}\nPlease make sure that there is a running \
-                                   `spotify_player` instance with a client socket running on port {}.", configs.app_config.client_port);
-            std::process::exit(1)
+            // no running `spotify_player` instance found,
+            // initialize a new client to handle the current CLI command
+
+            let (client_pub, _) = flume::unbounded::<event::ClientRequest>();
+
+            let auth_config = AuthConfig::new(configs)?;
+            let rt = tokio::runtime::Runtime::new()?;
+            let session = rt.block_on(new_session(&auth_config, false))?;
+
+            // create a Spotify API client
+            let client = client::Client::new(
+                session,
+                auth_config,
+                configs.app_config.client_id.clone(),
+                client_pub,
+            );
+            rt.block_on(client.init_token())?;
+
+            // create a client socket for handling CLI commands
+            let client_socket = rt.block_on(tokio::net::UdpSocket::bind(("127.0.0.1", port)))?;
+
+            // spawn a thread to handle the CLI request
+            std::thread::spawn(move || rt.block_on(start_socket(client, client_socket, None)));
+        } else {
+            return Err(err.into());
         }
-        return Err(err.into());
     }
 
+    Ok(())
+}
+
+pub fn handle_cli_subcommand(cmd: &str, args: &ArgMatches, configs: &state::Configs) -> Result<()> {
+    let socket = UdpSocket::bind("127.0.0.1:0")?;
+
+    try_connect_to_client(&socket, configs).context("try to connect to a client")?;
+
     // construct a socket request based on the CLI command and its arguments
     let request = match cmd {
         "get" => handle_get_subcommand(args)?,
diff --git a/spotify_player/src/client/mod.rs b/spotify_player/src/client/mod.rs
index 919bee6e..a9cbef31 100644
--- a/spotify_player/src/client/mod.rs
+++ b/spotify_player/src/client/mod.rs
@@ -101,12 +101,12 @@ impl Client {
         Ok(())
     }
 
-    /// handles a player request
+    /// handles a player request and returns a new playback on success
     pub async fn handle_player_request(
         &self,
-        state: &SharedState,
         request: PlayerRequest,
-    ) -> Result<()> {
+        playback: Option<SimplifiedPlayback>,
+    ) -> Result<Option<SimplifiedPlayback>> {
         // `TransferPlayback` needs to be handled separately
         // from other play requests because they don't require an active playback
         // transfer the current playback to another device
@@ -116,15 +116,10 @@ impl Client {
                 .await?;
 
             tracing::info!("Transferred the playback to device with {} id", device_id);
-            return Ok(());
+            return Ok(playback);
         }
 
-        let mut playback = match state.player.read().buffered_playback {
-            Some(ref playback) => playback.clone(),
-            None => {
-                anyhow::bail!("failed to handle the player request: no playback found");
-            }
-        };
+        let mut playback = playback.context("no playback found")?;
         let device_id = playback.device_id.as_deref();
 
         match request {
@@ -134,14 +129,12 @@ impl Client {
                 if !playback.is_playing {
                     self.spotify.resume_playback(device_id, None).await?;
                     playback.is_playing = true;
-                    state.player.write().buffered_playback = Some(playback);
                 }
             }
             PlayerRequest::Pause => {
                 if playback.is_playing {
                     self.spotify.pause_playback(device_id).await?;
                     playback.is_playing = false;
-                    state.player.write().buffered_playback = Some(playback);
                 }
             }
             PlayerRequest::ResumePause => {
@@ -151,7 +144,6 @@ impl Client {
                     self.spotify.pause_playback(device_id).await?
                 }
                 playback.is_playing = !playback.is_playing;
-                state.player.write().buffered_playback = Some(playback);
             }
             PlayerRequest::SeekTrack(position_ms) => {
                 self.spotify.seek_track(position_ms, device_id).await?
@@ -166,7 +158,6 @@ impl Client {
                 self.spotify.repeat(next_repeat_state, device_id).await?;
 
                 playback.repeat_state = next_repeat_state;
-                state.player.write().buffered_playback = Some(playback);
             }
             PlayerRequest::Shuffle => {
                 self.spotify
@@ -174,19 +165,15 @@ impl Client {
                     .await?;
 
                 playback.shuffle_state = !playback.shuffle_state;
-                state.player.write().buffered_playback = Some(playback);
             }
             PlayerRequest::Volume(volume) => {
                 self.spotify.volume(volume, device_id).await?;
 
                 playback.volume = Some(volume as u32);
-                // upon updating volume, also reset the player's mute state
-                state.player.write().mute_state = None;
-                state.player.write().buffered_playback = Some(playback);
+                playback.mute_state = None;
             }
             PlayerRequest::ToggleMute => {
-                let mute_state = state.player.read().mute_state;
-                let new_mute_state = match mute_state {
+                let new_mute_state = match playback.mute_state {
                     None => {
                         self.spotify.volume(0, device_id).await?;
                         Some(playback.volume.unwrap_or_default())
@@ -197,7 +184,7 @@ impl Client {
                     }
                 };
 
-                state.player.write().mute_state = new_mute_state;
+                playback.mute_state = new_mute_state;
             }
             PlayerRequest::StartPlayback(p, shuffle) => {
                 if let Some(shuffle) = shuffle {
@@ -215,7 +202,7 @@ impl Client {
             }
         };
 
-        Ok(())
+        Ok(Some(playback))
     }
 
     /// handles a client request
@@ -268,7 +255,9 @@ impl Client {
                 state.data.write().user_data.user = Some(user);
             }
             ClientRequest::Player(request) => {
-                self.handle_player_request(state, request).await?;
+                let playback = state.player.read().buffered_playback.clone();
+                let playback = self.handle_player_request(request, playback).await?;
+                state.player.write().buffered_playback = playback;
                 self.update_playback(state);
             }
             ClientRequest::GetCurrentPlayback => {
@@ -1287,16 +1276,15 @@ impl Client {
 
             if needs_update {
                 // new playback updates, the buffered playback becomes invalid and needs to be updated
-                player.buffered_playback = player.playback.as_ref().map(|p| SimplifiedPlayback {
-                    device_name: p.device.name.clone(),
-                    device_id: p.device.id.clone(),
-                    is_playing: p.is_playing,
-                    volume: match player.mute_state {
-                        Some(volume) => Some(volume),
-                        None => p.device.volume_percent,
-                    },
-                    repeat_state: p.repeat_state,
-                    shuffle_state: p.shuffle_state,
+                player.buffered_playback = player.playback.as_ref().map(|p| {
+                    let mut playback = SimplifiedPlayback::from_playback(p);
+                    // update buffered playback's mute state and volume
+                    let mute_state = player.buffered_playback.as_ref().and_then(|p| p.mute_state);
+                    if let Some(volume) = mute_state {
+                        playback.volume = Some(volume);
+                    }
+                    playback.mute_state = mute_state;
+                    playback
                 });
             }
 
diff --git a/spotify_player/src/main.rs b/spotify_player/src/main.rs
index 152b8139..f6bec588 100644
--- a/spotify_player/src/main.rs
+++ b/spotify_player/src/main.rs
@@ -91,13 +91,11 @@ async fn init_spotify(
     }
 
     // request user data
-    if !is_daemon {
-        client_pub.send(event::ClientRequest::GetCurrentUser)?;
-        client_pub.send(event::ClientRequest::GetUserPlaylists)?;
-        client_pub.send(event::ClientRequest::GetUserFollowedArtists)?;
-        client_pub.send(event::ClientRequest::GetUserSavedAlbums)?;
-        client_pub.send(event::ClientRequest::GetUserSavedTracks)?;
-    }
+    client_pub.send(event::ClientRequest::GetCurrentUser)?;
+    client_pub.send(event::ClientRequest::GetUserPlaylists)?;
+    client_pub.send(event::ClientRequest::GetUserFollowedArtists)?;
+    client_pub.send(event::ClientRequest::GetUserSavedAlbums)?;
+    client_pub.send(event::ClientRequest::GetUserSavedTracks)?;
 
     Ok(())
 }
@@ -203,8 +201,15 @@ async fn start_app(state: state::SharedState, is_daemon: bool) -> Result<()> {
         let client = client.clone();
         let state = state.clone();
         async move {
-            if let Err(err) = cli::start_socket(client, state).await {
-                tracing::warn!("Failed to run client socket for CLI: {err:#}");
+            let port = state.configs.app_config.client_port;
+            tracing::info!("Starting a client socket at 127.0.0.1:{port}");
+            match tokio::net::UdpSocket::bind(("127.0.0.1", port)).await {
+                Ok(socket) => cli::start_socket(client, socket, Some(state)).await,
+                Err(err) => {
+                    tracing::warn!(
+                        "Failed to create a client socket for handling CLI commands: {err:#}"
+                    )
+                }
             }
         }
     }));
diff --git a/spotify_player/src/state/model.rs b/spotify_player/src/state/model.rs
index 2b2ef9ec..ccd9572c 100644
--- a/spotify_player/src/state/model.rs
+++ b/spotify_player/src/state/model.rs
@@ -1,4 +1,5 @@
 pub use rspotify::model as rspotify_model;
+use rspotify::model::CurrentPlaybackContext;
 pub use rspotify::model::{AlbumId, ArtistId, Id, PlaylistId, TrackId, UserId};
 
 use crate::utils::{format_duration, map_join};
@@ -101,6 +102,7 @@ pub struct SimplifiedPlayback {
     pub is_playing: bool,
     pub repeat_state: rspotify_model::RepeatState,
     pub shuffle_state: bool,
+    pub mute_state: Option<u32>,
 }
 
 #[derive(Debug, Clone)]
@@ -467,3 +469,17 @@ impl Playback {
         }
     }
 }
+
+impl SimplifiedPlayback {
+    pub fn from_playback(p: &CurrentPlaybackContext) -> Self {
+        Self {
+            device_name: p.device.name.clone(),
+            device_id: p.device.id.clone(),
+            is_playing: p.is_playing,
+            volume: p.device.volume_percent,
+            repeat_state: p.repeat_state,
+            shuffle_state: p.shuffle_state,
+            mute_state: None,
+        }
+    }
+}
diff --git a/spotify_player/src/state/player.rs b/spotify_player/src/state/player.rs
index 0a180f22..20640c48 100644
--- a/spotify_player/src/state/player.rs
+++ b/spotify_player/src/state/player.rs
@@ -12,16 +12,14 @@ pub struct PlayerState {
     pub buffered_playback: Option<SimplifiedPlayback>,
 
     pub queue: Option<rspotify_model::CurrentUserQueue>,
-
-    pub mute_state: Option<u32>,
 }
 
 impl PlayerState {
-    /// gets the current playback state
+    /// gets the current playback
     ///
     /// # Note
     /// Because playback data stored inside the player state is buffered and cached,
-    /// this function tries to estimate the current playback based on the available state data.
+    /// the returned playback is estimated based on the available data.
     pub fn current_playback(&self) -> Option<rspotify_model::CurrentPlaybackContext> {
         let mut playback = self.playback.clone()?;
 
diff --git a/spotify_player/src/ui/playback.rs b/spotify_player/src/ui/playback.rs
index fd796e12..8bb8c489 100644
--- a/spotify_player/src/ui/playback.rs
+++ b/spotify_player/src/ui/playback.rs
@@ -96,15 +96,7 @@ pub fn render_playback_window(
             };
 
             if let Some(ref playback) = player.buffered_playback {
-                render_playback_text(
-                    frame,
-                    state,
-                    ui,
-                    metadata_rect,
-                    track,
-                    playback,
-                    player.mute_state,
-                );
+                render_playback_text(frame, state, ui, metadata_rect, track, playback);
             }
 
             let progress = std::cmp::min(
@@ -147,7 +139,6 @@ fn render_playback_text(
     rect: Rect,
     track: &rspotify_model::FullTrack,
     playback: &SimplifiedPlayback,
-    mute_state: Option<u32>,
 ) {
     // Construct a "styled" text (`playback_text`) from playback's data
     // based on a user-configurable format string (app_config.playback_format)
@@ -160,7 +151,7 @@ fn render_playback_text(
     let re = regex::Regex::new(r"\{.*?\}|\n").unwrap();
 
     // build the volume string (vol% when unmuted, old_vol% (muted) if currently muted)
-    let volume = match mute_state {
+    let volume = match playback.mute_state {
         Some(volume) => format!("{volume}% (muted)"),
         None => format!("{}%", playback.volume.unwrap_or_default()),
     };


Test patch: