AppKit: integrate with Now Playing
All checks were successful
Alpine 3.23 Success
Arch Linux AUR Success
OpenBSD 7.8 Success

Primarily to get media keys working;
neither the API nor the macOS widget is particularly great.

Bump liberty.
This commit is contained in:
2026-05-30 23:24:43 +02:00
parent 9ba7a0fce5
commit 190149a660
4 changed files with 232 additions and 35 deletions

View File

@@ -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}

6
NEWS
View File

@@ -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)

Submodule liberty updated: d7c70bd43a...eb01667896

255
nncmpp.c
View File

@@ -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,6 +1766,42 @@ app_layout_text (const char *str, chtype attrs, struct layout *out)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
struct app_song_info
{
const char *file, *subroot_basename, *name, *title, *artist, *album;
};
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".
if (s.file && *s.file != '/' && !strstr (s.file, "://"))
{
const char *last_slash = strrchr (s.file, '/');
if (last_slash)
s.subroot_basename = last_slash + 1;
else
s.subroot_basename = s.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)
{
@@ -1766,29 +1810,13 @@ app_layout_song_info (struct layout *out)
return;
chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
struct app_song_info s = app_extract_song_info (map);
// 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, "://"))
{
const char *last_slash = strrchr (file, '/');
if (last_slash)
subroot_basename = last_slash + 1;
else
subroot_basename = 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)
{
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;