mpc-bar

macOS menu bar client for the Music Player Daemon
Log | Files | Refs | README | LICENSE

mpc-bar.m (17165B)


      1 // Copyright (C) 2023-2025 Spencer Williams
      2 
      3 // SPDX-License-Identifier: GPL-2.0-or-later
      4 
      5 // This program is free software; you can redistribute it and/or
      6 // modify it under the terms of the GNU General Public License as
      7 // published by the Free Software Foundation; either version 2 of the
      8 // License, or (at your option) any later version.
      9 
     10 // This program is distributed in the hope that it will be useful, but
     11 // WITHOUT ANY WARRANTY; without even the implied warranty of
     12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
     13 // General Public License for more details.
     14 
     15 // You should have received a copy of the GNU General Public License
     16 // along with this program; if not, see
     17 // <https://www.gnu.org/licenses/>.
     18 
     19 #include "ini.h"
     20 #include "mpc/song_format.h"
     21 
     22 #include <assert.h>
     23 #include <lua/lauxlib.h>
     24 #include <lua/lua.h>
     25 #include <lua/lualib.h>
     26 #include <mpd/client.h>
     27 #include <stdio.h>
     28 #include <stdlib.h>
     29 #include <string.h>
     30 
     31 #import <Cocoa/Cocoa.h>
     32 
     33 #define VERSION "0.5.1"
     34 #define TITLE_MAX_LENGTH 96
     35 #define SLEEP_INTERVAL 0.2
     36 
     37 static NSString *utf8String(const char *s) {
     38   return [NSString stringWithCString:s encoding:NSUTF8StringEncoding];
     39 }
     40 
     41 static NSString *formatTime(unsigned int t) {
     42   unsigned int hours = (t / 3600), minutes = (t % 3600 / 60),
     43                seconds = (t % 60);
     44 
     45   if (hours)
     46     return [NSString stringWithFormat:@"%u:%02u:%02u", hours, minutes, seconds];
     47   else
     48     return [NSString stringWithFormat:@"%u:%02u", minutes, seconds];
     49 }
     50 
     51 struct config {
     52   const char *host, *password, *format, *idle_message, *lua_filter;
     53   int show_queue, show_queue_idle;
     54   unsigned port;
     55 };
     56 
     57 static int handler(void *userdata, const char *section, const char *name,
     58                    const char *value) {
     59 #define MATCH(s, n) ((strcmp(section, s) == 0) && (strcmp(name, n) == 0))
     60   struct config *c = (struct config *)userdata;
     61   if (MATCH("connection", "host")) {
     62     c->host = strdup(value);
     63   } else if (MATCH("connection", "port")) {
     64     c->port = atoi(value);
     65   } else if (MATCH("connection", "password")) {
     66     c->password = strdup(value);
     67   } else if (MATCH("display", "format")) {
     68     c->format = strdup(value);
     69   } else if (MATCH("display", "idle_message")) {
     70     c->idle_message = strdup(value);
     71   } else if (MATCH("display", "show_queue")) {
     72     c->show_queue = (strcmp(value, "false") != 0);
     73   } else if (MATCH("display", "show_queue_idle")) {
     74     c->show_queue_idle = (strcmp(value, "false") != 0);
     75   } else if (MATCH("display", "lua_filter")) {
     76     c->lua_filter = strdup(value);
     77   } else {
     78     return 0;
     79   }
     80   return 1;
     81 #undef MATCH
     82 }
     83 
     84 @interface MPDController : NSObject
     85 @end
     86 
     87 @implementation MPDController {
     88   struct config config;
     89   struct mpd_connection *connection;
     90   BOOL songMenuNeedsUpdate;
     91 
     92   NSString *errorMessage;
     93   NSMenu *controlMenu;
     94   NSMenuItem *timeItem, *timeSeparator, *playPauseItem, *stopItem, *nextItem,
     95       *previousItem, *singleItem, *clearItem, *updateDatabaseItem,
     96       *addToQueueItem;
     97   NSImage *playImage, *pauseImage, *stopImage, *nextImage, *previousImage,
     98       *singleImage, *clearImage;
     99   NSButton *menuButton;
    100 
    101   NSMenu *songMenu;
    102   NSMapTable *songMap;
    103 
    104   lua_State *L;
    105   const char *luaFilterPath;
    106 }
    107 - (void)initConfig {
    108   config.host = "localhost";
    109   config.port = 6600;
    110   config.format =
    111       "[%name%: &[[%artist%|%performer%|%composer%|%albumartist%] - "
    112       "]%title%]|%name%|[[%artist%|%performer%|%composer%|%albumartist%] - "
    113       "]%title%|%file%";
    114   config.idle_message = "No song playing";
    115   config.show_queue = 1;
    116   config.show_queue_idle = -1;
    117 }
    118 - (BOOL)tryReadConfigFile:(NSString *)file {
    119   return (0 == ini_parse([[NSHomeDirectory()
    120                              stringByAppendingPathComponent:file] UTF8String],
    121                          handler, &config));
    122 }
    123 - (void)readConfigFile {
    124   if (!([self tryReadConfigFile:@".mpc-bar.ini"] ||
    125         [self tryReadConfigFile:@".mpcbar"])) {
    126     NSLog(@"Failed to read config file");
    127   }
    128   if (config.show_queue_idle == -1) {
    129     config.show_queue_idle = config.show_queue;
    130   }
    131 }
    132 - (void)initLua {
    133   if (config.lua_filter) {
    134     L = luaL_newstate();
    135     luaL_openlibs(L);
    136     luaFilterPath = [[utf8String(config.lua_filter) stringByStandardizingPath]
    137         cStringUsingEncoding:NSUTF8StringEncoding];
    138   }
    139 }
    140 - (const char *)runLuaFilterOn:(const char *)s {
    141   if (luaL_dofile(L, luaFilterPath) != LUA_OK) {
    142     return s;
    143   }
    144   lua_getglobal(L, "filter");
    145   if (!lua_isfunction(L, -1)) {
    146     return s;
    147   }
    148   lua_pushstring(L, s);
    149   if (lua_pcall(L, 1, 1, 0) != LUA_OK) {
    150     return s;
    151   }
    152   return lua_tostring(L, -1);
    153 }
    154 - (void)connect {
    155   assert(connection == NULL);
    156 
    157   connection = mpd_connection_new(config.host, config.port, 0);
    158   if (!connection) {
    159     NSLog(@"Failed to create MPD connection");
    160     exit(1);
    161   }
    162 
    163   errorMessage = @"Failed to get status (is MPD running?)";
    164   if (mpd_connection_get_error(connection) == MPD_ERROR_SUCCESS) {
    165     if (config.password != NULL) {
    166       if (!mpd_run_password(connection, config.password)) {
    167         errorMessage = @"Auth failed (please fix password and restart service)";
    168       }
    169     }
    170   }
    171 
    172   songMenuNeedsUpdate = YES;
    173 }
    174 - (void)disconnect {
    175   assert(connection != NULL);
    176   mpd_connection_free(connection);
    177   connection = NULL;
    178   [songMap removeAllObjects];
    179   [songMenu removeAllItems];
    180 }
    181 - (void)disableAllItems {
    182   [playPauseItem setEnabled:NO];
    183   [stopItem setEnabled:NO];
    184   [nextItem setEnabled:NO];
    185   [previousItem setEnabled:NO];
    186   [singleItem setEnabled:NO];
    187   [updateDatabaseItem setEnabled:NO];
    188   [addToQueueItem setEnabled:NO];
    189 }
    190 - (void)updateLoop {
    191   for (;;) {
    192     [NSThread sleepForTimeInterval:SLEEP_INTERVAL];
    193     if (!connection) {
    194       [self disableAllItems];
    195       [self showError:errorMessage];
    196       [self connect];
    197     }
    198     if (!mpd_send_idle(connection)) {
    199       [self disconnect];
    200       continue;
    201     }
    202     enum mpd_idle mask = mpd_run_noidle(connection);
    203     enum mpd_error err;
    204     if (mask == 0 &&
    205         (err = mpd_connection_get_error(connection)) != MPD_ERROR_SUCCESS) {
    206       NSLog(@"mpd_run_idle error code %d: %s", err,
    207             mpd_connection_get_error_message(connection));
    208       [self disconnect];
    209       continue;
    210     }
    211 
    212     if ((mask & MPD_IDLE_DATABASE) || songMenuNeedsUpdate) {
    213       [self performSelectorOnMainThread:@selector(updateSongMenu)
    214                              withObject:nil
    215                           waitUntilDone:YES];
    216       songMenuNeedsUpdate = NO;
    217     }
    218 
    219     [self performSelectorOnMainThread:@selector(updateControlMenu)
    220                            withObject:nil
    221                         waitUntilDone:YES];
    222 
    223     [self performSelectorOnMainThread:@selector(updateStatus)
    224                            withObject:nil
    225                         waitUntilDone:YES];
    226   }
    227 }
    228 - (void)updateControlMenu {
    229   if (!connection)
    230     return;
    231 
    232   struct mpd_status *status = NULL;
    233   struct mpd_song *song = NULL;
    234   NSString *errorMsg = nil;
    235 
    236   NSMutableString *output = [NSMutableString new];
    237 
    238   status = mpd_run_status(connection);
    239   if (!status) {
    240     NSLog(@"%s", mpd_connection_get_error_message(connection));
    241 
    242     [self disconnect];
    243     goto cleanup;
    244   }
    245 
    246   enum mpd_state state = mpd_status_get_state(status);
    247   enum mpd_single_state single = mpd_status_get_single_state(status);
    248   bool active = (state == MPD_STATE_PLAY || state == MPD_STATE_PAUSE);
    249   if (active) {
    250     song = mpd_run_current_song(connection);
    251     if (!song) {
    252       errorMsg = @"Failed to retrieve current song";
    253       goto cleanup;
    254     }
    255 
    256     if (mpd_connection_get_error(connection) != MPD_ERROR_SUCCESS) {
    257       errorMsg = utf8String(mpd_connection_get_error_message(connection));
    258       goto cleanup;
    259     }
    260 
    261     if (state == MPD_STATE_PAUSE)
    262       [menuButton setImage:pauseImage];
    263     else if (state == MPD_STATE_PLAY &&
    264              (single == MPD_SINGLE_ON || single == MPD_SINGLE_ONESHOT))
    265       [menuButton setImage:singleImage];
    266     else
    267       [menuButton setImage:nil];
    268 
    269     char *s = format_song(song, config.format);
    270     if (L) {
    271       [output appendString:utf8String([self runLuaFilterOn:s])];
    272     } else {
    273       [output appendString:utf8String(s)];
    274     }
    275     free(s);
    276   } else {
    277     // FIXME: There's no point calling utf8String more than once, as
    278     // idle_message never changes.
    279     [output setString:utf8String(config.idle_message)];
    280     [menuButton setImage:nil];
    281   }
    282 
    283   int song_pos = mpd_status_get_song_pos(status);
    284   unsigned int queue_length = mpd_status_get_queue_length(status);
    285 
    286   if ((active && config.show_queue) || (!active && config.show_queue_idle)) {
    287     if (song_pos < 0)
    288       [output appendFormat:@" (%u)", queue_length];
    289     else
    290       [output appendFormat:@" (%u/%u)", song_pos + 1, queue_length];
    291   }
    292 
    293   if ([output length] > TITLE_MAX_LENGTH) {
    294     int leftCount = (TITLE_MAX_LENGTH - 3) / 2;
    295     int rightCount = TITLE_MAX_LENGTH - leftCount - 3;
    296     [menuButton setTitle:[@[
    297                   [output substringToIndex:leftCount],
    298                   [output substringFromIndex:[output length] - rightCount]
    299                 ] componentsJoinedByString:@"..."]];
    300   } else
    301     [menuButton setTitle:output];
    302 
    303   if (state == MPD_STATE_PLAY) {
    304     [playPauseItem setTitle:@"Pause"];
    305     [playPauseItem setImage:pauseImage];
    306     [playPauseItem setAction:@selector(pause)];
    307     [playPauseItem setEnabled:YES];
    308   } else {
    309     [playPauseItem setTitle:@"Play"];
    310     [playPauseItem setImage:playImage];
    311     [playPauseItem setAction:@selector(play)];
    312     [playPauseItem setEnabled:(queue_length > 0)];
    313   }
    314   [stopItem setEnabled:active];
    315   [nextItem setEnabled:(active && (song_pos < (queue_length - 1)))];
    316   [previousItem setEnabled:(active && (song_pos > 0))];
    317 
    318   if (queue_length == 0 && single == MPD_SINGLE_ONESHOT) {
    319     [self single_off];
    320     single = MPD_SINGLE_OFF;
    321   }
    322 
    323   if (single == MPD_SINGLE_OFF) {
    324     [singleItem setTitle:@"Pause After This Track"];
    325     [singleItem setAction:@selector(single_oneshot)];
    326   } else {
    327     [singleItem setTitle:@"Keep Playing After This Track"];
    328     [singleItem setAction:@selector(single_off)];
    329   }
    330 
    331   [singleItem setEnabled:(active && (song_pos < queue_length))];
    332   [clearItem setEnabled:(queue_length > 0)];
    333   [updateDatabaseItem setEnabled:YES];
    334 
    335 cleanup:
    336   if (song)
    337     mpd_song_free(song);
    338   if (status)
    339     mpd_status_free(status);
    340 
    341   if (errorMsg)
    342     [self showError:errorMsg];
    343 }
    344 - (NSMenuItem *)addControlMenuItemWithTitle:(NSString *)title
    345                                       image:(NSImage *)image
    346                                      action:(SEL)selector {
    347   NSMenuItem *item = [controlMenu addItemWithTitle:title
    348                                             action:selector
    349                                      keyEquivalent:@""];
    350   [item setTarget:self];
    351   [item setEnabled:NO];
    352   [item setImage:image];
    353 
    354   return item;
    355 }
    356 - (void)initControlMenu {
    357   controlMenu = [NSMenu new];
    358   [controlMenu setAutoenablesItems:NO];
    359 
    360 #define ICON(NAME, DESC)                                                       \
    361   [NSImage imageWithSystemSymbolName:@NAME accessibilityDescription:@DESC]
    362 
    363   playImage = ICON("play.fill", "Play");
    364   pauseImage = ICON("pause.fill", "Pause");
    365   stopImage = ICON("stop.fill", "Stop");
    366   nextImage = ICON("forward.fill", "Next");
    367   previousImage = ICON("backward.fill", "Previous");
    368   singleImage = ICON("playpause.fill", "Single");
    369   clearImage = ICON("clear.fill", "Clear");
    370 
    371   timeItem = [NSMenuItem new];
    372   [timeItem setEnabled:NO];
    373   timeSeparator = [NSMenuItem separatorItem];
    374 
    375 #define ADD_ITEM(TITLE, IMAGE, ACTION)                                         \
    376   [self addControlMenuItemWithTitle:@TITLE image:IMAGE action:@selector(ACTION)]
    377 
    378   playPauseItem = ADD_ITEM("Play", playImage, play);
    379   stopItem = ADD_ITEM("Stop", stopImage, stop);
    380   nextItem = ADD_ITEM("Next Track", nextImage, next);
    381   previousItem = ADD_ITEM("Previous Track", previousImage, previous);
    382   singleItem = ADD_ITEM("Pause After This Track", singleImage, single_oneshot);
    383 
    384   [controlMenu addItem:[NSMenuItem separatorItem]];
    385 
    386   updateDatabaseItem = ADD_ITEM("Update Database", nil, update);
    387 
    388   addToQueueItem = [controlMenu addItemWithTitle:@"Add to Queue"
    389                                           action:nil
    390                                    keyEquivalent:@""];
    391   [addToQueueItem setSubmenu:songMenu];
    392   [addToQueueItem setEnabled:NO];
    393 
    394   [controlMenu addItem:[NSMenuItem separatorItem]];
    395 
    396   clearItem = ADD_ITEM("Clear Queue", nil, clear);
    397 
    398   [controlMenu addItem:[NSMenuItem separatorItem]];
    399   [controlMenu addItemWithTitle:@"Quit MPC Bar"
    400                          action:@selector(terminate:)
    401                   keyEquivalent:@"q"];
    402 
    403   NSStatusBar *bar = [NSStatusBar systemStatusBar];
    404   NSStatusItem *item = [bar statusItemWithLength:NSVariableStatusItemLength];
    405   menuButton = [item button];
    406   [menuButton setImagePosition:NSImageLeft];
    407   [item setMenu:controlMenu];
    408   [self updateControlMenu];
    409   [self updateStatus];
    410 }
    411 - (void)initSongMenu {
    412   songMap = [NSMapTable new];
    413   songMenu = [NSMenu new];
    414   [self updateSongMenu];
    415 }
    416 - (void)updateSongMenu {
    417   if (!connection)
    418     return;
    419 
    420   [songMap removeAllObjects];
    421   [songMenu removeAllItems];
    422   if (!mpd_send_list_all(connection, "")) {
    423     [self disconnect];
    424     return;
    425   }
    426 
    427   [addToQueueItem setEnabled:YES];
    428 
    429   struct mpd_entity *entity;
    430   NSMutableArray *menus = [NSMutableArray new];
    431   [menus addObject:songMenu];
    432   BOOL directory;
    433   const char *s;
    434   while ((entity = mpd_recv_entity(connection))) {
    435     switch (mpd_entity_get_type(entity)) {
    436     case MPD_ENTITY_TYPE_DIRECTORY:
    437       directory = YES;
    438       s = mpd_directory_get_path(mpd_entity_get_directory(entity));
    439       break;
    440     case MPD_ENTITY_TYPE_SONG:
    441       directory = NO;
    442       s = mpd_song_get_uri(mpd_entity_get_song(entity));
    443       break;
    444     default:
    445       continue;
    446     }
    447 
    448     NSString *ss = utf8String(s);
    449     NSArray *components = [ss pathComponents];
    450 
    451     while ([menus count] > [components count])
    452       [menus removeLastObject];
    453 
    454     NSString *title =
    455         directory ? [components lastObject]
    456                   : [[components lastObject] stringByDeletingPathExtension];
    457 
    458     NSMenuItem *item = [[NSMenuItem alloc]
    459         initWithTitle:[title stringByReplacingOccurrencesOfString:@":"
    460                                                        withString:@"/"]
    461                action:@selector(enqueue:)
    462         keyEquivalent:@""];
    463 
    464     [item setTarget:self];
    465     [songMap setObject:ss forKey:item];
    466     [[menus lastObject] addItem:item];
    467     if (directory) {
    468       NSMenu *menu = [NSMenu new];
    469       [item setSubmenu:menu];
    470       [menus addObject:menu];
    471     }
    472     mpd_entity_free(entity);
    473   }
    474 }
    475 - (instancetype)init {
    476   if (self = [super init]) {
    477     [self initConfig];
    478     [self readConfigFile];
    479     [self initLua];
    480     [self connect];
    481     [self initSongMenu];
    482     [self initControlMenu];
    483 
    484     [[[NSThread alloc] initWithTarget:self
    485                              selector:@selector(updateLoop)
    486                                object:nil] start];
    487   }
    488   return self;
    489 }
    490 - (void)dealloc {
    491   mpd_connection_free(connection);
    492 }
    493 - (void)showError:(NSString *)msg {
    494   [menuButton setTitle:[NSString stringWithFormat:@"MPC Bar: %@", msg]];
    495 }
    496 - (void)play {
    497   mpd_run_play(connection);
    498 }
    499 - (void)pause {
    500   mpd_run_pause(connection, true);
    501 }
    502 - (void)stop {
    503   mpd_run_stop(connection);
    504 }
    505 - (void)next {
    506   mpd_run_next(connection);
    507 }
    508 - (void)previous {
    509   mpd_run_previous(connection);
    510 }
    511 - (void)single_oneshot {
    512   mpd_run_single_state(connection, MPD_SINGLE_ONESHOT);
    513 }
    514 - (void)single_off {
    515   mpd_run_single_state(connection, MPD_SINGLE_OFF);
    516 }
    517 - (void)update {
    518   mpd_run_update(connection, NULL);
    519 }
    520 - (void)clear {
    521   mpd_run_clear(connection);
    522 }
    523 - (void)enqueue:(id)item {
    524   mpd_run_add(connection, [[songMap objectForKey:item]
    525                               cStringUsingEncoding:NSUTF8StringEncoding]);
    526 }
    527 - (void)updateStatus {
    528   struct mpd_status *status = NULL;
    529   struct mpd_song *song = NULL;
    530 
    531   if (connection)
    532     status = mpd_run_status(connection);
    533 
    534   if (!status) {
    535     if (connection)
    536       [self disconnect];
    537     return;
    538   }
    539 
    540   enum mpd_state state = mpd_status_get_state(status);
    541   bool active = (state == MPD_STATE_PLAY || state == MPD_STATE_PAUSE);
    542 
    543   if (!active || !(song = mpd_run_current_song(connection))) {
    544     if ([controlMenu indexOfItem:timeItem] >= 0)
    545       [controlMenu removeItem:timeItem];
    546     if ([controlMenu indexOfItem:timeSeparator] >= 0)
    547       [controlMenu removeItem:timeSeparator];
    548     mpd_status_free(status);
    549     return;
    550   }
    551 
    552   unsigned int elapsed = mpd_status_get_elapsed_time(status);
    553   unsigned int dur = mpd_song_get_duration(song);
    554   [timeItem
    555       setTitle:[NSString stringWithFormat:@"%@ / %@", formatTime(elapsed),
    556                                           (dur > 0) ? formatTime(dur) : @"?"]];
    557 
    558   if ([controlMenu indexOfItem:timeItem] < 0)
    559     [controlMenu insertItem:timeItem atIndex:0];
    560   if ([controlMenu indexOfItem:timeSeparator] < 0)
    561     [controlMenu insertItem:timeSeparator atIndex:1];
    562 
    563   mpd_song_free(song);
    564   mpd_status_free(status);
    565 }
    566 @end
    567 
    568 int main(int argc, char *argv[]) {
    569   if (argc > 1 && strcmp(argv[1], "-v") == 0) {
    570     puts("MPC Bar " VERSION);
    571     return 0;
    572   }
    573 
    574   [NSApplication sharedApplication];
    575   [MPDController new];
    576   [NSApp run];
    577 
    578   return 0;
    579 }