Skip to content

Commit

Permalink
Add player event hook command (#244)
Browse files Browse the repository at this point in the history
Resolves #190. 
Resolves #103.

Added `player_event_hook_command` as a config option. Each time the application receives a player event, `player_event_hook_command ` is executed with the event JSON string as the standard input.
  • Loading branch information
aome510 authored Sep 4, 2023
1 parent 24e376a commit ceb0f53
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 13 deletions.
33 changes: 30 additions & 3 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- [General](#general)
- [Notes](#notes)
- [Media control](#media-control)
- [Player event hook command](#player-event-hook-command)
- [Device configurations](#device-configurations)
- [Themes](#themes)
- [Use script to add theme](#use-script-to-add-theme)
Expand All @@ -27,6 +29,7 @@ All configuration files should be placed inside the application's configuration
| `playback_format` | the format of the text in the playback's window | `{track} • {artists}\n{album}\n{metadata}` |
| `notify_format` | the format of a notification (`notify` feature only) | `{ summary = "{track} • {artists}", body = "{album}" }` |
| `copy_command` | the command used to execute a copy-to-clipboard action | `xclip -sel c` (Linux), `pbcopy` (MacOS), `clip` (Windows) |
| `player_event_hook_command` | the hook command executed when there is a new player event | `None` |
| `ap_port` | the application's Spotify session connection port | `None` |
| `proxy` | the application's Spotify session connection proxy | `None` |
| `theme` | the application's theme | `default` |
Expand All @@ -36,12 +39,12 @@ All configuration files should be placed inside the application's configuration
| `page_size_in_rows` | a page's size expressed as a number of rows (for page-navigation commands) | `20` |
| `track_table_item_max_len` | the maximum length of a column in a track table | `32` |
| `enable_media_control` | enable application media control support (`media-control` feature only) | `true` (Linux), `false` (Windows and MacOS) |
| `enable_streaming` | create a device for streaming (streaming feature only) | `Always` |
| `enable_streaming` | create a device for streaming (streaming feature only) | `Always` |
| `enable_cover_image_cache` | store album's cover images in the cache folder | `true` |
| `default_device` | the default device to connect to on startup if no playing device found | `spotify-player` |
| `play_icon` | the icon to indicate playing state of a Spotify item | `` |
| `play_icon` | the icon to indicate playing state of a Spotify item | `` |
| `pause_icon` | the icon to indicate pause state of a Spotify item | `▌▌` |
| `liked_icon` | the icon to indicate the liked state of a song | `` |
| `liked_icon` | the icon to indicate the liked state of a song | `` |
| `border_type` | the type of the application's borders | `Plain` |
| `progress_bar_type` | the type of the playback progress bar | `Rectangle` |
| `playback_window_position` | the position of the playback window | `Top` |
Expand Down Expand Up @@ -83,6 +86,30 @@ Media control support (`enable_media_control` option) is enabled by default on L

MacOS and Windows require **an open window** to listen to OS media event. As a result, `spotify_player` needs to spawn an invisible window on startup, which may steal focus from the running terminal. To interact with `spotify_player`, which is run on the terminal, user will need to re-focus the terminal. Because of this extra re-focus step, the media control support is disabled by default on MacOS and Windows to avoid possible confusion for first-time users.

### Player event hook command

Similar to `copy_command`, if specified, `player_event_hook_command` should be a struct with two fields `command` and `args`. Each time `spotify_player` receives a new player event, `player_event_hook_command` is executed with the event as the **standard input**.

A player event is a `json` string with either of the following values:

- `{ Changed: { old_track_id: String, new_track_id: String } }`
- `{ Playing: { track_id: String, position_ms: Number, duration_ms: Number } }`
- `{ Paused: { track_id: String, position_ms: Number, duration_ms: Number } }`
- `{ EndOfTrack: { track_id: String } }`

Example `player_event_hook_command` script, which reads the event from **stdin**, parses the event as a `json` string, and writes the parsed event into a file:

```python
#!/usr/bin/python3

import json

player_event = json.loads(input())

with open("/tmp/spotify_player_events.txt", "a") as f:
f.write(f"{player_event}\n")
```

### Device configurations

The configuration options for the [Librespot](https://github.com/librespot-org/librespot) integrated device are specified under the `[device]` section in the `app.toml` file:
Expand Down
2 changes: 1 addition & 1 deletion examples/app.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ tracks_playback_limit = 50
playback_format = "{track} • {artists}\n{album}\n{metadata}"
notify_format = { summary = "{track} • {artists}", body = "{album}" }
# the default `copy_command` is based on the OS
copy_command = { command = "pbcopy", args = [] } # macos
# copy_command = { command = "pbcopy", args = [] } # macos
# copy_command = { command = "xclip", args = ["-sel", "c"] } # linux
# copy_command = { command = "clip", args = [] } # windows
app_refresh_duration_in_ms = 32
Expand Down
1 change: 1 addition & 0 deletions spotify_player/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ impl Client {
session,
state.app_config.device.clone(),
self.client_pub.clone(),
state.app_config.player_event_hook_command.clone(),
);

let mut stream_conn = self.stream_conn.lock();
Expand Down
3 changes: 3 additions & 0 deletions spotify_player/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub struct AppConfig {
pub client_port: u16,

pub copy_command: Command,
pub player_event_hook_command: Option<Command>,

pub playback_format: String,
#[cfg(feature = "notify")]
Expand Down Expand Up @@ -197,6 +198,8 @@ impl Default for AppConfig {
args: vec![],
},

player_event_hook_command: None,

proxy: None,
ap_port: None,
app_refresh_duration_in_ms: 32,
Expand Down
129 changes: 120 additions & 9 deletions spotify_player/src/streaming.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,119 @@
use std::io::Write;

use crate::{config, event::ClientRequest};
use anyhow::Context;
use librespot_connect::spirc::Spirc;
use librespot_core::{
config::{ConnectConfig, DeviceType},
session::Session,
spotify_id,
};
use librespot_playback::mixer::MixerConfig;
use librespot_playback::{
audio_backend,
config::{AudioFormat, Bitrate, PlayerConfig},
mixer::{self, Mixer},
player::Player,
player,
};
use rspotify::model::TrackId;
use serde::Serialize;

#[derive(Debug, Serialize)]
enum PlayerEvent {
Changed {
old_track_id: TrackId<'static>,
new_track_id: TrackId<'static>,
},
Playing {
track_id: TrackId<'static>,
position_ms: u32,
duration_ms: u32,
},
Paused {
track_id: TrackId<'static>,
position_ms: u32,
duration_ms: u32,
},
EndOfTrack {
track_id: TrackId<'static>,
},
}

fn spotify_id_to_track_id(id: spotify_id::SpotifyId) -> anyhow::Result<TrackId<'static>> {
let uri = id.to_uri()?;
Ok(TrackId::from_uri(&uri)?.into_static())
}

impl PlayerEvent {
pub fn from_librespot_player_event(e: player::PlayerEvent) -> anyhow::Result<Option<Self>> {
Ok(match e {
player::PlayerEvent::Changed {
old_track_id,
new_track_id,
} => Some(PlayerEvent::Changed {
old_track_id: spotify_id_to_track_id(old_track_id)?,
new_track_id: spotify_id_to_track_id(new_track_id)?,
}),
player::PlayerEvent::Playing {
track_id,
position_ms,
duration_ms,
..
} => Some(PlayerEvent::Playing {
track_id: spotify_id_to_track_id(track_id)?,
position_ms,
duration_ms,
}),
player::PlayerEvent::Paused {
track_id,
position_ms,
duration_ms,
..
} => Some(PlayerEvent::Paused {
track_id: spotify_id_to_track_id(track_id)?,
position_ms,
duration_ms,
}),
player::PlayerEvent::EndOfTrack { track_id, .. } => Some(PlayerEvent::EndOfTrack {
track_id: spotify_id_to_track_id(track_id)?,
}),
_ => None,
})
}
}

fn execute_player_event_hook_command(
cmd: &config::Command,
event: PlayerEvent,
) -> anyhow::Result<()> {
let data = serde_json::to_vec(&event).context("serialize player event into json")?;

let mut child = std::process::Command::new(&cmd.command)
.args(&cmd.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()?;

let mut stdin = match child.stdin.take() {
Some(stdin) => stdin,
None => anyhow::bail!("no stdin found in the child command"),
};

stdin.write_all(&data)?;
Ok(())
}

/// Create a new streaming connection
pub fn new_connection(
session: Session,
device: config::DeviceConfig,
client_pub: flume::Sender<ClientRequest>,
player_event_hook_command: Option<config::Command>,
) -> Spirc {
// librespot volume is a u16 number ranging from 0 to 65535,
// `librespot` volume is a u16 number ranging from 0 to 65535,
// while a percentage volume value (from 0 to 100) is used for the device configuration.
// So we need to convert from one format to another
let volume = (std::cmp::min(device.volume, 100_u8) as f64 / 100.0 * 65535_f64).round() as u16;
let volume = (std::cmp::min(device.volume, 100_u8) as f64 / 100.0 * 65535.0).round() as u16;

let connect_config = ConnectConfig {
name: device.name,
Expand Down Expand Up @@ -55,7 +147,7 @@ pub fn new_connection(
session.device_id()
);

let (player, mut channel) = Player::new(
let (player, mut channel) = player::Player::new(
player_config,
session.clone(),
mixer.get_soft_volume(),
Expand All @@ -65,11 +157,30 @@ pub fn new_connection(
let player_event_task = tokio::task::spawn({
async move {
while let Some(event) = channel.recv().await {
tracing::info!("Got an event from the integrated player: {:?}", event);
client_pub
.send_async(ClientRequest::GetCurrentPlayback)
.await
.unwrap_or_default();
match PlayerEvent::from_librespot_player_event(event) {
Err(err) => {
tracing::warn!("Failed to convert a `librespot` player event into `spotify_player` player event: {err:#}");
}
Ok(Some(event)) => {
tracing::info!("Got a new player event: {event:?}");

// execute a player event hook command
if let Some(ref cmd) = player_event_hook_command {
if let Err(err) = execute_player_event_hook_command(cmd, event) {
tracing::warn!(
"Failed to execute player event hook command: {err:#}"
);
}
}

// notify the application about the new player event by making playback update request
client_pub
.send_async(ClientRequest::GetCurrentPlayback)
.await
.unwrap_or_default();
}
Ok(None) => {}
}
}
}
});
Expand Down

0 comments on commit ceb0f53

Please sign in to comment.