aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPřemysl Eric Janouch <p@janouch.name>2026-05-30 23:24:43 +0200
committerPřemysl Eric Janouch <p@janouch.name>2026-05-30 23:41:51 +0200
commit190149a660cb76b81037ebcd9d60c3b2cc2310ba (patch)
treedcfb90290edd50e322b3608d56503a3f1f7bcbcc
parent9ba7a0fce5b11e00c9e7c273bb2e8d20c00ee1f0 (diff)
downloadnncmpp-190149a660cb76b81037ebcd9d60c3b2cc2310ba.tar.gz
nncmpp-190149a660cb76b81037ebcd9d60c3b2cc2310ba.tar.xz
nncmpp-190149a660cb76b81037ebcd9d60c3b2cc2310ba.zip
AppKit: integrate with Now PlayingHEADorigin/mastermaster
Primarily to get media keys working; neither the API nor the macOS widget is particularly great. Bump liberty.
-rw-r--r--CMakeLists.txt4
-rw-r--r--NEWS6
m---------liberty0
-rw-r--r--nncmpp.c255
4 files changed, 231 insertions, 34 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index aaddf09..537fb2e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -82,8 +82,8 @@ if (WITH_APPKIT)
enable_language (OBJC)
set (CMAKE_OBJC_FLAGS
"${CMAKE_OBJC_FLAGS} -std=gnu99 -Wall -Wextra -Wno-unused-function")
- list (APPEND extra_libraries
- "-framework AppKit" "-framework CoreFoundation")
+ list (APPEND extra_libraries "-framework AppKit"
+ "-framework CoreFoundation" "-framework MediaPlayer")
endif ()
include_directories (${Unistring_INCLUDE_DIRS}
diff --git a/NEWS b/NEWS
index 130f23b..19ca68e 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,9 @@
+Unreleased
+
+ * AppKit: the application now shows up in Now Playing,
+ and as a consequence responds to media keys
+
+
2.2.0 (2026-05-26)
* Added an AppKit user interface; the -x (--x11) option has become -g (--gui)
diff --git a/liberty b/liberty
-Subproject d7c70bd43ab607f9e73653d417ff0fcef2ad2b8
+Subproject eb016678961b9ee7729de0fa3d586673d79ff92
diff --git a/nncmpp.c b/nncmpp.c
index 9eb9521..046dca3 100644
--- a/nncmpp.c
+++ b/nncmpp.c
@@ -76,6 +76,7 @@ enum
#endif // WITH_X11
#ifdef WITH_APPKIT
#define LIBERTY_XUI_WANT_APPKIT
+#import <MediaPlayer/MediaPlayer.h>
#endif // WITH_APPKIT
#include "liberty/liberty-xui.c"
@@ -1272,6 +1273,9 @@ static struct app_context
struct app_ui *ui; ///< User interface interface
int ui_dragging; ///< ID of any dragged widget
+#ifdef WITH_APPKIT
+ MPMediaItemArtwork *ui_artwork; ///< Now Playing image
+#endif
#ifdef WITH_FFTW
struct spectrum spectrum; ///< Spectrum analyser
@@ -1626,6 +1630,10 @@ app_free_context (void)
strv_free (&g.action_commands);
item_list_free (&g.playlist);
+#ifdef WITH_APPKIT
+ [g.ui_artwork release];
+#endif
+
#ifdef WITH_FFTW
spectrum_free (&g.spectrum);
if (g.spectrum_fd != -1)
@@ -1758,37 +1766,57 @@ app_layout_text (const char *str, chtype attrs, struct layout *out)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-static void
-app_layout_song_info (struct layout *out)
+struct app_song_info
{
- compact_map_t map;
- if (!(map = item_list_get (&g.playlist, g.song)))
- return;
+ const char *file, *subroot_basename, *name, *title, *artist, *album;
+};
- chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+static struct app_song_info
+app_extract_song_info (compact_map_t map)
+{
+ struct app_song_info s =
+ {
+ .file = compact_map_find (map, "file"),
+ .name = compact_map_find (map, "name"),
+ .title = compact_map_find (map, "title"),
+ .artist = compact_map_find (map, "artist"),
+ .album = compact_map_find (map, "album"),
+ };
// Split the path for files lying within MPD's "music_directory".
- const char *file = compact_map_find (map, "file");
- const char *subroot_basename = NULL;
- if (file && *file != '/' && !strstr (file, "://"))
+ if (s.file && *s.file != '/' && !strstr (s.file, "://"))
{
- const char *last_slash = strrchr (file, '/');
+ const char *last_slash = strrchr (s.file, '/');
if (last_slash)
- subroot_basename = last_slash + 1;
+ s.subroot_basename = last_slash + 1;
else
- subroot_basename = file;
+ s.subroot_basename = s.file;
}
- const char *title = NULL;
- const char *name = compact_map_find (map, "name");
- if ((title = compact_map_find (map, "title"))
- || (title = name)
- || (title = subroot_basename)
- || (title = file))
+ if (!s.title)
+ s.title = s.name;
+ if (!s.title)
+ s.title = s.subroot_basename;
+ if (!s.title)
+ s.title = s.file;
+ return s;
+}
+
+static void
+app_layout_song_info (struct layout *out)
+{
+ compact_map_t map;
+ if (!(map = item_list_get (&g.playlist, g.song)))
+ return;
+
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+ struct app_song_info s = app_extract_song_info (map);
+
+ if (s.title)
{
struct layout l = {};
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
- app_push (&l, g.ui->label (attrs[1], title));
+ app_push (&l, g.ui->label (attrs[1], s.title));
app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
app_flush_layout (&l, out);
@@ -1799,33 +1827,31 @@ app_layout_song_info (struct layout *out)
struct layout l = {};
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
- char *artist = compact_map_find (map, "artist");
- char *album = compact_map_find (map, "album");
- if (artist || album)
+ if (s.artist || s.album)
{
- if (artist)
+ if (s.artist)
{
app_push (&l, g.ui->label (attrs[0], "by "));
- app_push (&l, g.ui->label (attrs[1], artist));
+ app_push (&l, g.ui->label (attrs[1], s.artist));
}
- if (album)
+ if (s.album)
{
- app_push (&l, g.ui->label (attrs[0], &" from "[!artist]));
- app_push (&l, g.ui->label (attrs[1], album));
+ app_push (&l, g.ui->label (attrs[0], &" from "[!s.artist]));
+ app_push (&l, g.ui->label (attrs[1], s.album));
}
}
- else if (subroot_basename && subroot_basename != file)
+ else if (s.subroot_basename && s.subroot_basename != s.file)
{
- char *parent = xstrndup (file, subroot_basename - file - 1);
+ char *parent = xstrndup (s.file, s.subroot_basename - s.file - 1);
app_push (&l, g.ui->label (attrs[0], "in "));
app_push (&l, g.ui->label (attrs[1], parent));
free (parent);
}
- else if (file && *file != '/' && strstr (file, "://")
- && name && name != title)
+ else if (s.file && *s.file != '/' && strstr (s.file, "://")
+ && s.name && strcmp (s.name, s.title))
{
// This is likely to contain the name of an Internet radio.
- app_push (&l, g.ui->label (attrs[1], name));
+ app_push (&l, g.ui->label (attrs[1], s.name));
}
app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
@@ -5169,6 +5195,79 @@ mpd_set_elapsed_timer (int msec_past_second)
g.elapsed_since = elapsed_msec;
}
+#ifdef WITH_APPKIT
+
+static void
+appkit_update_now_playing (int msec_past_second)
+{
+ MPNowPlayingInfoCenter *np = [MPNowPlayingInfoCenter defaultCenter];
+ MPRemoteCommandCenter *cc = [MPRemoteCommandCenter sharedCommandCenter];
+
+ // Intentionally not showing anything in the stopped state.
+ // We may still receive commands from the keyboard, even those disabled.
+ compact_map_t map = item_list_get (&g.playlist, g.song);
+ if (g.state == PLAYER_STOPPED || !map)
+ {
+ np.nowPlayingInfo = nil;
+ np.playbackState = MPNowPlayingPlaybackStateStopped;
+
+ cc.playCommand.enabled = YES;
+ cc.stopCommand.enabled = NO;
+ cc.pauseCommand.enabled = NO;
+ cc.togglePlayPauseCommand.enabled = YES;
+ cc.nextTrackCommand.enabled = NO;
+ cc.previousTrackCommand.enabled = NO;
+ cc.changePlaybackPositionCommand.enabled = NO;
+ return;
+ }
+
+ struct app_song_info s = app_extract_song_info (map);
+
+ NSTimeInterval duration = MAX (0., g.song_duration);
+ // Note that macOS rounds this value when displaying.
+ NSTimeInterval elapsed_time = g.song_elapsed + msec_past_second / 1000.;
+
+ // We don't want it to advance on its own, as the view jumps around,
+ // given our active status polling.
+ double playback_rate = g.state == PLAYER_PLAYING ? FLT_EPSILON : 0.0;
+
+ // Many properties just aren't visibly useful.
+ np.nowPlayingInfo =
+ @{
+ MPMediaItemPropertyTitle:
+ s.title ? [NSString stringWithUTF8String:s.title] : [NSNull null],
+ MPMediaItemPropertyArtist:
+ s.artist ? [NSString stringWithUTF8String:s.artist] : [NSNull null],
+ MPMediaItemPropertyAlbumTitle:
+ s.album ? [NSString stringWithUTF8String:s.album] : [NSNull null],
+
+ MPMediaItemPropertyPlaybackDuration: @(duration),
+ MPMediaItemPropertyArtwork: g.ui_artwork,
+ MPNowPlayingInfoPropertyElapsedPlaybackTime: @(elapsed_time),
+ MPNowPlayingInfoPropertyIsLiveStream: @(duration <= 0),
+ MPNowPlayingInfoPropertyPlaybackQueueIndex: @(g.song),
+ MPNowPlayingInfoPropertyPlaybackQueueCount: @(g.playlist.len),
+ MPNowPlayingInfoPropertyPlaybackRate: @(playback_rate),
+ MPNowPlayingInfoPropertyDefaultPlaybackRate: @(FLT_EPSILON),
+ MPNowPlayingInfoPropertyMediaType:
+ @(MPNowPlayingInfoMediaTypeAudio),
+ };
+ np.playbackState = g.state == PLAYER_PLAYING
+ ? MPNowPlayingPlaybackStatePlaying
+ : MPNowPlayingPlaybackStatePaused;
+
+ cc.playCommand.enabled = g.state != PLAYER_PLAYING;
+ cc.stopCommand.enabled = YES;
+ cc.pauseCommand.enabled = g.state == PLAYER_PLAYING;
+ cc.togglePlayPauseCommand.enabled = YES;
+ // To match the main window, these should both be YES. I'm not sure here.
+ cc.nextTrackCommand.enabled = g.song + 1 < (int) g.playlist.len;
+ cc.previousTrackCommand.enabled = g.song > 0;
+ cc.changePlaybackPositionCommand.enabled = YES;
+}
+
+#endif // WITH_APPKIT
+
static void
mpd_update_playback_state (void)
{
@@ -5226,6 +5325,14 @@ mpd_update_playback_state (void)
mpd_update_playlist_time ();
xui_invalidate ();
+
+#ifdef WITH_APPKIT
+ if (g_xui.ui == &appkit_ui)
+ @autoreleasepool
+ {
+ appkit_update_now_playing (msec_past_second);
+ }
+#endif
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -6513,6 +6620,84 @@ app_init_poller_events (void)
: mpd_on_elapsed_time_tick;
}
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+#ifdef WITH_APPKIT
+
+static void
+appkit_init_now_playing (void)
+{
+ MPRemoteCommandCenter *cc = [MPRemoteCommandCenter sharedCommandCenter];
+
+#define APPKIT_MPD_SIMPLE(cmd, ...) \
+ [cc.cmd removeTarget:nil]; \
+ [cc.cmd addTargetWithHandler: \
+ ^MPRemoteCommandHandlerStatus (MPRemoteCommandEvent *e) \
+ { \
+ (void) e; \
+ if (!MPD_SIMPLE (__VA_ARGS__)) \
+ return MPRemoteCommandHandlerStatusCommandFailed; \
+ return MPRemoteCommandHandlerStatusSuccess; \
+ }];
+
+ APPKIT_MPD_SIMPLE (playCommand, "play")
+ APPKIT_MPD_SIMPLE (stopCommand, "stop")
+ APPKIT_MPD_SIMPLE (pauseCommand, "pause", "1")
+
+ // This command is sent by the play/pause media key.
+ APPKIT_MPD_SIMPLE (togglePlayPauseCommand,
+ g.state == PLAYER_STOPPED ? "play" : "pause")
+
+ APPKIT_MPD_SIMPLE (nextTrackCommand, "next")
+ APPKIT_MPD_SIMPLE (previousTrackCommand, "previous")
+
+ // Skipping forward/backward can also be added,
+ // however on macOS this replaces the next/previous buttons altogether.
+
+#undef APPKIT_MPD_SIMPLE
+
+ [cc.changePlaybackPositionCommand removeTarget:nil];
+ [cc.changePlaybackPositionCommand addTargetWithHandler:
+ ^MPRemoteCommandHandlerStatus (MPRemoteCommandEvent *e)
+ {
+ char *x = xstrdup_printf ("%f",
+ ((MPChangePlaybackPositionCommandEvent *) e).positionTime);
+ bool ok = MPD_SIMPLE ("seekcur", x);
+ free (x);
+
+ if (!ok)
+ return MPRemoteCommandHandlerStatusCommandFailed;
+ return MPRemoteCommandHandlerStatusSuccess;
+ }];
+
+ // So that we immediately show up in Now Playing,
+ // even when we start in a paused state.
+ [MPNowPlayingInfoCenter defaultCenter].playbackState =
+ MPNowPlayingPlaybackStatePlaying;
+
+ // Anything is better than the grey default.
+ // The artwork sadly does not support transparency (it turns white).
+ CGSize artwork_size = CGSizeMake (512, 512);
+ NSImage *artwork_image = [NSImage imageWithSize:artwork_size flipped:NO
+ drawingHandler:^BOOL (NSRect rect)
+ {
+ [[NSColor whiteColor] setFill];
+ NSRectFill (rect);
+ [[NSImage imageNamed:NSImageNameApplicationIcon]
+ drawInRect:NSInsetRect (rect, 64, 64)];
+ return YES;
+ }];
+ g.ui_artwork = [[MPMediaItemArtwork alloc]
+ initWithBoundsSize:artwork_size
+ requestHandler:^NSImage * (CGSize size)
+ {
+ (void) size;
+ return artwork_image;
+ }];
+}
+
+#endif // WITH_APPKIT
+
static void
app_init_ui (bool requested_x11)
{
@@ -6540,7 +6725,13 @@ app_init_ui (bool requested_x11)
#endif // WITH_X11
#ifdef WITH_APPKIT
if (g_xui.ui == &appkit_ui)
+ {
g.ui = &app_appkit_ui;
+ @autoreleasepool
+ {
+ appkit_init_now_playing ();
+ }
+ }
else
#endif // WITH_APPKIT
g.ui = &app_tui_ui;