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
fix(macos-media): add album artwork to macOS Now Playing metadata
fix(macos-media): add album artwork to macOS Now Playing metadata

- Extend MacMediaManager metadata command to carry optional artwork URL.
- Fetch artwork image and set MPMediaItemPropertyArtwork in Now Playing
info.
- Add macOS metadata sync path in main loop with dedupe/caching
behavior.
- Wire objc2-app-kit (NSImage) into macos-media feature/dependencies.
- Update track-changed call sites and lockfile accordingly.
  • Loading branch information
wfinken committed Mar 2, 2026
commit bee71dbbe97fd6418b32b665f1100b90b13a3f10
1 change: 1 addition & 0 deletions Cargo.lock

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

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ mpris-server = { version = "0.9", optional = true }
librespot-playback = { version = "0.8", optional = true, default-features = false, features = ["portaudio-backend"] }
objc2-media-player = { version = "0.3", optional = true }
objc2-foundation = { version = "0.3", optional = true }
objc2-app-kit = { version = "0.3", optional = true, default-features = false, features = ["NSImage"] }
objc2 = { version = "0.6", optional = true }
block2 = { version = "0.6", optional = true }

Expand All @@ -110,7 +111,7 @@ gstreamer-backend = ["streaming", "librespot-playback/gstreamer-backend"]
audio-viz = ["realfft", "pipewire"]
audio-viz-cpal = ["realfft", "cpal"] # Alternative for Windows/macOS or if pipewire issues
mpris = ["mpris-server", "streaming"] # MPRIS D-Bus integration (Linux only, requires streaming)
macos-media = ["objc2-media-player", "objc2-foundation", "objc2", "block2", "streaming"] # macOS Now Playing integration
macos-media = ["objc2-media-player", "objc2-foundation", "objc2-app-kit", "objc2", "block2", "streaming"] # macOS Now Playing integration
discord-rpc = ["discord-rich-presence"]
cover-art = ["ratatui-image", "image"]

