From d45248d6c612904528cb47fdb1cc436220601fa5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:15:56 +0000 Subject: [PATCH 1/5] Initial plan From 757174d25fa619c813ba09a31ece572b9cc4108e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:27:00 +0000 Subject: [PATCH 2/5] Add Discord Rich Presence and privacy controls for social presence sharing Co-authored-by: isaaclins <104733575+isaaclins@users.noreply.github.com> --- psst-gui/Cargo.toml | 2 + psst-gui/src/controller/playback.rs | 173 +++++++++++++++++++++++++--- psst-gui/src/data/config.rs | 13 +++ psst-gui/src/ui/preferences.rs | 62 ++++++++++ 4 files changed, 236 insertions(+), 14 deletions(-) diff --git a/psst-gui/Cargo.toml b/psst-gui/Cargo.toml index 7b771401..1b5be055 100644 --- a/psst-gui/Cargo.toml +++ b/psst-gui/Cargo.toml @@ -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" } diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index a432ed25..f16f6596 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -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, @@ -40,6 +44,7 @@ pub struct PlaybackController { media_controls: Option, has_scrobbled: bool, scrobbler: Option, + discord_client: Option, startup: bool, sender_disconnected: bool, } @@ -69,6 +74,36 @@ fn init_scrobbler_instance(data: &AppState) -> Option { None } +// Discord application ID for Psst +// This is a placeholder - in a real implementation, you would register your app at https://discord.com/developers +const DISCORD_APP_ID: &str = "1234567890123456789"; + +fn init_discord_client(config: &Config) -> Option { + if !config.enable_discord_presence { + log::info!("Discord Rich Presence is disabled"); + return None; + } + + match DiscordIpcClient::new(DISCORD_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 { @@ -78,6 +113,7 @@ impl PlaybackController { media_controls: None, has_scrobbled: false, scrobbler: None, + discord_client: None, startup: true, sender_disconnected: false, } @@ -243,20 +279,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() @@ -338,6 +386,75 @@ 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 based on privacy settings) + let mut state_parts = Vec::new(); + if config.presence_show_artist { + if let Playable::Track(track) = &now_playing.item { + state_parts.push(track.artist_name().to_string()); + } + } + if config.presence_show_album { + if let Playable::Track(track) = &now_playing.item { + if let Some(album) = &track.album { + state_parts.push(album.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, position: usize) { let playback_items = items.iter().map(|queued| PlaybackItem { item_id: queued.item.id(), @@ -455,7 +572,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"); } @@ -471,7 +589,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); } @@ -491,11 +610,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) => { @@ -505,6 +626,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) => { @@ -638,6 +760,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); } @@ -663,6 +786,28 @@ 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; + + 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); } } diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index ac7ddb13..aa5f196a 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -49,6 +49,7 @@ pub enum PreferencesTab { General, Appearance, Account, + Privacy, Cache, About, } @@ -139,6 +140,14 @@ 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 presence_show_artist: bool, + #[serde(default)] + pub presence_show_album: bool, + #[serde(default)] + pub presence_show_track_duration: bool, } impl Default for Config { @@ -166,6 +175,10 @@ impl Default for Config { lastfm_api_secret: None, lastfm_enable: false, sidebar_visible: true, + enable_discord_presence: false, + presence_show_artist: true, + presence_show_album: true, + presence_show_track_duration: true, } } } diff --git a/psst-gui/src/ui/preferences.rs b/psst-gui/src/ui/preferences.rs index bcadbca5..601826ce 100644 --- a/psst-gui/src/ui/preferences.rs +++ b/psst-gui/src/ui/preferences.rs @@ -95,6 +95,7 @@ pub fn preferences_widget() -> impl Widget { PreferencesTab::Account => { account_tab_widget(AccountTab::InPreferences).boxed() } + PreferencesTab::Privacy => privacy_tab_widget().boxed(), PreferencesTab::Cache => cache_tab_widget().boxed(), PreferencesTab::About => about_tab_widget().boxed(), }, @@ -157,6 +158,12 @@ fn tabs_widget() -> impl Widget { PreferencesTab::Account, )) .with_default_spacer() + .with_child(tab_link_widget( + "Privacy", + &icons::PREFERENCES, + PreferencesTab::Privacy, + )) + .with_default_spacer() .with_child(tab_link_widget( "Cache", &icons::STORAGE, @@ -1028,6 +1035,61 @@ impl> Controller for Authenticate { } } +fn privacy_tab_widget() -> impl Widget { + let mut col = Flex::column() + .cross_axis_alignment(CrossAxisAlignment::Start) + .must_fill_main_axis(true); + + // Discord Rich Presence section + col = col + .with_child(Label::new("Social Presence").with_font(theme::UI_FONT_MEDIUM)) + .with_spacer(theme::grid(2.0)) + .with_child( + Label::new("Control what information is shared when you're listening to music.") + .with_text_color(theme::PLACEHOLDER_COLOR) + .with_line_break_mode(LineBreaking::WordWrap), + ) + .with_spacer(theme::grid(2.0)); + + col = col + .with_child( + Checkbox::new("Enable Discord Rich Presence") + .lens(AppState::config.then(Config::enable_discord_presence)), + ) + .with_spacer(theme::grid(1.0)); + + col = col.with_spacer(theme::grid(3.0)); + + // Privacy controls section + col = col + .with_child(Label::new("Presence Information").with_font(theme::UI_FONT_MEDIUM)) + .with_spacer(theme::grid(2.0)) + .with_child( + Label::new("Choose what information to display in Discord Rich Presence and macOS Now Playing.") + .with_text_color(theme::PLACEHOLDER_COLOR) + .with_line_break_mode(LineBreaking::WordWrap), + ) + .with_spacer(theme::grid(2.0)); + + col = col + .with_child( + Checkbox::new("Show artist name") + .lens(AppState::config.then(Config::presence_show_artist)), + ) + .with_spacer(theme::grid(1.0)) + .with_child( + Checkbox::new("Show album name") + .lens(AppState::config.then(Config::presence_show_album)), + ) + .with_spacer(theme::grid(1.0)) + .with_child( + Checkbox::new("Show track duration") + .lens(AppState::config.then(Config::presence_show_track_duration)), + ); + + col +} + fn cache_tab_widget() -> impl Widget { let mut col = Flex::column().cross_axis_alignment(CrossAxisAlignment::Start); From 9eda85a72cd5eb96d5bc22652b80653624de77c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:30:14 +0000 Subject: [PATCH 3/5] Update Cargo.lock with discord-rich-presence dependency Co-authored-by: isaaclins <104733575+isaaclins@users.noreply.github.com> --- Cargo.lock | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6577cbbf..456986bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,7 +640,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid", + "uuid 1.18.0", ] [[package]] @@ -1137,6 +1137,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" +[[package]] +name = "discord-rich-presence" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b" +dependencies = [ + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "uuid 0.8.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -3898,6 +3911,7 @@ version = "0.1.0" dependencies = [ "crossbeam-channel", "directories", + "discord-rich-presence", "druid", "druid-enums", "druid-shell", @@ -5677,6 +5691,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.16", +] + [[package]] name = "uuid" version = "1.18.0" From f1470745a68a288a4f5559e245fa189a8e28d7a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:32:06 +0000 Subject: [PATCH 4/5] Improve Discord presence to properly handle podcast episodes Co-authored-by: isaaclins <104733575+isaaclins@users.noreply.github.com> --- psst-gui/src/controller/playback.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index f16f6596..e6b72a32 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -396,17 +396,23 @@ impl PlaybackController { // Set details (track name is always shown) activity = activity.details(now_playing.item.name().as_ref()); - // Set state (artist and/or album based on privacy settings) + // Set state (artist and/or album for tracks, show name for episodes) let mut state_parts = Vec::new(); - if config.presence_show_artist { - if let Playable::Track(track) = &now_playing.item { - state_parts.push(track.artist_name().to_string()); + 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()); + } + } } - } - if config.presence_show_album { - if let Playable::Track(track) = &now_playing.item { - 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()); } } } From 8aeafaa002d3b99b2f2b1ac6e5b374d88220a65a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 23:40:47 +0000 Subject: [PATCH 5/5] Allow users to enter their own Discord App ID in settings Co-authored-by: isaaclins <104733575+isaaclins@users.noreply.github.com> --- psst-gui/src/controller/playback.rs | 15 +++++++++------ psst-gui/src/data/config.rs | 3 +++ psst-gui/src/ui/preferences.rs | 23 ++++++++++++++++++++++- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index e6b72a32..538941bd 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -74,17 +74,19 @@ fn init_scrobbler_instance(data: &AppState) -> Option { None } -// Discord application ID for Psst -// This is a placeholder - in a real implementation, you would register your app at https://discord.com/developers -const DISCORD_APP_ID: &str = "1234567890123456789"; - fn init_discord_client(config: &Config) -> Option { if !config.enable_discord_presence { log::info!("Discord Rich Presence is disabled"); return None; } - match DiscordIpcClient::new(DISCORD_APP_ID) { + 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(()) => { @@ -793,7 +795,8 @@ where } // Reinitialize Discord client if presence settings changed - let discord_changed = old_data.config.enable_discord_presence != data.config.enable_discord_presence; + 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 diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index aa5f196a..af389b89 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -143,6 +143,8 @@ pub struct Config { #[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, @@ -176,6 +178,7 @@ impl Default for Config { 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, diff --git a/psst-gui/src/ui/preferences.rs b/psst-gui/src/ui/preferences.rs index 601826ce..f9fa7b91 100644 --- a/psst-gui/src/ui/preferences.rs +++ b/psst-gui/src/ui/preferences.rs @@ -1056,7 +1056,28 @@ fn privacy_tab_widget() -> impl Widget { Checkbox::new("Enable Discord Rich Presence") .lens(AppState::config.then(Config::enable_discord_presence)), ) - .with_spacer(theme::grid(1.0)); + .with_spacer(theme::grid(2.0)); + + // Discord App ID input + col = col + .with_child( + Label::new("Discord Application ID:") + .with_text_size(theme::TEXT_SIZE_SMALL), + ) + .with_spacer(theme::grid(0.5)) + .with_child( + TextBox::new() + .with_placeholder("Enter your Discord Application ID") + .lens(AppState::config.then(Config::discord_app_id)) + .fix_width(theme::grid(30.0)), + ) + .with_spacer(theme::grid(0.5)) + .with_child( + Label::new("Register an application at discord.com/developers to get an Application ID") + .with_text_color(theme::PLACEHOLDER_COLOR) + .with_text_size(theme::TEXT_SIZE_SMALL) + .with_line_break_mode(LineBreaking::WordWrap), + ); col = col.with_spacer(theme::grid(3.0));