1
0
Fork 0
mirror of https://github.com/librespot-org/librespot.git synced 2025-10-04 10:19:27 +02:00
librespot/connect/src/spirc.rs
Scott S. McCoy 6bdc0eb312
spirc: Configurable volume control steps (#1498)
* spirc: Configurable volume control steps

Allow the volume control steps to be configured via the `--volume-steps`
command line parameter. The author personally found the default volume
steps of `1024` to be completely unusable, and is presently using `128`
as his configuration. Perhaps consider this as a more reasonable
default.

Additionally, reduce the delay in volume update from a wopping two
seconds to 500ms, again for usability.

Also clean up the seemingly unnecessary use of a pattern match on
whether or not `--initial-volume` was supplied.

* fixup! spirc: Configurable volume control steps

* fixup! spirc: Configurable volume control steps

* fixup! spirc: Configurable volume control steps

* fixup! spirc: Configurable volume control steps

* fixup! spirc: Configurable volume control steps

* fixup! spirc: Configurable volume control steps

---------

Co-authored-by: Scott S. McCoy <scott.s.mccoy@acm.org>
2025-05-01 23:19:47 +02:00

1665 lines
61 KiB
Rust

use crate::{
context_resolver::{ContextAction, ContextResolver, ResolveContext},
core::{
authentication::Credentials,
dealer::{
manager::{BoxedStream, BoxedStreamResult, Reply, RequestReply},
protocol::{Command, Message, Request},
},
session::UserAttributes,
Error, Session, SpotifyId,
},
model::{LoadRequest, PlayingTrack, SpircPlayStatus},
playback::{
mixer::Mixer,
player::{Player, PlayerEvent, PlayerEventChannel},
},
protocol::{
connect::{Cluster, ClusterUpdate, LogoutCommand, SetVolumeCommand},
context::Context,
explicit_content_pubsub::UserAttributesUpdate,
playlist4_external::PlaylistModificationInfo,
social_connect_v2::SessionUpdate,
transfer_state::TransferState,
user_attributes::UserAttributesMutation,
},
state::{
context::{ContextType, ResetContext},
provider::IsProvider,
{ConnectConfig, ConnectState},
},
LoadContextOptions, LoadRequestOptions,
};
use futures_util::StreamExt;
use protobuf::MessageField;
use std::{
future::Future,
sync::atomic::{AtomicUsize, Ordering},
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use thiserror::Error;
use tokio::{sync::mpsc, time::sleep};
#[derive(Debug, Error)]
enum SpircError {
#[error("response payload empty")]
NoData,
#[error("{0} had no uri")]
NoUri(&'static str),
#[error("message pushed for another URI")]
InvalidUri(String),
#[error("failed to put connect state for new device")]
FailedDealerSetup,
#[error("unknown endpoint: {0:#?}")]
UnknownEndpoint(serde_json::Value),
}
impl From<SpircError> for Error {
fn from(err: SpircError) -> Self {
use SpircError::*;
match err {
NoData | NoUri(_) => Error::unavailable(err),
InvalidUri(_) | FailedDealerSetup => Error::aborted(err),
UnknownEndpoint(_) => Error::unimplemented(err),
}
}
}
struct SpircTask {
player: Arc<Player>,
mixer: Arc<dyn Mixer>,
/// the state management object
connect_state: ConnectState,
play_request_id: Option<u64>,
play_status: SpircPlayStatus,
connection_id_update: BoxedStreamResult<String>,
connect_state_update: BoxedStreamResult<ClusterUpdate>,
connect_state_volume_update: BoxedStreamResult<SetVolumeCommand>,
connect_state_logout_request: BoxedStreamResult<LogoutCommand>,
playlist_update: BoxedStreamResult<PlaylistModificationInfo>,
session_update: BoxedStreamResult<SessionUpdate>,
connect_state_command: BoxedStream<RequestReply>,
user_attributes_update: BoxedStreamResult<UserAttributesUpdate>,
user_attributes_mutation: BoxedStreamResult<UserAttributesMutation>,
commands: Option<mpsc::UnboundedReceiver<SpircCommand>>,
player_events: Option<PlayerEventChannel>,
context_resolver: ContextResolver,
shutdown: bool,
session: Session,
/// is set when transferring, and used after resolving the contexts to finish the transfer
pub transfer_state: Option<TransferState>,
/// when set to true, it will update the volume after [VOLUME_UPDATE_DELAY],
/// when no other future resolves, otherwise resets the delay
update_volume: bool,
/// when set to true, it will update the volume after [UPDATE_STATE_DELAY],
/// when no other future resolves, otherwise resets the delay
update_state: bool,
spirc_id: usize,
}
static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0);
#[derive(Debug)]
enum SpircCommand {
Play,
PlayPause,
Pause,
Prev,
Next,
VolumeUp,
VolumeDown,
Shutdown,
Shuffle(bool),
Repeat(bool),
RepeatTrack(bool),
Disconnect { pause: bool },
SetPosition(u32),
SetVolume(u16),
Activate,
Load(LoadRequest),
}
const CONTEXT_FETCH_THRESHOLD: usize = 2;
// delay to update volume after a certain amount of time, instead on each update request
const VOLUME_UPDATE_DELAY: Duration = Duration::from_millis(500);
// to reduce updates to remote, we group some request by waiting for a set amount of time
const UPDATE_STATE_DELAY: Duration = Duration::from_millis(200);
/// The spotify connect handle
pub struct Spirc {
commands: mpsc::UnboundedSender<SpircCommand>,
}
impl Spirc {
/// Initializes a new spotify connect device
///
/// The returned tuple consists out of a handle to the [`Spirc`] that
/// can control the local connect device when active. And a [`Future`]
/// which represents the [`Spirc`] event loop that processes the whole
/// connect device logic.
pub async fn new(
config: ConnectConfig,
session: Session,
credentials: Credentials,
player: Arc<Player>,
mixer: Arc<dyn Mixer>,
) -> Result<(Spirc, impl Future<Output = ()>), Error> {
fn extract_connection_id(msg: Message) -> Result<String, Error> {
let connection_id = msg
.headers
.get("Spotify-Connection-Id")
.ok_or_else(|| SpircError::InvalidUri(msg.uri.clone()))?;
Ok(connection_id.to_owned())
}
let spirc_id = SPIRC_COUNTER.fetch_add(1, Ordering::AcqRel);
debug!("new Spirc[{}]", spirc_id);
let connect_state = ConnectState::new(config, &session);
let connection_id_update = session
.dealer()
.listen_for("hm://pusher/v1/connections/", extract_connection_id)?;
let connect_state_update = session
.dealer()
.listen_for("hm://connect-state/v1/cluster", Message::from_raw)?;
let connect_state_volume_update = session
.dealer()
.listen_for("hm://connect-state/v1/connect/volume", Message::from_raw)?;
let connect_state_logout_request = session
.dealer()
.listen_for("hm://connect-state/v1/connect/logout", Message::from_raw)?;
let playlist_update = session
.dealer()
.listen_for("hm://playlist/v2/playlist/", Message::from_raw)?;
let session_update = session
.dealer()
.listen_for("social-connect/v2/session_update", Message::from_json)?;
let user_attributes_update = session
.dealer()
.listen_for("spotify:user:attributes:update", Message::from_raw)?;
// can be trigger by toggling autoplay in a desktop client
let user_attributes_mutation = session
.dealer()
.listen_for("spotify:user:attributes:mutated", Message::from_raw)?;
let connect_state_command = session
.dealer()
.handle_for("hm://connect-state/v1/player/command")?;
// pre-acquire client_token, preventing multiple request while running
let _ = session.spclient().client_token().await?;
// Connect *after* all message listeners are registered
session.connect(credentials, true).await?;
// pre-acquire access_token (we need to be authenticated to retrieve a token)
let _ = session.login5().auth_token().await?;
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let player_events = player.get_player_event_channel();
let mut task = SpircTask {
player,
mixer,
connect_state,
play_request_id: None,
play_status: SpircPlayStatus::Stopped,
connection_id_update,
connect_state_update,
connect_state_volume_update,
connect_state_logout_request,
playlist_update,
session_update,
connect_state_command,
user_attributes_update,
user_attributes_mutation,
commands: Some(cmd_rx),
player_events: Some(player_events),
context_resolver: ContextResolver::new(session.clone()),
shutdown: false,
session,
transfer_state: None,
update_volume: false,
update_state: false,
spirc_id,
};
let spirc = Spirc { commands: cmd_tx };
let initial_volume = task.connect_state.device_info().volume;
task.connect_state.set_volume(0);
match initial_volume.try_into() {
Ok(volume) => {
task.set_volume(volume);
// we don't want to update the volume initially,
// we just want to set the mixer to the correct volume
task.update_volume = false;
}
Err(why) => error!("failed to update initial volume: {why}"),
};
Ok((spirc, task.run()))
}
/// Safely shutdowns the spirc.
///
/// This pauses the playback, disconnects the connect device and
/// bring the future initially returned to an end.
pub fn shutdown(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Shutdown)?)
}
/// Resumes the playback
///
/// Does nothing if we are not the active device, or it isn't paused.
pub fn play(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Play)?)
}
/// Resumes or pauses the playback
///
/// Does nothing if we are not the active device.
pub fn play_pause(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::PlayPause)?)
}
/// Pauses the playback
///
/// Does nothing if we are not the active device, or if it isn't playing.
pub fn pause(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Pause)?)
}
/// Seeks to the beginning or skips to the previous track.
///
/// Seeks to the beginning when the current track position
/// is greater than 3 seconds.
///
/// Does nothing if we are not the active device.
pub fn prev(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Prev)?)
}
/// Skips to the next track.
///
/// Does nothing if we are not the active device.
pub fn next(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Next)?)
}
/// Increases the volume by configured steps of [ConnectConfig].
///
/// Does nothing if we are not the active device.
pub fn volume_up(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::VolumeUp)?)
}
/// Decreases the volume by configured steps of [ConnectConfig].
///
/// Does nothing if we are not the active device.
pub fn volume_down(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::VolumeDown)?)
}
/// Shuffles the playback according to the value.
///
/// If true shuffles/reshuffles the playback. Otherwise, does
/// nothing (if not shuffled) or unshuffles the playback while
/// resuming at the position of the current track.
///
/// Does nothing if we are not the active device.
pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Shuffle(shuffle))?)
}
/// Repeats the playback context according to the value.
///
/// Does nothing if we are not the active device.
pub fn repeat(&self, repeat: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Repeat(repeat))?)
}
/// Repeats the current track if true.
///
/// Does nothing if we are not the active device.
///
/// Skipping to the next track disables the repeating.
pub fn repeat_track(&self, repeat: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::RepeatTrack(repeat))?)
}
/// Update the volume to the given value.
///
/// Does nothing if we are not the active device.
pub fn set_volume(&self, volume: u16) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::SetVolume(volume))?)
}
/// Updates the position to the given value.
///
/// Does nothing if we are not the active device.
///
/// If value is greater than the track duration,
/// the update is ignored.
pub fn set_position_ms(&self, position_ms: u32) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::SetPosition(position_ms))?)
}
/// Load a new context and replace the current.
///
/// Does nothing if we are not the active device.
///
/// Does not overwrite the queue.
pub fn load(&self, command: LoadRequest) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Load(command))?)
}
/// Disconnects the current device and pauses the playback according the value.
///
/// Does nothing if we are not the active device.
pub fn disconnect(&self, pause: bool) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Disconnect { pause })?)
}
/// Acquires the control as active connect device.
///
/// Does nothing if we are not the active device.
pub fn activate(&self) -> Result<(), Error> {
Ok(self.commands.send(SpircCommand::Activate)?)
}
}
impl SpircTask {
async fn run(mut self) {
// simplify unwrapping of received item or parsed result
macro_rules! unwrap {
( $next:expr, |$some:ident| $use_some:expr ) => {
match $next {
Some($some) => $use_some,
None => {
error!("{} selected, but none received", stringify!($next));
break;
}
}
};
( $next:expr, match |$ok:ident| $use_ok:expr ) => {
unwrap!($next, |$ok| match $ok {
Ok($ok) => $use_ok,
Err(why) => error!("could not parse {}: {}", stringify!($ok), why),
})
};
}
if let Err(why) = self.session.dealer().start().await {
error!("starting dealer failed: {why}");
return;
}
while !self.session.is_invalid() && !self.shutdown {
let commands = self.commands.as_mut();
let player_events = self.player_events.as_mut();
// when state and volume update have a higher priority than context resolving
// because of that the context resolving has to wait, so that the other tasks can finish
let allow_context_resolving = !self.update_state && !self.update_volume;
tokio::select! {
// startup of the dealer requires a connection_id, which is retrieved at the very beginning
connection_id_update = self.connection_id_update.next() => unwrap! {
connection_id_update,
match |connection_id| if let Err(why) = self.handle_connection_id_update(connection_id).await {
error!("failed handling connection id update: {why}");
break;
}
},
// main dealer update of any remote device updates
cluster_update = self.connect_state_update.next() => unwrap! {
cluster_update,
match |cluster_update| if let Err(e) = self.handle_cluster_update(cluster_update).await {
error!("could not dispatch connect state update: {}", e);
}
},
// main dealer request handling (dealer expects an answer)
request = self.connect_state_command.next() => unwrap! {
request,
|request| if let Err(e) = self.handle_connect_state_request(request).await {
error!("couldn't handle connect state command: {}", e);
}
},
// volume request handling is send separately (it's more like a fire forget)
volume_update = self.connect_state_volume_update.next() => unwrap! {
volume_update,
match |volume_update| match volume_update.volume.try_into() {
Ok(volume) => self.set_volume(volume),
Err(why) => error!("can't update volume, failed to parse i32 to u16: {why}")
}
},
logout_request = self.connect_state_logout_request.next() => unwrap! {
logout_request,
|logout_request| {
error!("received logout request, currently not supported: {logout_request:#?}");
// todo: call logout handling
}
},
playlist_update = self.playlist_update.next() => unwrap! {
playlist_update,
match |playlist_update| if let Err(why) = self.handle_playlist_modification(playlist_update) {
error!("failed to handle playlist modification: {why}")
}
},
user_attributes_update = self.user_attributes_update.next() => unwrap! {
user_attributes_update,
match |attributes| self.handle_user_attributes_update(attributes)
},
user_attributes_mutation = self.user_attributes_mutation.next() => unwrap! {
user_attributes_mutation,
match |attributes| self.handle_user_attributes_mutation(attributes)
},
session_update = self.session_update.next() => unwrap! {
session_update,
match |session_update| self.handle_session_update(session_update)
},
cmd = async { commands?.recv().await }, if commands.is_some() => if let Some(cmd) = cmd {
if let Err(e) = self.handle_command(cmd).await {
debug!("could not dispatch command: {}", e);
}
},
event = async { player_events?.recv().await }, if player_events.is_some() => if let Some(event) = event {
if let Err(e) = self.handle_player_event(event) {
error!("could not dispatch player event: {}", e);
}
},
_ = async { sleep(UPDATE_STATE_DELAY).await }, if self.update_state => {
self.update_state = false;
if let Err(why) = self.notify().await {
error!("state update: {why}")
}
},
_ = async { sleep(VOLUME_UPDATE_DELAY).await }, if self.update_volume => {
self.update_volume = false;
info!("delayed volume update for all devices: volume is now {}", self.connect_state.device_info().volume);
if let Err(why) = self.connect_state.notify_volume_changed(&self.session).await {
error!("error updating connect state for volume update: {why}")
}
// for some reason the web-player does need two separate updates, so that the
// position of the current track is retained, other clients also send a state
// update before they send the volume update
if let Err(why) = self.notify().await {
error!("error updating connect state for volume update: {why}")
}
},
// context resolver handling, the idea/reason behind it the following:
//
// when we request a context that has multiple pages (for example an artist)
// resolving all pages at once can take around ~1-30sec, when we resolve
// everything at once that would block our main loop for that time
//
// to circumvent this behavior, we request each context separately here and
// finish after we received our last item of a type
next_context = async {
self.context_resolver.get_next_context(|| {
self.connect_state.recent_track_uris()
}).await
}, if allow_context_resolving && self.context_resolver.has_next() => {
let update_state = self.handle_next_context(next_context);
if update_state {
if let Err(why) = self.notify().await {
error!("update after context resolving failed: {why}")
}
}
},
else => break
}
}
if !self.shutdown && self.connect_state.is_active() {
warn!("unexpected shutdown");
if let Err(why) = self.handle_disconnect().await {
error!("error during disconnecting: {why}")
}
}
self.session.dealer().close().await;
}
fn handle_next_context(&mut self, next_context: Result<Context, Error>) -> bool {
let next_context = match next_context {
Err(why) => {
self.context_resolver.mark_next_unavailable();
self.context_resolver.remove_used_and_invalid();
error!("{why}");
return false;
}
Ok(ctx) => ctx,
};
debug!("handling next context {:?}", next_context.uri);
match self
.context_resolver
.apply_next_context(&mut self.connect_state, next_context)
{
Ok(remaining) => {
if let Some(remaining) = remaining {
self.context_resolver.add_list(remaining)
}
}
Err(why) => {
error!("{why}")
}
}
let update_state = if self
.context_resolver
.try_finish(&mut self.connect_state, &mut self.transfer_state)
{
self.add_autoplay_resolving_when_required();
true
} else {
false
};
self.context_resolver.remove_used_and_invalid();
update_state
}
// todo: is the time_delta still necessary?
fn now_ms(&self) -> i64 {
let dur = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|err| err.duration());
dur.as_millis() as i64 + 1000 * self.session.time_delta()
}
async fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> {
trace!("Received SpircCommand::{:?}", cmd);
match cmd {
SpircCommand::Shutdown => {
trace!("Received SpircCommand::Shutdown");
self.handle_pause();
self.handle_disconnect().await?;
self.shutdown = true;
if let Some(rx) = self.commands.as_mut() {
rx.close()
}
}
SpircCommand::Activate if !self.connect_state.is_active() => {
trace!("Received SpircCommand::{:?}", cmd);
self.handle_activate();
return self.notify().await;
}
SpircCommand::Activate => warn!(
"SpircCommand::{:?} will be ignored while already active",
cmd
),
_ if !self.connect_state.is_active() => {
warn!("SpircCommand::{:?} will be ignored while Not Active", cmd)
}
SpircCommand::Disconnect { pause } => {
if pause {
self.handle_pause()
}
return self.handle_disconnect().await;
}
SpircCommand::Play => self.handle_play(),
SpircCommand::PlayPause => self.handle_play_pause(),
SpircCommand::Pause => self.handle_pause(),
SpircCommand::Prev => self.handle_prev()?,
SpircCommand::Next => self.handle_next(None)?,
SpircCommand::VolumeUp => self.handle_volume_up(),
SpircCommand::VolumeDown => self.handle_volume_down(),
SpircCommand::Shuffle(shuffle) => self.handle_shuffle(shuffle)?,
SpircCommand::Repeat(repeat) => self.handle_repeat_context(repeat)?,
SpircCommand::RepeatTrack(repeat) => self.handle_repeat_track(repeat),
SpircCommand::SetPosition(position) => self.handle_seek(position),
SpircCommand::SetVolume(volume) => self.set_volume(volume),
SpircCommand::Load(command) => self.handle_load(command, None).await?,
};
self.notify().await
}
fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> {
if let PlayerEvent::TrackChanged { audio_item } = event {
self.connect_state.update_duration(audio_item.duration_ms);
self.update_state = true;
return Ok(());
}
// update play_request_id
if let PlayerEvent::PlayRequestIdChanged { play_request_id } = event {
self.play_request_id = Some(play_request_id);
return Ok(());
}
let is_current_track = matches! {
(event.get_play_request_id(), self.play_request_id),
(Some(event_id), Some(current_id)) if event_id == current_id
};
// we only process events if the play_request_id matches. If it doesn't, it is
// an event that belongs to a previous track and only arrives now due to a race
// condition. In this case we have updated the state already and don't want to
// mess with it.
if !is_current_track {
return Ok(());
}
match event {
PlayerEvent::EndOfTrack { .. } => {
let next_track = self
.connect_state
.repeat_track()
.then(|| self.connect_state.current_track(|t| t.uri.clone()));
self.handle_next(next_track)?
}
PlayerEvent::Loading { .. } => match self.play_status {
SpircPlayStatus::LoadingPlay { position_ms } => {
self.connect_state
.update_position(position_ms, self.now_ms());
trace!("==> LoadingPlay");
}
SpircPlayStatus::LoadingPause { position_ms } => {
self.connect_state
.update_position(position_ms, self.now_ms());
trace!("==> LoadingPause");
}
_ => {
self.connect_state.update_position(0, self.now_ms());
trace!("==> Loading");
}
},
PlayerEvent::Seeked { position_ms, .. } => {
trace!("==> Seeked");
self.connect_state
.update_position(position_ms, self.now_ms())
}
PlayerEvent::Playing { position_ms, .. }
| PlayerEvent::PositionCorrection { position_ms, .. } => {
trace!("==> Playing");
let new_nominal_start_time = self.now_ms() - position_ms as i64;
match self.play_status {
SpircPlayStatus::Playing {
ref mut nominal_start_time,
..
} => {
if (*nominal_start_time - new_nominal_start_time).abs() > 100 {
*nominal_start_time = new_nominal_start_time;
self.connect_state
.update_position(position_ms, self.now_ms());
} else {
return Ok(());
}
}
SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => {
self.connect_state
.update_position(position_ms, self.now_ms());
self.play_status = SpircPlayStatus::Playing {
nominal_start_time: new_nominal_start_time,
preloading_of_next_track_triggered: false,
};
}
_ => return Ok(()),
}
}
PlayerEvent::Paused {
position_ms: new_position_ms,
..
} => {
trace!("==> Paused");
match self.play_status {
SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => {
self.connect_state
.update_position(new_position_ms, self.now_ms());
self.play_status = SpircPlayStatus::Paused {
position_ms: new_position_ms,
preloading_of_next_track_triggered: false,
};
}
SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => {
self.connect_state
.update_position(new_position_ms, self.now_ms());
self.play_status = SpircPlayStatus::Paused {
position_ms: new_position_ms,
preloading_of_next_track_triggered: false,
};
}
_ => return Ok(()),
}
}
PlayerEvent::Stopped { .. } => {
trace!("==> Stopped");
match self.play_status {
SpircPlayStatus::Stopped => return Ok(()),
_ => self.play_status = SpircPlayStatus::Stopped,
}
}
PlayerEvent::TimeToPreloadNextTrack { .. } => {
self.handle_preload_next_track();
return Ok(());
}
PlayerEvent::Unavailable { track_id, .. } => {
self.handle_unavailable(track_id)?;
if self.connect_state.current_track(|t| &t.uri) == &track_id.to_uri()? {
self.handle_next(None)?
}
}
_ => return Ok(()),
}
self.update_state = true;
Ok(())
}
async fn handle_connection_id_update(&mut self, connection_id: String) -> Result<(), Error> {
trace!("Received connection ID update: {:?}", connection_id);
self.session.set_connection_id(&connection_id);
let cluster = match self
.connect_state
.notify_new_device_appeared(&self.session)
.await
{
Ok(res) => Cluster::parse_from_bytes(&res).ok(),
Err(why) => {
error!("{why:?}");
None
}
}
.ok_or(SpircError::FailedDealerSetup)?;
debug!(
"successfully put connect state for {} with connection-id {connection_id}",
self.session.device_id()
);
let same_session = cluster.player_state.session_id == self.session.session_id()
|| cluster.player_state.session_id.is_empty();
if !cluster.active_device_id.is_empty() || !same_session {
info!(
"active device is <{}> with session <{}>",
cluster.active_device_id, cluster.player_state.session_id
);
return Ok(());
} else if cluster.transfer_data.is_empty() {
debug!("got empty transfer state, do nothing");
return Ok(());
} else {
info!(
"trying to take over control automatically, session_id: {}",
cluster.player_state.session_id
)
}
use protobuf::Message;
match TransferState::parse_from_bytes(&cluster.transfer_data) {
Ok(transfer_state) => self.handle_transfer(transfer_state)?,
Err(why) => error!("failed to take over control: {why}"),
}
Ok(())
}
fn handle_user_attributes_update(&mut self, update: UserAttributesUpdate) {
trace!("Received attributes update: {:#?}", update);
let attributes: UserAttributes = update
.pairs
.iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect();
self.session.set_user_attributes(attributes)
}
fn handle_user_attributes_mutation(&mut self, mutation: UserAttributesMutation) {
for attribute in mutation.fields.iter() {
let key = &attribute.name;
if key == "autoplay" && self.session.config().autoplay.is_some() {
trace!("Autoplay override active. Ignoring mutation.");
continue;
}
if let Some(old_value) = self.session.user_data().attributes.get(key) {
let new_value = match old_value.as_ref() {
"0" => "1",
"1" => "0",
_ => old_value,
};
self.session.set_user_attribute(key, new_value);
trace!(
"Received attribute mutation, {} was {} is now {}",
key,
old_value,
new_value
);
if key == "filter-explicit-content" && new_value == "1" {
self.player
.emit_filter_explicit_content_changed_event(matches!(new_value, "1"));
}
if key == "autoplay" && old_value != new_value {
self.player
.emit_auto_play_changed_event(matches!(new_value, "1"));
self.add_autoplay_resolving_when_required()
}
} else {
trace!(
"Received attribute mutation for {} but key was not found!",
key
);
}
}
}
async fn handle_cluster_update(
&mut self,
mut cluster_update: ClusterUpdate,
) -> Result<(), Error> {
let reason = cluster_update.update_reason.enum_value();
let device_ids = cluster_update.devices_that_changed.join(", ");
debug!(
"cluster update: {reason:?} from {device_ids}, active device: {}",
cluster_update.cluster.active_device_id
);
if let Some(cluster) = cluster_update.cluster.take() {
let became_inactive = self.connect_state.is_active()
&& cluster.active_device_id != self.session.device_id();
if became_inactive {
info!("device became inactive");
self.connect_state.became_inactive(&self.session).await?;
self.handle_stop()
} else if self.connect_state.is_active() {
// fixme: workaround fix, because of missing information why it behaves like it does
// background: when another device sends a connect-state update, some player's position de-syncs
// tried: providing session_id, playback_id, track-metadata "track_player"
self.update_state = true;
}
} else if self.connect_state.is_active() {
self.connect_state.became_inactive(&self.session).await?;
}
Ok(())
}
async fn handle_connect_state_request(
&mut self,
(request, sender): RequestReply,
) -> Result<(), Error> {
self.connect_state.set_last_command(request.clone());
debug!(
"handling: '{}' from {}",
request.command, request.sent_by_device_id
);
let response = match self.handle_request(request).await {
Ok(_) => Reply::Success,
Err(why) => {
error!("failed to handle request: {why}");
Reply::Failure
}
};
sender.send(response).map_err(Into::into)
}
async fn handle_request(&mut self, request: Request) -> Result<(), Error> {
use Command::*;
match request.command {
// errors and unknown commands
Transfer(transfer) if transfer.data.is_none() => {
warn!("transfer endpoint didn't contain any data to transfer");
Err(SpircError::NoData)?
}
Unknown(unknown) => Err(SpircError::UnknownEndpoint(unknown))?,
// implicit update of the connect_state
UpdateContext(update_context) => {
if matches!(update_context.context.uri, Some(ref uri) if uri != self.connect_state.context_uri())
{
debug!(
"ignoring context update for <{:?}>, because it isn't the current context <{}>",
update_context.context.uri, self.connect_state.context_uri()
)
} else {
self.context_resolver.add(ResolveContext::from_context(
update_context.context,
ContextType::Default,
ContextAction::Replace,
))
}
return Ok(());
}
// modification and update of the connect_state
Transfer(transfer) => {
self.handle_transfer(transfer.data.expect("by condition checked"))?;
return self.notify().await;
}
Play(play) => {
let context_uri = play
.context
.uri
.clone()
.ok_or(SpircError::NoUri("context"))?;
let context_options = play
.options
.player_options_override
.map(Into::into)
.map(LoadContextOptions::Options);
self.handle_load(
LoadRequest::from_context_uri(
context_uri,
LoadRequestOptions {
start_playing: true,
seek_to: play.options.seek_to.unwrap_or_default(),
playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()),
context_options,
},
),
Some(play.context),
)
.await?;
self.connect_state.set_origin(play.play_origin)
}
Pause(_) => self.handle_pause(),
SeekTo(seek_to) => {
// for some reason the position is stored in value, not in position
trace!("seek to {seek_to:?}");
self.handle_seek(seek_to.value)
}
SetShufflingContext(shuffle) => self.handle_shuffle(shuffle.value)?,
SetRepeatingContext(repeat_context) => {
self.handle_repeat_context(repeat_context.value)?
}
SetRepeatingTrack(repeat_track) => self.handle_repeat_track(repeat_track.value),
AddToQueue(add_to_queue) => self.connect_state.add_to_queue(add_to_queue.track, true),
SetQueue(set_queue) => self.connect_state.handle_set_queue(set_queue),
SetOptions(set_options) => {
if let Some(repeat_context) = set_options.repeating_context {
self.handle_repeat_context(repeat_context)?
}
if let Some(repeat_track) = set_options.repeating_track {
self.handle_repeat_track(repeat_track)
}
let shuffle = set_options.shuffling_context;
if let Some(shuffle) = shuffle {
self.handle_shuffle(shuffle)?;
}
}
SkipNext(skip_next) => self.handle_next(skip_next.track.map(|t| t.uri))?,
SkipPrev(_) => self.handle_prev()?,
Resume(_) if matches!(self.play_status, SpircPlayStatus::Stopped) => {
self.load_track(true, 0)?
}
Resume(_) => self.handle_play(),
}
self.update_state = true;
Ok(())
}
fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> {
let mut ctx_uri = match transfer.current_session.context.uri {
None => Err(SpircError::NoUri("transfer context"))?,
Some(ref uri) => uri.clone(),
};
self.connect_state
.reset_context(ResetContext::WhenDifferent(&ctx_uri));
match self.connect_state.current_track_from_transfer(&transfer) {
Err(why) => warn!("didn't find initial track: {why}"),
Ok(track) => {
debug!("found initial track <{}>", track.uri);
self.connect_state.set_track(track)
}
};
let autoplay = self.connect_state.current_track(|t| t.is_autoplay());
if autoplay {
ctx_uri = ctx_uri.replace("station:", "");
}
let fallback = self.connect_state.current_track(|t| &t.uri).clone();
self.context_resolver.add(ResolveContext::from_uri(
ctx_uri.clone(),
&fallback,
ContextType::Default,
ContextAction::Replace,
));
let timestamp = self.now_ms();
let state = &mut self.connect_state;
state.set_active(true);
state.handle_initial_transfer(&mut transfer);
// adjust active context, so resolve knows for which context it should set up the state
state.active_context = if autoplay {
ContextType::Autoplay
} else {
ContextType::Default
};
// update position if the track continued playing
let transfer_timestamp = transfer.playback.timestamp.unwrap_or_default();
let position = match transfer.playback.position_as_of_timestamp {
Some(position) if transfer.playback.is_paused.unwrap_or_default() => position.into(),
// update position if the track continued playing
Some(position) if position > 0 => {
let time_since_position_update = timestamp - transfer_timestamp;
i64::from(position) + time_since_position_update
}
_ => 0,
};
let is_playing = !transfer.playback.is_paused();
if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay {
debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}");
self.context_resolver.add(ResolveContext::from_uri(
ctx_uri,
fallback,
ContextType::Autoplay,
ContextAction::Replace,
))
}
self.transfer_state = Some(transfer);
self.load_track(is_playing, position.try_into()?)
}
async fn handle_disconnect(&mut self) -> Result<(), Error> {
self.context_resolver.clear();
self.play_status = SpircPlayStatus::Stopped {};
self.connect_state
.update_position_in_relation(self.now_ms());
self.notify().await?;
self.connect_state.became_inactive(&self.session).await?;
// this should clear the active session id, leaving an empty state
self.session
.spclient()
.delete_connect_state_request()
.await?;
self.player
.emit_session_disconnected_event(self.session.connection_id(), self.session.username());
Ok(())
}
fn handle_stop(&mut self) {
self.player.stop();
self.connect_state.update_position(0, self.now_ms());
self.connect_state.clear_next_tracks();
if let Err(why) = self.connect_state.reset_playback_to_position(None) {
warn!("failed filling up next_track during stopping: {why}")
}
}
fn handle_activate(&mut self) {
self.connect_state.set_active(true);
self.player
.emit_session_connected_event(self.session.connection_id(), self.session.username());
self.player.emit_session_client_changed_event(
self.session.client_id(),
self.session.client_name(),
self.session.client_brand_name(),
self.session.client_model_name(),
);
self.player
.emit_volume_changed_event(self.connect_state.device_info().volume as u16);
self.player
.emit_auto_play_changed_event(self.session.autoplay());
self.player
.emit_filter_explicit_content_changed_event(self.session.filter_explicit_content());
self.player
.emit_shuffle_changed_event(self.connect_state.shuffling_context());
self.player.emit_repeat_changed_event(
self.connect_state.repeat_context(),
self.connect_state.repeat_track(),
);
}
async fn handle_load(
&mut self,
cmd: LoadRequest,
context: Option<Context>,
) -> Result<(), Error> {
self.connect_state
.reset_context(ResetContext::WhenDifferent(&cmd.context_uri));
self.connect_state.reset_options();
if !self.connect_state.is_active() {
self.handle_activate();
}
let fallback = if let Some(ref ctx) = context {
match ConnectState::get_context_uri_from_context(ctx) {
Some(ctx_uri) => ctx_uri,
None => Err(SpircError::InvalidUri(cmd.context_uri.clone()))?,
}
} else {
&cmd.context_uri
};
let update_context = if matches!(cmd.context_options, Some(LoadContextOptions::Autoplay)) {
ContextType::Autoplay
} else {
ContextType::Default
};
self.connect_state.set_active_context(update_context);
let current_context_uri = self.connect_state.context_uri();
if current_context_uri == &cmd.context_uri && fallback == cmd.context_uri {
debug!("context <{current_context_uri}> didn't change, no resolving required")
} else {
debug!("resolving context for load command");
self.context_resolver.clear();
self.context_resolver.add(ResolveContext::from_uri(
&cmd.context_uri,
fallback,
update_context,
ContextAction::Replace,
));
let context = self.context_resolver.get_next_context(Vec::new).await;
self.handle_next_context(context);
}
// for play commands with skip by uid, the context of the command contains
// tracks with uri and uid, so we merge the new context with the resolved/existing context
self.connect_state.merge_context(context);
// load here, so that we clear the queue only after we definitely retrieved a new context
self.connect_state.clear_next_tracks();
self.connect_state.clear_restrictions();
debug!("play track <{:?}>", cmd.playing_track);
let index = match cmd.playing_track {
None => None,
Some(ref playing_track) => Some(match playing_track {
PlayingTrack::Index(i) => *i as usize,
PlayingTrack::Uri(uri) => {
let ctx = self.connect_state.get_context(ContextType::Default)?;
ConnectState::find_index_in_context(ctx, |t| &t.uri == uri)?
}
PlayingTrack::Uid(uid) => {
let ctx = self.connect_state.get_context(ContextType::Default)?;
ConnectState::find_index_in_context(ctx, |t| &t.uid == uid)?
}
}),
};
if let Some(LoadContextOptions::Options(ref options)) = cmd.context_options {
debug!(
"loading with shuffle: <{}>, repeat track: <{}> context: <{}>",
options.shuffle, options.repeat, options.repeat_track
);
self.connect_state.set_shuffle(options.shuffle);
self.connect_state.set_repeat_context(options.repeat);
self.connect_state.set_repeat_track(options.repeat_track);
}
if matches!(cmd.context_options, Some(LoadContextOptions::Options(ref o)) if o.shuffle) {
if let Some(index) = index {
self.connect_state.set_current_track(index)?;
} else {
self.connect_state.set_current_track_random()?;
}
if self.context_resolver.has_next() {
self.connect_state.update_queue_revision()
} else {
self.connect_state.shuffle(None)?;
self.add_autoplay_resolving_when_required();
}
} else {
self.connect_state
.set_current_track(index.unwrap_or_default())?;
self.connect_state.reset_playback_to_position(index)?;
self.add_autoplay_resolving_when_required();
}
if self.connect_state.current_track(MessageField::is_some) {
self.load_track(cmd.start_playing, cmd.seek_to)?;
} else {
info!("No active track, stopping");
self.handle_stop()
}
Ok(())
}
fn handle_play(&mut self) {
match self.play_status {
SpircPlayStatus::Paused {
position_ms,
preloading_of_next_track_triggered,
} => {
self.player.play();
self.connect_state
.update_position(position_ms, self.now_ms());
self.play_status = SpircPlayStatus::Playing {
nominal_start_time: self.now_ms() - position_ms as i64,
preloading_of_next_track_triggered,
};
}
SpircPlayStatus::LoadingPause { position_ms } => {
self.player.play();
self.play_status = SpircPlayStatus::LoadingPlay { position_ms };
}
_ => return,
}
// Synchronize the volume from the mixer. This is useful on
// systems that can switch sources from and back to librespot.
let current_volume = self.mixer.volume();
self.set_volume(current_volume);
}
fn handle_play_pause(&mut self) {
match self.play_status {
SpircPlayStatus::Paused { .. } | SpircPlayStatus::LoadingPause { .. } => {
self.handle_play()
}
SpircPlayStatus::Playing { .. } | SpircPlayStatus::LoadingPlay { .. } => {
self.handle_pause()
}
_ => (),
}
}
fn handle_pause(&mut self) {
match self.play_status {
SpircPlayStatus::Playing {
nominal_start_time,
preloading_of_next_track_triggered,
} => {
self.player.pause();
let position_ms = (self.now_ms() - nominal_start_time) as u32;
self.connect_state
.update_position(position_ms, self.now_ms());
self.play_status = SpircPlayStatus::Paused {
position_ms,
preloading_of_next_track_triggered,
};
}
SpircPlayStatus::LoadingPlay { position_ms } => {
self.player.pause();
self.play_status = SpircPlayStatus::LoadingPause { position_ms };
}
_ => (),
}
}
fn handle_seek(&mut self, position_ms: u32) {
let duration = self.connect_state.player().duration;
if i64::from(position_ms) > duration {
warn!("tried to seek to {position_ms}ms of {duration}ms");
return;
}
self.connect_state
.update_position(position_ms, self.now_ms());
self.player.seek(position_ms);
let now = self.now_ms();
match self.play_status {
SpircPlayStatus::Stopped => (),
SpircPlayStatus::LoadingPause {
position_ms: ref mut position,
}
| SpircPlayStatus::LoadingPlay {
position_ms: ref mut position,
}
| SpircPlayStatus::Paused {
position_ms: ref mut position,
..
} => *position = position_ms,
SpircPlayStatus::Playing {
ref mut nominal_start_time,
..
} => *nominal_start_time = now - position_ms as i64,
};
}
fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> {
self.player.emit_shuffle_changed_event(shuffle);
self.connect_state.handle_shuffle(shuffle)
}
fn handle_repeat_context(&mut self, repeat: bool) -> Result<(), Error> {
self.player
.emit_repeat_changed_event(repeat, self.connect_state.repeat_track());
self.connect_state.handle_set_repeat_context(repeat)
}
fn handle_repeat_track(&mut self, repeat: bool) {
self.player
.emit_repeat_changed_event(self.connect_state.repeat_context(), repeat);
self.connect_state.set_repeat_track(repeat);
}
fn handle_preload_next_track(&mut self) {
// Requests the player thread to preload the next track
match self.play_status {
SpircPlayStatus::Paused {
ref mut preloading_of_next_track_triggered,
..
}
| SpircPlayStatus::Playing {
ref mut preloading_of_next_track_triggered,
..
} => {
*preloading_of_next_track_triggered = true;
}
_ => (),
}
if let Some(track_id) = self.connect_state.preview_next_track() {
self.player.preload(track_id);
}
}
// Mark unavailable tracks so we can skip them later
fn handle_unavailable(&mut self, track_id: SpotifyId) -> Result<(), Error> {
self.connect_state.mark_unavailable(track_id)?;
self.handle_preload_next_track();
Ok(())
}
fn add_autoplay_resolving_when_required(&mut self) {
let require_load_new = !self
.connect_state
.has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD))
&& self.session.autoplay();
if !require_load_new {
return;
}
let current_context = self.connect_state.context_uri();
let fallback = self.connect_state.current_track(|t| &t.uri);
let has_tracks = self
.connect_state
.get_context(ContextType::Autoplay)
.map(|c| !c.tracks.is_empty())
.unwrap_or_default();
let resolve = ResolveContext::from_uri(
current_context,
fallback,
ContextType::Autoplay,
if has_tracks {
ContextAction::Append
} else {
ContextAction::Replace
},
);
self.context_resolver.add(resolve);
}
fn handle_next(&mut self, track_uri: Option<String>) -> Result<(), Error> {
let continue_playing = self.connect_state.is_playing();
let current_uri = self.connect_state.current_track(|t| &t.uri);
let mut has_next_track =
matches!(track_uri, Some(ref track_uri) if current_uri == track_uri);
if !has_next_track {
has_next_track = loop {
let index = self.connect_state.next_track()?;
let current_uri = self.connect_state.current_track(|t| &t.uri);
if matches!(track_uri, Some(ref track_uri) if current_uri != track_uri) {
continue;
} else {
break index.is_some();
}
};
};
if has_next_track {
self.add_autoplay_resolving_when_required();
self.load_track(continue_playing, 0)
} else {
info!("Not playing next track because there are no more tracks left in queue.");
self.handle_stop();
Ok(())
}
}
fn handle_prev(&mut self) -> Result<(), Error> {
// Previous behaves differently based on the position
// Under 3s it goes to the previous song (starts playing)
// Over 3s it seeks to zero (retains previous play status)
if self.position() < 3000 {
let repeat_context = self.connect_state.repeat_context();
match self.connect_state.prev_track()? {
None if repeat_context => self.connect_state.reset_playback_to_position(None)?,
None => {
self.connect_state.reset_playback_to_position(None)?;
self.handle_stop()
}
Some(_) => self.load_track(self.connect_state.is_playing(), 0)?,
}
} else {
self.handle_seek(0);
}
Ok(())
}
fn handle_volume_up(&mut self) {
let volume = (self.connect_state.device_info().volume as u16)
.saturating_add(self.connect_state.volume_step_size);
self.set_volume(volume);
}
fn handle_volume_down(&mut self) {
let volume = (self.connect_state.device_info().volume as u16)
.saturating_sub(self.connect_state.volume_step_size);
self.set_volume(volume);
}
fn handle_playlist_modification(
&mut self,
playlist_modification_info: PlaylistModificationInfo,
) -> Result<(), Error> {
let uri = playlist_modification_info
.uri
.ok_or(SpircError::NoUri("playlist modification"))?;
let uri = String::from_utf8(uri)?;
if self.connect_state.context_uri() != &uri {
debug!("ignoring playlist modification update for playlist <{uri}>, because it isn't the current context");
return Ok(());
}
debug!("playlist modification for current context: {uri}");
self.context_resolver.add(ResolveContext::from_uri(
uri,
self.connect_state.current_track(|t| &t.uri),
ContextType::Default,
ContextAction::Replace,
));
Ok(())
}
fn handle_session_update(&mut self, mut session_update: SessionUpdate) {
let reason = session_update.reason.enum_value();
let mut session = match session_update.session.take() {
None => return,
Some(session) => session,
};
let active_device = session.host_active_device_id.take();
if matches!(active_device, Some(ref device) if device == self.session.device_id()) {
info!(
"session update: <{:?}> for self, current session_id {}, new session_id {}",
reason,
self.session.session_id(),
session.session_id
);
if self.session.session_id() != session.session_id {
self.session.set_session_id(&session.session_id);
self.connect_state.set_session_id(session.session_id);
}
} else {
debug!("session update: <{reason:?}> from active session host: <{active_device:?}>");
}
// this seems to be used for jams or handling the current session_id
//
// handling this event was intended to keep the playback when other clients (primarily
// mobile) connects, otherwise they would steel the current playback when there was no
// session_id provided on the initial PutStateReason::NEW_DEVICE state update
//
// by generating an initial session_id from the get-go we prevent that behavior and
// currently don't need to handle this event, might still be useful for later "jam" support
}
fn position(&mut self) -> u32 {
match self.play_status {
SpircPlayStatus::Stopped => 0,
SpircPlayStatus::LoadingPlay { position_ms }
| SpircPlayStatus::LoadingPause { position_ms }
| SpircPlayStatus::Paused { position_ms, .. } => position_ms,
SpircPlayStatus::Playing {
nominal_start_time, ..
} => (self.now_ms() - nominal_start_time) as u32,
}
}
fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> {
if self.connect_state.current_track(MessageField::is_none) {
debug!("current track is none, stopping playback");
self.handle_stop();
return Ok(());
}
let current_uri = self.connect_state.current_track(|t| &t.uri);
let id = SpotifyId::from_uri(current_uri)?;
self.player.load(id, start_playing, position_ms);
self.connect_state
.update_position(position_ms, self.now_ms());
if start_playing {
self.play_status = SpircPlayStatus::LoadingPlay { position_ms };
} else {
self.play_status = SpircPlayStatus::LoadingPause { position_ms };
}
self.connect_state.set_status(&self.play_status);
Ok(())
}
async fn notify(&mut self) -> Result<(), Error> {
self.connect_state.set_status(&self.play_status);
if self.connect_state.is_playing() {
self.connect_state
.update_position_in_relation(self.now_ms());
}
self.connect_state.set_now(self.now_ms() as u64);
self.connect_state
.send_state(&self.session)
.await
.map(|_| ())
}
fn set_volume(&mut self, volume: u16) {
debug!("SpircTask::set_volume({})", volume);
let old_volume = self.connect_state.device_info().volume;
let new_volume = volume as u32;
if old_volume != new_volume || self.mixer.volume() != volume {
self.update_volume = true;
self.connect_state.set_volume(new_volume);
self.mixer.set_volume(volume);
if let Some(cache) = self.session.cache() {
cache.save_volume(volume)
}
if self.connect_state.is_active() {
self.player.emit_volume_changed_event(volume);
}
}
}
}
impl Drop for SpircTask {
fn drop(&mut self) {
debug!("drop Spirc[{}]", self.spirc_id);
}
}