summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSpencer Williams <spnw@plexwave.org>2023-07-24 15:25:08 -0400
committerSpencer Williams <spnw@plexwave.org>2023-07-24 15:25:08 -0400
commitd9c21e6fcdfc804998bedd7aa1afdf39792079b4 (patch)
treeb6b889ebd319d8af2a1a63ad46fb5c003507545c
Initial commit
-rw-r--r--LICENSE21
-rw-r--r--Makefile9
-rw-r--r--compile_flags.txt3
-rw-r--r--mpc-bar.m457
4 files changed, 490 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b306fdd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Spencer Williams
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..391f86e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,9 @@
+TARGET = mpc-bar
+OBJCFLAGS = -O2 -fobjc-arc -Wall
+LDFLAGS = -lmpdclient -framework Cocoa
+
+$(TARGET): mpc-bar.m
+ $(CC) $(OBJCFLAGS) $< $(LDFLAGS) -o $@
+
+clean:
+ rm -f $(TARGET)
diff --git a/compile_flags.txt b/compile_flags.txt
new file mode 100644
index 0000000..4b58d82
--- /dev/null
+++ b/compile_flags.txt
@@ -0,0 +1,3 @@
+-I/usr/local/include
+-fobjc-arc
+-Wall
diff --git a/mpc-bar.m b/mpc-bar.m
new file mode 100644
index 0000000..42e26ae
--- /dev/null
+++ b/mpc-bar.m
@@ -0,0 +1,457 @@
+#import <Cocoa/Cocoa.h>
+#include <MacTypes.h>
+#include <assert.h>
+#include <mpd/client.h>
+#include <objc/NSObjCRuntime.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define TITLE_MAX_LENGTH 96
+#define SLEEP_INTERVAL 0.2
+
+static NSString *utf8String(const char *s) {
+ return [NSString stringWithCString:s encoding:NSUTF8StringEncoding];
+}
+
+static NSString *formatTime(unsigned int t) {
+ unsigned int hours = (t / 3600), minutes = (t % 3600 / 60),
+ seconds = (t % 60);
+
+ if (hours)
+ return [NSString stringWithFormat:@"%u:%02u:%02u", hours, minutes, seconds];
+ else
+ return [NSString stringWithFormat:@"%u:%02u", minutes, seconds];
+}
+
+@interface MPDController : NSObject
+@end
+
+@implementation MPDController {
+ struct mpd_connection *connection;
+ BOOL songMenuNeedsUpdate;
+
+ NSMenu *controlMenu;
+ NSMenuItem *timeItem, *timeSeparator, *playPauseItem, *stopItem, *nextItem,
+ *previousItem, *singleItem, *clearItem, *updateDatabaseItem,
+ *addToQueueItem;
+ NSImage *playImage, *pauseImage, *stopImage, *nextImage, *previousImage,
+ *singleImage, *clearImage;
+ NSButton *menuButton;
+
+ NSMenu *songMenu;
+ NSMapTable *songMap;
+}
+- (void)connect {
+ assert(connection == NULL);
+
+ connection = mpd_connection_new(NULL, 0, 0);
+ if (!connection) {
+ NSLog(@"Failed to create MPD connection");
+ exit(1);
+ }
+
+ songMenuNeedsUpdate = YES;
+}
+- (void)disconnect {
+ assert(connection != NULL);
+ mpd_connection_free(connection);
+ connection = NULL;
+ [songMap removeAllObjects];
+ [songMenu removeAllItems];
+}
+- (void)disableAllItems {
+ [playPauseItem setEnabled:NO];
+ [stopItem setEnabled:NO];
+ [nextItem setEnabled:NO];
+ [previousItem setEnabled:NO];
+ [singleItem setEnabled:NO];
+ [updateDatabaseItem setEnabled:NO];
+ [addToQueueItem setEnabled:NO];
+}
+- (void)updateLoop {
+ for (;;) {
+ [NSThread sleepForTimeInterval:SLEEP_INTERVAL];
+ if (!connection) {
+ [self disableAllItems];
+ [self showError:@"Failed to get status (is MPD running?)"];
+ [self connect];
+ }
+ if (!mpd_send_idle(connection)) {
+ [self disconnect];
+ continue;
+ }
+ enum mpd_idle mask = mpd_run_noidle(connection);
+ enum mpd_error err;
+ if (mask == 0 &&
+ (err = mpd_connection_get_error(connection)) != MPD_ERROR_SUCCESS) {
+ NSLog(@"mpd_run_idle error code %d: %s", err,
+ mpd_connection_get_error_message(connection));
+ [self disconnect];
+ continue;
+ }
+
+ if ((mask & MPD_IDLE_DATABASE) || songMenuNeedsUpdate) {
+ [self performSelectorOnMainThread:@selector(updateSongMenu)
+ withObject:nil
+ waitUntilDone:YES];
+ songMenuNeedsUpdate = NO;
+ }
+
+ [self performSelectorOnMainThread:@selector(updateControlMenu)
+ withObject:nil
+ waitUntilDone:YES];
+
+ [self performSelectorOnMainThread:@selector(updateStatus)
+ withObject:nil
+ waitUntilDone:YES];
+ }
+}
+- (void)updateControlMenu {
+ if (!connection)
+ return;
+
+ struct mpd_status *status = NULL;
+ struct mpd_song *song = NULL;
+ NSString *errorMsg = nil;
+
+ [playPauseItem setEnabled:NO];
+ [stopItem setEnabled:NO];
+ [nextItem setEnabled:NO];
+ [previousItem setEnabled:NO];
+ [singleItem setEnabled:NO];
+ [updateDatabaseItem setEnabled:YES];
+
+ NSMutableString *output = [NSMutableString new];
+
+ status = mpd_run_status(connection);
+ if (!status) {
+ NSLog(@"%s", mpd_connection_get_error_message(connection));
+
+ [self disconnect];
+ goto cleanup;
+ }
+
+ enum mpd_state state = mpd_status_get_state(status);
+ enum mpd_single_state single = mpd_status_get_single_state(status);
+ bool active = (state == MPD_STATE_PLAY || state == MPD_STATE_PAUSE);
+ if (active) {
+ song = mpd_run_current_song(connection);
+ if (!song) {
+ errorMsg = @"Failed to retrieve current song";
+ goto cleanup;
+ }
+
+ if (mpd_connection_get_error(connection) != MPD_ERROR_SUCCESS) {
+ errorMsg = utf8String(mpd_connection_get_error_message(connection));
+ goto cleanup;
+ }
+
+ if (state == MPD_STATE_PAUSE)
+ [menuButton setImage:pauseImage];
+ else if (state == MPD_STATE_PLAY &&
+ (single == MPD_SINGLE_ON || single == MPD_SINGLE_ONESHOT))
+ [menuButton setImage:singleImage];
+ else
+ [menuButton setImage:nil];
+
+ const char *artist = mpd_song_get_tag(song, MPD_TAG_ARTIST, 0);
+ const char *title = mpd_song_get_tag(song, MPD_TAG_TITLE, 0);
+
+ if (artist)
+ [output appendString:utf8String(artist)];
+ if (artist && title)
+ [output appendString:@" - "];
+ if (title)
+ [output appendString:utf8String(title)];
+ if (!(artist || title))
+ [output appendString:utf8String(mpd_song_get_uri(song))];
+ } else {
+ [output setString:@"No song playing"];
+ [menuButton setImage:nil];
+ }
+
+ int song_pos = mpd_status_get_song_pos(status);
+ unsigned int queue_length = mpd_status_get_queue_length(status);
+ if (song_pos < 0)
+ [output appendFormat:@" (%u)", queue_length];
+ else
+ [output appendFormat:@" (%u/%u)", song_pos + 1, queue_length];
+
+ if ([output length] > TITLE_MAX_LENGTH) {
+ int leftCount = (TITLE_MAX_LENGTH - 3) / 2;
+ int rightCount = TITLE_MAX_LENGTH - leftCount - 3;
+ [menuButton setTitle:[@[
+ [output substringToIndex:leftCount],
+ [output substringFromIndex:[output length] - rightCount]
+ ] componentsJoinedByString:@"..."]];
+ } else
+ [menuButton setTitle:output];
+
+ if (state == MPD_STATE_PLAY) {
+ [playPauseItem setTitle:@"Pause"];
+ [playPauseItem setImage:pauseImage];
+ [playPauseItem setAction:@selector(pause)];
+ [playPauseItem setEnabled:YES];
+ } else {
+ [playPauseItem setTitle:@"Play"];
+ [playPauseItem setImage:playImage];
+ [playPauseItem setAction:@selector(play)];
+ [playPauseItem setEnabled:(queue_length > 0)];
+ }
+ [stopItem setEnabled:active];
+ [nextItem setEnabled:(active && (song_pos < (queue_length - 1)))];
+ [previousItem setEnabled:(active && (song_pos > 0))];
+
+ if (queue_length == 0 && single == MPD_SINGLE_ONESHOT) {
+ [self single_off];
+ single = MPD_SINGLE_OFF;
+ }
+
+ if (single == MPD_SINGLE_OFF) {
+ [singleItem setTitle:@"Pause After This Track"];
+ [singleItem setAction:@selector(single_oneshot)];
+ } else {
+ [singleItem setTitle:@"Keep Playing After This Track"];
+ [singleItem setAction:@selector(single_off)];
+ }
+
+ [singleItem setEnabled:(active && (song_pos < queue_length))];
+ [clearItem setEnabled:(queue_length > 0)];
+
+cleanup:
+ if (song)
+ mpd_song_free(song);
+ if (status)
+ mpd_status_free(status);
+
+ if (errorMsg)
+ [self showError:errorMsg];
+}
+- (NSMenuItem *)addControlMenuItemWithTitle:(NSString *)title
+ image:(NSImage *)image
+ action:(SEL)selector {
+ NSMenuItem *item =
+ [controlMenu addItemWithTitle:title action:selector keyEquivalent:@""];
+ [item setTarget:self];
+ [item setEnabled:NO];
+ [item setImage:image];
+
+ return item;
+}
+- (void)initControlMenu {
+ controlMenu = [NSMenu new];
+ [controlMenu setAutoenablesItems:NO];
+
+#define ICON(NAME, DESC) \
+ [NSImage imageWithSystemSymbolName:@NAME accessibilityDescription:@DESC]
+
+ playImage = ICON("play.fill", "Play");
+ pauseImage = ICON("pause.fill", "Pause");
+ stopImage = ICON("stop.fill", "Stop");
+ nextImage = ICON("forward.fill", "Next");
+ previousImage = ICON("backward.fill", "Previous");
+ singleImage = ICON("playpause.fill", "Single");
+ clearImage = ICON("clear.fill", "Clear");
+
+ timeItem = [NSMenuItem new];
+ [timeItem setEnabled:NO];
+ timeSeparator = [NSMenuItem separatorItem];
+
+#define ADD_ITEM(TITLE, IMAGE, ACTION) \
+ [self addControlMenuItemWithTitle:@TITLE image:IMAGE action:@selector(ACTION)]
+
+ playPauseItem = ADD_ITEM("Play", playImage, play);
+ stopItem = ADD_ITEM("Stop", stopImage, stop);
+ nextItem = ADD_ITEM("Next Track", nextImage, next);
+ previousItem = ADD_ITEM("Previous Track", previousImage, previous);
+ singleItem = ADD_ITEM("Pause After This Track", singleImage, single_oneshot);
+
+ [controlMenu addItem:[NSMenuItem separatorItem]];
+
+ updateDatabaseItem = ADD_ITEM("Update Database", nil, update);
+
+ addToQueueItem = [controlMenu addItemWithTitle:@"Add to Queue"
+ action:nil
+ keyEquivalent:@""];
+ [addToQueueItem setSubmenu:songMenu];
+ [addToQueueItem setEnabled:NO];
+
+ [controlMenu addItem:[NSMenuItem separatorItem]];
+
+ clearItem = ADD_ITEM("Clear Queue", nil, clear);
+
+ [controlMenu addItem:[NSMenuItem separatorItem]];
+ [controlMenu addItemWithTitle:@"Quit MPC Bar"
+ action:@selector(terminate:)
+ keyEquivalent:@"q"];
+
+ NSStatusBar *bar = [NSStatusBar systemStatusBar];
+ NSStatusItem *item = [bar statusItemWithLength:NSVariableStatusItemLength];
+ menuButton = [item button];
+ [menuButton setImagePosition:NSImageLeft];
+ [item setMenu:controlMenu];
+ [self updateControlMenu];
+ [self updateStatus];
+}
+- (void)initSongMenu {
+ songMap = [NSMapTable new];
+ songMenu = [NSMenu new];
+ [self updateSongMenu];
+}
+- (void)updateSongMenu {
+ if (!connection)
+ return;
+
+ [songMap removeAllObjects];
+ [songMenu removeAllItems];
+ if (!mpd_send_list_all(connection, "")) {
+ [self disconnect];
+ return;
+ }
+
+ [addToQueueItem setEnabled:YES];
+
+ struct mpd_entity *entity;
+ NSMutableArray *menus = [NSMutableArray new];
+ [menus addObject:songMenu];
+ BOOL directory;
+ const char *s;
+ while ((entity = mpd_recv_entity(connection))) {
+ switch (mpd_entity_get_type(entity)) {
+ case MPD_ENTITY_TYPE_DIRECTORY:
+ directory = YES;
+ s = mpd_directory_get_path(mpd_entity_get_directory(entity));
+ break;
+ case MPD_ENTITY_TYPE_SONG:
+ directory = NO;
+ s = mpd_song_get_uri(mpd_entity_get_song(entity));
+ break;
+ default:
+ continue;
+ }
+
+ NSString *ss = utf8String(s);
+ NSArray *components = [ss pathComponents];
+
+ while ([menus count] > [components count])
+ [menus removeLastObject];
+
+ NSString *title =
+ directory ? [components lastObject]
+ : [[components lastObject] stringByDeletingPathExtension];
+
+ NSMenuItem *item = [[NSMenuItem alloc]
+ initWithTitle:[title stringByReplacingOccurrencesOfString:@":"
+ withString:@"/"]
+ action:@selector(enqueue:)
+ keyEquivalent:@""];
+
+ [item setTarget:self];
+ [songMap setObject:ss forKey:item];
+ [[menus lastObject] addItem:item];
+ if (directory) {
+ NSMenu *menu = [NSMenu new];
+ [item setSubmenu:menu];
+ [menus addObject:menu];
+ }
+ mpd_entity_free(entity);
+ }
+}
+- (instancetype)init {
+ if (self = [super init]) {
+ [self connect];
+ [self initSongMenu];
+ [self initControlMenu];
+
+ [[[NSThread alloc] initWithTarget:self
+ selector:@selector(updateLoop)
+ object:nil] start];
+ }
+ return self;
+}
+- (void)dealloc {
+ mpd_connection_free(connection);
+}
+- (void)showError:(NSString *)msg {
+ [menuButton setTitle:[NSString stringWithFormat:@"MPC Bar: %@", msg]];
+}
+- (void)play {
+ mpd_run_play(connection);
+}
+- (void)pause {
+ mpd_run_pause(connection, true);
+}
+- (void)stop {
+ mpd_run_stop(connection);
+}
+- (void)next {
+ mpd_run_next(connection);
+}
+- (void)previous {
+ mpd_run_previous(connection);
+}
+- (void)single_oneshot {
+ mpd_run_single_state(connection, MPD_SINGLE_ONESHOT);
+}
+- (void)single_off {
+ mpd_run_single_state(connection, MPD_SINGLE_OFF);
+}
+- (void)update {
+ mpd_run_update(connection, NULL);
+}
+- (void)clear {
+ mpd_run_clear(connection);
+}
+- (void)enqueue:(id)item {
+ mpd_run_add(connection, [[songMap objectForKey:item]
+ cStringUsingEncoding:NSUTF8StringEncoding]);
+}
+- (void)updateStatus {
+ struct mpd_status *status = NULL;
+ struct mpd_song *song = NULL;
+
+ if (connection)
+ status = mpd_run_status(connection);
+
+ if (!status) {
+ if (connection)
+ [self disconnect];
+ return;
+ }
+
+ enum mpd_state state = mpd_status_get_state(status);
+ bool active = (state == MPD_STATE_PLAY || state == MPD_STATE_PAUSE);
+
+ if (!active || !(song = mpd_run_current_song(connection))) {
+ if ([controlMenu indexOfItem:timeItem] >= 0)
+ [controlMenu removeItem:timeItem];
+ if ([controlMenu indexOfItem:timeSeparator] >= 0)
+ [controlMenu removeItem:timeSeparator];
+ mpd_status_free(status);
+ return;
+ }
+
+ unsigned int elapsed = mpd_status_get_elapsed_time(status);
+ unsigned int dur = mpd_song_get_duration(song);
+ [timeItem
+ setTitle:[NSString stringWithFormat:@"%@ / %@", formatTime(elapsed),
+ (dur > 0) ? formatTime(dur) : @"?"]];
+
+ if ([controlMenu indexOfItem:timeItem] < 0)
+ [controlMenu insertItem:timeItem atIndex:0];
+ if ([controlMenu indexOfItem:timeSeparator] < 0)
+ [controlMenu insertItem:timeSeparator atIndex:1];
+
+ mpd_song_free(song);
+ mpd_status_free(status);
+}
+@end
+
+int main(int argc, char *argv[]) {
+ [NSApplication sharedApplication];
+ [MPDController new];
+ [NSApp run];
+ return 0;
+}