/* Old-school Athena widget UI for libmoc: now-playing info, transport * controls, a local directory browser, and the live-synced playlist. */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "moc_library.h" #define REFRESH_MS 250 struct app { struct moc *mh; Widget now_label; Widget dir_list; Widget plist_list; char cwd[PATH_MAX]; char **dir_strs; int dir_count; char *selected_path; /* full path of the last-clicked dir-browser entry */ int selected_is_dir; int last_click_index; /* for double-click detection */ Time last_click_time; char **plist_strs; char **plist_files; /* parallel array: plist_strs[i] is a label (possibly just the tag title), plist_files[i] is always the real path, needed for play/remove */ int plist_count; char *last_playlist_filename; /* mh->filename as of the last refresh, to detect when the highlighted now-playing entry needs to move */ char *plist_selected_file; int plist_last_click_index; Time plist_last_click_time; Widget toplevel; }; /* mocp's decoder plugins cover roughly this set; anything else found while * recursively adding a directory is almost certainly not a track. */ static const char *sound_extensions[] = { ".mp3", ".ogg", ".oga", ".flac", ".wav", ".m4a", ".aac", ".wma", ".opus", ".ape", ".mpc", ".wv", ".mid", ".midi", NULL }; static int is_sound_file (const char *path) { const char *dot = strrchr(path, '.'); int i; if (!dot) return 0; for (i = 0; sound_extensions[i]; i++) if (!strcasecmp(dot, sound_extensions[i])) return 1; return 0; } static char *time_format (int sec) { char *str = malloc(32); if (sec < 0) sec = 0; snprintf(str, 32, "%d:%02d", sec / 60, sec % 60); return str; } static void free_str_array (char **arr, int count) { int i; if (!arr) return; for (i = 0; i < count; i++) free(arr[i]); free(arr); } /* Pick a starting directory: an explicit argv[1], failing that wherever * mocp itself last left off (same ~/.moc/last_directory file it reads via * read_last_dir() in interface.c), failing that $HOME. */ static void pick_start_dir (struct app *app, int argc, char **argv) { struct stat st; char *home = getenv("HOME"); if (argc > 1 && stat(argv[1], &st) == 0 && S_ISDIR(st.st_mode)) { strncpy(app->cwd, argv[1], sizeof(app->cwd) - 1); return; } if (home) { char last_dir_path[PATH_MAX]; FILE *f; snprintf(last_dir_path, sizeof(last_dir_path), "%s/.moc/last_directory", home); if ((f = fopen(last_dir_path, "r"))) { size_t n = fread(app->cwd, 1, sizeof(app->cwd) - 1, f); fclose(f); if (n > 0) { app->cwd[n] = '\0'; if (stat(app->cwd, &st) == 0 && S_ISDIR(st.st_mode)) return; } } } strncpy(app->cwd, home ? home : "/", sizeof(app->cwd) - 1); } static int dirent_filter (const struct dirent *d) { /* Skip dotfiles, but keep "." (so the current directory itself is * selectable for Add) and ".." (so we can navigate up). */ if (!strcmp(d->d_name, ".") || !strcmp(d->d_name, "..")) return 1; if (d->d_name[0] == '.') return 0; return 1; } /* Re-read app->cwd and rebuild the left-pane list. */ static void reload_dir (struct app *app) { struct dirent **entries; int n, i; char path[PATH_MAX]; n = scandir(app->cwd, &entries, dirent_filter, alphasort); if (n < 0) { fprintf(stderr, "moc_xaw: can't read directory %s\n", app->cwd); return; } free_str_array(app->dir_strs, app->dir_count); free(app->selected_path); app->selected_path = NULL; app->selected_is_dir = 0; /* XawListChange requires a NULL-terminated array when nitems <= 0, so * always leave room for (and set) a trailing NULL even when n > 0. */ app->dir_strs = malloc(sizeof(char*) * (n + 1)); app->dir_strs[n] = NULL; app->dir_count = n; for (i = 0; i < n; i++) { int is_dir; struct stat st; snprintf(path, sizeof(path), "%s/%s", strcmp(app->cwd, "/") ? app->cwd : "", entries[i]->d_name); is_dir = (stat(path, &st) == 0 && S_ISDIR(st.st_mode)); app->dir_strs[i] = malloc(strlen(entries[i]->d_name) + 2); sprintf(app->dir_strs[i], "%s%s", entries[i]->d_name, is_dir ? "/" : ""); free(entries[i]); } free(entries); XawListChange(app->dir_list, app->dir_strs, app->dir_count, 0, True); XawListUnhighlight(app->dir_list); } /* Recursively walk path, adding every file that looks like a track to the * playlist - the directory-browser equivalent of mocp's shift-A. */ static void add_dir_recursive (struct app *app, const char *path) { struct dirent **entries; int n, i; n = scandir(path, &entries, dirent_filter, alphasort); if (n < 0) return; for (i = 0; i < n; i++) { char child[PATH_MAX]; struct stat st; /* dirent_filter lets "." and ".." through for the browser's benefit * (so "." is selectable for Add), but recursing into either here would * just loop forever on the same directory. */ if (!strcmp(entries[i]->d_name, ".") || !strcmp(entries[i]->d_name, "..")) continue; snprintf(child, sizeof(child), "%s/%s", path, entries[i]->d_name); if (stat(child, &st) != 0) continue; if (S_ISDIR(st.st_mode)) add_dir_recursive(app, child); else if (S_ISREG(st.st_mode) && is_sound_file(child)) moc_playlist_add(app->mh, child); } for (i = 0; i < n; i++) free(entries[i]); free(entries); } static void navigate_to (struct app *app, const char *name) { char path[PATH_MAX]; if (!strcmp(name, "..")) { char *slash = strrchr(app->cwd, '/'); if (slash && slash != app->cwd) *slash = '\0'; else strcpy(app->cwd, "/"); } else { size_t len = strlen(name); char trimmed[PATH_MAX]; strncpy(trimmed, name, sizeof(trimmed) - 1); trimmed[sizeof(trimmed) - 1] = '\0'; if (len > 0 && trimmed[len - 1] == '/') trimmed[len - 1] = '\0'; snprintf(path, sizeof(path), "%s/%s", strcmp(app->cwd, "/") ? app->cwd : "", trimmed); strncpy(app->cwd, path, sizeof(app->cwd) - 1); } reload_dir(app); } /* Single click selects an entry (so the Add button knows what to act on) * without leaving the current directory. Clicking the same entry again * within DOUBLE_CLICK_MS is treated as a double-click, which descends into * a directory (".." always navigates immediately - there's nothing else a * single click on it could mean). */ #define DOUBLE_CLICK_MS 500 static void dir_list_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; XawListReturnStruct *ret = (XawListReturnStruct *)call_data; char *name = ret->string; size_t len; char path[PATH_MAX]; Time now; int is_double; if (ret->list_index == XAW_LIST_NONE || !name) return; if (!strcmp(name, "..")) { navigate_to(app, name); return; } now = XtLastTimestampProcessed(XtDisplay(w)); is_double = (ret->list_index == app->last_click_index && (now - app->last_click_time) < DOUBLE_CLICK_MS); app->last_click_index = ret->list_index; app->last_click_time = now; len = strlen(name); if (is_double && len > 0 && name[len - 1] == '/') { navigate_to(app, name); return; } free(app->selected_path); snprintf(path, sizeof(path), "%s/%s", strcmp(app->cwd, "/") ? app->cwd : "", name); if (len > 0 && name[len - 1] == '/') path[strlen(path) - 1] = '\0'; /* drop the trailing marker slash */ app->selected_path = strdup(path); app->selected_is_dir = (len > 0 && name[len - 1] == '/'); } static void add_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; if (!app->selected_path) return; if (app->selected_is_dir) add_dir_recursive(app, app->selected_path); else moc_playlist_add(app->mh, app->selected_path); } static void play_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; moc_play(app->mh, ""); } static void pause_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; if (app->mh->state == PAUSED) moc_unpause(app->mh); else moc_pause(app->mh); } static void stop_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; moc_stop(app->mh); } static void next_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; moc_next(app->mh); } static void prev_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; moc_prev(app->mh); } /* Single click selects (so Remove knows what to act on); double click * (same row, within DOUBLE_CLICK_MS) plays that track immediately. */ static void plist_list_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; XawListReturnStruct *ret = (XawListReturnStruct *)call_data; Time now; int is_double; const char *file; if (ret->list_index == XAW_LIST_NONE || ret->list_index >= app->plist_count) return; file = app->plist_files[ret->list_index]; now = XtLastTimestampProcessed(XtDisplay(w)); is_double = (ret->list_index == app->plist_last_click_index && (now - app->plist_last_click_time) < DOUBLE_CLICK_MS); app->plist_last_click_index = ret->list_index; app->plist_last_click_time = now; free(app->plist_selected_file); app->plist_selected_file = strdup(file); if (is_double) moc_play(app->mh, file); } static void remove_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; if (!app->plist_selected_file) return; moc_playlist_remove(app->mh, app->plist_selected_file); free(app->plist_selected_file); app->plist_selected_file = NULL; } static void clear_callback (Widget w, XtPointer client_data, XtPointer call_data) { struct app *app = (struct app *)client_data; moc_playlist_clear(app->mh); free(app->plist_selected_file); app->plist_selected_file = NULL; } static void refresh_now_playing (struct app *app) { char buf[1024]; char title[1024]; char *t, *l; if (app->mh->status != CONNECTED) { snprintf(buf, sizeof(buf), "%s", moc_str_status(app->mh->status)); XtVaSetValues(app->now_label, XtNlabel, buf, NULL); XtVaSetValues(app->toplevel, XtNtitle, "moc_xaw", NULL); return; } if (app->mh->state != PLAYING && app->mh->state != PAUSED) { snprintf(buf, sizeof(buf), "%s", moc_str_state(app->mh->state)); XtVaSetValues(app->now_label, XtNlabel, buf, NULL); XtVaSetValues(app->toplevel, XtNtitle, "moc_xaw", NULL); return; } t = time_format(app->mh->time); l = time_format(app->mh->length); snprintf(buf, sizeof(buf), "%s\n%s\n%s\n%s\n%s / %s", moc_str_state(app->mh->state), app->mh->title ? app->mh->title : "", app->mh->artist ? app->mh->artist : "", app->mh->album ? app->mh->album : "", t, l); free(t); free(l); XtVaSetValues(app->now_label, XtNlabel, buf, NULL); /* mirrors mocp's own ncurses title bar, e.g. "MOC [play] - Artist - Title" */ snprintf(title, sizeof(title), "moc_xaw [%s] - %s%s%s", app->mh->state == PAUSED ? "paused" : "playing", app->mh->artist ? app->mh->artist : "", (app->mh->artist && app->mh->title) ? " - " : "", app->mh->title ? app->mh->title : ""); XtVaSetValues(app->toplevel, XtNtitle, title, NULL); } /* XawListChange unconditionally rebuilds the widget's internal state and * resets scroll position, and re-highlighting every tick would undo * whatever row the user just clicked on. So: only touch the list when its * contents actually changed, and only move the highlight when the * now-playing file itself changes - not on every 250ms refresh. */ static void refresh_playlist (struct app *app) { struct moc_plist_item *item; int n = 0, i; char **new_strs, **new_files; int changed; for (item = app->mh->playlist; item; item = item->next) n++; new_strs = malloc(sizeof(char*) * (n + 1)); new_strs[n] = NULL; new_files = malloc(sizeof(char*) * (n + 1)); new_files[n] = NULL; for (item = app->mh->playlist, i = 0; item; item = item->next, i++) { const char *label = (item->title && item->title[0]) ? item->title : item->file; int is_playing = app->mh->filename && !strcmp(item->file, app->mh->filename); char buf[1024]; snprintf(buf, sizeof(buf), "%s%s", is_playing ? "> " : "", label); new_strs[i] = strdup(buf); new_files[i] = strdup(item->file); } changed = (n != app->plist_count); if (!changed) for (i = 0; i < n; i++) if (strcmp(new_strs[i], app->plist_strs[i]) != 0) { changed = 1; break; } if (changed) { free_str_array(app->plist_strs, app->plist_count); free_str_array(app->plist_files, app->plist_count); app->plist_strs = new_strs; app->plist_files = new_files; app->plist_count = n; XawListChange(app->plist_list, app->plist_strs, app->plist_count, 0, True); } else { free_str_array(new_strs, n); free_str_array(new_files, n); } if (!app->mh->filename) { free(app->last_playlist_filename); app->last_playlist_filename = NULL; } else if (!app->last_playlist_filename || strcmp(app->mh->filename, app->last_playlist_filename) != 0) { int playing_index = -1; for (item = app->mh->playlist, i = 0; item; item = item->next, i++) if (!strcmp(item->file, app->mh->filename)) { playing_index = i; break; } if (playing_index >= 0) XawListHighlight(app->plist_list, playing_index); free(app->last_playlist_filename); app->last_playlist_filename = strdup(app->mh->filename); } } static void do_refresh (struct app *app) { pthread_mutex_lock(&app->mh->lock); refresh_now_playing(app); refresh_playlist(app); pthread_mutex_unlock(&app->mh->lock); } static void timer_callback (XtPointer client_data, XtIntervalId *id) { struct app *app = (struct app *)client_data; do_refresh(app); XtAppAddTimeOut(XtWidgetToApplicationContext(app->now_label), REFRESH_MS, timer_callback, client_data); } int main (int argc, char **argv) { XtAppContext ctx; Widget toplevel, form, prev_b, play_b, pause_b, stop_b, next_b, add_b, remove_b, clear_b, now_label, dir_view, dir_list, plist_view, plist_list; struct app app; memset(&app, 0, sizeof(app)); app.mh = moc_init(); if (!app.mh) { fprintf(stderr, "moc_xaw: failed to initialize library\n"); return 1; } pick_start_dir(&app, argc, argv); app.plist_count = -1; /* sentinel: forces the first refresh_playlist() call to actually sync the (possibly empty) list widget instead of leaving its uninitialized placeholder content on screen */ toplevel = XtAppInitialize(&ctx, "MocXaw", NULL, 0, &argc, argv, NULL, NULL, 0); XtVaSetValues(toplevel, XtNtitle, "moc_xaw", XtNiconName, "moc_xaw", NULL); app.toplevel = toplevel; form = XtVaCreateManagedWidget("form", formWidgetClass, toplevel, NULL); now_label = XtVaCreateManagedWidget("nowPlaying", labelWidgetClass, form, XtNlabel, "connecting...", XtNwidth, 660, XtNheight, 120, XtNjustify, XtJustifyLeft, XtNresize, False, NULL); app.now_label = now_label; prev_b = XtVaCreateManagedWidget("prev", commandWidgetClass, form, XtNlabel, "<<", XtNfromVert, now_label, NULL); play_b = XtVaCreateManagedWidget("play", commandWidgetClass, form, XtNlabel, ">", XtNfromVert, now_label, XtNfromHoriz, prev_b, NULL); pause_b = XtVaCreateManagedWidget("pause", commandWidgetClass, form, XtNlabel, "||", XtNfromVert, now_label, XtNfromHoriz, play_b, NULL); stop_b = XtVaCreateManagedWidget("stop", commandWidgetClass, form, XtNlabel, "[]", XtNfromVert, now_label, XtNfromHoriz, pause_b, NULL); next_b = XtVaCreateManagedWidget("next", commandWidgetClass, form, XtNlabel, ">>", XtNfromVert, now_label, XtNfromHoriz, stop_b, NULL); dir_view = XtVaCreateManagedWidget("dirView", viewportWidgetClass, form, XtNfromVert, prev_b, XtNwidth, 300, XtNheight, 300, XtNallowVert, True, NULL); dir_list = XtVaCreateManagedWidget("dirList", listWidgetClass, dir_view, XtNdefaultColumns, 1, XtNforceColumns, True, NULL); app.dir_list = dir_list; add_b = XtVaCreateManagedWidget("add", commandWidgetClass, form, XtNlabel, "Add =>", XtNfromVert, prev_b, XtNfromHoriz, dir_view, NULL); remove_b = XtVaCreateManagedWidget("remove", commandWidgetClass, form, XtNlabel, "Remove", XtNfromVert, add_b, XtNfromHoriz, dir_view, NULL); clear_b = XtVaCreateManagedWidget("clear", commandWidgetClass, form, XtNlabel, "Clear", XtNfromVert, remove_b, XtNfromHoriz, dir_view, NULL); plist_view = XtVaCreateManagedWidget("plistView", viewportWidgetClass, form, XtNfromVert, prev_b, XtNfromHoriz, add_b, XtNwidth, 300, XtNheight, 300, XtNallowVert, True, NULL); plist_list = XtVaCreateManagedWidget("plistList", listWidgetClass, plist_view, XtNdefaultColumns, 1, XtNforceColumns, True, NULL); app.plist_list = plist_list; XtAddCallback(dir_list, XtNcallback, dir_list_callback, &app); XtAddCallback(add_b, XtNcallback, add_callback, &app); XtAddCallback(plist_list, XtNcallback, plist_list_callback, &app); XtAddCallback(remove_b, XtNcallback, remove_callback, &app); XtAddCallback(clear_b, XtNcallback, clear_callback, &app); XtAddCallback(play_b, XtNcallback, play_callback, &app); XtAddCallback(pause_b, XtNcallback, pause_callback, &app); XtAddCallback(stop_b, XtNcallback, stop_callback, &app); XtAddCallback(next_b, XtNcallback, next_callback, &app); XtAddCallback(prev_b, XtNcallback, prev_callback, &app); XtRealizeWidget(toplevel); reload_dir(&app); do_refresh(&app); XtAppAddTimeOut(ctx, REFRESH_MS, timer_callback, &app); XtAppMainLoop(ctx); return 0; }