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 }