Expand Down
142 changes: 95 additions & 47 deletions src/infra/macos_media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ use block2::RcBlock;
use log::info;
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject};
use objc2_foundation::{NSDate, NSMutableDictionary, NSNumber, NSRunLoop, NSString};
use objc2::AnyThread;
use objc2_app_kit::NSImage;
use objc2_foundation::{NSData, NSDate, NSMutableDictionary, NSNumber, NSRunLoop, NSString};
use objc2_media_player::{
MPMediaItemPropertyAlbumTitle, MPMediaItemPropertyArtist, MPMediaItemPropertyPlaybackDuration,
MPMediaItemPropertyTitle, MPNowPlayingInfoCenter, MPNowPlayingInfoPropertyElapsedPlaybackTime,
MPMediaItemArtwork, MPMediaItemPropertyAlbumTitle, MPMediaItemPropertyArtist,
MPMediaItemPropertyArtwork, MPMediaItemPropertyPlaybackDuration, MPMediaItemPropertyTitle,
MPNowPlayingInfoCenter, MPNowPlayingInfoPropertyElapsedPlaybackTime,
MPNowPlayingInfoPropertyPlaybackRate, MPNowPlayingPlaybackState, MPRemoteCommandCenter,
MPRemoteCommandEvent, MPRemoteCommandHandlerStatus,
};
Expand Down Expand Up @@ -45,6 +48,7 @@ pub enum MacMediaCommand {
artists: Vec<String>,
album: String,
duration_ms: u32,
art_url: Option<String>,
},
SetPlaybackStatus(bool), // true = playing, false = paused
SetPosition(u64), // position in milliseconds
Expand Down Expand Up @@ -193,7 +197,7 @@ impl MacMediaManager {
loop {
tokio::select! {
Some(cmd) = command_rx.recv() => {
handle_now_playing_command(&cmd, &info_center);
handle_now_playing_command(&cmd, &info_center).await;
}
_ = interval.tick() => {
NSRunLoop::currentRunLoop()
Expand All @@ -218,12 +222,20 @@ impl MacMediaManager {
}

/// Update track metadata
pub fn set_metadata(&self, title: &str, artists: &[String], album: &str, duration_ms: u32) {
pub fn set_metadata(
&self,
title: &str,
artists: &[String],
album: &str,
duration_ms: u32,
art_url: Option<String>,
) {
let _ = self.command_tx.send(MacMediaCommand::SetMetadata {
title: title.to_string(),
artists: artists.to_vec(),
album: album.to_string(),
duration_ms,
art_url,
});
}

Expand Down Expand Up @@ -257,15 +269,21 @@ impl MacMediaManager {

/// Process a single Now Playing command, updating the info center state.
/// Must be called from the dedicated macOS media thread that owns `info_center`.
fn handle_now_playing_command(cmd: &MacMediaCommand, info_center: &MPNowPlayingInfoCenter) {
unsafe {
match cmd {
MacMediaCommand::SetMetadata {
title,
artists,
album,
duration_ms,
} => {
async fn handle_now_playing_command(cmd: &MacMediaCommand, info_center: &MPNowPlayingInfoCenter) {
match cmd {
MacMediaCommand::SetMetadata {
title,
artists,
album,
duration_ms,
art_url,
} => {
let artwork = match art_url.as_deref() {
Some(url) => fetch_artwork_from_url(url).await,
None => None,
};

unsafe {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::new();

Expand All @@ -284,43 +302,73 @@ fn handle_now_playing_command(cmd: &MacMediaCommand, info_center: &MPNowPlayingI
let rate = NSNumber::numberWithDouble(1.0);
dict.insert(MPNowPlayingInfoPropertyPlaybackRate, &*rate);

info_center.setNowPlayingInfo(Some(&dict));
}
MacMediaCommand::SetPlaybackStatus(is_playing) => {
let state = if *is_playing {
MPNowPlayingPlaybackState::Playing
} else {
MPNowPlayingPlaybackState::Paused
};
info_center.setPlaybackState(state);

// Update playback rate in the existing nowPlayingInfo so macOS
// knows whether to advance the elapsed time counter.
if let Some(existing) = info_center.nowPlayingInfo() {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::dictionaryWithDictionary(&existing);
let rate = NSNumber::numberWithDouble(if *is_playing { 1.0 } else { 0.0 });
dict.insert(MPNowPlayingInfoPropertyPlaybackRate, &*rate);
info_center.setNowPlayingInfo(Some(&dict));
}
}
MacMediaCommand::SetPosition(position_ms) => {
// Update elapsed playback time in the existing nowPlayingInfo dict
if let Some(existing) = info_center.nowPlayingInfo() {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::dictionaryWithDictionary(&existing);
let elapsed = NSNumber::numberWithDouble(*position_ms as f64 / 1000.0);
dict.insert(MPNowPlayingInfoPropertyElapsedPlaybackTime, &*elapsed);
info_center.setNowPlayingInfo(Some(&dict));
if let Some(artwork) = artwork.as_ref() {
dict.insert(MPMediaItemPropertyArtwork, &**artwork);
}

info_center.setNowPlayingInfo(Some(&dict));
}
MacMediaCommand::SetVolume(_) => {
// Volume is not directly supported by Now Playing center
}
MacMediaCommand::SetPlaybackStatus(is_playing) => unsafe {
let state = if *is_playing {
MPNowPlayingPlaybackState::Playing
} else {
MPNowPlayingPlaybackState::Paused
};
info_center.setPlaybackState(state);

// Update playback rate in the existing nowPlayingInfo so macOS
// knows whether to advance the elapsed time counter.
if let Some(existing) = info_center.nowPlayingInfo() {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::dictionaryWithDictionary(&existing);
let rate = NSNumber::numberWithDouble(if *is_playing { 1.0 } else { 0.0 });
dict.insert(MPNowPlayingInfoPropertyPlaybackRate, &*rate);
info_center.setNowPlayingInfo(Some(&dict));
}
MacMediaCommand::SetStopped => {
info_center.setPlaybackState(MPNowPlayingPlaybackState::Stopped);
info_center.setNowPlayingInfo(None);
},
MacMediaCommand::SetPosition(position_ms) => unsafe {
// Update elapsed playback time in the existing nowPlayingInfo dict
if let Some(existing) = info_center.nowPlayingInfo() {
let dict: objc2::rc::Retained<NSMutableDictionary<NSString, AnyObject>> =
NSMutableDictionary::dictionaryWithDictionary(&existing);
let elapsed = NSNumber::numberWithDouble(*position_ms as f64 / 1000.0);
dict.insert(MPNowPlayingInfoPropertyElapsedPlaybackTime, &*elapsed);
info_center.setNowPlayingInfo(Some(&dict));
}
},
MacMediaCommand::SetVolume(_) => {
// Volume is not directly supported by Now Playing center
}
MacMediaCommand::SetStopped => unsafe {
info_center.setPlaybackState(MPNowPlayingPlaybackState::Stopped);
info_center.setNowPlayingInfo(None);
},
}
}

async fn fetch_artwork_from_url(art_url: &str) -> Option<objc2::rc::Retained<MPMediaItemArtwork>> {
let response = reqwest::get(art_url).await.ok()?;
if !response.status().is_success() {
return None;
}

let bytes = response.bytes().await.ok()?;
if bytes.is_empty() {
return None;
}

unsafe {
let data = NSData::dataWithBytes_length(bytes.as_ptr().cast(), bytes.len());
let image = NSImage::initWithData(NSImage::alloc(), &data)?;
let image_for_handler = image.clone();
let request_handler =
RcBlock::new(move |_requested_size| NonNull::from(image_for_handler.as_ref()));

Some(MPMediaItemArtwork::initWithBoundsSize_requestHandler(
MPMediaItemArtwork::alloc(),
image.size(),
&request_handler,
))
}
}
92 changes: 91 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ struct MprisMetadata {
#[cfg(feature = "mpris")]
type MprisMetadataTuple = (String, Vec<String>, String, u32, Option<String>);

#[cfg(all(feature = "macos-media", target_os = "macos"))]
#[derive(Default, PartialEq)]
struct MacosMetadata {
title: String,
artists: Vec<String>,
album: String,
duration_ms: u32,
art_url: Option<String>,
}
#[cfg(all(feature = "macos-media", target_os = "macos"))]
type MacosMetadataTuple = (String, Vec<String>, String, u32, Option<String>);

#[cfg(feature = "discord-rpc")]
fn resolve_discord_app_id(user_config: &UserConfig) -> Option<String> {
std::env::var("SPOTATUI_DISCORD_APP_ID")
Expand Down Expand Up @@ -256,6 +268,34 @@ fn get_mpris_metadata(app: &App) -> Option<MprisMetadataTuple> {
}
}

#[cfg(all(feature = "macos-media", target_os = "macos"))]
fn get_macos_metadata(app: &App) -> Option<MacosMetadataTuple> {
use crate::tui::ui::util::create_artist_string;
use rspotify::model::PlayableItem;

if let Some(context) = &app.current_playback_context {
let item = context.item.as_ref()?;
match item {
PlayableItem::Track(track) => Some((
track.name.clone(),
vec![create_artist_string(&track.artists)],
track.album.name.clone(),
track.duration.num_milliseconds() as u32,
track.album.images.first().map(|image| image.url.clone()),
)),
PlayableItem::Episode(episode) => Some((
episode.name.clone(),
vec![episode.show.name.clone()],
String::new(),
episode.duration.num_milliseconds() as u32,
episode.images.first().map(|image| image.url.clone()),
)),
}
} else {
None
}
}

#[cfg(feature = "discord-rpc")]
fn update_discord_presence(
manager: &discord_rpc::DiscordRpcManager,
Expand Down Expand Up @@ -325,6 +365,31 @@ fn update_mpris_metadata(
}
}

#[cfg(all(feature = "macos-media", target_os = "macos"))]
fn update_macos_metadata(
manager: &macos_media::MacMediaManager,
last_metadata: &mut Option<MacosMetadata>,
app: &App,
) {
if let Some((title, artists, album, duration_ms, art_url)) = get_macos_metadata(app) {
let new_metadata = MacosMetadata {
title: title.clone(),
artists: artists.clone(),
album: album.clone(),
duration_ms,
art_url: art_url.clone(),
};

// Only update if metadata changed to avoid repeated artwork fetches.
if last_metadata.as_ref() != Some(&new_metadata) {
manager.set_metadata(&title, &artists, &album, duration_ms, art_url);
*last_metadata = Some(new_metadata);
}
} else if last_metadata.is_some() {
*last_metadata = None;
}
}

// Manual token cache helpers since rspotify's built-in caching isn't working
async fn save_token_to_file(spotify: &AuthCodePkceSpotify, path: &PathBuf) -> Result<()> {
let token_lock = spotify.token.lock().await.expect("Failed to lock token");
Expand Down Expand Up @@ -1199,6 +1264,25 @@ of the app. Beware that this comes at a CPU cost!",
}
}

// Keep Now Playing metadata (including artwork URL from Web API playback state)
// synchronized with Control Center.
#[cfg(all(feature = "macos-media", target_os = "macos"))]
if let Some(ref macos_media) = macos_media_manager {
let macos_media_for_metadata = Arc::clone(macos_media);
let app_for_macos_metadata = Arc::clone(&app);
tokio::spawn(async move {
let mut last_metadata: Option<MacosMetadata> = None;
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));

loop {
interval.tick().await;
if let Ok(app) = app_for_macos_metadata.try_lock() {
update_macos_metadata(&macos_media_for_metadata, &mut last_metadata, &app);
}
}
});
}

// Clone MPRIS manager for player event handler
#[cfg(all(feature = "mpris", target_os = "linux"))]
let mpris_for_events = mpris_manager.clone();
Expand Down Expand Up @@ -1709,7 +1793,13 @@ async fn handle_player_events(
// Update macOS Now Playing metadata
#[cfg(all(feature = "macos-media", target_os = "macos"))]
if let Some(ref macos_media) = macos_media_manager {
macos_media.set_metadata(&audio_item.name, &artists, &album, audio_item.duration_ms);
macos_media.set_metadata(
&audio_item.name,
&artists,
&album,
audio_item.duration_ms,
None,
);
}

// Track metadata updates are critical for playbar correctness; do not drop
Expand Down
Loading