Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions psst-gui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ raw-window-handle = "0.5.2" # Must stay compatible with Druid
souvlaki = { version = "0.8.2", default-features = false, features = ["use_zbus"] }
sanitize_html = "0.9.0"
rustfm-scrobble = "1.1.1"
discord-rich-presence = "0.2.4"

[target.'cfg(windows)'.build-dependencies]
winres = { version = "0.1.12" }
image = { version = "0.25.6" }
Expand Down
182 changes: 168 additions & 14 deletions psst-gui/src/controller/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
};
use std::time::{SystemTime, UNIX_EPOCH};
use discord_rich_presence::{
activity::{Activity, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};

use crate::{
cmd,
Expand All @@ -40,6 +44,7 @@ pub struct PlaybackController {
media_controls: Option<MediaControls>,
has_scrobbled: bool,
scrobbler: Option<Scrobbler>,
discord_client: Option<DiscordIpcClient>,
startup: bool,
sender_disconnected: bool,
}
Expand Down Expand Up @@ -69,6 +74,38 @@ fn init_scrobbler_instance(data: &AppState) -> Option<Scrobbler> {
None
}

fn init_discord_client(config: &Config) -> Option<DiscordIpcClient> {
if !config.enable_discord_presence {
log::info!("Discord Rich Presence is disabled");
return None;
}

let app_id = config.discord_app_id.trim();
if app_id.is_empty() {
log::warn!("Discord Rich Presence enabled but no Application ID configured");
return None;
}

match DiscordIpcClient::new(app_id) {
Ok(mut client) => {
match client.connect() {
Ok(()) => {
log::info!("Discord Rich Presence connected successfully");
Some(client)
}
Err(e) => {
log::warn!("Failed to connect to Discord Rich Presence: {}", e);
None
}
}
}
Err(e) => {
log::warn!("Failed to create Discord IPC client: {}", e);
None
}
}
}

impl PlaybackController {
pub fn new() -> Self {
Self {
Expand All @@ -78,6 +115,7 @@ impl PlaybackController {
media_controls: None,
has_scrobbled: false,
scrobbler: None,
discord_client: None,
startup: true,
sender_disconnected: false,
}
Expand Down Expand Up @@ -243,20 +281,32 @@ impl PlaybackController {
}
}

fn update_media_control_metadata(&mut self, playback: &Playback) {
fn update_media_control_metadata(&mut self, playback: &Playback, config: &Config) {
if let Some(media_controls) = self.media_controls.as_mut() {
let title = playback.now_playing.as_ref().map(|p| p.item.name().clone());
let album = playback
.now_playing
.as_ref()
.and_then(|p| p.item.track())
.map(|t| t.album_name());
let artist = playback
.now_playing
.as_ref()
.and_then(|p| p.item.track())
.map(|t| t.artist_name());
let duration = playback.now_playing.as_ref().map(|p| p.item.duration());
let album = if config.presence_show_album {
playback
.now_playing
.as_ref()
.and_then(|p| p.item.track())
.map(|t| t.album_name())
} else {
None
};
let artist = if config.presence_show_artist {
playback
.now_playing
.as_ref()
.and_then(|p| p.item.track())
.map(|t| t.artist_name())
} else {
None
};
let duration = if config.presence_show_track_duration {
playback.now_playing.as_ref().map(|p| p.item.duration())
} else {
None
};
let cover_url = playback
.now_playing
.as_ref()
Expand Down Expand Up @@ -338,6 +388,81 @@ impl PlaybackController {
}
}

fn update_discord_presence(&mut self, playback: &Playback, config: &Config) {
if let Some(client) = &mut self.discord_client {
match playback.state {
PlaybackState::Playing => {
if let Some(now_playing) = &playback.now_playing {
let mut activity = Activity::new();

// Set details (track name is always shown)
activity = activity.details(now_playing.item.name().as_ref());

// Set state (artist and/or album for tracks, show name for episodes)
let mut state_parts = Vec::new();
match &now_playing.item {
Playable::Track(track) => {
if config.presence_show_artist {
state_parts.push(track.artist_name().to_string());
}
if config.presence_show_album {
if let Some(album) = &track.album {
state_parts.push(album.name.to_string());
}
}
}
Playable::Episode(episode) => {
// For episodes, show the podcast name
if config.presence_show_artist {
state_parts.push(episode.show.name.to_string());
}
}
}
if !state_parts.is_empty() {
activity = activity.state(&state_parts.join(" • "));
}

// Set timestamps based on privacy settings
if config.presence_show_track_duration {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let elapsed = now_playing.progress.as_secs() as i64;
let duration = now_playing.item.duration().as_secs() as i64;
let start_time = now - elapsed;
let end_time = start_time + duration;

activity = activity.timestamps(
Timestamps::new()
.start(start_time)
.end(end_time)
);
}

// Set large image (album/episode art)
activity = activity.assets(
Assets::new()
.large_image("psst_logo")
.large_text("Psst - Fast Spotify Client")
);

if let Err(e) = client.set_activity(activity) {
log::warn!("Failed to update Discord Rich Presence: {}", e);
}
}
}
PlaybackState::Paused | PlaybackState::Stopped => {
// Clear the presence when paused or stopped
if let Err(e) = client.clear_activity() {
log::warn!("Failed to clear Discord Rich Presence: {}", e);
}
}
_ => {}
}
}
}

fn play(&mut self, items: &Vector<QueueEntry>, position: usize) {
let playback_items = items.iter().map(|queued| PlaybackItem {
item_id: queued.item.id(),
Expand Down Expand Up @@ -455,7 +580,8 @@ where
if let Some(queued) = data.queued_entry(*item) {
data.loading_playback(queued.item, queued.origin);
self.update_media_control_playback(&data.playback);
self.update_media_control_metadata(&data.playback);
self.update_media_control_metadata(&data.playback, &data.config);
self.update_discord_presence(&data.playback, &data.config);
} else {
log::warn!("loaded item not found in playback queue");
}
Expand All @@ -471,7 +597,8 @@ where
if let Some(queued) = data.queued_entry(*item) {
data.start_playback(queued.item, queued.origin, progress.to_owned());
self.update_media_control_playback(&data.playback);
self.update_media_control_metadata(&data.playback);
self.update_media_control_metadata(&data.playback, &data.config);
self.update_discord_presence(&data.playback, &data.config);
if let Some(now_playing) = &data.playback.now_playing {
self.update_lyrics(ctx, data, now_playing);
}
Expand All @@ -491,11 +618,13 @@ where
Event::Command(cmd) if cmd.is(cmd::PLAYBACK_PAUSING) => {
data.pause_playback();
self.update_media_control_playback(&data.playback);
self.update_discord_presence(&data.playback, &data.config);
ctx.set_handled();
}
Event::Command(cmd) if cmd.is(cmd::PLAYBACK_RESUMING) => {
data.resume_playback();
self.update_media_control_playback(&data.playback);
self.update_discord_presence(&data.playback, &data.config);
ctx.set_handled();
}
Event::Command(cmd) if cmd.is(cmd::PLAYBACK_BLOCKED) => {
Expand All @@ -505,6 +634,7 @@ where
Event::Command(cmd) if cmd.is(cmd::PLAYBACK_STOPPED) => {
data.stop_playback();
self.update_media_control_playback(&data.playback);
self.update_discord_presence(&data.playback, &data.config);
ctx.set_handled();
}
Event::Command(cmd) if cmd.is(cmd::PLAY_TRACKS) => {
Expand Down Expand Up @@ -638,6 +768,7 @@ where
if self.startup {
self.startup = false;
self.scrobbler = init_scrobbler_instance(data);
self.discord_client = init_discord_client(&data.config);
}
child.lifecycle(ctx, event, data, env);
}
Expand All @@ -663,6 +794,29 @@ where
self.scrobbler = init_scrobbler_instance(data);
}

// Reinitialize Discord client if presence settings changed
let discord_changed = old_data.config.enable_discord_presence != data.config.enable_discord_presence
|| old_data.config.discord_app_id != data.config.discord_app_id;

if discord_changed {
// Disconnect existing client if any
if let Some(mut client) = self.discord_client.take() {
let _ = client.close();
}
// Initialize new client if enabled
self.discord_client = init_discord_client(&data.config);
}

// Update presence if privacy settings changed
let privacy_changed = old_data.config.presence_show_artist != data.config.presence_show_artist
|| old_data.config.presence_show_album != data.config.presence_show_album
|| old_data.config.presence_show_track_duration != data.config.presence_show_track_duration;

if privacy_changed {
self.update_discord_presence(&data.playback, &data.config);
self.update_media_control_metadata(&data.playback, &data.config);
}

child.update(ctx, old_data, data, env);
}
}
Expand Down
16 changes: 16 additions & 0 deletions psst-gui/src/data/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub enum PreferencesTab {
General,
Appearance,
Account,
Privacy,
Cache,
About,
}
Expand Down Expand Up @@ -139,6 +140,16 @@ pub struct Config {
pub lastfm_enable: bool,
#[serde(default = "default_sidebar_visible")]
pub sidebar_visible: bool,
#[serde(default)]
pub enable_discord_presence: bool,
#[serde(default)]
pub discord_app_id: String,
#[serde(default)]
pub presence_show_artist: bool,
#[serde(default)]
pub presence_show_album: bool,
#[serde(default)]
pub presence_show_track_duration: bool,
}

impl Default for Config {
Expand Down Expand Up @@ -166,6 +177,11 @@ impl Default for Config {
lastfm_api_secret: None,
lastfm_enable: false,
sidebar_visible: true,
enable_discord_presence: false,
discord_app_id: String::new(),
presence_show_artist: true,
presence_show_album: true,
presence_show_track_duration: true,
}
}
}
Expand Down
Loading