diff options
author | Spencer Williams <spnw@plexwave.org> | 2023-07-24 15:25:08 -0400 |
---|---|---|
committer | Spencer Williams <spnw@plexwave.org> | 2023-07-24 15:25:08 -0400 |
commit | d9c21e6fcdfc804998bedd7aa1afdf39792079b4 (patch) | |
tree | b6b889ebd319d8af2a1a63ad46fb5c003507545c |
Initial commit
-rw-r--r-- | LICENSE | 21 | ||||
-rw-r--r-- | Makefile | 9 | ||||
-rw-r--r-- | compile_flags.txt | 3 | ||||
-rw-r--r-- | mpc-bar.m | 457 |
4 files changed, 490 insertions, 0 deletions
@@ -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; +} |