mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Merge pull request #1053 from JasonLG1979/connect-event
Major events overhaul
This commit is contained in:
commit
ccd501d22e
17 changed files with 1239 additions and 639 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -79,9 +79,20 @@ https://github.com/librespot-org/librespot
|
||||||
disabled such content. Applications that use librespot as a library without
|
disabled such content. Applications that use librespot as a library without
|
||||||
Connect should use the 'filter-explicit-content' user attribute in the session.
|
Connect should use the 'filter-explicit-content' user attribute in the session.
|
||||||
- [metadata] All metadata fields in the protobufs are now exposed (breaking)
|
- [metadata] All metadata fields in the protobufs are now exposed (breaking)
|
||||||
|
- [connect] Add session events
|
||||||
|
- [playback] Add metadata support via a `TrackChanged` event
|
||||||
|
- [main] Add all player events to `player_event_handler.rs`
|
||||||
|
- [contrib] Add `event_handler_example.py`
|
||||||
|
- [connect] Add `repeat`, `set_position_ms` and `set_volume` to `spirc.rs`
|
||||||
|
- [main] Add an event worker thread that runs async to the main thread(s) but sync to itself to prevent potential data races for event consumers
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- [connect] Set `PlayStatus` to the correct value when Player is loading to avoid blanking out the controls when `self.play_status` is `LoadingPlay` or `LoadingPause` in `spirc.rs`
|
||||||
|
- [connect] Handle attempts to play local files better by basically ignoring attempts to load them in `handle_remote_update` in `spirc.rs`
|
||||||
|
- [playback] Handle invalid track start positions by just starting the track from the beginning
|
||||||
|
- [playback, connect] Clean up and de-noise events and event firing
|
||||||
|
- [playback] Handle disappearing and invalid devices better
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- [main] `autoplay` is no longer a command-line option. Instead, librespot now
|
- [main] `autoplay` is no longer a command-line option. Instead, librespot now
|
||||||
|
|
|
@ -44,6 +44,8 @@ use crate::{
|
||||||
pub enum SpircError {
|
pub enum SpircError {
|
||||||
#[error("response payload empty")]
|
#[error("response payload empty")]
|
||||||
NoData,
|
NoData,
|
||||||
|
#[error("playback of local files is not supported")]
|
||||||
|
UnsupportedLocalPlayBack,
|
||||||
#[error("message addressed at another ident: {0}")]
|
#[error("message addressed at another ident: {0}")]
|
||||||
Ident(String),
|
Ident(String),
|
||||||
#[error("message pushed for another URI")]
|
#[error("message pushed for another URI")]
|
||||||
|
@ -52,10 +54,10 @@ pub enum SpircError {
|
||||||
|
|
||||||
impl From<SpircError> for Error {
|
impl From<SpircError> for Error {
|
||||||
fn from(err: SpircError) -> Self {
|
fn from(err: SpircError) -> Self {
|
||||||
|
use SpircError::*;
|
||||||
match err {
|
match err {
|
||||||
SpircError::NoData => Error::unavailable(err),
|
NoData | UnsupportedLocalPlayBack => Error::unavailable(err),
|
||||||
SpircError::Ident(_) => Error::aborted(err),
|
Ident(_) | InvalidUri(_) => Error::aborted(err),
|
||||||
SpircError::InvalidUri(_) => Error::aborted(err),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -113,6 +115,7 @@ struct SpircTask {
|
||||||
|
|
||||||
static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum SpircCommand {
|
pub enum SpircCommand {
|
||||||
Play,
|
Play,
|
||||||
PlayPause,
|
PlayPause,
|
||||||
|
@ -122,7 +125,11 @@ pub enum SpircCommand {
|
||||||
VolumeUp,
|
VolumeUp,
|
||||||
VolumeDown,
|
VolumeDown,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
Shuffle,
|
Shuffle(bool),
|
||||||
|
Repeat(bool),
|
||||||
|
Disconnect,
|
||||||
|
SetPosition(u32),
|
||||||
|
SetVolume(u16),
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTEXT_TRACKS_HISTORY: usize = 10;
|
const CONTEXT_TRACKS_HISTORY: usize = 10;
|
||||||
|
@ -243,10 +250,8 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState {
|
||||||
msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes);
|
msg.set_typ(protocol::spirc::CapabilityType::kSupportedTypes);
|
||||||
{
|
{
|
||||||
let repeated = msg.mut_stringValue();
|
let repeated = msg.mut_stringValue();
|
||||||
repeated.push(::std::convert::Into::into("audio/local"));
|
|
||||||
repeated.push(::std::convert::Into::into("audio/track"));
|
repeated.push(::std::convert::Into::into("audio/track"));
|
||||||
repeated.push(::std::convert::Into::into("audio/episode"));
|
repeated.push(::std::convert::Into::into("audio/episode"));
|
||||||
repeated.push(::std::convert::Into::into("local"));
|
|
||||||
repeated.push(::std::convert::Into::into("track"))
|
repeated.push(::std::convert::Into::into("track"))
|
||||||
};
|
};
|
||||||
msg
|
msg
|
||||||
|
@ -416,8 +421,20 @@ impl Spirc {
|
||||||
pub fn shutdown(&self) -> Result<(), Error> {
|
pub fn shutdown(&self) -> Result<(), Error> {
|
||||||
Ok(self.commands.send(SpircCommand::Shutdown)?)
|
Ok(self.commands.send(SpircCommand::Shutdown)?)
|
||||||
}
|
}
|
||||||
pub fn shuffle(&self) -> Result<(), Error> {
|
pub fn shuffle(&self, shuffle: bool) -> Result<(), Error> {
|
||||||
Ok(self.commands.send(SpircCommand::Shuffle)?)
|
Ok(self.commands.send(SpircCommand::Shuffle(shuffle))?)
|
||||||
|
}
|
||||||
|
pub fn repeat(&self, repeat: bool) -> Result<(), Error> {
|
||||||
|
Ok(self.commands.send(SpircCommand::Repeat(repeat))?)
|
||||||
|
}
|
||||||
|
pub fn set_volume(&self, volume: u16) -> Result<(), Error> {
|
||||||
|
Ok(self.commands.send(SpircCommand::SetVolume(volume))?)
|
||||||
|
}
|
||||||
|
pub fn set_position_ms(&self, position_ms: u32) -> Result<(), Error> {
|
||||||
|
Ok(self.commands.send(SpircCommand::SetPosition(position_ms))?)
|
||||||
|
}
|
||||||
|
pub fn disconnect(&self) -> Result<(), Error> {
|
||||||
|
Ok(self.commands.send(SpircCommand::Disconnect)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -552,76 +569,71 @@ impl SpircTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> {
|
fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> {
|
||||||
let active = self.device.get_is_active();
|
if matches!(cmd, SpircCommand::Shutdown) {
|
||||||
match cmd {
|
trace!("Received SpircCommand::Shutdown");
|
||||||
SpircCommand::Play => {
|
|
||||||
if active {
|
|
||||||
self.handle_play();
|
|
||||||
self.notify(None)
|
|
||||||
} else {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypePlay).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SpircCommand::PlayPause => {
|
|
||||||
if active {
|
|
||||||
self.handle_play_pause();
|
|
||||||
self.notify(None)
|
|
||||||
} else {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypePlayPause).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SpircCommand::Pause => {
|
|
||||||
if active {
|
|
||||||
self.handle_pause();
|
|
||||||
self.notify(None)
|
|
||||||
} else {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypePause).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SpircCommand::Prev => {
|
|
||||||
if active {
|
|
||||||
self.handle_prev();
|
|
||||||
self.notify(None)
|
|
||||||
} else {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypePrev).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SpircCommand::Next => {
|
|
||||||
if active {
|
|
||||||
self.handle_next();
|
|
||||||
self.notify(None)
|
|
||||||
} else {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypeNext).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SpircCommand::VolumeUp => {
|
|
||||||
if active {
|
|
||||||
self.handle_volume_up();
|
|
||||||
self.notify(None)
|
|
||||||
} else {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypeVolumeUp).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SpircCommand::VolumeDown => {
|
|
||||||
if active {
|
|
||||||
self.handle_volume_down();
|
|
||||||
self.notify(None)
|
|
||||||
} else {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypeVolumeDown).send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SpircCommand::Shutdown => {
|
|
||||||
CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?;
|
CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?;
|
||||||
self.player.stop();
|
self.handle_disconnect();
|
||||||
self.shutdown = true;
|
self.shutdown = true;
|
||||||
if let Some(rx) = self.commands.as_mut() {
|
if let Some(rx) = self.commands.as_mut() {
|
||||||
rx.close()
|
rx.close()
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else if self.device.get_is_active() {
|
||||||
|
trace!("Received SpircCommand::{:?}", cmd);
|
||||||
|
match cmd {
|
||||||
|
SpircCommand::Play => {
|
||||||
|
self.handle_play();
|
||||||
|
self.notify(None)
|
||||||
}
|
}
|
||||||
SpircCommand::Shuffle => {
|
SpircCommand::PlayPause => {
|
||||||
CommandSender::new(self, MessageType::kMessageTypeShuffle).send()
|
self.handle_play_pause();
|
||||||
|
self.notify(None)
|
||||||
}
|
}
|
||||||
|
SpircCommand::Pause => {
|
||||||
|
self.handle_pause();
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::Prev => {
|
||||||
|
self.handle_prev();
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::Next => {
|
||||||
|
self.handle_next();
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::VolumeUp => {
|
||||||
|
self.handle_volume_up();
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::VolumeDown => {
|
||||||
|
self.handle_volume_down();
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::Disconnect => {
|
||||||
|
self.handle_disconnect();
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::Shuffle(shuffle) => {
|
||||||
|
self.state.set_shuffle(shuffle);
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::Repeat(repeat) => {
|
||||||
|
self.state.set_repeat(repeat);
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::SetPosition(position) => {
|
||||||
|
self.handle_seek(position);
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
SpircCommand::SetVolume(volume) => {
|
||||||
|
self.set_volume(volume);
|
||||||
|
self.notify(None)
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("SpircCommand::{:?} will be ignored while Not Active", cmd);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -635,11 +647,28 @@ impl SpircTask {
|
||||||
match event {
|
match event {
|
||||||
PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track(),
|
PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track(),
|
||||||
PlayerEvent::Loading { .. } => {
|
PlayerEvent::Loading { .. } => {
|
||||||
trace!("==> kPlayStatusLoading");
|
match self.play_status {
|
||||||
|
SpircPlayStatus::LoadingPlay { position_ms } => {
|
||||||
|
self.update_state_position(position_ms);
|
||||||
|
self.state.set_status(PlayStatus::kPlayStatusPlay);
|
||||||
|
trace!("==> kPlayStatusPlay");
|
||||||
|
}
|
||||||
|
SpircPlayStatus::LoadingPause { position_ms } => {
|
||||||
|
self.update_state_position(position_ms);
|
||||||
|
self.state.set_status(PlayStatus::kPlayStatusPause);
|
||||||
|
trace!("==> kPlayStatusPause");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
self.state.set_status(PlayStatus::kPlayStatusLoading);
|
self.state.set_status(PlayStatus::kPlayStatusLoading);
|
||||||
|
self.update_state_position(0);
|
||||||
|
trace!("==> kPlayStatusLoading");
|
||||||
|
}
|
||||||
|
}
|
||||||
self.notify(None)
|
self.notify(None)
|
||||||
}
|
}
|
||||||
PlayerEvent::Playing { position_ms, .. } => {
|
PlayerEvent::Playing { position_ms, .. }
|
||||||
|
| PlayerEvent::PositionCorrection { position_ms, .. }
|
||||||
|
| PlayerEvent::Seeked { position_ms, .. } => {
|
||||||
trace!("==> kPlayStatusPlay");
|
trace!("==> kPlayStatusPlay");
|
||||||
let new_nominal_start_time = self.now_ms() - position_ms as i64;
|
let new_nominal_start_time = self.now_ms() - position_ms as i64;
|
||||||
match self.play_status {
|
match self.play_status {
|
||||||
|
@ -674,17 +703,14 @@ impl SpircTask {
|
||||||
} => {
|
} => {
|
||||||
trace!("==> kPlayStatusPause");
|
trace!("==> kPlayStatusPause");
|
||||||
match self.play_status {
|
match self.play_status {
|
||||||
SpircPlayStatus::Paused {
|
SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => {
|
||||||
ref mut position_ms,
|
self.state.set_status(PlayStatus::kPlayStatusPause);
|
||||||
..
|
|
||||||
} => {
|
|
||||||
if *position_ms != new_position_ms {
|
|
||||||
*position_ms = new_position_ms;
|
|
||||||
self.update_state_position(new_position_ms);
|
self.update_state_position(new_position_ms);
|
||||||
|
self.play_status = SpircPlayStatus::Paused {
|
||||||
|
position_ms: new_position_ms,
|
||||||
|
preloading_of_next_track_triggered: false,
|
||||||
|
};
|
||||||
self.notify(None)
|
self.notify(None)
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SpircPlayStatus::LoadingPlay { .. }
|
SpircPlayStatus::LoadingPlay { .. }
|
||||||
| SpircPlayStatus::LoadingPause { .. } => {
|
| SpircPlayStatus::LoadingPause { .. } => {
|
||||||
|
@ -762,7 +788,13 @@ impl SpircTask {
|
||||||
);
|
);
|
||||||
|
|
||||||
if key == "filter-explicit-content" && new_value == "1" {
|
if key == "filter-explicit-content" && new_value == "1" {
|
||||||
self.player.skip_explicit_content();
|
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"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
trace!(
|
trace!(
|
||||||
|
@ -785,13 +817,31 @@ impl SpircTask {
|
||||||
return Err(SpircError::Ident(ident.to_string()).into());
|
return Err(SpircError::Ident(ident.to_string()).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let old_client_id = self.session.client_id();
|
||||||
|
|
||||||
for entry in update.get_device_state().get_metadata().iter() {
|
for entry in update.get_device_state().get_metadata().iter() {
|
||||||
if entry.get_field_type() == "client_id" {
|
match entry.get_field_type() {
|
||||||
self.session.set_client_id(entry.get_metadata());
|
"client-id" => self.session.set_client_id(entry.get_metadata()),
|
||||||
break;
|
"brand_display_name" => self.session.set_client_brand_name(entry.get_metadata()),
|
||||||
|
"model_display_name" => self.session.set_client_model_name(entry.get_metadata()),
|
||||||
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.session
|
||||||
|
.set_client_name(update.get_device_state().get_name());
|
||||||
|
|
||||||
|
let new_client_id = self.session.client_id();
|
||||||
|
|
||||||
|
if self.device.get_is_active() && new_client_id != old_client_id {
|
||||||
|
self.player.emit_session_client_changed_event(
|
||||||
|
new_client_id,
|
||||||
|
self.session.client_name(),
|
||||||
|
self.session.client_brand_name(),
|
||||||
|
self.session.client_model_name(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
match update.get_typ() {
|
match update.get_typ() {
|
||||||
MessageType::kMessageTypeHello => self.notify(Some(ident)),
|
MessageType::kMessageTypeHello => self.notify(Some(ident)),
|
||||||
|
|
||||||
|
@ -800,6 +850,40 @@ impl SpircTask {
|
||||||
let now = self.now_ms();
|
let now = self.now_ms();
|
||||||
self.device.set_is_active(true);
|
self.device.set_is_active(true);
|
||||||
self.device.set_became_active_at(now);
|
self.device.set_became_active_at(now);
|
||||||
|
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.device.get_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.state.get_shuffle());
|
||||||
|
|
||||||
|
self.player
|
||||||
|
.emit_repeat_changed_event(self.state.get_repeat());
|
||||||
|
}
|
||||||
|
|
||||||
|
let context_uri = update.get_state().get_context_uri().to_owned();
|
||||||
|
|
||||||
|
// completely ignore local playback.
|
||||||
|
if context_uri.starts_with("spotify:local-files") {
|
||||||
|
self.notify(None)?;
|
||||||
|
return Err(SpircError::UnsupportedLocalPlayBack.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.update_tracks(&update);
|
self.update_tracks(&update);
|
||||||
|
@ -852,12 +936,17 @@ impl SpircTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageType::kMessageTypeRepeat => {
|
MessageType::kMessageTypeRepeat => {
|
||||||
self.state.set_repeat(update.get_state().get_repeat());
|
let repeat = update.get_state().get_repeat();
|
||||||
|
self.state.set_repeat(repeat);
|
||||||
|
|
||||||
|
self.player.emit_repeat_changed_event(repeat);
|
||||||
|
|
||||||
self.notify(None)
|
self.notify(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageType::kMessageTypeShuffle => {
|
MessageType::kMessageTypeShuffle => {
|
||||||
self.state.set_shuffle(update.get_state().get_shuffle());
|
let shuffle = update.get_state().get_shuffle();
|
||||||
|
self.state.set_shuffle(shuffle);
|
||||||
if self.state.get_shuffle() {
|
if self.state.get_shuffle() {
|
||||||
let current_index = self.state.get_playing_track_index();
|
let current_index = self.state.get_playing_track_index();
|
||||||
let tracks = self.state.mut_track();
|
let tracks = self.state.mut_track();
|
||||||
|
@ -873,6 +962,9 @@ impl SpircTask {
|
||||||
let context = self.state.get_context_uri();
|
let context = self.state.get_context_uri();
|
||||||
debug!("{:?}", context);
|
debug!("{:?}", context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.player.emit_shuffle_changed_event(shuffle);
|
||||||
|
|
||||||
self.notify(None)
|
self.notify(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -882,6 +974,14 @@ impl SpircTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageType::kMessageTypeReplace => {
|
MessageType::kMessageTypeReplace => {
|
||||||
|
let context_uri = update.get_state().get_context_uri().to_owned();
|
||||||
|
|
||||||
|
// completely ignore local playback.
|
||||||
|
if context_uri.starts_with("spotify:local-files") {
|
||||||
|
self.notify(None)?;
|
||||||
|
return Err(SpircError::UnsupportedLocalPlayBack.into());
|
||||||
|
}
|
||||||
|
|
||||||
self.update_tracks(&update);
|
self.update_tracks(&update);
|
||||||
|
|
||||||
if let SpircPlayStatus::Playing {
|
if let SpircPlayStatus::Playing {
|
||||||
|
@ -915,16 +1015,23 @@ impl SpircTask {
|
||||||
&& self.device.get_became_active_at()
|
&& self.device.get_became_active_at()
|
||||||
<= update.get_device_state().get_became_active_at()
|
<= update.get_device_state().get_became_active_at()
|
||||||
{
|
{
|
||||||
self.device.set_is_active(false);
|
self.handle_disconnect();
|
||||||
self.handle_stop();
|
|
||||||
}
|
}
|
||||||
Ok(())
|
self.notify(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_disconnect(&mut self) {
|
||||||
|
self.device.set_is_active(false);
|
||||||
|
self.handle_stop();
|
||||||
|
|
||||||
|
self.player
|
||||||
|
.emit_session_disconnected_event(self.session.connection_id(), self.session.username());
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_stop(&mut self) {
|
fn handle_stop(&mut self) {
|
||||||
self.player.stop();
|
self.player.stop();
|
||||||
}
|
}
|
||||||
|
@ -1100,17 +1207,7 @@ impl SpircTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
if new_index >= tracks_len {
|
if new_index >= tracks_len {
|
||||||
let autoplay = self
|
if self.session.autoplay() {
|
||||||
.session
|
|
||||||
.get_user_attribute("autoplay")
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
warn!(
|
|
||||||
"Unable to get autoplay user attribute. Continuing with autoplay disabled."
|
|
||||||
);
|
|
||||||
"0".into()
|
|
||||||
});
|
|
||||||
|
|
||||||
if autoplay == "1" {
|
|
||||||
// Extend the playlist
|
// Extend the playlist
|
||||||
debug!("Extending playlist <{}>", context_uri);
|
debug!("Extending playlist <{}>", context_uri);
|
||||||
self.update_tracks_from_context();
|
self.update_tracks_from_context();
|
||||||
|
@ -1282,13 +1379,11 @@ impl SpircTask {
|
||||||
|| context_uri.starts_with("spotify:dailymix:")
|
|| context_uri.starts_with("spotify:dailymix:")
|
||||||
{
|
{
|
||||||
self.context_fut = self.resolve_station(&context_uri);
|
self.context_fut = self.resolve_station(&context_uri);
|
||||||
} else if let Some(autoplay) = self.session.get_user_attribute("autoplay") {
|
} else if self.session.autoplay() {
|
||||||
if &autoplay == "1" {
|
|
||||||
info!("Fetching autoplay context uri");
|
info!("Fetching autoplay context uri");
|
||||||
// Get autoplay_station_uri for regular playlists
|
// Get autoplay_station_uri for regular playlists
|
||||||
self.autoplay_fut = self.resolve_autoplay_uri(&context_uri);
|
self.autoplay_fut = self.resolve_autoplay_uri(&context_uri);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.player
|
self.player
|
||||||
.set_auto_normalise_as_album(context_uri.starts_with("spotify:album:"));
|
.set_auto_normalise_as_album(context_uri.starts_with("spotify:album:"));
|
||||||
|
@ -1422,12 +1517,18 @@ impl SpircTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_volume(&mut self, volume: u16) {
|
fn set_volume(&mut self, volume: u16) {
|
||||||
self.device.set_volume(volume as u32);
|
let old_volume = self.device.get_volume();
|
||||||
|
let new_volume = volume as u32;
|
||||||
|
if old_volume != new_volume {
|
||||||
|
self.device.set_volume(new_volume);
|
||||||
self.mixer.set_volume(volume);
|
self.mixer.set_volume(volume);
|
||||||
if let Some(cache) = self.session.cache() {
|
if let Some(cache) = self.session.cache() {
|
||||||
cache.save_volume(volume)
|
cache.save_volume(volume)
|
||||||
}
|
}
|
||||||
self.player.emit_volume_set_event(volume);
|
if self.device.get_is_active() {
|
||||||
|
self.player.emit_volume_changed_event(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
77
contrib/event_handler_example.py
Normal file
77
contrib/event_handler_example.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
player_event = os.getenv('PLAYER_EVENT')
|
||||||
|
|
||||||
|
json_dict = {
|
||||||
|
'event_time': str(datetime.now()),
|
||||||
|
'event': player_event,
|
||||||
|
}
|
||||||
|
|
||||||
|
if player_event in ('session_connected', 'session_disconnected'):
|
||||||
|
json_dict['user_name'] = os.environ['USER_NAME']
|
||||||
|
json_dict['connection_id'] = os.environ['CONNECTION_ID']
|
||||||
|
|
||||||
|
elif player_event == 'session_client_changed':
|
||||||
|
json_dict['client_id'] = os.environ['CLIENT_ID']
|
||||||
|
json_dict['client_name'] = os.environ['CLIENT_NAME']
|
||||||
|
json_dict['client_brand_name'] = os.environ['CLIENT_BRAND_NAME']
|
||||||
|
json_dict['client_model_name'] = os.environ['CLIENT_MODEL_NAME']
|
||||||
|
|
||||||
|
elif player_event == 'shuffle_changed':
|
||||||
|
json_dict['shuffle'] = os.environ['SHUFFLE']
|
||||||
|
|
||||||
|
elif player_event == 'repeat_changed':
|
||||||
|
json_dict['repeat'] = os.environ['REPEAT']
|
||||||
|
|
||||||
|
elif player_event == 'auto_play_changed':
|
||||||
|
json_dict['auto_play'] = os.environ['AUTO_PLAY']
|
||||||
|
|
||||||
|
elif player_event == 'filter_explicit_content_changed':
|
||||||
|
json_dict['filter'] = os.environ['FILTER']
|
||||||
|
|
||||||
|
elif player_event == 'volume_changed':
|
||||||
|
json_dict['volume'] = os.environ['VOLUME']
|
||||||
|
|
||||||
|
elif player_event in ('seeked', 'position_correction', 'playing', 'paused'):
|
||||||
|
json_dict['track_id'] = os.environ['TRACK_ID']
|
||||||
|
json_dict['position_ms'] = os.environ['POSITION_MS']
|
||||||
|
|
||||||
|
elif player_event in ('unavailable', 'end_of_track', 'preload_next', 'preloading', 'loading', 'stopped'):
|
||||||
|
json_dict['track_id'] = os.environ['TRACK_ID']
|
||||||
|
|
||||||
|
elif player_event == 'track_changed':
|
||||||
|
common_metadata_fields = {}
|
||||||
|
item_type = os.environ['ITEM_TYPE']
|
||||||
|
common_metadata_fields['item_type'] = item_type
|
||||||
|
common_metadata_fields['track_id'] = os.environ['TRACK_ID']
|
||||||
|
common_metadata_fields['uri'] = os.environ['URI']
|
||||||
|
common_metadata_fields['name'] = os.environ['NAME']
|
||||||
|
common_metadata_fields['duration_ms'] = os.environ['DURATION_MS']
|
||||||
|
common_metadata_fields['is_explicit'] = os.environ['IS_EXPLICIT']
|
||||||
|
common_metadata_fields['language'] = os.environ['LANGUAGE'].split('\n')
|
||||||
|
common_metadata_fields['covers'] = os.environ['COVERS'].split('\n')
|
||||||
|
json_dict['common_metadata_fields'] = common_metadata_fields
|
||||||
|
|
||||||
|
|
||||||
|
if item_type == 'Track':
|
||||||
|
track_metadata_fields = {}
|
||||||
|
track_metadata_fields['number'] = os.environ['NUMBER']
|
||||||
|
track_metadata_fields['disc_number'] = os.environ['DISC_NUMBER']
|
||||||
|
track_metadata_fields['popularity'] = os.environ['POPULARITY']
|
||||||
|
track_metadata_fields['album'] = os.environ['ALBUM']
|
||||||
|
track_metadata_fields['artists'] = os.environ['ARTISTS'].split('\n')
|
||||||
|
track_metadata_fields['album_artists'] = os.environ['ALBUM_ARTISTS'].split('\n')
|
||||||
|
json_dict['track_metadata_fields'] = track_metadata_fields
|
||||||
|
|
||||||
|
elif item_type == 'Episode':
|
||||||
|
episode_metadata_fields = {}
|
||||||
|
episode_metadata_fields['show_name'] = os.environ['SHOW_NAME']
|
||||||
|
publish_time = datetime.utcfromtimestamp(int(os.environ['PUBLISH_TIME'])).strftime('%Y-%m-%d')
|
||||||
|
episode_metadata_fields['publish_time'] = publish_time
|
||||||
|
episode_metadata_fields['description'] = os.environ['DESCRIPTION']
|
||||||
|
json_dict['episode_metadata_fields'] = episode_metadata_fields
|
||||||
|
|
||||||
|
print(json.dumps(json_dict, indent = 4))
|
|
@ -26,7 +26,7 @@ impl From<AuthenticationError> for Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The credentials are used to log into the Spotify API.
|
/// The credentials are used to log into the Spotify API.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|
||||||
|
|
|
@ -73,6 +73,9 @@ pub struct UserData {
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct SessionData {
|
struct SessionData {
|
||||||
client_id: String,
|
client_id: String,
|
||||||
|
client_name: String,
|
||||||
|
client_brand_name: String,
|
||||||
|
client_model_name: String,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
time_delta: i64,
|
time_delta: i64,
|
||||||
invalid: bool,
|
invalid: bool,
|
||||||
|
@ -383,6 +386,30 @@ impl Session {
|
||||||
self.0.data.write().client_id = client_id.to_owned();
|
self.0.data.write().client_id = client_id.to_owned();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn client_name(&self) -> String {
|
||||||
|
self.0.data.read().client_name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_client_name(&self, client_name: &str) {
|
||||||
|
self.0.data.write().client_name = client_name.to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_brand_name(&self) -> String {
|
||||||
|
self.0.data.read().client_brand_name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_client_brand_name(&self, client_brand_name: &str) {
|
||||||
|
self.0.data.write().client_brand_name = client_brand_name.to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_model_name(&self) -> String {
|
||||||
|
self.0.data.read().client_model_name.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_client_model_name(&self, client_model_name: &str) {
|
||||||
|
self.0.data.write().client_model_name = client_model_name.to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn connection_id(&self) -> String {
|
pub fn connection_id(&self) -> String {
|
||||||
self.0.data.read().connection_id.clone()
|
self.0.data.read().connection_id.clone()
|
||||||
}
|
}
|
||||||
|
@ -403,6 +430,20 @@ impl Session {
|
||||||
self.0.data.read().user_data.country.clone()
|
self.0.data.read().user_data.country.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn filter_explicit_content(&self) -> bool {
|
||||||
|
match self.get_user_attribute("filter-explicit-content") {
|
||||||
|
Some(value) => matches!(&*value, "1"),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn autoplay(&self) -> bool {
|
||||||
|
match self.get_user_attribute("autoplay") {
|
||||||
|
Some(value) => matches!(&*value, "1"),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_user_attribute(&self, key: &str, value: &str) -> Option<String> {
|
pub fn set_user_attribute(&self, key: &str, value: &str) -> Option<String> {
|
||||||
let mut dummy_attributes = UserAttributes::new();
|
let mut dummy_attributes = UserAttributes::new();
|
||||||
dummy_attributes.insert(key.to_owned(), value.to_owned());
|
dummy_attributes.insert(key.to_owned(), value.to_owned());
|
||||||
|
|
|
@ -36,7 +36,7 @@ async fn main() {
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut player, _) = Player::new(player_config, session, Box::new(NoOpVolume), move || {
|
let mut player = Player::new(player_config, session, Box::new(NoOpVolume), move || {
|
||||||
backend(None, audio_format)
|
backend(None, audio_format)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -97,7 +97,7 @@ impl TryFrom<&<Self as Metadata>::Message> for Album {
|
||||||
date: album.get_date().try_into()?,
|
date: album.get_date().try_into()?,
|
||||||
popularity: album.get_popularity(),
|
popularity: album.get_popularity(),
|
||||||
genres: album.get_genre().to_vec(),
|
genres: album.get_genre().to_vec(),
|
||||||
covers: album.get_cover().into(),
|
covers: album.get_cover_group().into(),
|
||||||
external_ids: album.get_external_id().into(),
|
external_ids: album.get_external_id().into(),
|
||||||
discs: album.get_disc().try_into()?,
|
discs: album.get_disc().try_into()?,
|
||||||
reviews: album.get_review().to_vec(),
|
reviews: album.get_review().to_vec(),
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
artist::ArtistsWithRole,
|
||||||
availability::{AudioItemAvailability, Availabilities, UnavailabilityReason},
|
availability::{AudioItemAvailability, Availabilities, UnavailabilityReason},
|
||||||
episode::Episode,
|
episode::Episode,
|
||||||
error::MetadataError,
|
error::MetadataError,
|
||||||
|
image::{ImageSize, Images},
|
||||||
restriction::Restrictions,
|
restriction::Restrictions,
|
||||||
track::{Track, Tracks},
|
track::{Track, Tracks},
|
||||||
|
Metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::file::AudioFiles;
|
use super::file::AudioFiles;
|
||||||
|
@ -16,37 +19,199 @@ use librespot_core::{
|
||||||
|
|
||||||
pub type AudioItemResult = Result<AudioItem, Error>;
|
pub type AudioItemResult = Result<AudioItem, Error>;
|
||||||
|
|
||||||
// A wrapper with fields the player needs
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CoverImage {
|
||||||
|
pub url: String,
|
||||||
|
pub size: ImageSize,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AudioItem {
|
pub struct AudioItem {
|
||||||
pub id: SpotifyId,
|
pub track_id: SpotifyId,
|
||||||
pub spotify_uri: String,
|
pub uri: String,
|
||||||
pub files: AudioFiles,
|
pub files: AudioFiles,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub duration: i32,
|
pub covers: Vec<CoverImage>,
|
||||||
|
pub language: Vec<String>,
|
||||||
|
pub duration_ms: u32,
|
||||||
|
pub is_explicit: bool,
|
||||||
pub availability: AudioItemAvailability,
|
pub availability: AudioItemAvailability,
|
||||||
pub alternatives: Option<Tracks>,
|
pub alternatives: Option<Tracks>,
|
||||||
pub is_explicit: bool,
|
pub unique_fields: UniqueFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum UniqueFields {
|
||||||
|
Track {
|
||||||
|
artists: ArtistsWithRole,
|
||||||
|
album: String,
|
||||||
|
album_artists: Vec<String>,
|
||||||
|
popularity: u8,
|
||||||
|
number: u32,
|
||||||
|
disc_number: u32,
|
||||||
|
},
|
||||||
|
Episode {
|
||||||
|
description: String,
|
||||||
|
publish_time: Date,
|
||||||
|
show_name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioItem {
|
impl AudioItem {
|
||||||
pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult {
|
pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult {
|
||||||
|
let image_url = session
|
||||||
|
.get_user_attribute("image-url")
|
||||||
|
.unwrap_or_else(|| String::from("https://i.scdn.co/image/{file_id}"));
|
||||||
|
|
||||||
match id.item_type {
|
match id.item_type {
|
||||||
SpotifyItemType::Track => Track::get_audio_item(session, id).await,
|
SpotifyItemType::Track => {
|
||||||
SpotifyItemType::Episode => Episode::get_audio_item(session, id).await,
|
let track = Track::get(session, &id).await?;
|
||||||
|
|
||||||
|
if track.duration <= 0 {
|
||||||
|
return Err(Error::unavailable(MetadataError::InvalidDuration(
|
||||||
|
track.duration,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if track.is_explicit && session.filter_explicit_content() {
|
||||||
|
return Err(Error::unavailable(MetadataError::ExplicitContentFiltered));
|
||||||
|
}
|
||||||
|
|
||||||
|
let track_id = track.id;
|
||||||
|
let uri = track_id.to_uri()?;
|
||||||
|
let album = track.album.name;
|
||||||
|
|
||||||
|
let album_artists = track
|
||||||
|
.album
|
||||||
|
.artists
|
||||||
|
.0
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.name)
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
|
let covers = get_covers(track.album.covers, image_url);
|
||||||
|
|
||||||
|
let alternatives = if track.alternatives.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(track.alternatives)
|
||||||
|
};
|
||||||
|
|
||||||
|
let availability = if Date::now_utc() < track.earliest_live_timestamp {
|
||||||
|
Err(UnavailabilityReason::Embargo)
|
||||||
|
} else {
|
||||||
|
available_for_user(
|
||||||
|
&session.user_data(),
|
||||||
|
&track.availability,
|
||||||
|
&track.restrictions,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let popularity = track.popularity.max(0).min(100) as u8;
|
||||||
|
let number = track.number.max(0) as u32;
|
||||||
|
let disc_number = track.disc_number.max(0) as u32;
|
||||||
|
|
||||||
|
let unique_fields = UniqueFields::Track {
|
||||||
|
artists: track.artists_with_role,
|
||||||
|
album,
|
||||||
|
album_artists,
|
||||||
|
popularity,
|
||||||
|
number,
|
||||||
|
disc_number,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
track_id,
|
||||||
|
uri,
|
||||||
|
files: track.files,
|
||||||
|
name: track.name,
|
||||||
|
covers,
|
||||||
|
language: track.language_of_performance,
|
||||||
|
duration_ms: track.duration as u32,
|
||||||
|
is_explicit: track.is_explicit,
|
||||||
|
availability,
|
||||||
|
alternatives,
|
||||||
|
unique_fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
SpotifyItemType::Episode => {
|
||||||
|
let episode = Episode::get(session, &id).await?;
|
||||||
|
|
||||||
|
if episode.duration <= 0 {
|
||||||
|
return Err(Error::unavailable(MetadataError::InvalidDuration(
|
||||||
|
episode.duration,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if episode.is_explicit && session.filter_explicit_content() {
|
||||||
|
return Err(Error::unavailable(MetadataError::ExplicitContentFiltered));
|
||||||
|
}
|
||||||
|
|
||||||
|
let track_id = episode.id;
|
||||||
|
let uri = track_id.to_uri()?;
|
||||||
|
|
||||||
|
let covers = get_covers(episode.covers, image_url);
|
||||||
|
|
||||||
|
let availability = available_for_user(
|
||||||
|
&session.user_data(),
|
||||||
|
&episode.availability,
|
||||||
|
&episode.restrictions,
|
||||||
|
);
|
||||||
|
|
||||||
|
let unique_fields = UniqueFields::Episode {
|
||||||
|
description: episode.description,
|
||||||
|
publish_time: episode.publish_time,
|
||||||
|
show_name: episode.show_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
track_id,
|
||||||
|
uri,
|
||||||
|
files: episode.audio,
|
||||||
|
name: episode.name,
|
||||||
|
covers,
|
||||||
|
language: vec![episode.language],
|
||||||
|
duration_ms: episode.duration as u32,
|
||||||
|
is_explicit: episode.is_explicit,
|
||||||
|
availability,
|
||||||
|
alternatives: None,
|
||||||
|
unique_fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
_ => Err(Error::unavailable(MetadataError::NonPlayable)),
|
_ => Err(Error::unavailable(MetadataError::NonPlayable)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
fn get_covers(covers: Images, image_url: String) -> Vec<CoverImage> {
|
||||||
pub trait InnerAudioItem {
|
let mut covers = covers;
|
||||||
async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult;
|
|
||||||
|
|
||||||
fn allowed_for_user(
|
covers.sort_by(|a, b| b.width.cmp(&a.width));
|
||||||
user_data: &UserData,
|
|
||||||
restrictions: &Restrictions,
|
covers
|
||||||
) -> AudioItemAvailability {
|
.iter()
|
||||||
|
.filter_map(|cover| {
|
||||||
|
let cover_id = cover.id.to_string();
|
||||||
|
|
||||||
|
if !cover_id.is_empty() {
|
||||||
|
let cover_image = CoverImage {
|
||||||
|
url: image_url.replace("{file_id}", &cover_id),
|
||||||
|
size: cover.size,
|
||||||
|
width: cover.width,
|
||||||
|
height: cover.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(cover_image)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_for_user(user_data: &UserData, restrictions: &Restrictions) -> AudioItemAvailability {
|
||||||
let country = &user_data.country;
|
let country = &user_data.country;
|
||||||
let user_catalogue = match user_data.attributes.get("catalogue") {
|
let user_catalogue = match user_data.attributes.get("catalogue") {
|
||||||
Some(catalogue) => catalogue,
|
Some(catalogue) => catalogue,
|
||||||
|
@ -106,8 +271,7 @@ pub trait InnerAudioItem {
|
||||||
availability: &Availabilities,
|
availability: &Availabilities,
|
||||||
restrictions: &Restrictions,
|
restrictions: &Restrictions,
|
||||||
) -> AudioItemAvailability {
|
) -> AudioItemAvailability {
|
||||||
Self::available(availability)?;
|
available(availability)?;
|
||||||
Self::allowed_for_user(user_data, restrictions)?;
|
allowed_for_user(user_data, restrictions)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -2,4 +2,4 @@ pub mod file;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
|
|
||||||
pub use file::{AudioFileFormat, AudioFiles};
|
pub use file::{AudioFileFormat, AudioFiles};
|
||||||
pub use item::AudioItem;
|
pub use item::{AudioItem, UniqueFields};
|
||||||
|
|
|
@ -5,10 +5,7 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
audio::{
|
audio::file::AudioFiles,
|
||||||
file::AudioFiles,
|
|
||||||
item::{AudioItem, AudioItemResult, InnerAudioItem},
|
|
||||||
},
|
|
||||||
availability::Availabilities,
|
availability::Availabilities,
|
||||||
content_rating::ContentRatings,
|
content_rating::ContentRatings,
|
||||||
image::Images,
|
image::Images,
|
||||||
|
@ -36,7 +33,7 @@ pub struct Episode {
|
||||||
pub covers: Images,
|
pub covers: Images,
|
||||||
pub language: String,
|
pub language: String,
|
||||||
pub is_explicit: bool,
|
pub is_explicit: bool,
|
||||||
pub show: SpotifyId,
|
pub show_name: String,
|
||||||
pub videos: VideoFiles,
|
pub videos: VideoFiles,
|
||||||
pub video_previews: VideoFiles,
|
pub video_previews: VideoFiles,
|
||||||
pub audio_previews: AudioFiles,
|
pub audio_previews: AudioFiles,
|
||||||
|
@ -57,29 +54,6 @@ pub struct Episodes(pub Vec<SpotifyId>);
|
||||||
|
|
||||||
impl_deref_wrapped!(Episodes, Vec<SpotifyId>);
|
impl_deref_wrapped!(Episodes, Vec<SpotifyId>);
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl InnerAudioItem for Episode {
|
|
||||||
async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult {
|
|
||||||
let episode = Self::get(session, &id).await?;
|
|
||||||
let availability = Self::available_for_user(
|
|
||||||
&session.user_data(),
|
|
||||||
&episode.availability,
|
|
||||||
&episode.restrictions,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(AudioItem {
|
|
||||||
id,
|
|
||||||
spotify_uri: id.to_uri()?,
|
|
||||||
files: episode.audio,
|
|
||||||
name: episode.name,
|
|
||||||
duration: episode.duration,
|
|
||||||
availability,
|
|
||||||
alternatives: None,
|
|
||||||
is_explicit: episode.is_explicit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Metadata for Episode {
|
impl Metadata for Episode {
|
||||||
type Message = protocol::metadata::Episode;
|
type Message = protocol::metadata::Episode;
|
||||||
|
@ -107,7 +81,7 @@ impl TryFrom<&<Self as Metadata>::Message> for Episode {
|
||||||
covers: episode.get_cover_image().get_image().into(),
|
covers: episode.get_cover_image().get_image().into(),
|
||||||
language: episode.get_language().to_owned(),
|
language: episode.get_language().to_owned(),
|
||||||
is_explicit: episode.get_explicit().to_owned(),
|
is_explicit: episode.get_explicit().to_owned(),
|
||||||
show: episode.get_show().try_into()?,
|
show_name: episode.get_show().get_name().to_owned(),
|
||||||
videos: episode.get_video().into(),
|
videos: episode.get_video().into(),
|
||||||
video_previews: episode.get_video_preview().into(),
|
video_previews: episode.get_video_preview().into(),
|
||||||
audio_previews: episode.get_audio_preview().into(),
|
audio_previews: episode.get_audio_preview().into(),
|
||||||
|
|
|
@ -7,4 +7,8 @@ pub enum MetadataError {
|
||||||
Empty,
|
Empty,
|
||||||
#[error("audio item is non-playable when it should be")]
|
#[error("audio item is non-playable when it should be")]
|
||||||
NonPlayable,
|
NonPlayable,
|
||||||
|
#[error("audio item duration can not be: {0}")]
|
||||||
|
InvalidDuration(i32),
|
||||||
|
#[error("track is marked as explicit, which client setting forbids")]
|
||||||
|
ExplicitContentFiltered,
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ use librespot_core::{FileId, SpotifyId};
|
||||||
|
|
||||||
use librespot_protocol as protocol;
|
use librespot_protocol as protocol;
|
||||||
use protocol::metadata::Image as ImageMessage;
|
use protocol::metadata::Image as ImageMessage;
|
||||||
|
use protocol::metadata::ImageGroup;
|
||||||
pub use protocol::metadata::Image_Size as ImageSize;
|
pub use protocol::metadata::Image_Size as ImageSize;
|
||||||
use protocol::playlist4_external::PictureSize as PictureSizeMessage;
|
use protocol::playlist4_external::PictureSize as PictureSizeMessage;
|
||||||
use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage;
|
use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage;
|
||||||
|
@ -25,6 +26,12 @@ pub struct Image {
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Images(pub Vec<Image>);
|
pub struct Images(pub Vec<Image>);
|
||||||
|
|
||||||
|
impl From<&ImageGroup> for Images {
|
||||||
|
fn from(image_group: &ImageGroup) -> Self {
|
||||||
|
Self(image_group.image.iter().map(|i| i.into()).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl_deref_wrapped!(Images, Vec<Image>);
|
impl_deref_wrapped!(Images, Vec<Image>);
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
|
@ -8,11 +8,8 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
artist::{Artists, ArtistsWithRole},
|
artist::{Artists, ArtistsWithRole},
|
||||||
audio::{
|
audio::file::AudioFiles,
|
||||||
file::AudioFiles,
|
availability::Availabilities,
|
||||||
item::{AudioItem, AudioItemResult, InnerAudioItem},
|
|
||||||
},
|
|
||||||
availability::{Availabilities, UnavailabilityReason},
|
|
||||||
content_rating::ContentRatings,
|
content_rating::ContentRatings,
|
||||||
external_id::ExternalIds,
|
external_id::ExternalIds,
|
||||||
restriction::Restrictions,
|
restriction::Restrictions,
|
||||||
|
@ -58,42 +55,6 @@ pub struct Tracks(pub Vec<SpotifyId>);
|
||||||
|
|
||||||
impl_deref_wrapped!(Tracks, Vec<SpotifyId>);
|
impl_deref_wrapped!(Tracks, Vec<SpotifyId>);
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl InnerAudioItem for Track {
|
|
||||||
async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult {
|
|
||||||
let track = Self::get(session, &id).await?;
|
|
||||||
let alternatives = {
|
|
||||||
if track.alternatives.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(track.alternatives.clone())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: check meaning of earliest_live_timestamp in
|
|
||||||
let availability = if Date::now_utc() < track.earliest_live_timestamp {
|
|
||||||
Err(UnavailabilityReason::Embargo)
|
|
||||||
} else {
|
|
||||||
Self::available_for_user(
|
|
||||||
&session.user_data(),
|
|
||||||
&track.availability,
|
|
||||||
&track.restrictions,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(AudioItem {
|
|
||||||
id,
|
|
||||||
spotify_uri: id.to_uri()?,
|
|
||||||
files: track.files,
|
|
||||||
name: track.name,
|
|
||||||
duration: track.duration,
|
|
||||||
availability,
|
|
||||||
alternatives,
|
|
||||||
is_explicit: track.is_explicit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Metadata for Track {
|
impl Metadata for Track {
|
||||||
type Message = protocol::metadata::Track;
|
type Message = protocol::metadata::Track;
|
||||||
|
|
|
@ -442,6 +442,7 @@ impl Sink for AlsaSink {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> SinkResult<()> {
|
fn stop(&mut self) -> SinkResult<()> {
|
||||||
|
if self.pcm.is_some() {
|
||||||
// Zero fill the remainder of the period buffer and
|
// Zero fill the remainder of the period buffer and
|
||||||
// write any leftover data before draining the actual PCM buffer.
|
// write any leftover data before draining the actual PCM buffer.
|
||||||
self.period_buffer.resize(self.period_buffer.capacity(), 0);
|
self.period_buffer.resize(self.period_buffer.capacity(), 0);
|
||||||
|
@ -450,6 +451,7 @@ impl Sink for AlsaSink {
|
||||||
let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?;
|
let pcm = self.pcm.take().ok_or(AlsaError::NotConnected)?;
|
||||||
|
|
||||||
pcm.drain().map_err(AlsaError::DrainFailure)?;
|
pcm.drain().map_err(AlsaError::DrainFailure)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -489,9 +491,13 @@ impl AlsaSink {
|
||||||
pub const NAME: &'static str = "alsa";
|
pub const NAME: &'static str = "alsa";
|
||||||
|
|
||||||
fn write_buf(&mut self) -> SinkResult<()> {
|
fn write_buf(&mut self) -> SinkResult<()> {
|
||||||
|
if self.pcm.is_some() {
|
||||||
|
let write_result = {
|
||||||
let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?;
|
let pcm = self.pcm.as_mut().ok_or(AlsaError::NotConnected)?;
|
||||||
|
|
||||||
if let Err(e) = pcm.io_bytes().writei(&self.period_buffer) {
|
match pcm.io_bytes().writei(&self.period_buffer) {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => {
|
||||||
// Capture and log the original error as a warning, and then try to recover.
|
// Capture and log the original error as a warning, and then try to recover.
|
||||||
// If recovery fails then forward that error back to player.
|
// If recovery fails then forward that error back to player.
|
||||||
warn!(
|
warn!(
|
||||||
|
@ -499,7 +505,15 @@ impl AlsaSink {
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
|
|
||||||
pcm.try_recover(e, false).map_err(AlsaError::OnWrite)?
|
pcm.try_recover(e, false).map_err(AlsaError::OnWrite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = write_result {
|
||||||
|
self.pcm = None;
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.period_buffer.clear();
|
self.period_buffer.clear();
|
||||||
|
|
|
@ -111,9 +111,26 @@ enum PlayerCommand {
|
||||||
Seek(u32),
|
Seek(u32),
|
||||||
AddEventSender(mpsc::UnboundedSender<PlayerEvent>),
|
AddEventSender(mpsc::UnboundedSender<PlayerEvent>),
|
||||||
SetSinkEventCallback(Option<SinkEventCallback>),
|
SetSinkEventCallback(Option<SinkEventCallback>),
|
||||||
EmitVolumeSetEvent(u16),
|
EmitVolumeChangedEvent(u16),
|
||||||
SetAutoNormaliseAsAlbum(bool),
|
SetAutoNormaliseAsAlbum(bool),
|
||||||
SkipExplicitContent(),
|
EmitSessionDisconnectedEvent {
|
||||||
|
connection_id: String,
|
||||||
|
user_name: String,
|
||||||
|
},
|
||||||
|
EmitSessionConnectedEvent {
|
||||||
|
connection_id: String,
|
||||||
|
user_name: String,
|
||||||
|
},
|
||||||
|
EmitSessionClientChangedEvent {
|
||||||
|
client_id: String,
|
||||||
|
client_name: String,
|
||||||
|
client_brand_name: String,
|
||||||
|
client_model_name: String,
|
||||||
|
},
|
||||||
|
EmitFilterExplicitContentChangedEvent(bool),
|
||||||
|
EmitShuffleChangedEvent(bool),
|
||||||
|
EmitRepeatChangedEvent(bool),
|
||||||
|
EmitAutoPlayChangedEvent(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -123,19 +140,6 @@ pub enum PlayerEvent {
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
},
|
},
|
||||||
// The player started working on playback of a track while it was in a stopped state.
|
|
||||||
// This is always immediately followed up by a "Loading" or "Playing" event.
|
|
||||||
Started {
|
|
||||||
play_request_id: u64,
|
|
||||||
track_id: SpotifyId,
|
|
||||||
position_ms: u32,
|
|
||||||
},
|
|
||||||
// Same as started but in the case that the player already had a track loaded.
|
|
||||||
// The player was either playing the loaded track or it was paused.
|
|
||||||
Changed {
|
|
||||||
old_track_id: SpotifyId,
|
|
||||||
new_track_id: SpotifyId,
|
|
||||||
},
|
|
||||||
// The player is delayed by loading a track.
|
// The player is delayed by loading a track.
|
||||||
Loading {
|
Loading {
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
|
@ -157,14 +161,12 @@ pub enum PlayerEvent {
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
position_ms: u32,
|
position_ms: u32,
|
||||||
duration_ms: u32,
|
|
||||||
},
|
},
|
||||||
// The player entered a paused state.
|
// The player entered a paused state.
|
||||||
Paused {
|
Paused {
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
position_ms: u32,
|
position_ms: u32,
|
||||||
duration_ms: u32,
|
|
||||||
},
|
},
|
||||||
// The player thinks it's a good idea to issue a preload command for the next track now.
|
// The player thinks it's a good idea to issue a preload command for the next track now.
|
||||||
// This event is intended for use within spirc.
|
// This event is intended for use within spirc.
|
||||||
|
@ -173,8 +175,7 @@ pub enum PlayerEvent {
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
},
|
},
|
||||||
// The player reached the end of a track.
|
// The player reached the end of a track.
|
||||||
// This event is intended for use within spirc. Spirc will respond by issuing another command
|
// This event is intended for use within spirc. Spirc will respond by issuing another command.
|
||||||
// which will trigger another event (e.g. Changed or Stopped)
|
|
||||||
EndOfTrack {
|
EndOfTrack {
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
|
@ -185,9 +186,48 @@ pub enum PlayerEvent {
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
},
|
},
|
||||||
// The mixer volume was set to a new level.
|
// The mixer volume was set to a new level.
|
||||||
VolumeSet {
|
VolumeChanged {
|
||||||
volume: u16,
|
volume: u16,
|
||||||
},
|
},
|
||||||
|
PositionCorrection {
|
||||||
|
play_request_id: u64,
|
||||||
|
track_id: SpotifyId,
|
||||||
|
position_ms: u32,
|
||||||
|
},
|
||||||
|
Seeked {
|
||||||
|
play_request_id: u64,
|
||||||
|
track_id: SpotifyId,
|
||||||
|
position_ms: u32,
|
||||||
|
},
|
||||||
|
TrackChanged {
|
||||||
|
audio_item: Box<AudioItem>,
|
||||||
|
},
|
||||||
|
SessionConnected {
|
||||||
|
connection_id: String,
|
||||||
|
user_name: String,
|
||||||
|
},
|
||||||
|
SessionDisconnected {
|
||||||
|
connection_id: String,
|
||||||
|
user_name: String,
|
||||||
|
},
|
||||||
|
SessionClientChanged {
|
||||||
|
client_id: String,
|
||||||
|
client_name: String,
|
||||||
|
client_brand_name: String,
|
||||||
|
client_model_name: String,
|
||||||
|
},
|
||||||
|
ShuffleChanged {
|
||||||
|
shuffle: bool,
|
||||||
|
},
|
||||||
|
RepeatChanged {
|
||||||
|
repeat: bool,
|
||||||
|
},
|
||||||
|
AutoPlayChanged {
|
||||||
|
auto_play: bool,
|
||||||
|
},
|
||||||
|
FilterExplicitContentChanged {
|
||||||
|
filter: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerEvent {
|
impl PlayerEvent {
|
||||||
|
@ -200,9 +240,6 @@ impl PlayerEvent {
|
||||||
| Unavailable {
|
| Unavailable {
|
||||||
play_request_id, ..
|
play_request_id, ..
|
||||||
}
|
}
|
||||||
| Started {
|
|
||||||
play_request_id, ..
|
|
||||||
}
|
|
||||||
| Playing {
|
| Playing {
|
||||||
play_request_id, ..
|
play_request_id, ..
|
||||||
}
|
}
|
||||||
|
@ -217,8 +254,14 @@ impl PlayerEvent {
|
||||||
}
|
}
|
||||||
| Stopped {
|
| Stopped {
|
||||||
play_request_id, ..
|
play_request_id, ..
|
||||||
|
}
|
||||||
|
| PositionCorrection {
|
||||||
|
play_request_id, ..
|
||||||
|
}
|
||||||
|
| Seeked {
|
||||||
|
play_request_id, ..
|
||||||
} => Some(*play_request_id),
|
} => Some(*play_request_id),
|
||||||
Changed { .. } | Preloading { .. } | VolumeSet { .. } => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -370,12 +413,11 @@ impl Player {
|
||||||
session: Session,
|
session: Session,
|
||||||
volume_getter: Box<dyn VolumeGetter + Send>,
|
volume_getter: Box<dyn VolumeGetter + Send>,
|
||||||
sink_builder: F,
|
sink_builder: F,
|
||||||
) -> (Player, PlayerEventChannel)
|
) -> Self
|
||||||
where
|
where
|
||||||
F: FnOnce() -> Box<dyn Sink> + Send + 'static,
|
F: FnOnce() -> Box<dyn Sink> + Send + 'static,
|
||||||
{
|
{
|
||||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||||
let (event_sender, event_receiver) = mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
if config.normalisation {
|
if config.normalisation {
|
||||||
debug!("Normalisation Type: {:?}", config.normalisation_type);
|
debug!("Normalisation Type: {:?}", config.normalisation_type);
|
||||||
|
@ -421,7 +463,7 @@ impl Player {
|
||||||
sink_status: SinkStatus::Closed,
|
sink_status: SinkStatus::Closed,
|
||||||
sink_event_callback: None,
|
sink_event_callback: None,
|
||||||
volume_getter,
|
volume_getter,
|
||||||
event_senders: [event_sender].to_vec(),
|
event_senders: vec![],
|
||||||
converter,
|
converter,
|
||||||
|
|
||||||
normalisation_peak: 0.0,
|
normalisation_peak: 0.0,
|
||||||
|
@ -440,14 +482,11 @@ impl Player {
|
||||||
debug!("PlayerInternal thread finished.");
|
debug!("PlayerInternal thread finished.");
|
||||||
});
|
});
|
||||||
|
|
||||||
(
|
Self {
|
||||||
Player {
|
|
||||||
commands: Some(cmd_tx),
|
commands: Some(cmd_tx),
|
||||||
thread_handle: Some(handle),
|
thread_handle: Some(handle),
|
||||||
play_request_id_generator: SeqGenerator::new(0),
|
play_request_id_generator: SeqGenerator::new(0),
|
||||||
},
|
}
|
||||||
event_receiver,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command(&self, cmd: PlayerCommand) {
|
fn command(&self, cmd: PlayerCommand) {
|
||||||
|
@ -512,16 +551,57 @@ impl Player {
|
||||||
self.command(PlayerCommand::SetSinkEventCallback(callback));
|
self.command(PlayerCommand::SetSinkEventCallback(callback));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_volume_set_event(&self, volume: u16) {
|
pub fn emit_volume_changed_event(&self, volume: u16) {
|
||||||
self.command(PlayerCommand::EmitVolumeSetEvent(volume));
|
self.command(PlayerCommand::EmitVolumeChangedEvent(volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_auto_normalise_as_album(&self, setting: bool) {
|
pub fn set_auto_normalise_as_album(&self, setting: bool) {
|
||||||
self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting));
|
self.command(PlayerCommand::SetAutoNormaliseAsAlbum(setting));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn skip_explicit_content(&self) {
|
pub fn emit_filter_explicit_content_changed_event(&self, filter: bool) {
|
||||||
self.command(PlayerCommand::SkipExplicitContent());
|
self.command(PlayerCommand::EmitFilterExplicitContentChangedEvent(filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_session_connected_event(&self, connection_id: String, user_name: String) {
|
||||||
|
self.command(PlayerCommand::EmitSessionConnectedEvent {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_session_disconnected_event(&self, connection_id: String, user_name: String) {
|
||||||
|
self.command(PlayerCommand::EmitSessionDisconnectedEvent {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_session_client_changed_event(
|
||||||
|
&self,
|
||||||
|
client_id: String,
|
||||||
|
client_name: String,
|
||||||
|
client_brand_name: String,
|
||||||
|
client_model_name: String,
|
||||||
|
) {
|
||||||
|
self.command(PlayerCommand::EmitSessionClientChangedEvent {
|
||||||
|
client_id,
|
||||||
|
client_name,
|
||||||
|
client_brand_name,
|
||||||
|
client_model_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_shuffle_changed_event(&self, shuffle: bool) {
|
||||||
|
self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_repeat_changed_event(&self, repeat: bool) {
|
||||||
|
self.command(PlayerCommand::EmitRepeatChangedEvent(repeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_auto_play_changed_event(&self, auto_play: bool) {
|
||||||
|
self.command(PlayerCommand::EmitAutoPlayChangedEvent(auto_play));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -541,6 +621,7 @@ struct PlayerLoadedTrackData {
|
||||||
decoder: Decoder,
|
decoder: Decoder,
|
||||||
normalisation_data: NormalisationData,
|
normalisation_data: NormalisationData,
|
||||||
stream_loader_controller: StreamLoaderController,
|
stream_loader_controller: StreamLoaderController,
|
||||||
|
audio_item: AudioItem,
|
||||||
bytes_per_second: usize,
|
bytes_per_second: usize,
|
||||||
duration_ms: u32,
|
duration_ms: u32,
|
||||||
stream_position_ms: u32,
|
stream_position_ms: u32,
|
||||||
|
@ -573,6 +654,7 @@ enum PlayerState {
|
||||||
track_id: SpotifyId,
|
track_id: SpotifyId,
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
decoder: Decoder,
|
decoder: Decoder,
|
||||||
|
audio_item: AudioItem,
|
||||||
normalisation_data: NormalisationData,
|
normalisation_data: NormalisationData,
|
||||||
normalisation_factor: f64,
|
normalisation_factor: f64,
|
||||||
stream_loader_controller: StreamLoaderController,
|
stream_loader_controller: StreamLoaderController,
|
||||||
|
@ -587,6 +669,7 @@ enum PlayerState {
|
||||||
play_request_id: u64,
|
play_request_id: u64,
|
||||||
decoder: Decoder,
|
decoder: Decoder,
|
||||||
normalisation_data: NormalisationData,
|
normalisation_data: NormalisationData,
|
||||||
|
audio_item: AudioItem,
|
||||||
normalisation_factor: f64,
|
normalisation_factor: f64,
|
||||||
stream_loader_controller: StreamLoaderController,
|
stream_loader_controller: StreamLoaderController,
|
||||||
bytes_per_second: usize,
|
bytes_per_second: usize,
|
||||||
|
@ -660,6 +743,7 @@ impl PlayerState {
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
is_explicit,
|
is_explicit,
|
||||||
|
audio_item,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
*self = EndOfTrack {
|
*self = EndOfTrack {
|
||||||
|
@ -669,6 +753,7 @@ impl PlayerState {
|
||||||
decoder,
|
decoder,
|
||||||
normalisation_data,
|
normalisation_data,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
|
audio_item,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
|
@ -694,6 +779,7 @@ impl PlayerState {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
audio_item,
|
||||||
normalisation_data,
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
|
@ -707,13 +793,15 @@ impl PlayerState {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
audio_item,
|
||||||
normalisation_data,
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
reported_nominal_start_time: None,
|
reported_nominal_start_time: Instant::now()
|
||||||
|
.checked_sub(Duration::from_millis(stream_position_ms as u64)),
|
||||||
suggested_to_preload_next_track,
|
suggested_to_preload_next_track,
|
||||||
is_explicit,
|
is_explicit,
|
||||||
};
|
};
|
||||||
|
@ -736,20 +824,22 @@ impl PlayerState {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
audio_item,
|
||||||
normalisation_data,
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
reported_nominal_start_time: _,
|
|
||||||
suggested_to_preload_next_track,
|
suggested_to_preload_next_track,
|
||||||
is_explicit,
|
is_explicit,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
*self = Paused {
|
*self = Paused {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder,
|
decoder,
|
||||||
|
audio_item,
|
||||||
normalisation_data,
|
normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
|
@ -777,13 +867,13 @@ struct PlayerTrackLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerTrackLoader {
|
impl PlayerTrackLoader {
|
||||||
async fn find_available_alternative(&self, audio: AudioItem) -> Option<AudioItem> {
|
async fn find_available_alternative(&self, audio_item: AudioItem) -> Option<AudioItem> {
|
||||||
if let Err(e) = audio.availability {
|
if let Err(e) = audio_item.availability {
|
||||||
error!("Track is unavailable: {}", e);
|
error!("Track is unavailable: {}", e);
|
||||||
None
|
None
|
||||||
} else if !audio.files.is_empty() {
|
} else if !audio_item.files.is_empty() {
|
||||||
Some(audio)
|
Some(audio_item)
|
||||||
} else if let Some(alternatives) = &audio.alternatives {
|
} else if let Some(alternatives) = &audio_item.alternatives {
|
||||||
let alternatives: FuturesUnordered<_> = alternatives
|
let alternatives: FuturesUnordered<_> = alternatives
|
||||||
.iter()
|
.iter()
|
||||||
.map(|alt_id| AudioItem::get_file(&self.session, *alt_id))
|
.map(|alt_id| AudioItem::get_file(&self.session, *alt_id))
|
||||||
|
@ -822,7 +912,7 @@ impl PlayerTrackLoader {
|
||||||
spotify_id: SpotifyId,
|
spotify_id: SpotifyId,
|
||||||
position_ms: u32,
|
position_ms: u32,
|
||||||
) -> Option<PlayerLoadedTrackData> {
|
) -> Option<PlayerLoadedTrackData> {
|
||||||
let audio = match AudioItem::get_file(&self.session, spotify_id).await {
|
let audio_item = match AudioItem::get_file(&self.session, spotify_id).await {
|
||||||
Ok(audio) => match self.find_available_alternative(audio).await {
|
Ok(audio) => match self.find_available_alternative(audio).await {
|
||||||
Some(audio) => audio,
|
Some(audio) => audio,
|
||||||
None => {
|
None => {
|
||||||
|
@ -841,31 +931,9 @@ impl PlayerTrackLoader {
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Loading <{}> with Spotify URI <{}>",
|
"Loading <{}> with Spotify URI <{}>",
|
||||||
audio.name, audio.spotify_uri
|
audio_item.name, audio_item.uri
|
||||||
);
|
);
|
||||||
|
|
||||||
let is_explicit = audio.is_explicit;
|
|
||||||
|
|
||||||
if is_explicit {
|
|
||||||
if let Some(value) = self.session.get_user_attribute("filter-explicit-content") {
|
|
||||||
if &value == "1" {
|
|
||||||
warn!("Track is marked as explicit, which client setting forbids.");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if audio.duration < 0 {
|
|
||||||
error!(
|
|
||||||
"Track duration for <{}> cannot be {}",
|
|
||||||
spotify_id.to_uri().unwrap_or_default(),
|
|
||||||
audio.duration
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let duration_ms = audio.duration as u32;
|
|
||||||
|
|
||||||
// (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it
|
// (Most) podcasts seem to support only 96 kbps Ogg Vorbis, so fall back to it
|
||||||
let formats = match self.config.bitrate {
|
let formats = match self.config.bitrate {
|
||||||
Bitrate::Bitrate96 => [
|
Bitrate::Bitrate96 => [
|
||||||
|
@ -900,13 +968,16 @@ impl PlayerTrackLoader {
|
||||||
let (format, file_id) =
|
let (format, file_id) =
|
||||||
match formats
|
match formats
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|format| match audio.files.get(format) {
|
.find_map(|format| match audio_item.files.get(format) {
|
||||||
Some(&file_id) => Some((*format, file_id)),
|
Some(&file_id) => Some((*format, file_id)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}) {
|
}) {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => {
|
None => {
|
||||||
warn!("<{}> is not available in any supported format", audio.name);
|
warn!(
|
||||||
|
"<{}> is not available in any supported format",
|
||||||
|
audio_item.name
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1020,6 +1091,17 @@ impl PlayerTrackLoader {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let duration_ms = audio_item.duration_ms;
|
||||||
|
// Don't try to seek past the track's duration.
|
||||||
|
// If the position is invalid just start from
|
||||||
|
// the beginning of the track.
|
||||||
|
let position_ms = if position_ms > duration_ms {
|
||||||
|
warn!("Invalid start position of {}ms exceeds track's duration of {}ms, starting track from the beginning", position_ms, duration_ms);
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
position_ms
|
||||||
|
};
|
||||||
|
|
||||||
// Ensure the starting position. Even when we want to play from the beginning,
|
// Ensure the starting position. Even when we want to play from the beginning,
|
||||||
// the cursor may have been moved by parsing normalisation data. This may not
|
// the cursor may have been moved by parsing normalisation data. This may not
|
||||||
// matter for playback (but won't hurt either), but may be useful for the
|
// matter for playback (but won't hurt either), but may be useful for the
|
||||||
|
@ -1038,12 +1120,15 @@ impl PlayerTrackLoader {
|
||||||
// Ensure streaming mode now that we are ready to play from the requested position.
|
// Ensure streaming mode now that we are ready to play from the requested position.
|
||||||
stream_loader_controller.set_stream_mode();
|
stream_loader_controller.set_stream_mode();
|
||||||
|
|
||||||
info!("<{}> ({} ms) loaded", audio.name, audio.duration);
|
let is_explicit = audio_item.is_explicit;
|
||||||
|
|
||||||
|
info!("<{}> ({} ms) loaded", audio_item.name, duration_ms);
|
||||||
|
|
||||||
return Some(PlayerLoadedTrackData {
|
return Some(PlayerLoadedTrackData {
|
||||||
decoder,
|
decoder,
|
||||||
normalisation_data,
|
normalisation_data,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
|
audio_item,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
|
@ -1164,7 +1249,6 @@ impl Future for PlayerInternal {
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
ref mut stream_position_ms,
|
ref mut stream_position_ms,
|
||||||
ref mut reported_nominal_start_time,
|
ref mut reported_nominal_start_time,
|
||||||
duration_ms,
|
|
||||||
..
|
..
|
||||||
} = self.state
|
} = self.state
|
||||||
{
|
{
|
||||||
|
@ -1226,11 +1310,10 @@ impl Future for PlayerInternal {
|
||||||
if notify_about_position {
|
if notify_about_position {
|
||||||
*reported_nominal_start_time =
|
*reported_nominal_start_time =
|
||||||
now.checked_sub(new_stream_position);
|
now.checked_sub(new_stream_position);
|
||||||
self.send_event(PlayerEvent::Playing {
|
self.send_event(PlayerEvent::PositionCorrection {
|
||||||
track_id,
|
|
||||||
play_request_id,
|
play_request_id,
|
||||||
|
track_id,
|
||||||
position_ms: new_stream_position_ms as u32,
|
position_ms: new_stream_position_ms as u32,
|
||||||
duration_ms,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1315,7 +1398,7 @@ impl PlayerInternal {
|
||||||
Ok(()) => self.sink_status = SinkStatus::Running,
|
Ok(()) => self.sink_status = SinkStatus::Running,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
exit(1);
|
self.handle_pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1396,7 +1479,6 @@ impl PlayerInternal {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
duration_ms,
|
|
||||||
..
|
..
|
||||||
} = self.state
|
} = self.state
|
||||||
{
|
{
|
||||||
|
@ -1405,7 +1487,6 @@ impl PlayerInternal {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
position_ms: stream_position_ms,
|
position_ms: stream_position_ms,
|
||||||
duration_ms,
|
|
||||||
});
|
});
|
||||||
self.ensure_sink_running();
|
self.ensure_sink_running();
|
||||||
} else {
|
} else {
|
||||||
|
@ -1414,14 +1495,14 @@ impl PlayerInternal {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_pause(&mut self) {
|
fn handle_pause(&mut self) {
|
||||||
if let PlayerState::Playing {
|
match self.state {
|
||||||
|
PlayerState::Paused { .. } => self.ensure_sink_stopped(false),
|
||||||
|
PlayerState::Playing {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
duration_ms,
|
|
||||||
..
|
..
|
||||||
} = self.state
|
} => {
|
||||||
{
|
|
||||||
self.state.playing_to_paused();
|
self.state.playing_to_paused();
|
||||||
|
|
||||||
self.ensure_sink_stopped(false);
|
self.ensure_sink_stopped(false);
|
||||||
|
@ -1429,10 +1510,9 @@ impl PlayerInternal {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
position_ms: stream_position_ms,
|
position_ms: stream_position_ms,
|
||||||
duration_ms,
|
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
error!("Player::pause called from invalid state: {:?}", self.state);
|
_ => error!("Player::pause called from invalid state: {:?}", self.state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1555,7 +1635,7 @@ impl PlayerInternal {
|
||||||
|
|
||||||
if let Err(e) = self.sink.write(packet, &mut self.converter) {
|
if let Err(e) = self.sink.write(packet, &mut self.converter) {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
exit(1);
|
self.handle_pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1587,6 +1667,10 @@ impl PlayerInternal {
|
||||||
loaded_track: PlayerLoadedTrackData,
|
loaded_track: PlayerLoadedTrackData,
|
||||||
start_playback: bool,
|
start_playback: bool,
|
||||||
) {
|
) {
|
||||||
|
let audio_item = Box::new(loaded_track.audio_item.clone());
|
||||||
|
|
||||||
|
self.send_event(PlayerEvent::TrackChanged { audio_item });
|
||||||
|
|
||||||
let position_ms = loaded_track.stream_position_ms;
|
let position_ms = loaded_track.stream_position_ms;
|
||||||
|
|
||||||
let mut config = self.config.clone();
|
let mut config = self.config.clone();
|
||||||
|
@ -1602,18 +1686,17 @@ impl PlayerInternal {
|
||||||
|
|
||||||
if start_playback {
|
if start_playback {
|
||||||
self.ensure_sink_running();
|
self.ensure_sink_running();
|
||||||
|
|
||||||
self.send_event(PlayerEvent::Playing {
|
self.send_event(PlayerEvent::Playing {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
position_ms,
|
position_ms,
|
||||||
duration_ms: loaded_track.duration_ms,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.state = PlayerState::Playing {
|
self.state = PlayerState::Playing {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder: loaded_track.decoder,
|
decoder: loaded_track.decoder,
|
||||||
|
audio_item: loaded_track.audio_item,
|
||||||
normalisation_data: loaded_track.normalisation_data,
|
normalisation_data: loaded_track.normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller: loaded_track.stream_loader_controller,
|
stream_loader_controller: loaded_track.stream_loader_controller,
|
||||||
|
@ -1632,6 +1715,7 @@ impl PlayerInternal {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
decoder: loaded_track.decoder,
|
decoder: loaded_track.decoder,
|
||||||
|
audio_item: loaded_track.audio_item,
|
||||||
normalisation_data: loaded_track.normalisation_data,
|
normalisation_data: loaded_track.normalisation_data,
|
||||||
normalisation_factor,
|
normalisation_factor,
|
||||||
stream_loader_controller: loaded_track.stream_loader_controller,
|
stream_loader_controller: loaded_track.stream_loader_controller,
|
||||||
|
@ -1646,7 +1730,6 @@ impl PlayerInternal {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
position_ms,
|
position_ms,
|
||||||
duration_ms: loaded_track.duration_ms,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1661,39 +1744,13 @@ impl PlayerInternal {
|
||||||
if !self.config.gapless {
|
if !self.config.gapless {
|
||||||
self.ensure_sink_stopped(play);
|
self.ensure_sink_stopped(play);
|
||||||
}
|
}
|
||||||
// emit the correct player event
|
|
||||||
match self.state {
|
if matches!(self.state, PlayerState::Invalid { .. }) {
|
||||||
PlayerState::Playing {
|
|
||||||
track_id: old_track_id,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
| PlayerState::Paused {
|
|
||||||
track_id: old_track_id,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
| PlayerState::EndOfTrack {
|
|
||||||
track_id: old_track_id,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
| PlayerState::Loading {
|
|
||||||
track_id: old_track_id,
|
|
||||||
..
|
|
||||||
} => self.send_event(PlayerEvent::Changed {
|
|
||||||
old_track_id,
|
|
||||||
new_track_id: track_id,
|
|
||||||
}),
|
|
||||||
PlayerState::Stopped => self.send_event(PlayerEvent::Started {
|
|
||||||
track_id,
|
|
||||||
play_request_id,
|
|
||||||
position_ms,
|
|
||||||
}),
|
|
||||||
PlayerState::Invalid { .. } => {
|
|
||||||
return Err(Error::internal(format!(
|
return Err(Error::internal(format!(
|
||||||
"Player::handle_command_load called from invalid state: {:?}",
|
"Player::handle_command_load called from invalid state: {:?}",
|
||||||
self.state
|
self.state
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Now we check at different positions whether we already have a pre-loaded version
|
// Now we check at different positions whether we already have a pre-loaded version
|
||||||
// of this track somewhere. If so, use it and return.
|
// of this track somewhere. If so, use it and return.
|
||||||
|
@ -1754,6 +1811,7 @@ impl PlayerInternal {
|
||||||
if let PlayerState::Playing {
|
if let PlayerState::Playing {
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
decoder,
|
decoder,
|
||||||
|
audio_item,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -1764,6 +1822,7 @@ impl PlayerInternal {
|
||||||
| PlayerState::Paused {
|
| PlayerState::Paused {
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
decoder,
|
decoder,
|
||||||
|
audio_item,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
|
@ -1776,6 +1835,7 @@ impl PlayerInternal {
|
||||||
decoder,
|
decoder,
|
||||||
normalisation_data,
|
normalisation_data,
|
||||||
stream_loader_controller,
|
stream_loader_controller,
|
||||||
|
audio_item,
|
||||||
bytes_per_second,
|
bytes_per_second,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
stream_position_ms,
|
stream_position_ms,
|
||||||
|
@ -1925,14 +1985,24 @@ impl PlayerInternal {
|
||||||
Ok(new_position_ms) => {
|
Ok(new_position_ms) => {
|
||||||
if let PlayerState::Playing {
|
if let PlayerState::Playing {
|
||||||
ref mut stream_position_ms,
|
ref mut stream_position_ms,
|
||||||
|
track_id,
|
||||||
|
play_request_id,
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
| PlayerState::Paused {
|
| PlayerState::Paused {
|
||||||
ref mut stream_position_ms,
|
ref mut stream_position_ms,
|
||||||
|
track_id,
|
||||||
|
play_request_id,
|
||||||
..
|
..
|
||||||
} = self.state
|
} = self.state
|
||||||
{
|
{
|
||||||
*stream_position_ms = new_position_ms;
|
*stream_position_ms = new_position_ms;
|
||||||
|
|
||||||
|
self.send_event(PlayerEvent::Seeked {
|
||||||
|
play_request_id,
|
||||||
|
track_id,
|
||||||
|
position_ms: new_position_ms,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => error!("PlayerInternal::handle_command_seek error: {}", e),
|
Err(e) => error!("PlayerInternal::handle_command_seek error: {}", e),
|
||||||
|
@ -1945,35 +2015,12 @@ impl PlayerInternal {
|
||||||
self.preload_data_before_playback()?;
|
self.preload_data_before_playback()?;
|
||||||
|
|
||||||
if let PlayerState::Playing {
|
if let PlayerState::Playing {
|
||||||
track_id,
|
|
||||||
play_request_id,
|
|
||||||
ref mut reported_nominal_start_time,
|
ref mut reported_nominal_start_time,
|
||||||
duration_ms,
|
|
||||||
..
|
..
|
||||||
} = self.state
|
} = self.state
|
||||||
{
|
{
|
||||||
*reported_nominal_start_time =
|
*reported_nominal_start_time =
|
||||||
Instant::now().checked_sub(Duration::from_millis(position_ms as u64));
|
Instant::now().checked_sub(Duration::from_millis(position_ms as u64));
|
||||||
self.send_event(PlayerEvent::Playing {
|
|
||||||
track_id,
|
|
||||||
play_request_id,
|
|
||||||
position_ms,
|
|
||||||
duration_ms,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let PlayerState::Paused {
|
|
||||||
track_id,
|
|
||||||
play_request_id,
|
|
||||||
duration_ms,
|
|
||||||
..
|
|
||||||
} = self.state
|
|
||||||
{
|
|
||||||
self.send_event(PlayerEvent::Paused {
|
|
||||||
track_id,
|
|
||||||
play_request_id,
|
|
||||||
position_ms,
|
|
||||||
duration_ms,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -2003,15 +2050,58 @@ impl PlayerInternal {
|
||||||
|
|
||||||
PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback,
|
PlayerCommand::SetSinkEventCallback(callback) => self.sink_event_callback = callback,
|
||||||
|
|
||||||
PlayerCommand::EmitVolumeSetEvent(volume) => {
|
PlayerCommand::EmitVolumeChangedEvent(volume) => {
|
||||||
self.send_event(PlayerEvent::VolumeSet { volume })
|
self.send_event(PlayerEvent::VolumeChanged { volume })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlayerCommand::EmitRepeatChangedEvent(repeat) => {
|
||||||
|
self.send_event(PlayerEvent::RepeatChanged { repeat })
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerCommand::EmitShuffleChangedEvent(shuffle) => {
|
||||||
|
self.send_event(PlayerEvent::ShuffleChanged { shuffle })
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => {
|
||||||
|
self.send_event(PlayerEvent::AutoPlayChanged { auto_play })
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerCommand::EmitSessionClientChangedEvent {
|
||||||
|
client_id,
|
||||||
|
client_name,
|
||||||
|
client_brand_name,
|
||||||
|
client_model_name,
|
||||||
|
} => self.send_event(PlayerEvent::SessionClientChanged {
|
||||||
|
client_id,
|
||||||
|
client_name,
|
||||||
|
client_brand_name,
|
||||||
|
client_model_name,
|
||||||
|
}),
|
||||||
|
|
||||||
|
PlayerCommand::EmitSessionConnectedEvent {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
} => self.send_event(PlayerEvent::SessionConnected {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
}),
|
||||||
|
|
||||||
|
PlayerCommand::EmitSessionDisconnectedEvent {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
} => self.send_event(PlayerEvent::SessionDisconnected {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
}),
|
||||||
|
|
||||||
PlayerCommand::SetAutoNormaliseAsAlbum(setting) => {
|
PlayerCommand::SetAutoNormaliseAsAlbum(setting) => {
|
||||||
self.auto_normalise_as_album = setting
|
self.auto_normalise_as_album = setting
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerCommand::SkipExplicitContent() => {
|
PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => {
|
||||||
|
self.send_event(PlayerEvent::FilterExplicitContentChanged { filter });
|
||||||
|
|
||||||
|
if filter {
|
||||||
if let PlayerState::Playing {
|
if let PlayerState::Playing {
|
||||||
track_id,
|
track_id,
|
||||||
play_request_id,
|
play_request_id,
|
||||||
|
@ -2034,6 +2124,7 @@ impl PlayerInternal {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
@ -2133,7 +2224,7 @@ impl Drop for PlayerInternal {
|
||||||
|
|
||||||
impl fmt::Debug for PlayerCommand {
|
impl fmt::Debug for PlayerCommand {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match *self {
|
match self {
|
||||||
PlayerCommand::Load {
|
PlayerCommand::Load {
|
||||||
track_id,
|
track_id,
|
||||||
play,
|
play,
|
||||||
|
@ -2156,14 +2247,58 @@ impl fmt::Debug for PlayerCommand {
|
||||||
PlayerCommand::SetSinkEventCallback(_) => {
|
PlayerCommand::SetSinkEventCallback(_) => {
|
||||||
f.debug_tuple("SetSinkEventCallback").finish()
|
f.debug_tuple("SetSinkEventCallback").finish()
|
||||||
}
|
}
|
||||||
PlayerCommand::EmitVolumeSetEvent(volume) => {
|
PlayerCommand::EmitVolumeChangedEvent(volume) => f
|
||||||
f.debug_tuple("VolumeSet").field(&volume).finish()
|
.debug_tuple("EmitVolumeChangedEvent")
|
||||||
}
|
.field(&volume)
|
||||||
|
.finish(),
|
||||||
PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f
|
PlayerCommand::SetAutoNormaliseAsAlbum(setting) => f
|
||||||
.debug_tuple("SetAutoNormaliseAsAlbum")
|
.debug_tuple("SetAutoNormaliseAsAlbum")
|
||||||
.field(&setting)
|
.field(&setting)
|
||||||
.finish(),
|
.finish(),
|
||||||
PlayerCommand::SkipExplicitContent() => f.debug_tuple("SkipExplicitContent").finish(),
|
PlayerCommand::EmitFilterExplicitContentChangedEvent(filter) => f
|
||||||
|
.debug_tuple("EmitFilterExplicitContentChangedEvent")
|
||||||
|
.field(&filter)
|
||||||
|
.finish(),
|
||||||
|
PlayerCommand::EmitSessionConnectedEvent {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
} => f
|
||||||
|
.debug_tuple("EmitSessionConnectedEvent")
|
||||||
|
.field(&connection_id)
|
||||||
|
.field(&user_name)
|
||||||
|
.finish(),
|
||||||
|
PlayerCommand::EmitSessionDisconnectedEvent {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
} => f
|
||||||
|
.debug_tuple("EmitSessionDisconnectedEvent")
|
||||||
|
.field(&connection_id)
|
||||||
|
.field(&user_name)
|
||||||
|
.finish(),
|
||||||
|
PlayerCommand::EmitSessionClientChangedEvent {
|
||||||
|
client_id,
|
||||||
|
client_name,
|
||||||
|
client_brand_name,
|
||||||
|
client_model_name,
|
||||||
|
} => f
|
||||||
|
.debug_tuple("EmitSessionClientChangedEvent")
|
||||||
|
.field(&client_id)
|
||||||
|
.field(&client_name)
|
||||||
|
.field(&client_brand_name)
|
||||||
|
.field(&client_model_name)
|
||||||
|
.finish(),
|
||||||
|
PlayerCommand::EmitShuffleChangedEvent(shuffle) => f
|
||||||
|
.debug_tuple("EmitShuffleChangedEvent")
|
||||||
|
.field(&shuffle)
|
||||||
|
.finish(),
|
||||||
|
PlayerCommand::EmitRepeatChangedEvent(repeat) => f
|
||||||
|
.debug_tuple("EmitRepeatChangedEvent")
|
||||||
|
.field(&repeat)
|
||||||
|
.finish(),
|
||||||
|
PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f
|
||||||
|
.debug_tuple("EmitAutoPlayChangedEvent")
|
||||||
|
.field(&auto_play)
|
||||||
|
.finish(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
66
src/main.rs
66
src/main.rs
|
@ -13,7 +13,6 @@ use futures_util::StreamExt;
|
||||||
use log::{error, info, trace, warn};
|
use log::{error, info, trace, warn};
|
||||||
use sha1::{Digest, Sha1};
|
use sha1::{Digest, Sha1};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::mpsc::UnboundedReceiver;
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use librespot::{
|
use librespot::{
|
||||||
|
@ -29,7 +28,7 @@ use librespot::{
|
||||||
},
|
},
|
||||||
dither,
|
dither,
|
||||||
mixer::{self, MixerConfig, MixerFn},
|
mixer::{self, MixerConfig, MixerFn},
|
||||||
player::{coefficient_to_duration, duration_to_coefficient, Player, PlayerEvent},
|
player::{coefficient_to_duration, duration_to_coefficient, Player},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -37,7 +36,7 @@ use librespot::{
|
||||||
use librespot::playback::mixer::alsamixer::AlsaMixer;
|
use librespot::playback::mixer::alsamixer::AlsaMixer;
|
||||||
|
|
||||||
mod player_event_handler;
|
mod player_event_handler;
|
||||||
use player_event_handler::{emit_sink_event, run_program_on_events};
|
use player_event_handler::{run_program_on_sink_events, EventHandler};
|
||||||
|
|
||||||
fn device_id(name: &str) -> String {
|
fn device_id(name: &str) -> String {
|
||||||
hex::encode(Sha1::digest(name.as_bytes()))
|
hex::encode(Sha1::digest(name.as_bytes()))
|
||||||
|
@ -1598,10 +1597,10 @@ async fn main() {
|
||||||
let mut last_credentials = None;
|
let mut last_credentials = None;
|
||||||
let mut spirc: Option<Spirc> = None;
|
let mut spirc: Option<Spirc> = None;
|
||||||
let mut spirc_task: Option<Pin<_>> = None;
|
let mut spirc_task: Option<Pin<_>> = None;
|
||||||
let mut player_event_channel: Option<UnboundedReceiver<PlayerEvent>> = None;
|
|
||||||
let mut auto_connect_times: Vec<Instant> = vec![];
|
let mut auto_connect_times: Vec<Instant> = vec![];
|
||||||
let mut discovery = None;
|
let mut discovery = None;
|
||||||
let mut connecting = false;
|
let mut connecting = false;
|
||||||
|
let mut _event_handler: Option<EventHandler> = None;
|
||||||
|
|
||||||
let session = Session::new(setup.session_config.clone(), setup.cache.clone());
|
let session = Session::new(setup.session_config.clone(), setup.cache.clone());
|
||||||
|
|
||||||
|
@ -1669,32 +1668,21 @@ async fn main() {
|
||||||
let format = setup.format;
|
let format = setup.format;
|
||||||
let backend = setup.backend;
|
let backend = setup.backend;
|
||||||
let device = setup.device.clone();
|
let device = setup.device.clone();
|
||||||
let (player, event_channel) =
|
let player = Player::new(player_config, session.clone(), soft_volume, move || {
|
||||||
Player::new(player_config, session.clone(), soft_volume, move || {
|
|
||||||
(backend)(device, format)
|
(backend)(device, format)
|
||||||
});
|
});
|
||||||
|
|
||||||
if setup.emit_sink_events {
|
|
||||||
if let Some(player_event_program) = setup.player_event_program.clone() {
|
if let Some(player_event_program) = setup.player_event_program.clone() {
|
||||||
|
_event_handler = Some(EventHandler::new(player.get_player_event_channel(), &player_event_program));
|
||||||
|
|
||||||
|
if setup.emit_sink_events {
|
||||||
player.set_sink_event_callback(Some(Box::new(move |sink_status| {
|
player.set_sink_event_callback(Some(Box::new(move |sink_status| {
|
||||||
match emit_sink_event(sink_status, &player_event_program) {
|
run_program_on_sink_events(sink_status, &player_event_program)
|
||||||
Ok(e) if e.success() => (),
|
|
||||||
Ok(e) => {
|
|
||||||
if let Some(code) = e.code() {
|
|
||||||
warn!("Sink event program returned exit code {}", code);
|
|
||||||
} else {
|
|
||||||
warn!("Sink event program returned failure");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Emitting sink event failed: {}", e);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (spirc_, spirc_task_) = match Spirc::new(connect_config, session.clone(), last_credentials.clone().unwrap(), player, mixer).await {
|
let (spirc_, spirc_task_) = match Spirc::new(connect_config, session.clone(), last_credentials.clone().unwrap_or_default(), player, mixer).await {
|
||||||
Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_),
|
Ok((spirc_, spirc_task_)) => (spirc_, spirc_task_),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("could not initialize spirc: {}", e);
|
error!("could not initialize spirc: {}", e);
|
||||||
|
@ -1703,7 +1691,6 @@ async fn main() {
|
||||||
};
|
};
|
||||||
spirc = Some(spirc_);
|
spirc = Some(spirc_);
|
||||||
spirc_task = Some(Box::pin(spirc_task_));
|
spirc_task = Some(Box::pin(spirc_task_));
|
||||||
player_event_channel = Some(event_channel);
|
|
||||||
|
|
||||||
connecting = false;
|
connecting = false;
|
||||||
},
|
},
|
||||||
|
@ -1732,41 +1719,6 @@ async fn main() {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
event = async {
|
|
||||||
match player_event_channel.as_mut() {
|
|
||||||
Some(p) => p.recv().await,
|
|
||||||
_ => None
|
|
||||||
}
|
|
||||||
}, if player_event_channel.is_some() => match event {
|
|
||||||
Some(event) => {
|
|
||||||
if let Some(program) = &setup.player_event_program {
|
|
||||||
if let Some(child) = run_program_on_events(event, program) {
|
|
||||||
if let Ok(mut child) = child {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match child.wait().await {
|
|
||||||
Ok(e) if e.success() => (),
|
|
||||||
Ok(e) => {
|
|
||||||
if let Some(code) = e.code() {
|
|
||||||
warn!("On event program returned exit code {}", code);
|
|
||||||
} else {
|
|
||||||
warn!("On event program returned failure");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
warn!("On event program failed: {}", e);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
warn!("On event program failed to start");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
None => {
|
|
||||||
player_event_channel = None;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ = tokio::signal::ctrl_c() => {
|
_ = tokio::signal::ctrl_c() => {
|
||||||
break;
|
break;
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,61 +1,96 @@
|
||||||
use log::info;
|
use log::{debug, error, warn};
|
||||||
|
|
||||||
use std::{
|
use std::{collections::HashMap, process::Command, thread};
|
||||||
collections::HashMap,
|
|
||||||
io::{Error, ErrorKind, Result},
|
use librespot::{
|
||||||
process::{Command, ExitStatus},
|
metadata::audio::UniqueFields,
|
||||||
|
playback::player::{PlayerEvent, PlayerEventChannel, SinkStatus},
|
||||||
};
|
};
|
||||||
|
|
||||||
use tokio::process::{Child as AsyncChild, Command as AsyncCommand};
|
pub struct EventHandler {
|
||||||
|
thread_handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
use librespot::playback::player::{PlayerEvent, SinkStatus};
|
impl EventHandler {
|
||||||
|
pub fn new(mut player_events: PlayerEventChannel, onevent: &str) -> Self {
|
||||||
pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result<AsyncChild>> {
|
let on_event = onevent.to_string();
|
||||||
|
let thread_handle = Some(thread::spawn(move || loop {
|
||||||
|
match player_events.blocking_recv() {
|
||||||
|
None => break,
|
||||||
|
Some(event) => {
|
||||||
let mut env_vars = HashMap::new();
|
let mut env_vars = HashMap::new();
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
PlayerEvent::Changed {
|
PlayerEvent::TrackChanged { audio_item } => {
|
||||||
old_track_id,
|
match audio_item.track_id.to_base62() {
|
||||||
new_track_id,
|
|
||||||
} => match old_track_id.to_base62() {
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Some(Err(Error::new(
|
warn!("PlayerEvent::TrackChanged: Invalid track id: {}", e)
|
||||||
ErrorKind::InvalidData,
|
|
||||||
format!("PlayerEvent::Changed: Invalid old track id: {}", e),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Ok(old_id) => match new_track_id.to_base62() {
|
|
||||||
Err(e) => {
|
|
||||||
return Some(Err(Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
format!("PlayerEvent::Changed: Invalid old track id: {}", e),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Ok(new_id) => {
|
|
||||||
env_vars.insert("PLAYER_EVENT", "changed".to_string());
|
|
||||||
env_vars.insert("OLD_TRACK_ID", old_id);
|
|
||||||
env_vars.insert("TRACK_ID", new_id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
PlayerEvent::Started { track_id, .. } => match track_id.to_base62() {
|
|
||||||
Err(e) => {
|
|
||||||
return Some(Err(Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
format!("PlayerEvent::Started: Invalid track id: {}", e),
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "started".to_string());
|
env_vars.insert("PLAYER_EVENT", "track_changed".to_string());
|
||||||
env_vars.insert("TRACK_ID", id);
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
env_vars.insert("URI", audio_item.uri);
|
||||||
|
env_vars.insert("NAME", audio_item.name);
|
||||||
|
env_vars.insert(
|
||||||
|
"COVERS",
|
||||||
|
audio_item
|
||||||
|
.covers
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| c.url)
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
env_vars.insert("LANGUAGE", audio_item.language.join("\n"));
|
||||||
|
env_vars
|
||||||
|
.insert("DURATION_MS", audio_item.duration_ms.to_string());
|
||||||
|
env_vars
|
||||||
|
.insert("IS_EXPLICIT", audio_item.is_explicit.to_string());
|
||||||
|
|
||||||
|
match audio_item.unique_fields {
|
||||||
|
UniqueFields::Track {
|
||||||
|
artists,
|
||||||
|
album,
|
||||||
|
album_artists,
|
||||||
|
popularity,
|
||||||
|
number,
|
||||||
|
disc_number,
|
||||||
|
} => {
|
||||||
|
env_vars.insert("ITEM_TYPE", "Track".to_string());
|
||||||
|
env_vars.insert(
|
||||||
|
"ARTISTS",
|
||||||
|
artists
|
||||||
|
.0
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.name)
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
env_vars
|
||||||
|
.insert("ALBUM_ARTISTS", album_artists.join("\n"));
|
||||||
|
env_vars.insert("ALBUM", album);
|
||||||
|
env_vars.insert("POPULARITY", popularity.to_string());
|
||||||
|
env_vars.insert("NUMBER", number.to_string());
|
||||||
|
env_vars.insert("DISC_NUMBER", disc_number.to_string());
|
||||||
|
}
|
||||||
|
UniqueFields::Episode {
|
||||||
|
description,
|
||||||
|
publish_time,
|
||||||
|
show_name,
|
||||||
|
} => {
|
||||||
|
env_vars.insert("ITEM_TYPE", "Episode".to_string());
|
||||||
|
env_vars.insert("DESCRIPTION", description);
|
||||||
|
env_vars.insert(
|
||||||
|
"PUBLISH_TIME",
|
||||||
|
publish_time.unix_timestamp().to_string(),
|
||||||
|
);
|
||||||
|
env_vars.insert("SHOW_NAME", show_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() {
|
PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() {
|
||||||
Err(e) => {
|
Err(e) => warn!("PlayerEvent::Stopped: Invalid track id: {}", e),
|
||||||
return Some(Err(Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
format!("PlayerEvent::Stopped: Invalid track id: {}", e),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "stopped".to_string());
|
env_vars.insert("PLAYER_EVENT", "stopped".to_string());
|
||||||
env_vars.insert("TRACK_ID", id);
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
@ -63,86 +98,210 @@ pub fn run_program_on_events(event: PlayerEvent, onevent: &str) -> Option<Result
|
||||||
},
|
},
|
||||||
PlayerEvent::Playing {
|
PlayerEvent::Playing {
|
||||||
track_id,
|
track_id,
|
||||||
duration_ms,
|
|
||||||
position_ms,
|
position_ms,
|
||||||
..
|
..
|
||||||
} => match track_id.to_base62() {
|
} => match track_id.to_base62() {
|
||||||
Err(e) => {
|
Err(e) => warn!("PlayerEvent::Playing: Invalid track id: {}", e),
|
||||||
return Some(Err(Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
format!("PlayerEvent::Playing: Invalid track id: {}", e),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "playing".to_string());
|
env_vars.insert("PLAYER_EVENT", "playing".to_string());
|
||||||
env_vars.insert("TRACK_ID", id);
|
env_vars.insert("TRACK_ID", id);
|
||||||
env_vars.insert("DURATION_MS", duration_ms.to_string());
|
|
||||||
env_vars.insert("POSITION_MS", position_ms.to_string());
|
env_vars.insert("POSITION_MS", position_ms.to_string());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
PlayerEvent::Paused {
|
PlayerEvent::Paused {
|
||||||
track_id,
|
track_id,
|
||||||
duration_ms,
|
|
||||||
position_ms,
|
position_ms,
|
||||||
..
|
..
|
||||||
} => match track_id.to_base62() {
|
} => match track_id.to_base62() {
|
||||||
Err(e) => {
|
Err(e) => warn!("PlayerEvent::Paused: Invalid track id: {}", e),
|
||||||
return Some(Err(Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
format!("PlayerEvent::Paused: Invalid track id: {}", e),
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "paused".to_string());
|
env_vars.insert("PLAYER_EVENT", "paused".to_string());
|
||||||
env_vars.insert("TRACK_ID", id);
|
env_vars.insert("TRACK_ID", id);
|
||||||
env_vars.insert("DURATION_MS", duration_ms.to_string());
|
|
||||||
env_vars.insert("POSITION_MS", position_ms.to_string());
|
env_vars.insert("POSITION_MS", position_ms.to_string());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() {
|
PlayerEvent::Loading { track_id, .. } => match track_id.to_base62() {
|
||||||
Err(e) => {
|
Err(e) => warn!("PlayerEvent::Loading: Invalid track id: {}", e),
|
||||||
return Some(Err(Error::new(
|
Ok(id) => {
|
||||||
ErrorKind::InvalidData,
|
env_vars.insert("PLAYER_EVENT", "loading".to_string());
|
||||||
format!("PlayerEvent::Preloading: Invalid track id: {}", e),
|
env_vars.insert("TRACK_ID", id);
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
PlayerEvent::Preloading { track_id, .. } => match track_id.to_base62() {
|
||||||
|
Err(e) => warn!("PlayerEvent::Preloading: Invalid track id: {}", e),
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
env_vars.insert("PLAYER_EVENT", "preloading".to_string());
|
env_vars.insert("PLAYER_EVENT", "preloading".to_string());
|
||||||
env_vars.insert("TRACK_ID", id);
|
env_vars.insert("TRACK_ID", id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
PlayerEvent::VolumeSet { volume } => {
|
PlayerEvent::TimeToPreloadNextTrack { track_id, .. } => {
|
||||||
env_vars.insert("PLAYER_EVENT", "volume_set".to_string());
|
match track_id.to_base62() {
|
||||||
|
Err(e) => warn!(
|
||||||
|
"PlayerEvent::TimeToPreloadNextTrack: Invalid track id: {}",
|
||||||
|
e
|
||||||
|
),
|
||||||
|
Ok(id) => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "preload_next".to_string());
|
||||||
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerEvent::EndOfTrack { track_id, .. } => match track_id.to_base62() {
|
||||||
|
Err(e) => warn!("PlayerEvent::EndOfTrack: Invalid track id: {}", e),
|
||||||
|
Ok(id) => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "end_of_track".to_string());
|
||||||
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerEvent::Unavailable { track_id, .. } => match track_id.to_base62() {
|
||||||
|
Err(e) => warn!("PlayerEvent::Unavailable: Invalid track id: {}", e),
|
||||||
|
Ok(id) => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "unavailable".to_string());
|
||||||
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerEvent::VolumeChanged { volume } => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "volume_changed".to_string());
|
||||||
env_vars.insert("VOLUME", volume.to_string());
|
env_vars.insert("VOLUME", volume.to_string());
|
||||||
}
|
}
|
||||||
_ => return None,
|
PlayerEvent::Seeked {
|
||||||
|
track_id,
|
||||||
|
position_ms,
|
||||||
|
..
|
||||||
|
} => match track_id.to_base62() {
|
||||||
|
Err(e) => warn!("PlayerEvent::Seeked: Invalid track id: {}", e),
|
||||||
|
Ok(id) => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "seeked".to_string());
|
||||||
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
env_vars.insert("POSITION_MS", position_ms.to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerEvent::PositionCorrection {
|
||||||
|
track_id,
|
||||||
|
position_ms,
|
||||||
|
..
|
||||||
|
} => match track_id.to_base62() {
|
||||||
|
Err(e) => {
|
||||||
|
warn!("PlayerEvent::PositionCorrection: Invalid track id: {}", e)
|
||||||
|
}
|
||||||
|
Ok(id) => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "position_correction".to_string());
|
||||||
|
env_vars.insert("TRACK_ID", id);
|
||||||
|
env_vars.insert("POSITION_MS", position_ms.to_string());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PlayerEvent::SessionConnected {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
} => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "session_connected".to_string());
|
||||||
|
env_vars.insert("CONNECTION_ID", connection_id);
|
||||||
|
env_vars.insert("USER_NAME", user_name);
|
||||||
|
}
|
||||||
|
PlayerEvent::SessionDisconnected {
|
||||||
|
connection_id,
|
||||||
|
user_name,
|
||||||
|
} => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "session_disconnected".to_string());
|
||||||
|
env_vars.insert("CONNECTION_ID", connection_id);
|
||||||
|
env_vars.insert("USER_NAME", user_name);
|
||||||
|
}
|
||||||
|
PlayerEvent::SessionClientChanged {
|
||||||
|
client_id,
|
||||||
|
client_name,
|
||||||
|
client_brand_name,
|
||||||
|
client_model_name,
|
||||||
|
} => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "session_client_changed".to_string());
|
||||||
|
env_vars.insert("CLIENT_ID", client_id);
|
||||||
|
env_vars.insert("CLIENT_NAME", client_name);
|
||||||
|
env_vars.insert("CLIENT_BRAND_NAME", client_brand_name);
|
||||||
|
env_vars.insert("CLIENT_MODEL_NAME", client_model_name);
|
||||||
|
}
|
||||||
|
PlayerEvent::ShuffleChanged { shuffle } => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "shuffle_changed".to_string());
|
||||||
|
env_vars.insert("SHUFFLE", shuffle.to_string());
|
||||||
|
}
|
||||||
|
PlayerEvent::RepeatChanged { repeat } => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "repeat_changed".to_string());
|
||||||
|
env_vars.insert("REPEAT", repeat.to_string());
|
||||||
|
}
|
||||||
|
PlayerEvent::AutoPlayChanged { auto_play } => {
|
||||||
|
env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string());
|
||||||
|
env_vars.insert("AUTO_PLAY", auto_play.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut v: Vec<&str> = onevent.split_whitespace().collect();
|
PlayerEvent::FilterExplicitContentChanged { filter } => {
|
||||||
info!("Running {:?} with environment variables {:?}", v, env_vars);
|
env_vars.insert(
|
||||||
Some(
|
"PLAYER_EVENT",
|
||||||
AsyncCommand::new(&v.remove(0))
|
"filter_explicit_content_changed".to_string(),
|
||||||
.args(&v)
|
);
|
||||||
.envs(env_vars.iter())
|
env_vars.insert("FILTER", filter.to_string());
|
||||||
.spawn(),
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_sink_event(sink_status: SinkStatus, onevent: &str) -> Result<ExitStatus> {
|
if !env_vars.is_empty() {
|
||||||
|
run_program(env_vars, &on_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Self { thread_handle }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EventHandler {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
debug!("Shutting down EventHandler thread ...");
|
||||||
|
if let Some(handle) = self.thread_handle.take() {
|
||||||
|
if let Err(e) = handle.join() {
|
||||||
|
error!("EventHandler thread Error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_program_on_sink_events(sink_status: SinkStatus, onevent: &str) {
|
||||||
let mut env_vars = HashMap::new();
|
let mut env_vars = HashMap::new();
|
||||||
|
|
||||||
env_vars.insert("PLAYER_EVENT", "sink".to_string());
|
env_vars.insert("PLAYER_EVENT", "sink".to_string());
|
||||||
|
|
||||||
let sink_status = match sink_status {
|
let sink_status = match sink_status {
|
||||||
SinkStatus::Running => "running",
|
SinkStatus::Running => "running",
|
||||||
SinkStatus::TemporarilyClosed => "temporarily_closed",
|
SinkStatus::TemporarilyClosed => "temporarily_closed",
|
||||||
SinkStatus::Closed => "closed",
|
SinkStatus::Closed => "closed",
|
||||||
};
|
};
|
||||||
env_vars.insert("SINK_STATUS", sink_status.to_string());
|
|
||||||
let mut v: Vec<&str> = onevent.split_whitespace().collect();
|
|
||||||
info!("Running {:?} with environment variables {:?}", v, env_vars);
|
|
||||||
|
|
||||||
Command::new(&v.remove(0))
|
env_vars.insert("SINK_STATUS", sink_status.to_string());
|
||||||
|
|
||||||
|
run_program(env_vars, onevent);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_program(env_vars: HashMap<&str, String>, onevent: &str) {
|
||||||
|
let mut v: Vec<&str> = onevent.split_whitespace().collect();
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Running {} with environment variables:\n{:#?}",
|
||||||
|
onevent, env_vars
|
||||||
|
);
|
||||||
|
|
||||||
|
match Command::new(&v.remove(0))
|
||||||
.args(&v)
|
.args(&v)
|
||||||
.envs(env_vars.iter())
|
.envs(env_vars.iter())
|
||||||
.spawn()?
|
.spawn()
|
||||||
.wait()
|
{
|
||||||
|
Err(e) => warn!("On event program {} failed to start: {}", onevent, e),
|
||||||
|
Ok(mut child) => match child.wait() {
|
||||||
|
Err(e) => warn!("On event program {} failed: {}", onevent, e),
|
||||||
|
Ok(e) if e.success() => (),
|
||||||
|
Ok(e) => {
|
||||||
|
if let Some(code) = e.code() {
|
||||||
|
warn!("On event program {} returned exit code {}", onevent, code);
|
||||||
|
} else {
|
||||||
|
warn!("On event program {} returned failure: {}", onevent, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue