Přemysl Eric Janouch
641803df35
Also fix pclose() handling within Info plugins, and prevent them from screwing up the terminal with error output on initialization. This is still rather crude, but at least it's possible.
6389 lines
171 KiB
C
6389 lines
171 KiB
C
/*
|
|
* nncmpp -- the MPD client you never knew you needed
|
|
*
|
|
* Copyright (c) 2016 - 2024, Přemysl Eric Janouch <p@janouch.name>
|
|
*
|
|
* Permission to use, copy, modify, and/or distribute this software for any
|
|
* purpose with or without fee is hereby granted.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
|
* SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
|
* OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
|
* CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
*
|
|
*/
|
|
|
|
#include "config.h"
|
|
|
|
// We "need" to have an enum for attributes before including liberty.
|
|
// Avoiding colours in the defaults here in order to support dumb terminals.
|
|
#define ATTRIBUTE_TABLE(XX) \
|
|
XX( NORMAL, normal, -1, -1, 0 ) \
|
|
XX( HIGHLIGHT, highlight, -1, -1, A_BOLD ) \
|
|
/* Gauge */ \
|
|
XX( ELAPSED, elapsed, -1, -1, A_REVERSE ) \
|
|
XX( REMAINS, remains, -1, -1, A_UNDERLINE ) \
|
|
/* Tab bar */ \
|
|
XX( TAB_BAR, tab_bar, -1, -1, A_REVERSE ) \
|
|
XX( TAB_ACTIVE, tab_active, -1, -1, A_BOLD ) \
|
|
/* Listview */ \
|
|
XX( HEADER, header, -1, -1, A_UNDERLINE ) \
|
|
XX( EVEN, even, -1, -1, 0 ) \
|
|
XX( ODD, odd, -1, -1, 0 ) \
|
|
XX( DIRECTORY, directory, -1, -1, 0 ) \
|
|
XX( SELECTION, selection, -1, -1, A_REVERSE ) \
|
|
/* Cyan is good with both black and white.
|
|
* Can't use A_REVERSE because bold'd be bright.
|
|
* Unfortunately ran out of B&W attributes. */ \
|
|
XX( MULTISELECT, multiselect, -1, 6, 0 ) \
|
|
/* This ought to be indicative enough. */ \
|
|
XX( DEFOCUSED, defocused, -1, -1, A_UNDERLINE ) \
|
|
XX( SCROLLBAR, scrollbar, -1, -1, 0 ) \
|
|
/* These are for debugging only */ \
|
|
XX( WARNING, warning, 3, -1, 0 ) \
|
|
XX( ERROR, error, 1, -1, 0 ) \
|
|
XX( INCOMING, incoming, 2, -1, 0 ) \
|
|
XX( OUTGOING, outgoing, 4, -1, 0 )
|
|
|
|
enum
|
|
{
|
|
#define XX(name, config, fg_, bg_, attrs_) ATTRIBUTE_ ## name,
|
|
ATTRIBUTE_TABLE (XX)
|
|
#undef XX
|
|
ATTRIBUTE_COUNT
|
|
};
|
|
|
|
// My battle-tested C framework acting as a GLib replacement. Its one big
|
|
// disadvantage is missing support for i18n but that can eventually be added
|
|
// as an optional feature. Localised applications look super awkward, though.
|
|
|
|
// User data for logger functions to enable formatted logging
|
|
#define print_fatal_data ((void *) ATTRIBUTE_ERROR)
|
|
#define print_error_data ((void *) ATTRIBUTE_ERROR)
|
|
#define print_warning_data ((void *) ATTRIBUTE_WARNING)
|
|
|
|
#define LIBERTY_WANT_POLLER
|
|
#define LIBERTY_WANT_ASYNC
|
|
#define LIBERTY_WANT_PROTO_HTTP
|
|
#define LIBERTY_WANT_PROTO_MPD
|
|
#include "liberty/liberty.c"
|
|
|
|
#ifdef WITH_X11
|
|
#define LIBERTY_XUI_WANT_X11
|
|
#endif // WITH_X11
|
|
#include "liberty/liberty-xui.c"
|
|
|
|
#include <dirent.h>
|
|
#include <locale.h>
|
|
#include <math.h>
|
|
|
|
// We need cURL to extract links from Internet stream playlists. It'd be way
|
|
// too much code to do this all by ourselves, and there's nothing better around.
|
|
#include <curl/curl.h>
|
|
|
|
// The spectrum analyser requires a DFT transform. The FFTW library is fairly
|
|
// efficient, and doesn't have a requirement on the number of bins.
|
|
#ifdef WITH_FFTW
|
|
#include <fftw3.h>
|
|
#endif // WITH_FFTW
|
|
|
|
// Remote MPD control needs appropriate volume controls.
|
|
#ifdef WITH_PULSE
|
|
#include "liberty/liberty-pulse.c"
|
|
#include <pulse/context.h>
|
|
#include <pulse/error.h>
|
|
#include <pulse/introspect.h>
|
|
#include <pulse/subscribe.h>
|
|
#include <pulse/sample.h>
|
|
#endif // WITH_PULSE
|
|
|
|
#define APP_TITLE PROGRAM_NAME ///< Left top corner
|
|
|
|
#include "nncmpp-actions.h"
|
|
|
|
// --- Utilities ---------------------------------------------------------------
|
|
|
|
static void
|
|
shell_quote (const char *str, struct str *output)
|
|
{
|
|
// See SUSv3 Shell and Utilities, 2.2.3 Double-Quotes
|
|
str_append_c (output, '"');
|
|
for (const char *p = str; *p; p++)
|
|
{
|
|
if (strchr ("`$\"\\", *p))
|
|
str_append_c (output, '\\');
|
|
str_append_c (output, *p);
|
|
}
|
|
str_append_c (output, '"');
|
|
}
|
|
|
|
static bool
|
|
xstrtoul_map (const struct str_map *map, const char *key, unsigned long *out)
|
|
{
|
|
const char *field = str_map_find (map, key);
|
|
return field && xstrtoul (out, field, 10);
|
|
}
|
|
|
|
static const char *
|
|
xbasename (const char *path)
|
|
{
|
|
const char *last_slash = strrchr (path, '/');
|
|
return last_slash ? last_slash + 1 : path;
|
|
}
|
|
|
|
static char *xstrdup0 (const char *s) { return s ? xstrdup (s) : NULL; }
|
|
|
|
static char *
|
|
latin1_to_utf8 (const char *latin1)
|
|
{
|
|
struct str converted = str_make ();
|
|
while (*latin1)
|
|
{
|
|
uint8_t c = *latin1++;
|
|
if (c < 0x80)
|
|
str_append_c (&converted, c);
|
|
else
|
|
{
|
|
str_append_c (&converted, 0xC0 | (c >> 6));
|
|
str_append_c (&converted, 0x80 | (c & 0x3F));
|
|
}
|
|
}
|
|
return str_steal (&converted);
|
|
}
|
|
|
|
static void
|
|
str_enforce_utf8 (struct str *self)
|
|
{
|
|
if (!utf8_validate (self->str, self->len))
|
|
{
|
|
char *sanitized = latin1_to_utf8 (self->str);
|
|
str_reset (self);
|
|
str_append (self, sanitized);
|
|
free (sanitized);
|
|
}
|
|
}
|
|
|
|
static void
|
|
cstr_uncapitalize (char *s)
|
|
{
|
|
if (isupper (s[0]) && islower (s[1]))
|
|
s[0] = tolower_ascii (s[0]);
|
|
}
|
|
|
|
static int
|
|
print_curl_debug (CURL *easy, curl_infotype type, char *data, size_t len,
|
|
void *ud)
|
|
{
|
|
(void) easy;
|
|
(void) ud;
|
|
(void) type;
|
|
|
|
char copy[len + 1];
|
|
for (size_t i = 0; i < len; i++)
|
|
{
|
|
uint8_t c = data[i];
|
|
copy[i] = !iscntrl_ascii (c) || c == '\n' ? c : '.';
|
|
}
|
|
copy[len] = '\0';
|
|
|
|
char *next;
|
|
for (char *p = copy; p; p = next)
|
|
{
|
|
if ((next = strchr (p, '\n')))
|
|
*next++ = '\0';
|
|
if (!*p)
|
|
continue;
|
|
|
|
if (!utf8_validate (p, strlen (p)))
|
|
{
|
|
char *fixed = latin1_to_utf8 (p);
|
|
print_debug ("cURL: %s", fixed);
|
|
free (fixed);
|
|
}
|
|
else
|
|
print_debug ("cURL: %s", p);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static char *
|
|
mpd_parse_kv (char *line, char **value)
|
|
{
|
|
char *key = mpd_client_parse_kv (line, value);
|
|
if (!key) print_debug ("%s: %s", "erroneous MPD output", line);
|
|
return key;
|
|
}
|
|
|
|
static void
|
|
mpd_read_time (const char *value, int *sec, int *optional_msec)
|
|
{
|
|
if (!value)
|
|
return;
|
|
|
|
char *end = NULL;
|
|
long n = strtol (value, &end, 10);
|
|
if (n < 0 || (*end && *end != '.'))
|
|
return;
|
|
|
|
int msec = 0;
|
|
if (*end == '.')
|
|
{
|
|
// In practice, MPD always uses three decimal digits
|
|
size_t digits = strspn (++end, "0123456789");
|
|
if (end[digits])
|
|
return;
|
|
|
|
if (digits--) msec += (*end++ - '0') * 100;
|
|
if (digits--) msec += (*end++ - '0') * 10;
|
|
if (digits--) msec += *end++ - '0';
|
|
}
|
|
|
|
*sec = MIN (INT_MAX, n);
|
|
if (optional_msec)
|
|
*optional_msec = msec;
|
|
}
|
|
|
|
// --- cURL async wrapper ------------------------------------------------------
|
|
|
|
// You are meant to subclass this structure, no user_data pointers needed
|
|
struct poller_curl_task;
|
|
|
|
/// Receives notification for finished transfers
|
|
typedef void (*poller_curl_done_fn)
|
|
(CURLMsg *msg, struct poller_curl_task *task);
|
|
|
|
struct poller_curl_task
|
|
{
|
|
CURL *easy; ///< cURL easy interface handle
|
|
char curl_error[CURL_ERROR_SIZE]; ///< cURL error info buffer
|
|
poller_curl_done_fn on_done; ///< Done callback
|
|
};
|
|
|
|
struct poller_curl_fd
|
|
{
|
|
LIST_HEADER (struct poller_curl_fd)
|
|
struct poller_fd fd; ///< Poller FD
|
|
};
|
|
|
|
struct poller_curl
|
|
{
|
|
struct poller *poller; ///< Parent poller
|
|
struct poller_timer timer; ///< cURL timer
|
|
CURLM *multi; ///< cURL multi interface handle
|
|
struct poller_curl_fd *fds; ///< List of all FDs
|
|
|
|
// TODO: also make sure to dispose of them at the end of the program
|
|
|
|
int registered; ///< Number of attached easy handles
|
|
};
|
|
|
|
static void
|
|
poller_curl_collect (struct poller_curl *self, curl_socket_t s, int ev_bitmask)
|
|
{
|
|
int running = 0;
|
|
CURLMcode res;
|
|
// XXX: ignoring errors, in particular CURLM_CALL_MULTI_PERFORM
|
|
if ((res = curl_multi_socket_action (self->multi, s, ev_bitmask, &running)))
|
|
print_debug ("cURL: %s", curl_multi_strerror (res));
|
|
|
|
CURLMsg *msg;
|
|
while ((msg = curl_multi_info_read (self->multi, &running)))
|
|
if (msg->msg == CURLMSG_DONE)
|
|
{
|
|
struct poller_curl_task *task = NULL;
|
|
hard_assert (!curl_easy_getinfo
|
|
(msg->easy_handle, CURLINFO_PRIVATE, &task));
|
|
task->on_done (msg, task);
|
|
}
|
|
}
|
|
|
|
static void
|
|
poller_curl_on_socket (const struct pollfd *pfd, void *user_data)
|
|
{
|
|
int mask = 0;
|
|
if (pfd->revents & POLLIN) mask |= CURL_CSELECT_IN;
|
|
if (pfd->revents & POLLOUT) mask |= CURL_CSELECT_OUT;
|
|
if (pfd->revents & POLLERR) mask |= CURL_CSELECT_ERR;
|
|
poller_curl_collect (user_data, pfd->fd, mask);
|
|
}
|
|
|
|
static int
|
|
poller_curl_on_socket_action (CURL *easy, curl_socket_t s, int what,
|
|
void *user_data, void *socket_data)
|
|
{
|
|
(void) easy;
|
|
struct poller_curl *self = user_data;
|
|
|
|
struct poller_curl_fd *fd;
|
|
if (!(fd = socket_data))
|
|
{
|
|
set_cloexec (s);
|
|
|
|
fd = xmalloc (sizeof *fd);
|
|
LIST_PREPEND (self->fds, fd);
|
|
|
|
fd->fd = poller_fd_make (self->poller, s);
|
|
fd->fd.dispatcher = poller_curl_on_socket;
|
|
fd->fd.user_data = self;
|
|
curl_multi_assign (self->multi, s, fd);
|
|
}
|
|
if (what == CURL_POLL_REMOVE)
|
|
{
|
|
// Some annoying cURL bug. Never trust libraries.
|
|
fd->fd.closed = fcntl(fd->fd.fd, F_GETFL) < 0 && errno == EBADF;
|
|
|
|
poller_fd_reset (&fd->fd);
|
|
LIST_UNLINK (self->fds, fd);
|
|
free (fd);
|
|
}
|
|
else
|
|
{
|
|
short events = 0;
|
|
if (what == CURL_POLL_IN) events = POLLIN;
|
|
if (what == CURL_POLL_OUT) events = POLLOUT;
|
|
if (what == CURL_POLL_INOUT) events = POLLIN | POLLOUT;
|
|
poller_fd_set (&fd->fd, events);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static void
|
|
poller_curl_on_timer (void *user_data)
|
|
{
|
|
poller_curl_collect (user_data, CURL_SOCKET_TIMEOUT, 0);
|
|
}
|
|
|
|
static int
|
|
poller_curl_on_timer_change (CURLM *multi, long timeout_ms, void *user_data)
|
|
{
|
|
(void) multi;
|
|
struct poller_curl *self = user_data;
|
|
|
|
if (timeout_ms < 0)
|
|
poller_timer_reset (&self->timer);
|
|
else
|
|
poller_timer_set (&self->timer, timeout_ms);
|
|
return 0;
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static bool
|
|
poller_curl_init (struct poller_curl *self, struct poller *poller,
|
|
struct error **e)
|
|
{
|
|
memset (self, 0, sizeof *self);
|
|
if (!(self->multi = curl_multi_init ()))
|
|
return error_set (e, "cURL setup failed");
|
|
|
|
CURLMcode mres;
|
|
if ((mres = curl_multi_setopt (self->multi,
|
|
CURLMOPT_SOCKETFUNCTION, poller_curl_on_socket_action))
|
|
|| (mres = curl_multi_setopt (self->multi,
|
|
CURLMOPT_TIMERFUNCTION, poller_curl_on_timer_change))
|
|
|| (mres = curl_multi_setopt (self->multi, CURLMOPT_SOCKETDATA, self))
|
|
|| (mres = curl_multi_setopt (self->multi, CURLMOPT_TIMERDATA, self)))
|
|
{
|
|
curl_multi_cleanup (self->multi);
|
|
self->multi = NULL;
|
|
return error_set (e, "%s: %s",
|
|
"cURL setup failed", curl_multi_strerror (mres));
|
|
}
|
|
|
|
self->timer = poller_timer_make ((self->poller = poller));
|
|
self->timer.dispatcher = poller_curl_on_timer;
|
|
self->timer.user_data = self;
|
|
return true;
|
|
}
|
|
|
|
static void
|
|
poller_curl_free (struct poller_curl *self)
|
|
{
|
|
curl_multi_cleanup (self->multi);
|
|
poller_timer_reset (&self->timer);
|
|
|
|
LIST_FOR_EACH (struct poller_curl_fd, iter, self->fds)
|
|
{
|
|
poller_fd_reset (&iter->fd);
|
|
free (iter);
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
/// Initialize a task with a new easy instance that can be used with the poller
|
|
static bool
|
|
poller_curl_spawn (struct poller_curl_task *task, struct error **e)
|
|
{
|
|
CURL *easy;
|
|
if (!(easy = curl_easy_init ()))
|
|
return error_set (e, "cURL setup failed");
|
|
|
|
// We already take care of SIGPIPE, and native DNS timeouts are only
|
|
// a problem for people without the AsynchDNS feature.
|
|
//
|
|
// Unfortunately, cURL doesn't allow custom callbacks for DNS.
|
|
// The most we could try is parse out the hostname and provide an address
|
|
// override for it using CURLOPT_RESOLVE. Or be our own SOCKS4A/5 proxy.
|
|
|
|
CURLcode res;
|
|
if ((res = curl_easy_setopt (easy, CURLOPT_NOSIGNAL, 1L))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_ERRORBUFFER, task->curl_error))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_PRIVATE, task)))
|
|
{
|
|
curl_easy_cleanup (easy);
|
|
return error_set (e, "%s", curl_easy_strerror (res));
|
|
}
|
|
|
|
task->easy = easy;
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
poller_curl_add (struct poller_curl *self, CURL *easy, struct error **e)
|
|
{
|
|
CURLMcode mres;
|
|
// "CURLMOPT_TIMERFUNCTION [...] will be called from within this function"
|
|
if ((mres = curl_multi_add_handle (self->multi, easy)))
|
|
return error_set (e, "%s", curl_multi_strerror (mres));
|
|
self->registered++;
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
poller_curl_remove (struct poller_curl *self, CURL *easy, struct error **e)
|
|
{
|
|
CURLMcode mres;
|
|
if ((mres = curl_multi_remove_handle (self->multi, easy)))
|
|
return error_set (e, "%s", curl_multi_strerror (mres));
|
|
self->registered--;
|
|
return true;
|
|
}
|
|
|
|
// --- Compact map -------------------------------------------------------------
|
|
|
|
// MPD provides us with a hefty amount of little key-value maps. The overhead
|
|
// of str_map for such constant (string -> string) maps is too high and it's
|
|
// much better to serialize them (mainly cache locality and memory efficiency).
|
|
//
|
|
// This isn't intended to be reusable and has case insensitivity built-in.
|
|
|
|
typedef uint8_t *compact_map_t; ///< Compacted (string -> string) map
|
|
|
|
static compact_map_t
|
|
compact_map (struct str_map *map)
|
|
{
|
|
struct str s = str_make ();
|
|
struct str_map_iter iter = str_map_iter_make (map);
|
|
|
|
char *value;
|
|
static const size_t zero = 0, alignment = sizeof zero;
|
|
while ((value = str_map_iter_next (&iter)))
|
|
{
|
|
size_t entry_len = iter.link->key_length + 1 + strlen (value) + 1;
|
|
size_t padding_len = (alignment - entry_len % alignment) % alignment;
|
|
entry_len += padding_len;
|
|
|
|
str_append_data (&s, &entry_len, sizeof entry_len);
|
|
str_append_printf (&s, "%s%c%s%c", iter.link->key, 0, value, 0);
|
|
str_append_data (&s, &zero, padding_len);
|
|
}
|
|
str_append_data (&s, &zero, sizeof zero);
|
|
return (compact_map_t) str_steal (&s);
|
|
}
|
|
|
|
static char *
|
|
compact_map_find (compact_map_t data, const char *needle)
|
|
{
|
|
size_t entry_len;
|
|
while ((entry_len = *(size_t *) data))
|
|
{
|
|
data += sizeof entry_len;
|
|
if (!strcasecmp_ascii (needle, (const char *) data))
|
|
return (char *) data + strlen (needle) + 1;
|
|
data += entry_len;
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
struct item_list
|
|
{
|
|
compact_map_t *items; ///< Compacted (string -> string) maps
|
|
size_t len; ///< Length
|
|
size_t alloc; ///< Allocated items
|
|
};
|
|
|
|
static struct item_list
|
|
item_list_make (void)
|
|
{
|
|
struct item_list self = {};
|
|
self.items = xcalloc ((self.alloc = 16), sizeof *self.items);
|
|
return self;
|
|
}
|
|
|
|
static void
|
|
item_list_free (struct item_list *self)
|
|
{
|
|
for (size_t i = 0; i < self->len; i++)
|
|
free (self->items[i]);
|
|
free (self->items);
|
|
}
|
|
|
|
static bool
|
|
item_list_set (struct item_list *self, int i, struct str_map *item)
|
|
{
|
|
if (i < 0 || (size_t) i >= self->len)
|
|
return false;
|
|
|
|
free (self->items[i]);
|
|
self->items[i] = compact_map (item);
|
|
return true;
|
|
}
|
|
|
|
static compact_map_t
|
|
item_list_get (struct item_list *self, int i)
|
|
{
|
|
if (i < 0 || (size_t) i >= self->len || !self->items[i])
|
|
return NULL;
|
|
return self->items[i];
|
|
}
|
|
|
|
static void
|
|
item_list_resize (struct item_list *self, size_t len)
|
|
{
|
|
// Make the allocated array big enough but not too large
|
|
size_t new_alloc = self->alloc;
|
|
while (new_alloc < len)
|
|
new_alloc <<= 1;
|
|
while ((new_alloc >> 1) >= len
|
|
&& (new_alloc - len) >= 1024)
|
|
new_alloc >>= 1;
|
|
|
|
for (size_t i = len; i < self->len; i++)
|
|
free (self->items[i]);
|
|
if (new_alloc != self->alloc)
|
|
self->items = xreallocarray (self->items,
|
|
sizeof *self->items, (self->alloc = new_alloc));
|
|
for (size_t i = self->len; i < len; i++)
|
|
self->items[i] = NULL;
|
|
|
|
self->len = len;
|
|
}
|
|
|
|
// --- Spectrum analyzer -------------------------------------------------------
|
|
|
|
// See http://www.zytrax.com/tech/audio/equalization.html
|
|
// for a good write-up about this problem domain
|
|
|
|
#ifdef WITH_FFTW
|
|
|
|
struct spectrum
|
|
{
|
|
int sampling_rate; ///< Number of samples per seconds
|
|
int channels; ///< Number of sampled channels
|
|
int bits; ///< Number of bits per sample
|
|
int bars; ///< Number of output vertical bars
|
|
|
|
int bins; ///< Number of DFT bins
|
|
int useful_bins; ///< Bins up to the Nyquist frequency
|
|
int samples; ///< Number of windows to average
|
|
float accumulator_scale; ///< Scaling factor for accum. values
|
|
int *top_bins; ///< Top DFT bin index for each bar
|
|
char *rendered; ///< String buffer for the "render"
|
|
float *spectrum; ///< The "render" as normalized floats
|
|
|
|
void *buffer; ///< Input buffer
|
|
size_t buffer_len; ///< Input buffer fill level
|
|
size_t buffer_size; ///< Input buffer size
|
|
|
|
/// Decode the respective part of the buffer into the second half of data
|
|
void (*decode) (struct spectrum *, int sample);
|
|
|
|
float *data; ///< Normalized audio data
|
|
float *window; ///< Sampled window function
|
|
float *windowed; ///< data * window
|
|
fftwf_complex *out; ///< DFT output
|
|
fftwf_plan p; ///< DFT plan/FFTW configuration
|
|
float *accumulator; ///< Accumulated powers of samples
|
|
};
|
|
|
|
// - - Windows - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
// Out: float[n] of 0..1
|
|
static void
|
|
window_hann (float *coefficients, size_t n)
|
|
{
|
|
for (size_t i = 0; i < n; i++)
|
|
{
|
|
float sine = sin (M_PI * i / n);
|
|
coefficients[i] = sine * sine;
|
|
}
|
|
}
|
|
|
|
// In: float[n] of -1..1, float[n] of 0..1; out: float[n] of -1..1
|
|
static void
|
|
window_apply (const float *in, const float *coefficients, float *out, size_t n)
|
|
{
|
|
for (size_t i = 0; i < n; i++)
|
|
out[i] = in[i] * coefficients[i];
|
|
}
|
|
|
|
// In: float[n] of 0..1; out: float 0..n, describing the coherent gain
|
|
static float
|
|
window_coherent_gain (const float *in, size_t n)
|
|
{
|
|
float sum = 0;
|
|
for (size_t i = 0; i < n; i++)
|
|
sum += in[i];
|
|
return sum;
|
|
}
|
|
|
|
// - - Decoding - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
spectrum_decode_8 (struct spectrum *s, int sample)
|
|
{
|
|
size_t n = s->useful_bins;
|
|
float *data = s->data + n;
|
|
for (int8_t *p = (int8_t *) s->buffer + sample * n * s->channels;
|
|
n--; p += s->channels)
|
|
{
|
|
int32_t acc = 0;
|
|
for (int ch = 0; ch < s->channels; ch++)
|
|
acc += p[ch];
|
|
*data++ = (float) acc / s->channels / -INT8_MIN;
|
|
}
|
|
}
|
|
|
|
static void
|
|
spectrum_decode_16 (struct spectrum *s, int sample)
|
|
{
|
|
size_t n = s->useful_bins;
|
|
float *data = s->data + n;
|
|
for (int16_t *p = (int16_t *) s->buffer + sample * n * s->channels;
|
|
n--; p += s->channels)
|
|
{
|
|
int32_t acc = 0;
|
|
for (int ch = 0; ch < s->channels; ch++)
|
|
acc += p[ch];
|
|
*data++ = (float) acc / s->channels / -INT16_MIN;
|
|
}
|
|
}
|
|
|
|
static void
|
|
spectrum_decode_16_2 (struct spectrum *s, int sample)
|
|
{
|
|
size_t n = s->useful_bins;
|
|
float *data = s->data + n;
|
|
for (int16_t *p = (int16_t *) s->buffer + sample * n * 2; n--; p += 2)
|
|
*data++ = ((int32_t) p[0] + p[1]) / 2. / -INT16_MIN;
|
|
}
|
|
|
|
// - - Spectrum analysis - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static const char *spectrum_bars[] =
|
|
{ " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" };
|
|
|
|
/// Assuming the input buffer is full, updates the rendered spectrum
|
|
static void
|
|
spectrum_sample (struct spectrum *s)
|
|
{
|
|
memset (s->accumulator, 0, sizeof *s->accumulator * s->useful_bins);
|
|
|
|
// Credit for the algorithm goes to Audacity's /src/SpectrumAnalyst.cpp,
|
|
// apparently Welch's method
|
|
for (int sample = 0; sample < s->samples; sample++)
|
|
{
|
|
// We use 50% overlap and start with data from the last run (if any)
|
|
memmove (s->data, s->data + s->useful_bins,
|
|
sizeof *s->data * s->useful_bins);
|
|
s->decode (s, sample);
|
|
|
|
window_apply (s->data, s->window, s->windowed, s->bins);
|
|
fftwf_execute (s->p);
|
|
|
|
for (int bin = 0; bin < s->useful_bins; bin++)
|
|
{
|
|
// out[0][0] is the DC component, not useful to us
|
|
float re = s->out[bin + 1][0];
|
|
float im = s->out[bin + 1][1];
|
|
s->accumulator[bin] += re * re + im * im;
|
|
}
|
|
}
|
|
|
|
int last_bin = 0;
|
|
char *p = s->rendered;
|
|
for (int bar = 0; bar < s->bars; bar++)
|
|
{
|
|
int top_bin = s->top_bins[bar];
|
|
|
|
// Think of this as accumulating energies within bands,
|
|
// so that it matches our non-linear hearing--there's no averaging.
|
|
// For more precision, we could employ an "equal loudness contour".
|
|
float acc = 0;
|
|
for (int bin = last_bin; bin < top_bin; bin++)
|
|
acc += s->accumulator[bin];
|
|
|
|
last_bin = top_bin;
|
|
float db = 10 * log10f (acc * s->accumulator_scale);
|
|
if (db > 0)
|
|
db = 0;
|
|
|
|
// Assuming decibels are always negative (i.e., properly normalized).
|
|
// The division defines the cutoff: 8 * 7 = 56 dB of range.
|
|
int height = N_ELEMENTS (spectrum_bars) - 1 + (int) (db / 7);
|
|
p += strlen (strcpy (p, spectrum_bars[MAX (height, 0)]));
|
|
|
|
// Even with slightly the higher display resolutions provided by X11,
|
|
// 60 dB roughly covers the useful range.
|
|
s->spectrum[bar] = MAX (0, 1 + db / 60);
|
|
}
|
|
}
|
|
|
|
static bool
|
|
spectrum_init (struct spectrum *s, char *format, int bars, int fps,
|
|
struct error **e)
|
|
{
|
|
errno = 0;
|
|
|
|
long sampling_rate, bits, channels;
|
|
if (!format
|
|
|| (sampling_rate = strtol (format, &format, 10), *format++ != ':')
|
|
|| (bits = strtol (format, &format, 10), *format++ != ':')
|
|
|| (channels = strtol (format, &format, 10), *format)
|
|
|| errno != 0)
|
|
return error_set (e, "invalid format, expected RATE:BITS:CHANNELS");
|
|
|
|
if (sampling_rate < 20000 || sampling_rate > INT_MAX)
|
|
return error_set (e, "unsupported sampling rate (%ld)", sampling_rate);
|
|
if (bits != 8 && bits != 16)
|
|
return error_set (e, "unsupported bit count (%ld)", bits);
|
|
if (channels < 1 || channels > INT_MAX)
|
|
return error_set (e, "no channels to sample (%ld)", channels);
|
|
if (bars < 1 || bars > 12)
|
|
return error_set (e, "requested too few or too many bars (%d)", bars);
|
|
|
|
// All that can fail henceforth is memory allocation
|
|
*s = (struct spectrum)
|
|
{
|
|
.sampling_rate = sampling_rate,
|
|
.bits = bits,
|
|
.channels = channels,
|
|
.bars = bars,
|
|
};
|
|
|
|
// The number of bars is always smaller than that of the samples (bins).
|
|
// Let's start with the equation of the top FFT bin to use for a given bar:
|
|
// top_bin = (num_bins + 1) ^ (bar / num_bars) - 1
|
|
// N.b. if we didn't subtract, the power function would make this ≥ 1.
|
|
// N.b. we then also need to extend the range by the same amount.
|
|
//
|
|
// We need the amount of bins for the first bar to be at least one:
|
|
// 1 ≤ (num_bins + 1) ^ (1 / num_bars) - 1
|
|
//
|
|
// Solving with Wolfram Alpha gives us:
|
|
// num_bins ≥ (2 ^ num_bars) - 1 [for y > 0]
|
|
//
|
|
// And we need to remember that half of the FFT bins are useless/missing--
|
|
// FFTW skips useless points past the Nyquist frequency.
|
|
int necessary_bins = 2 << s->bars;
|
|
|
|
// Discard frequencies above 20 kHz, which take up a constant ratio
|
|
// of all bins, given by the sampling rate. A more practical/efficient
|
|
// solution would be to just handle 96/192/... kHz rates as bitshifts.
|
|
//
|
|
// Filtering out sub-20 Hz frequencies would be even more wasteful than
|
|
// this wild DFT size, so we don't even try. While we may just shift
|
|
// the lowest used bin easily within the extra range provided by this
|
|
// extension (the Nyquist is usually above 22 kHz, and it hardly matters
|
|
// if we go a bit beyond 20 kHz in the last bin), for a small number of bars
|
|
// the first bin already includes audible frequencies, and even for larger
|
|
// numbers it wouldn't be too accurate. An exact solution would require
|
|
// having the amount of bins be strictly a factor of Nyquist / 20 (stemming
|
|
// from the equation 20 = Nyquist / bins). Since log2(44100 / 2 / 20) > 10,
|
|
// it would be fairly expensive, and somewhat slowly updating. Always.
|
|
// (Note that you can increase window overlap to get smoother framerates,
|
|
// but it would remain laggy.)
|
|
double audible_ratio = s->sampling_rate / 2. / 20000;
|
|
s->bins = ceil (necessary_bins * MAX (audible_ratio, 1));
|
|
s->useful_bins = s->bins / 2;
|
|
|
|
int used_bins = necessary_bins / 2;
|
|
s->rendered = xcalloc (s->bars * 3 + 1, sizeof *s->rendered);
|
|
s->spectrum = xcalloc (s->bars, sizeof *s->spectrum);
|
|
s->top_bins = xcalloc (s->bars, sizeof *s->top_bins);
|
|
for (int bar = 0; bar < s->bars; bar++)
|
|
{
|
|
int top_bin = floor (pow (used_bins + 1, (bar + 1.) / s->bars)) - 1;
|
|
s->top_bins[bar] = MIN (top_bin, used_bins);
|
|
}
|
|
|
|
s->samples = s->sampling_rate / s->bins * 2 / MAX (fps, 1);
|
|
if (s->samples < 1)
|
|
s->samples = 1;
|
|
|
|
// XXX: we average the channels but might want to average the DFT results
|
|
if (s->bits == 8) s->decode = spectrum_decode_8;
|
|
if (s->bits == 16) s->decode = spectrum_decode_16;
|
|
|
|
// Micro-optimize to achieve some piece of mind; it's weak but measurable
|
|
if (s->bits == 16 && s->channels == 2)
|
|
s->decode = spectrum_decode_16_2;
|
|
|
|
s->buffer_size = s->samples * s->useful_bins * s->bits / 8 * s->channels;
|
|
s->buffer = xcalloc (1, s->buffer_size);
|
|
|
|
// Prepare the window
|
|
s->window = xcalloc (s->bins, sizeof *s->window);
|
|
window_hann (s->window, s->bins);
|
|
|
|
// Multiply by 2 for only using half of the DFT's result, then adjust to
|
|
// the total energy of the window. Both squared, because the accumulator
|
|
// contains squared values. Compute the average, and convert to decibels.
|
|
// See also the mildly confusing https://dsp.stackexchange.com/a/14945.
|
|
float coherent_gain = window_coherent_gain (s->window, s->bins);
|
|
s->accumulator_scale = 2 * 2 / coherent_gain / coherent_gain / s->samples;
|
|
|
|
s->data = xcalloc (s->bins, sizeof *s->data);
|
|
s->windowed = fftw_malloc (s->bins * sizeof *s->windowed);
|
|
s->out = fftw_malloc ((s->useful_bins + 1) * sizeof *s->out);
|
|
s->p = fftwf_plan_dft_r2c_1d (s->bins, s->windowed, s->out, FFTW_MEASURE);
|
|
s->accumulator = xcalloc (s->useful_bins, sizeof *s->accumulator);
|
|
return true;
|
|
}
|
|
|
|
static void
|
|
spectrum_free (struct spectrum *s)
|
|
{
|
|
free (s->accumulator);
|
|
fftwf_destroy_plan (s->p);
|
|
fftw_free (s->out);
|
|
fftw_free (s->windowed);
|
|
free (s->data);
|
|
free (s->window);
|
|
#if 0
|
|
// We don't particularly want to discard wisdom.
|
|
fftwf_cleanup ();
|
|
#endif
|
|
|
|
free (s->rendered);
|
|
free (s->spectrum);
|
|
free (s->top_bins);
|
|
free (s->buffer);
|
|
|
|
memset (s, 0, sizeof *s);
|
|
}
|
|
|
|
#endif // WITH_FFTW
|
|
|
|
// --- PulseAudio --------------------------------------------------------------
|
|
|
|
#ifdef WITH_PULSE
|
|
|
|
struct pulse
|
|
{
|
|
struct poller_timer make_context; ///< Event to establish connection
|
|
pa_mainloop_api *api; ///< PulseAudio event loop proxy
|
|
pa_context *context; ///< PulseAudio connection context
|
|
uint32_t sink_candidate; ///< Used while searching for MPD
|
|
uint32_t sink; ///< The relevant sink or -1
|
|
pa_cvolume sink_volume; ///< Current volume
|
|
bool sink_muted; ///< Currently muted?
|
|
|
|
void (*on_update) (void); ///< Update callback
|
|
};
|
|
|
|
static void
|
|
pulse_on_sink_info (pa_context *context, const pa_sink_info *info, int eol,
|
|
void *userdata)
|
|
{
|
|
(void) context;
|
|
(void) eol;
|
|
|
|
struct pulse *self = userdata;
|
|
if (info)
|
|
{
|
|
self->sink_volume = info->volume;
|
|
self->sink_muted = !!info->mute;
|
|
self->on_update ();
|
|
}
|
|
}
|
|
|
|
static void
|
|
pulse_update_from_sink (struct pulse *self)
|
|
{
|
|
if (self->sink == PA_INVALID_INDEX)
|
|
return;
|
|
|
|
pa_operation_unref (pa_context_get_sink_info_by_index
|
|
(self->context, self->sink, pulse_on_sink_info, self));
|
|
}
|
|
|
|
static void
|
|
pulse_on_sink_input_info (pa_context *context,
|
|
const struct pa_sink_input_info *info, int eol, void *userdata)
|
|
{
|
|
(void) context;
|
|
(void) eol;
|
|
|
|
struct pulse *self = userdata;
|
|
if (!info)
|
|
{
|
|
if ((self->sink = self->sink_candidate) != PA_INVALID_INDEX)
|
|
pulse_update_from_sink (self);
|
|
else
|
|
self->on_update ();
|
|
return;
|
|
}
|
|
|
|
// TODO: also save info->mute as a different mute level,
|
|
// and perhaps info->index (they can appear and disappear)
|
|
const char *name =
|
|
pa_proplist_gets (info->proplist, PA_PROP_APPLICATION_NAME);
|
|
if (name && !strcmp (name, "Music Player Daemon"))
|
|
self->sink_candidate = info->sink;
|
|
}
|
|
|
|
static void
|
|
pulse_read_sink_inputs (struct pulse *self)
|
|
{
|
|
self->sink_candidate = PA_INVALID_INDEX;
|
|
pa_operation_unref (pa_context_get_sink_input_info_list
|
|
(self->context, pulse_on_sink_input_info, self));
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
pulse_on_event (pa_context *context, pa_subscription_event_type_t event,
|
|
uint32_t index, void *userdata)
|
|
{
|
|
(void) context;
|
|
|
|
struct pulse *self = userdata;
|
|
switch (event & PA_SUBSCRIPTION_EVENT_FACILITY_MASK)
|
|
{
|
|
case PA_SUBSCRIPTION_EVENT_SINK_INPUT:
|
|
pulse_read_sink_inputs (self);
|
|
break;
|
|
case PA_SUBSCRIPTION_EVENT_SINK:
|
|
if (index == self->sink)
|
|
pulse_update_from_sink (self);
|
|
}
|
|
}
|
|
|
|
static void
|
|
pulse_on_subscribe_finish (pa_context *context, int success, void *userdata)
|
|
{
|
|
(void) context;
|
|
|
|
struct pulse *self = userdata;
|
|
if (success)
|
|
pulse_read_sink_inputs (self);
|
|
else
|
|
{
|
|
print_debug ("PulseAudio failed to subscribe for events");
|
|
self->on_update ();
|
|
pa_context_disconnect (context);
|
|
}
|
|
}
|
|
|
|
static void
|
|
pulse_on_context_state_change (pa_context *context, void *userdata)
|
|
{
|
|
struct pulse *self = userdata;
|
|
switch (pa_context_get_state (context))
|
|
{
|
|
case PA_CONTEXT_FAILED:
|
|
case PA_CONTEXT_TERMINATED:
|
|
print_debug ("PulseAudio context failed or has been terminated");
|
|
|
|
pa_context_unref (context);
|
|
self->context = NULL;
|
|
self->sink = PA_INVALID_INDEX;
|
|
self->on_update ();
|
|
|
|
// Retry after an arbitrary delay of 5 seconds
|
|
poller_timer_set (&self->make_context, 5000);
|
|
break;
|
|
case PA_CONTEXT_READY:
|
|
pa_context_set_subscribe_callback (context, pulse_on_event, userdata);
|
|
pa_operation_unref (pa_context_subscribe (context,
|
|
PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SINK_INPUT,
|
|
pulse_on_subscribe_finish, userdata));
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
static void
|
|
pulse_make_context (void *user_data)
|
|
{
|
|
struct pulse *self = user_data;
|
|
self->context = pa_context_new (self->api, PROGRAM_NAME);
|
|
pa_context_set_state_callback (self->context,
|
|
pulse_on_context_state_change, self);
|
|
pa_context_connect (self->context, NULL, PA_CONTEXT_NOAUTOSPAWN, NULL);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
pulse_on_finish (pa_context *context, int success, void *userdata)
|
|
{
|
|
(void) context;
|
|
(void) success;
|
|
(void) userdata;
|
|
|
|
// Just like... whatever, man
|
|
}
|
|
|
|
static bool
|
|
pulse_volume_mute (struct pulse *self)
|
|
{
|
|
if (!self->context || self->sink == PA_INVALID_INDEX)
|
|
return false;
|
|
|
|
pa_operation_unref (pa_context_set_sink_mute_by_index (self->context,
|
|
self->sink, !self->sink_muted, pulse_on_finish, self));
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
pulse_volume_set (struct pulse *self, int arg)
|
|
{
|
|
if (!self->context || self->sink == PA_INVALID_INDEX)
|
|
return false;
|
|
|
|
pa_cvolume volume = self->sink_volume;
|
|
if (arg > 0)
|
|
pa_cvolume_inc (&volume, (pa_volume_t) arg * PA_VOLUME_NORM / 100);
|
|
else
|
|
pa_cvolume_dec (&volume, (pa_volume_t) -arg * PA_VOLUME_NORM / 100);
|
|
pa_operation_unref (pa_context_set_sink_volume_by_index (self->context,
|
|
self->sink, &volume, pulse_on_finish, self));
|
|
return true;
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
pulse_init (struct pulse *self, struct poller *poller)
|
|
{
|
|
memset (self, 0, sizeof *self);
|
|
self->sink = PA_INVALID_INDEX;
|
|
if (!poller)
|
|
return;
|
|
|
|
self->api = poller_pa_new (poller);
|
|
|
|
self->make_context = poller_timer_make (poller);
|
|
self->make_context.dispatcher = pulse_make_context;
|
|
self->make_context.user_data = self;
|
|
poller_timer_set (&self->make_context, 0);
|
|
}
|
|
|
|
static void
|
|
pulse_free (struct pulse *self)
|
|
{
|
|
if (self->context)
|
|
pa_context_unref (self->context);
|
|
if (self->api)
|
|
{
|
|
poller_pa_destroy (self->api);
|
|
poller_timer_reset (&self->make_context);
|
|
}
|
|
|
|
pulse_init (self, NULL);
|
|
}
|
|
|
|
#define VOLUME_PERCENT(x) (((x) * 100 + PA_VOLUME_NORM / 2) / PA_VOLUME_NORM)
|
|
|
|
static bool
|
|
pulse_volume_status (struct pulse *self, struct str *s)
|
|
{
|
|
if (!self->context || self->sink == PA_INVALID_INDEX
|
|
|| !self->sink_volume.channels)
|
|
return false;
|
|
|
|
if (self->sink_muted)
|
|
{
|
|
str_append (s, "Muted");
|
|
return true;
|
|
}
|
|
|
|
str_append_printf (s,
|
|
"%u%%", VOLUME_PERCENT (self->sink_volume.values[0]));
|
|
if (!pa_cvolume_channels_equal_to (&self->sink_volume,
|
|
self->sink_volume.values[0]))
|
|
{
|
|
for (size_t i = 1; i < self->sink_volume.channels; i++)
|
|
str_append_printf (s, " / %u%%",
|
|
VOLUME_PERCENT (self->sink_volume.values[i]));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#endif // WITH_PULSE
|
|
|
|
// --- Application -------------------------------------------------------------
|
|
|
|
// Function names are prefixed mostly because of curses, which clutters the
|
|
// global namespace and makes it harder to distinguish what functions relate to.
|
|
|
|
// The user interface is focused on conceptual simplicity. That is important
|
|
// since we use a custom toolkit, so code would get bloated rather fast--
|
|
// especially given our TUI/GUI duality.
|
|
//
|
|
// There is an independent top pane displaying general status information,
|
|
// followed by a tab bar and a listview served by a per-tab event handler.
|
|
//
|
|
// For simplicity, the listview can only work with items that are one row high.
|
|
|
|
// Widget identification, mostly for mouse events.
|
|
enum
|
|
{
|
|
WIDGET_NONE = 0, WIDGET_BUTTON, WIDGET_GAUGE, WIDGET_VOLUME,
|
|
WIDGET_TAB, WIDGET_SPECTRUM, WIDGET_LIST, WIDGET_SCROLLBAR, WIDGET_MESSAGE,
|
|
};
|
|
|
|
struct layout
|
|
{
|
|
struct widget *head;
|
|
struct widget *tail;
|
|
};
|
|
|
|
struct app_ui
|
|
{
|
|
struct widget *(*padding) (chtype attrs, float width, float height);
|
|
struct widget *(*label) (chtype attrs, const char *label);
|
|
struct widget *(*button) (chtype attrs, const char *label, enum action a);
|
|
struct widget *(*gauge) (chtype attrs);
|
|
struct widget *(*spectrum) (chtype attrs, int width);
|
|
struct widget *(*scrollbar) (chtype attrs);
|
|
struct widget *(*list) (void);
|
|
struct widget *(*editor) (chtype attrs);
|
|
|
|
bool have_icons;
|
|
};
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
struct tab;
|
|
|
|
/// Try to handle an action in the tab
|
|
typedef bool (*tab_action_fn) (enum action action);
|
|
|
|
/// Return a line of widgets for the row
|
|
typedef struct layout (*tab_item_layout_fn) (size_t item_index);
|
|
|
|
struct tab
|
|
{
|
|
LIST_HEADER (struct tab)
|
|
|
|
char *name; ///< Visible identifier
|
|
char *header; ///< The header, should there be any
|
|
|
|
// Implementation:
|
|
|
|
tab_action_fn on_action; ///< User action handler callback
|
|
tab_item_layout_fn on_item_layout; ///< Item layout callback
|
|
|
|
// Provided by tab owner:
|
|
|
|
bool can_multiselect; ///< Multiple items can be selected
|
|
size_t item_count; ///< Total item count
|
|
|
|
// Managed by the common handler:
|
|
|
|
int item_top; ///< Index of the topmost item
|
|
int item_selected; ///< Index of the selected item
|
|
int item_mark; ///< Multiselect second point index
|
|
};
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
enum player_state { PLAYER_STOPPED, PLAYER_PLAYING, PLAYER_PAUSED };
|
|
|
|
// Basically a container for most of the globals; no big sense in handing
|
|
// around a pointer to this, hence it is a simple global variable as well.
|
|
// There is enough global state as it is.
|
|
|
|
static struct app_context
|
|
{
|
|
// Event loop:
|
|
|
|
struct poller poller; ///< Poller
|
|
struct poller_curl poller_curl; ///< cURL abstractor
|
|
bool quitting; ///< Quit signal for the event loop
|
|
bool polling; ///< The event loop is running
|
|
|
|
struct poller_fd tty_event; ///< Terminal input event
|
|
struct poller_fd signal_event; ///< Signal FD event
|
|
|
|
struct poller_timer message_timer; ///< Message timeout
|
|
char *message; ///< Message to show in the statusbar
|
|
char *message_detail; ///< Non-emphasized part
|
|
|
|
// Connection:
|
|
|
|
struct mpd_client client; ///< MPD client interface
|
|
struct poller_timer connect_event; ///< MPD reconnect timer
|
|
|
|
enum player_state state; ///< Player state
|
|
struct str_map playback_info; ///< Current song info
|
|
|
|
struct poller_timer elapsed_event; ///< Seconds elapsed event
|
|
int64_t elapsed_since; ///< Last tick ts or last elapsed time
|
|
bool elapsed_poll; ///< Poll MPD for the elapsed time?
|
|
|
|
int song; ///< Current song index
|
|
int song_elapsed; ///< Song elapsed in seconds
|
|
int song_duration; ///< Song duration in seconds
|
|
int volume; ///< Current volume
|
|
|
|
struct item_list playlist; ///< Current playlist
|
|
uint32_t playlist_version; ///< Playlist version
|
|
int playlist_time; ///< Play time in seconds
|
|
|
|
// Data:
|
|
|
|
struct config config; ///< Program configuration
|
|
struct strv streams; ///< List of "name NUL URI NUL"
|
|
struct strv enqueue; ///< Items to enqueue once connected
|
|
struct strv action_names; ///< User-defined action names
|
|
struct strv action_descriptions; ///< User-defined action descriptions
|
|
struct strv action_commands; ///< User-defined action commands
|
|
|
|
struct tab *help_tab; ///< Special help tab
|
|
struct tab *tabs; ///< All other tabs
|
|
struct tab *active_tab; ///< Active tab
|
|
struct tab *last_tab; ///< Previous tab
|
|
|
|
// User interface:
|
|
|
|
struct app_ui *ui; ///< User interface interface
|
|
int ui_dragging; ///< ID of any dragged widget
|
|
|
|
#ifdef WITH_FFTW
|
|
struct spectrum spectrum; ///< Spectrum analyser
|
|
int spectrum_fd; ///< FIFO file descriptor (non-blocking)
|
|
struct poller_fd spectrum_event; ///< FIFO watcher
|
|
#endif // WITH_FFTW
|
|
|
|
#ifdef WITH_PULSE
|
|
struct pulse pulse; ///< PulseAudio control
|
|
#endif // WITH_PULSE
|
|
bool pulse_control_requested; ///< PulseAudio control desired by user
|
|
|
|
struct line_editor editor; ///< Line editor
|
|
|
|
// Terminal:
|
|
|
|
bool use_partial_boxes; ///< Use Unicode box drawing chars
|
|
|
|
struct attrs attrs[ATTRIBUTE_COUNT];
|
|
}
|
|
g;
|
|
|
|
/// Shortcut to retrieve named terminal attributes
|
|
#define APP_ATTR(name) g.attrs[ATTRIBUTE_ ## name].attrs
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
tab_init (struct tab *self, const char *name)
|
|
{
|
|
memset (self, 0, sizeof *self);
|
|
|
|
// Add some padding for decorative purposes
|
|
self->name = xstrdup_printf (" %s ", name);
|
|
self->item_selected = 0;
|
|
self->item_mark = -1;
|
|
}
|
|
|
|
static void
|
|
tab_free (struct tab *self)
|
|
{
|
|
free (self->name);
|
|
}
|
|
|
|
static struct tab_range { int from, upto; }
|
|
tab_selection_range (struct tab *self)
|
|
{
|
|
if (self->item_selected < 0 || !self->item_count)
|
|
return (struct tab_range) { -1, -1 };
|
|
if (self->item_mark < 0)
|
|
return (struct tab_range) { self->item_selected, self->item_selected };
|
|
return (struct tab_range) { MIN (self->item_selected, self->item_mark),
|
|
MAX (self->item_selected, self->item_mark) };
|
|
}
|
|
|
|
// --- Configuration -----------------------------------------------------------
|
|
|
|
static void
|
|
on_poll_elapsed_time_changed (struct config_item *item)
|
|
{
|
|
// This is only set once, on application startup
|
|
g.elapsed_poll = item->value.boolean;
|
|
}
|
|
|
|
static void
|
|
on_pulseaudio_changed (struct config_item *item)
|
|
{
|
|
// This is only set once, on application startup
|
|
g.pulse_control_requested = item->value.boolean;
|
|
}
|
|
|
|
static const struct config_schema g_config_settings[] =
|
|
{
|
|
{ .name = "address",
|
|
.comment = "Address to connect to the MPD server",
|
|
.type = CONFIG_ITEM_STRING,
|
|
.default_ = "\"localhost\"" },
|
|
{ .name = "password",
|
|
.comment = "Password to use for MPD authentication",
|
|
.type = CONFIG_ITEM_STRING },
|
|
|
|
// NOTE: this is unused--in theory we could allow manual metadata adjustment
|
|
// NOTE: the "config" command may return "music_directory" for local clients
|
|
{ .name = "root",
|
|
.comment = "Where all the files MPD is playing are located",
|
|
.type = CONFIG_ITEM_STRING },
|
|
|
|
#ifdef WITH_FFTW
|
|
{ .name = "spectrum_path",
|
|
.comment = "Visualizer feed path to a FIFO audio output",
|
|
.type = CONFIG_ITEM_STRING },
|
|
// MPD's "outputs" command doesn't include this information
|
|
{ .name = "spectrum_format",
|
|
.comment = "Visualizer feed data format",
|
|
.type = CONFIG_ITEM_STRING,
|
|
.default_ = "\"44100:16:2\"" },
|
|
// 10 is about the useful limit, then it gets too computationally expensive
|
|
{ .name = "spectrum_bars",
|
|
.comment = "Number of computed audio spectrum bars",
|
|
.type = CONFIG_ITEM_INTEGER,
|
|
.default_ = "8" },
|
|
{ .name = "spectrum_fps",
|
|
.comment = "Maximum frames per second, affects CPU usage",
|
|
.type = CONFIG_ITEM_INTEGER,
|
|
.default_ = "30" },
|
|
#endif // WITH_FFTW
|
|
|
|
#ifdef WITH_PULSE
|
|
{ .name = "pulseaudio",
|
|
.comment = "Look up MPD in PulseAudio for improved volume controls",
|
|
.type = CONFIG_ITEM_BOOLEAN,
|
|
.on_change = on_pulseaudio_changed,
|
|
.default_ = "off" },
|
|
#endif // WITH_PULSE
|
|
|
|
#ifdef WITH_X11
|
|
{ .name = "x11_font",
|
|
.comment = "Fontconfig name/pattern for the X11 font to use",
|
|
.type = CONFIG_ITEM_STRING,
|
|
.default_ = "`sans\\-serif-11`" },
|
|
#endif // WITH_X11
|
|
|
|
// Disabling this minimises MPD traffic and has the following caveats:
|
|
// - when MPD stalls on retrieving audio data, we keep ticking
|
|
// - when the "play" succeeds in ACTION_MPD_REPLACE for the same item as
|
|
// is currently playing, we do not reset g.song_elapsed (we could ask
|
|
// for a response which feels racy, or rethink the mechanism there)
|
|
{ .name = "poll_elapsed_time",
|
|
.comment = "Whether to actively poll MPD for the elapsed time",
|
|
.type = CONFIG_ITEM_BOOLEAN,
|
|
.on_change = on_poll_elapsed_time_changed,
|
|
.default_ = "on" },
|
|
{}
|
|
};
|
|
|
|
static const struct config_schema g_config_colors[] =
|
|
{
|
|
#define XX(name_, config, fg_, bg_, attrs_) \
|
|
{ .name = #config, .type = CONFIG_ITEM_STRING },
|
|
ATTRIBUTE_TABLE (XX)
|
|
#undef XX
|
|
{}
|
|
};
|
|
|
|
static const struct config_schema g_config_actions[] =
|
|
{
|
|
{ .name = "description",
|
|
.comment = "Human-readable description of the action",
|
|
.type = CONFIG_ITEM_STRING },
|
|
{ .name = "command",
|
|
.comment = "Shell command to run",
|
|
.type = CONFIG_ITEM_STRING },
|
|
{}
|
|
};
|
|
|
|
static const char *
|
|
get_config_string (struct config_item *root, const char *key)
|
|
{
|
|
struct config_item *item = config_item_get (root, key, NULL);
|
|
hard_assert (item);
|
|
if (item->type == CONFIG_ITEM_NULL)
|
|
return NULL;
|
|
hard_assert (config_item_type_is_string (item->type));
|
|
return item->value.string.str;
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
load_config_settings (struct config_item *subtree, void *user_data)
|
|
{
|
|
config_schema_apply_to_object (g_config_settings, subtree, user_data);
|
|
}
|
|
|
|
static void
|
|
load_config_colors (struct config_item *subtree, void *user_data)
|
|
{
|
|
config_schema_apply_to_object (g_config_colors, subtree, user_data);
|
|
|
|
// The attributes cannot be changed dynamically right now, so it doesn't
|
|
// make much sense to make use of "on_change" callbacks either.
|
|
// For simplicity, we should reload the entire table on each change anyway.
|
|
const char *value;
|
|
#define XX(name, config, fg_, bg_, attrs_) \
|
|
if ((value = get_config_string (subtree, #config))) \
|
|
g.attrs[ATTRIBUTE_ ## name] = attrs_decode (value);
|
|
ATTRIBUTE_TABLE (XX)
|
|
#undef XX
|
|
}
|
|
|
|
static int
|
|
app_casecmp (const uint8_t *a, const uint8_t *b)
|
|
{
|
|
int res;
|
|
// XXX: this seems to produce some strange results
|
|
if (u8_casecmp (a, strlen ((const char *) a), b, strlen ((const char *) b),
|
|
NULL, NULL, &res))
|
|
res = u8_strcmp (a, b);
|
|
return res;
|
|
}
|
|
|
|
static int
|
|
strv_sort_utf8_cb (const void *a, const void *b)
|
|
{
|
|
return app_casecmp (*(const uint8_t **) a, *(const uint8_t **) b);
|
|
}
|
|
|
|
static void
|
|
load_config_streams (struct config_item *subtree, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
// XXX: we can't use the tab in load_config_streams() because it hasn't
|
|
// been initialized yet, and we cannot initialize it before the
|
|
// configuration has been loaded. Thus we load it into the app_context.
|
|
struct str_map_iter iter = str_map_iter_make (&subtree->value.object);
|
|
struct config_item *item;
|
|
while ((item = str_map_iter_next (&iter)))
|
|
if (!config_item_type_is_string (item->type))
|
|
print_warning ("`%s': stream URIs must be strings", iter.link->key);
|
|
else
|
|
{
|
|
strv_append_owned (&g.streams, xstrdup_printf ("%s%c%s",
|
|
iter.link->key, 0, item->value.string.str));
|
|
}
|
|
qsort (g.streams.vector, g.streams.len,
|
|
sizeof *g.streams.vector, strv_sort_utf8_cb);
|
|
}
|
|
|
|
static void
|
|
load_config_actions (struct config_item *subtree, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
struct str_map_iter iter = str_map_iter_make (&subtree->value.object);
|
|
while (str_map_iter_next (&iter))
|
|
strv_append (&g.action_names, iter.link->key);
|
|
qsort (g.action_names.vector, g.action_names.len,
|
|
sizeof *g.action_names.vector, strv_sort_utf8_cb);
|
|
|
|
for (size_t i = 0; i < g.action_names.len; i++)
|
|
{
|
|
const char *name = g.action_names.vector[i];
|
|
struct config_item *item = config_item_get (subtree, name, NULL);
|
|
hard_assert (item != NULL);
|
|
if (item->type != CONFIG_ITEM_OBJECT)
|
|
exit_fatal ("`%s': invalid user action, expected an object", name);
|
|
|
|
config_schema_apply_to_object (g_config_actions, item, NULL);
|
|
config_schema_call_changed (item);
|
|
|
|
const char *description = get_config_string (item, "description");
|
|
const char *command = get_config_string (item, "command");
|
|
strv_append (&g.action_descriptions, description ? description : name);
|
|
strv_append (&g.action_commands, command ? command : "");
|
|
}
|
|
}
|
|
|
|
static void
|
|
app_load_configuration (void)
|
|
{
|
|
struct config *config = &g.config;
|
|
config_register_module (config, "settings", load_config_settings, NULL);
|
|
config_register_module (config, "colors", load_config_colors, NULL);
|
|
config_register_module (config, "streams", load_config_streams, NULL);
|
|
// This must run before bindings are parsed in app_init_ui().
|
|
config_register_module (config, "actions", load_config_actions, NULL);
|
|
|
|
// Bootstrap configuration, so that we can access schema items at all
|
|
config_load (config, config_item_object ());
|
|
|
|
char *filename = resolve_filename
|
|
(PROGRAM_NAME ".conf", resolve_relative_config_filename);
|
|
if (!filename)
|
|
return;
|
|
|
|
struct error *e = NULL;
|
|
struct config_item *root = config_read_from_file (filename, &e);
|
|
free (filename);
|
|
|
|
if (e)
|
|
{
|
|
print_error ("error loading configuration: %s", e->message);
|
|
error_free (e);
|
|
exit (EXIT_FAILURE);
|
|
}
|
|
if (root)
|
|
{
|
|
config_load (&g.config, root);
|
|
config_schema_call_changed (g.config.root);
|
|
}
|
|
}
|
|
|
|
// --- Application -------------------------------------------------------------
|
|
|
|
static void
|
|
app_init_attributes (void)
|
|
{
|
|
#define XX(name, config, fg_, bg_, attrs_) \
|
|
g.attrs[ATTRIBUTE_ ## name].fg = fg_; \
|
|
g.attrs[ATTRIBUTE_ ## name].bg = bg_; \
|
|
g.attrs[ATTRIBUTE_ ## name].attrs = attrs_;
|
|
ATTRIBUTE_TABLE (XX)
|
|
#undef XX
|
|
}
|
|
|
|
static bool
|
|
app_on_insufficient_color (void)
|
|
{
|
|
app_init_attributes ();
|
|
return true;
|
|
}
|
|
|
|
static void
|
|
app_init_context (void)
|
|
{
|
|
poller_init (&g.poller);
|
|
hard_assert (poller_curl_init (&g.poller_curl, &g.poller, NULL));
|
|
g.client = mpd_client_make (&g.poller);
|
|
g.song_elapsed = g.song_duration = g.volume = g.song = -1;
|
|
g.playlist = item_list_make ();
|
|
g.config = config_make ();
|
|
g.streams = strv_make ();
|
|
g.enqueue = strv_make ();
|
|
g.action_names = strv_make ();
|
|
g.action_descriptions = strv_make ();
|
|
g.action_commands = strv_make ();
|
|
|
|
g.playback_info = str_map_make (free);
|
|
g.playback_info.key_xfrm = tolower_ascii_strxfrm;
|
|
|
|
#ifdef WITH_FFTW
|
|
g.spectrum_fd = -1;
|
|
#endif // WITH_FFTW
|
|
|
|
#ifdef WITH_PULSE
|
|
pulse_init (&g.pulse, NULL);
|
|
#endif // WITH_PULSE
|
|
|
|
app_init_attributes ();
|
|
}
|
|
|
|
static void
|
|
app_free_context (void)
|
|
{
|
|
mpd_client_free (&g.client);
|
|
str_map_free (&g.playback_info);
|
|
strv_free (&g.streams);
|
|
strv_free (&g.enqueue);
|
|
strv_free (&g.action_names);
|
|
strv_free (&g.action_descriptions);
|
|
strv_free (&g.action_commands);
|
|
item_list_free (&g.playlist);
|
|
|
|
#ifdef WITH_FFTW
|
|
spectrum_free (&g.spectrum);
|
|
if (g.spectrum_fd != -1)
|
|
{
|
|
poller_fd_reset (&g.spectrum_event);
|
|
xclose (g.spectrum_fd);
|
|
}
|
|
#endif // WITH_FFTW
|
|
|
|
#ifdef WITH_PULSE
|
|
pulse_free (&g.pulse);
|
|
#endif // WITH_PULSE
|
|
|
|
line_editor_free (&g.editor);
|
|
|
|
config_free (&g.config);
|
|
poller_curl_free (&g.poller_curl);
|
|
poller_free (&g.poller);
|
|
free (g.message);
|
|
free (g.message_detail);
|
|
}
|
|
|
|
static void
|
|
app_quit (void)
|
|
{
|
|
g.quitting = true;
|
|
|
|
// So far there's nothing for us to wait on, so let's just stop looping;
|
|
// otherwise we might want to e.g. cleanly bring down the MPD interface
|
|
g.polling = false;
|
|
}
|
|
|
|
// --- Layouting ---------------------------------------------------------------
|
|
|
|
static void
|
|
app_append_layout (struct layout *l, struct layout *dest)
|
|
{
|
|
struct widget *last = dest->tail;
|
|
if (!last)
|
|
*dest = *l;
|
|
else if (l->head)
|
|
{
|
|
// Assuming there is no unclaimed vertical space.
|
|
LIST_FOR_EACH (struct widget, w, l->head)
|
|
widget_move (w, 0, last->y + last->height);
|
|
|
|
last->next = l->head;
|
|
l->head->prev = last;
|
|
dest->tail = l->tail;
|
|
}
|
|
|
|
*l = (struct layout) {};
|
|
}
|
|
|
|
/// Replaces negative widths amongst widgets in the layout by redistributing
|
|
/// any width remaining after all positive claims are satisfied from "width".
|
|
/// Also unifies heights to the maximum value of the run.
|
|
/// Then the widths are taken as final, and used to initialize X coordinates.
|
|
static void
|
|
app_flush_layout_full (struct layout *l, int width, struct layout *dest)
|
|
{
|
|
hard_assert (l != NULL && l->head != NULL);
|
|
|
|
int parts = 0, max_height = 0;
|
|
LIST_FOR_EACH (struct widget, w, l->head)
|
|
{
|
|
max_height = MAX (max_height, w->height);
|
|
if (w->width < 0)
|
|
parts -= w->width;
|
|
else
|
|
width -= w->width;
|
|
}
|
|
|
|
int remaining = MAX (width, 0), part_width = parts ? remaining / parts : 0;
|
|
struct widget *last = NULL;
|
|
LIST_FOR_EACH (struct widget, w, l->head)
|
|
{
|
|
w->height = max_height;
|
|
if (w->width < 0)
|
|
{
|
|
remaining -= (w->width *= -part_width);
|
|
last = w;
|
|
}
|
|
}
|
|
if (last)
|
|
last->width += remaining;
|
|
|
|
int x = 0;
|
|
LIST_FOR_EACH (struct widget, w, l->head)
|
|
{
|
|
widget_move (w, x - w->x, 0);
|
|
x += w->width;
|
|
}
|
|
|
|
app_append_layout (l, dest);
|
|
}
|
|
|
|
static void
|
|
app_flush_layout (struct layout *l, struct layout *out)
|
|
{
|
|
app_flush_layout_full (l, g_xui.width, out);
|
|
}
|
|
|
|
static struct widget *
|
|
app_push (struct layout *l, struct widget *w)
|
|
{
|
|
LIST_APPEND_WITH_TAIL (l->head, l->tail, w);
|
|
return w;
|
|
}
|
|
|
|
static struct widget *
|
|
app_push_fill (struct layout *l, struct widget *w)
|
|
{
|
|
w->width = -1;
|
|
LIST_APPEND_WITH_TAIL (l->head, l->tail, w);
|
|
return w;
|
|
}
|
|
|
|
/// Write the given UTF-8 string padded with spaces.
|
|
/// @param[in] attrs Text attributes for the text, including padding.
|
|
static void
|
|
app_layout_text (const char *str, chtype attrs, struct layout *out)
|
|
{
|
|
struct layout l = {};
|
|
app_push (&l, g.ui->padding (attrs, 0.25, 1));
|
|
app_push_fill (&l, g.ui->label (attrs, str));
|
|
app_push (&l, g.ui->padding (attrs, 0.25, 1));
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
app_layout_song_info (struct layout *out)
|
|
{
|
|
compact_map_t map;
|
|
if (!(map = item_list_get (&g.playlist, g.song)))
|
|
return;
|
|
|
|
chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
|
|
|
|
// Split the path for files lying within MPD's "music_directory".
|
|
const char *file = compact_map_find (map, "file");
|
|
const char *subroot_basename = NULL;
|
|
if (file && *file != '/' && !strstr (file, "://"))
|
|
{
|
|
const char *last_slash = strrchr (file, '/');
|
|
if (last_slash)
|
|
subroot_basename = last_slash + 1;
|
|
else
|
|
subroot_basename = file;
|
|
}
|
|
|
|
const char *title = NULL;
|
|
const char *name = compact_map_find (map, "name");
|
|
if ((title = compact_map_find (map, "title"))
|
|
|| (title = name)
|
|
|| (title = subroot_basename)
|
|
|| (title = file))
|
|
{
|
|
struct layout l = {};
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_push (&l, g.ui->label (attrs[1], title));
|
|
app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
// Showing a blank line is better than having the controls jump around
|
|
// while switching between files that we do and don't have enough data for.
|
|
struct layout l = {};
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
|
|
char *artist = compact_map_find (map, "artist");
|
|
char *album = compact_map_find (map, "album");
|
|
if (artist || album)
|
|
{
|
|
if (artist)
|
|
{
|
|
app_push (&l, g.ui->label (attrs[0], "by "));
|
|
app_push (&l, g.ui->label (attrs[1], artist));
|
|
}
|
|
if (album)
|
|
{
|
|
app_push (&l, g.ui->label (attrs[0], &" from "[!artist]));
|
|
app_push (&l, g.ui->label (attrs[1], album));
|
|
}
|
|
}
|
|
else if (subroot_basename && subroot_basename != file)
|
|
{
|
|
char *parent = xstrndup (file, subroot_basename - file - 1);
|
|
app_push (&l, g.ui->label (attrs[0], "in "));
|
|
app_push (&l, g.ui->label (attrs[1], parent));
|
|
free (parent);
|
|
}
|
|
else if (file && *file != '/' && strstr (file, "://")
|
|
&& name && name != title)
|
|
{
|
|
// This is likely to contain the name of an Internet radio.
|
|
app_push (&l, g.ui->label (attrs[1], name));
|
|
}
|
|
|
|
app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
static char *
|
|
app_time_string (int seconds)
|
|
{
|
|
int minutes = seconds / 60; seconds %= 60;
|
|
int hours = minutes / 60; minutes %= 60;
|
|
|
|
struct str s = str_make ();
|
|
if (hours)
|
|
str_append_printf (&s, "%d:%02d:", hours, minutes);
|
|
else
|
|
str_append_printf (&s, "%d:", minutes);
|
|
|
|
str_append_printf (&s, "%02d", seconds);
|
|
return str_steal (&s);
|
|
}
|
|
|
|
static void
|
|
app_layout_status (struct layout *out)
|
|
{
|
|
bool stopped = g.state == PLAYER_STOPPED;
|
|
if (!stopped)
|
|
app_layout_song_info (out);
|
|
|
|
chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
|
|
struct layout l = {};
|
|
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_push (&l, g.ui->button (attrs[!stopped], "<<", ACTION_MPD_PREVIOUS));
|
|
app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
|
|
const char *toggle = g.state == PLAYER_PLAYING ? "||" : "|>";
|
|
app_push (&l, g.ui->button (attrs[1], toggle, ACTION_MPD_TOGGLE));
|
|
app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
|
|
app_push (&l, g.ui->button (attrs[!stopped], "[]", ACTION_MPD_STOP));
|
|
app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
|
|
app_push (&l, g.ui->button (attrs[!stopped], ">>", ACTION_MPD_NEXT));
|
|
app_push (&l, g.ui->padding (attrs[0], 1, 1));
|
|
|
|
if (stopped)
|
|
app_push_fill (&l, g.ui->label (attrs[0], "Stopped"));
|
|
else
|
|
{
|
|
if (g.song_elapsed >= 0)
|
|
{
|
|
char *s = app_time_string (g.song_elapsed);
|
|
app_push (&l, g.ui->label (attrs[0], s));
|
|
free (s);
|
|
}
|
|
if (g.song_duration >= 1)
|
|
{
|
|
char *s = app_time_string (g.song_duration);
|
|
app_push (&l, g.ui->label (attrs[0], " / "));
|
|
app_push (&l, g.ui->label (attrs[0], s));
|
|
free (s);
|
|
}
|
|
|
|
app_push (&l, g.ui->padding (attrs[0], 1, 1));
|
|
}
|
|
|
|
struct str volume = str_make ();
|
|
#ifdef WITH_PULSE
|
|
if (g.pulse_control_requested)
|
|
{
|
|
if (pulse_volume_status (&g.pulse, &volume))
|
|
{
|
|
if (g.volume >= 0 && g.volume != 100)
|
|
str_append_printf (&volume, " (%d%%)", g.volume);
|
|
}
|
|
else
|
|
{
|
|
if (g.volume >= 0)
|
|
str_append_printf (&volume, "(%d%%)", g.volume);
|
|
}
|
|
}
|
|
else
|
|
#endif // WITH_PULSE
|
|
if (g.volume >= 0)
|
|
str_append_printf (&volume, "%3d%%", g.volume);
|
|
|
|
if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1)
|
|
app_push (&l, g.ui->gauge (attrs[0]))
|
|
->id = WIDGET_GAUGE;
|
|
else
|
|
app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
|
|
|
|
if (volume.len)
|
|
{
|
|
app_push (&l, g.ui->padding (attrs[0], 1, 1));
|
|
app_push (&l, g.ui->label (attrs[0], volume.str))
|
|
->id = WIDGET_VOLUME;
|
|
}
|
|
str_free (&volume);
|
|
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
static void
|
|
app_layout_tabs (struct layout *out)
|
|
{
|
|
chtype attrs[2] = { APP_ATTR (TAB_BAR), APP_ATTR (TAB_ACTIVE) };
|
|
struct layout l = {};
|
|
|
|
// The help tab is disguised so that it's not too intruding
|
|
app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.25, 1))
|
|
->id = WIDGET_TAB;
|
|
app_push (&l, g.ui->label (attrs[g.active_tab == g.help_tab], APP_TITLE))
|
|
->id = WIDGET_TAB;
|
|
|
|
// XXX: attrs[0]?
|
|
app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.5, 1))
|
|
->id = WIDGET_TAB;
|
|
|
|
int i = 0;
|
|
LIST_FOR_EACH (struct tab, iter, g.tabs)
|
|
{
|
|
struct widget *w = app_push (&l,
|
|
g.ui->label (attrs[iter == g.active_tab], iter->name));
|
|
w->id = WIDGET_TAB;
|
|
w->userdata = ++i;
|
|
}
|
|
|
|
app_push_fill (&l, g.ui->padding (attrs[0], 1, 1));
|
|
|
|
#ifdef WITH_FFTW
|
|
// This seems like the most reasonable, otherwise unoccupied space
|
|
if (g.spectrum_fd != -1)
|
|
{
|
|
app_push (&l, g.ui->spectrum (attrs[0], g.spectrum.bars))
|
|
->id = WIDGET_SPECTRUM;
|
|
}
|
|
#endif // WITH_FFTW
|
|
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
static void
|
|
app_layout_padding (chtype attrs, struct layout *out)
|
|
{
|
|
struct layout l = {};
|
|
app_push_fill (&l, g.ui->padding (attrs, 0, 0.125));
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
static void
|
|
app_layout_header (struct layout *out)
|
|
{
|
|
if (g.client.state == MPD_CONNECTED)
|
|
{
|
|
app_layout_padding (APP_ATTR (NORMAL), out);
|
|
app_layout_status (out);
|
|
app_layout_padding (APP_ATTR (NORMAL), out);
|
|
}
|
|
|
|
app_layout_tabs (out);
|
|
|
|
const char *header = g.active_tab->header;
|
|
if (header)
|
|
app_layout_text (header, APP_ATTR (HEADER), out);
|
|
}
|
|
|
|
/// Figure out scrollbar appearance. @a s is the minimal slider length as well
|
|
/// as the scrollbar resolution per @a visible item.
|
|
struct scrollbar { long length, start; }
|
|
app_compute_scrollbar (struct tab *tab, long visible, long s)
|
|
{
|
|
long top = s * tab->item_top, total = s * tab->item_count;
|
|
if (total < visible)
|
|
return (struct scrollbar) { 0, 0 };
|
|
if (visible == 1)
|
|
return (struct scrollbar) { s, 0 };
|
|
if (visible == 2)
|
|
return (struct scrollbar) { s, top >= total / 2 ? s : 0 };
|
|
|
|
// Only be at the top or bottom when the top or bottom item can be seen.
|
|
// The algorithm isn't optimal but it's a bitch to get right.
|
|
double available_length = visible - 2 - s + 1;
|
|
|
|
double lenf = s + available_length * visible / total, length = 0.;
|
|
long offset = 1 + available_length * top / total + modf (lenf, &length);
|
|
|
|
if (top == 0)
|
|
return (struct scrollbar) { length, 0 };
|
|
if (top + visible >= total)
|
|
return (struct scrollbar) { length, visible - length };
|
|
|
|
return (struct scrollbar) { length, offset };
|
|
}
|
|
|
|
static struct layout
|
|
app_layout_row (struct tab *tab, int item_index)
|
|
{
|
|
int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN);
|
|
|
|
bool override_colors = true;
|
|
if (item_index == tab->item_selected)
|
|
row_attrs = g_xui.focused
|
|
? APP_ATTR (SELECTION) : APP_ATTR (DEFOCUSED);
|
|
else if (tab->item_mark > -1 &&
|
|
((item_index >= tab->item_mark && item_index <= tab->item_selected)
|
|
|| (item_index >= tab->item_selected && item_index <= tab->item_mark)))
|
|
row_attrs = g_xui.focused
|
|
? APP_ATTR (MULTISELECT) : APP_ATTR (DEFOCUSED);
|
|
else
|
|
override_colors = false;
|
|
|
|
// The padding must be added before the recoloring below.
|
|
struct layout l = tab->on_item_layout (item_index);
|
|
struct widget *w = g.ui->padding (0, 0.25, 1);
|
|
LIST_PREPEND (l.head, w);
|
|
app_push (&l, g.ui->padding (0, 0.25, 1));
|
|
|
|
// Combine attributes used by the handler with the defaults.
|
|
LIST_FOR_EACH (struct widget, w, l.head)
|
|
{
|
|
chtype *attrs = &w->attrs;
|
|
if (override_colors)
|
|
*attrs = (*attrs & ~(A_COLOR | A_REVERSE)) | row_attrs;
|
|
else if ((*attrs & A_COLOR) && (row_attrs & A_COLOR))
|
|
*attrs |= (row_attrs & ~A_COLOR);
|
|
else
|
|
*attrs |= row_attrs;
|
|
}
|
|
return l;
|
|
}
|
|
|
|
static void
|
|
app_layout_view (struct layout *out, int height)
|
|
{
|
|
struct layout l = {};
|
|
struct widget *list = app_push_fill (&l, g.ui->list ());
|
|
list->id = WIDGET_LIST;
|
|
list->height = height;
|
|
list->width = g_xui.width;
|
|
|
|
struct tab *tab = g.active_tab;
|
|
if ((int) tab->item_count * g_xui.vunit > list->height)
|
|
{
|
|
struct widget *scrollbar = g.ui->scrollbar (APP_ATTR (SCROLLBAR));
|
|
list->width -= scrollbar->width;
|
|
app_push (&l, scrollbar)->id = WIDGET_SCROLLBAR;
|
|
}
|
|
|
|
int to_show = MIN ((int) tab->item_count - tab->item_top,
|
|
ceil ((double) list->height / g_xui.vunit));
|
|
|
|
struct layout children = {};
|
|
for (int row = 0; row < to_show; row++)
|
|
{
|
|
int item_index = tab->item_top + row;
|
|
struct layout subl = app_layout_row (tab, item_index);
|
|
// TODO: Change layouting so that we don't need to know list->width.
|
|
app_flush_layout_full (&subl, list->width, &children);
|
|
}
|
|
list->children = children.head;
|
|
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
static void
|
|
app_layout_mpd_status_playlist (struct layout *l, chtype attrs)
|
|
{
|
|
char *songs = (g.playlist.len == 1)
|
|
? xstrdup_printf ("1 song")
|
|
: xstrdup_printf ("%zu songs", g.playlist.len);
|
|
app_push (l, g.ui->label (attrs, songs));
|
|
free (songs);
|
|
|
|
int hours = g.playlist_time / 3600;
|
|
int minutes = g.playlist_time % 3600 / 60;
|
|
if (hours || minutes)
|
|
{
|
|
struct str length = str_make ();
|
|
if (hours == 1)
|
|
str_append_printf (&length, " 1 hour");
|
|
else if (hours)
|
|
str_append_printf (&length, " %d hours", hours);
|
|
|
|
if (minutes == 1)
|
|
str_append_printf (&length, " 1 minute");
|
|
else if (minutes)
|
|
str_append_printf (&length, " %d minutes", minutes);
|
|
|
|
app_push (l, g.ui->padding (attrs, 1, 1));
|
|
app_push (l, g.ui->label (attrs, length.str + 1));
|
|
str_free (&length);
|
|
}
|
|
|
|
const char *task = NULL;
|
|
if (g.poller_curl.registered)
|
|
task = "Downloading...";
|
|
else if (str_map_find (&g.playback_info, "updating_db"))
|
|
task = "Updating database...";
|
|
|
|
if (task)
|
|
{
|
|
app_push (l, g.ui->padding (attrs, 1, 1));
|
|
app_push (l, g.ui->label (attrs, task));
|
|
}
|
|
}
|
|
|
|
static void
|
|
app_layout_mpd_status (struct layout *out)
|
|
{
|
|
struct layout l = {};
|
|
chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
|
|
if (g.active_tab->item_mark > -1)
|
|
{
|
|
struct tab_range r = tab_selection_range (g.active_tab);
|
|
char *msg = xstrdup_printf (r.from == r.upto
|
|
? "Selected %d item" : "Selected %d items", r.upto - r.from + 1);
|
|
app_push_fill (&l, g.ui->label (attrs[0], msg));
|
|
free (msg);
|
|
}
|
|
else
|
|
{
|
|
app_layout_mpd_status_playlist (&l, attrs[0]);
|
|
l.tail->width = -1;
|
|
}
|
|
|
|
const char *s = NULL;
|
|
struct str_map *map = &g.playback_info;
|
|
bool repeat = (s = str_map_find (map, "repeat")) && strcmp (s, "0");
|
|
bool random = (s = str_map_find (map, "random")) && strcmp (s, "0");
|
|
bool single = (s = str_map_find (map, "single")) && strcmp (s, "0");
|
|
bool consume = (s = str_map_find (map, "consume")) && strcmp (s, "0");
|
|
|
|
if (g.ui->have_icons || repeat)
|
|
{
|
|
app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
|
|
app_push (&l,
|
|
g.ui->button (attrs[repeat], "repeat", ACTION_MPD_REPEAT));
|
|
}
|
|
if (g.ui->have_icons || random)
|
|
{
|
|
app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
|
|
app_push (&l,
|
|
g.ui->button (attrs[random], "random", ACTION_MPD_RANDOM));
|
|
}
|
|
if (g.ui->have_icons || single)
|
|
{
|
|
app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
|
|
app_push (&l,
|
|
g.ui->button (attrs[single], "single", ACTION_MPD_SINGLE));
|
|
}
|
|
if (g.ui->have_icons || consume)
|
|
{
|
|
app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
|
|
app_push (&l,
|
|
g.ui->button (attrs[consume], "consume", ACTION_MPD_CONSUME));
|
|
}
|
|
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_flush_layout (&l, out);
|
|
}
|
|
|
|
static void
|
|
app_layout_statusbar (struct layout *out)
|
|
{
|
|
chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
|
|
app_layout_padding (attrs[0], out);
|
|
|
|
struct layout l = {};
|
|
if (g.message)
|
|
{
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
if (!g.message_detail)
|
|
app_push_fill (&l, g.ui->label (attrs[1], g.message));
|
|
else
|
|
{
|
|
app_push (&l, g.ui->label (attrs[1], g.message));
|
|
app_push_fill (&l, g.ui->label (attrs[0], g.message_detail));
|
|
}
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
|
|
app_flush_layout (&l, out);
|
|
LIST_FOR_EACH (struct widget, w, l.head)
|
|
w->id = WIDGET_MESSAGE;
|
|
}
|
|
else if (g.editor.line)
|
|
{
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_push (&l, g.ui->editor (attrs[1]));
|
|
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
|
|
app_flush_layout (&l, out);
|
|
}
|
|
else if (g.client.state == MPD_CONNECTED)
|
|
app_layout_mpd_status (out);
|
|
else if (g.client.state == MPD_CONNECTING)
|
|
app_layout_text ("Connecting to MPD...", attrs[0], out);
|
|
else if (g.client.state == MPD_DISCONNECTED)
|
|
app_layout_text ("Disconnected", attrs[0], out);
|
|
|
|
app_layout_padding (attrs[0], out);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static struct widget *
|
|
app_widget_by_id (int id)
|
|
{
|
|
LIST_FOR_EACH (struct widget, w, g_xui.widgets)
|
|
if (w->id == id)
|
|
return w;
|
|
return NULL;
|
|
}
|
|
|
|
static int
|
|
app_visible_items_height (void)
|
|
{
|
|
struct widget *list = app_widget_by_id (WIDGET_LIST);
|
|
hard_assert (list != NULL);
|
|
|
|
// The raw number of items that would have fit on the terminal
|
|
return MAX (0, list->height);
|
|
}
|
|
|
|
static int
|
|
app_visible_items (void)
|
|
{
|
|
return app_visible_items_height () / g_xui.vunit;
|
|
}
|
|
|
|
/// Checks what items are visible and returns if the range was alright
|
|
static bool
|
|
app_fix_view_range (void)
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
if (tab->item_top < 0)
|
|
{
|
|
tab->item_top = 0;
|
|
return false;
|
|
}
|
|
|
|
// If the contents are at least as long as the screen, always fill it
|
|
int max_item_top = (int) tab->item_count - app_visible_items ();
|
|
// But don't let that suggest a negative offset
|
|
max_item_top = MAX (max_item_top, 0);
|
|
|
|
if (tab->item_top > max_item_top)
|
|
{
|
|
tab->item_top = max_item_top;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static void
|
|
app_layout (void)
|
|
{
|
|
struct layout top = {}, bottom = {};
|
|
app_layout_header (&top);
|
|
app_layout_statusbar (&bottom);
|
|
|
|
int available_height = g_xui.height;
|
|
if (top.tail)
|
|
available_height -= top.tail->y + top.tail->height;
|
|
if (bottom.tail)
|
|
available_height -= bottom.tail->y + bottom.tail->height;
|
|
|
|
struct layout widgets = {};
|
|
app_append_layout (&top, &widgets);
|
|
app_layout_view (&widgets, available_height);
|
|
app_append_layout (&bottom, &widgets);
|
|
g_xui.widgets = widgets.head;
|
|
|
|
app_fix_view_range();
|
|
|
|
curs_set (0);
|
|
}
|
|
|
|
// --- Actions -----------------------------------------------------------------
|
|
|
|
/// Scroll down (positive) or up (negative) @a n items
|
|
static bool
|
|
app_scroll (int n)
|
|
{
|
|
g.active_tab->item_top += n;
|
|
xui_invalidate ();
|
|
return app_fix_view_range ();
|
|
}
|
|
|
|
static void
|
|
app_ensure_selection_visible (void)
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
if (tab->item_selected < 0 || !tab->item_count)
|
|
return;
|
|
|
|
int too_high = tab->item_top - tab->item_selected;
|
|
if (too_high > 0)
|
|
app_scroll (-too_high);
|
|
|
|
int too_low = tab->item_selected
|
|
- (tab->item_top + app_visible_items () - 1);
|
|
if (too_low > 0)
|
|
app_scroll (too_low);
|
|
}
|
|
|
|
static bool
|
|
app_center_cursor (void)
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
if (tab->item_selected < 0 || !tab->item_count)
|
|
return false;
|
|
|
|
int offset = tab->item_selected - tab->item_top;
|
|
int target = app_visible_items () / 2;
|
|
app_scroll (offset - target);
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
app_move_selection (int diff)
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
int fixed = tab->item_selected + diff;
|
|
fixed = MIN (fixed, (int) tab->item_count - 1);
|
|
fixed = MAX (fixed, 0);
|
|
|
|
bool result = !diff || tab->item_selected != fixed;
|
|
tab->item_selected = fixed;
|
|
xui_invalidate ();
|
|
|
|
app_ensure_selection_visible ();
|
|
return result;
|
|
}
|
|
|
|
static void
|
|
app_show_message (char *message, char *detail)
|
|
{
|
|
cstr_set (&g.message, message);
|
|
cstr_set (&g.message_detail, detail);
|
|
poller_timer_set (&g.message_timer, 5000);
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static void
|
|
app_hide_message (void)
|
|
{
|
|
if (!g.message)
|
|
return;
|
|
|
|
cstr_set (&g.message, NULL);
|
|
cstr_set (&g.message_detail, NULL);
|
|
poller_timer_reset (&g.message_timer);
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static void
|
|
app_on_clipboard_copy (const char *text)
|
|
{
|
|
app_show_message (xstrdup ("Text copied to clipboard: "), xstrdup (text));
|
|
}
|
|
|
|
static struct widget *
|
|
app_make_label (chtype attrs, const char *label)
|
|
{
|
|
return g_xui.ui->label (attrs, 0, label);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
app_prepend_tab (struct tab *tab)
|
|
{
|
|
LIST_PREPEND (g.tabs, tab);
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static void
|
|
app_switch_tab (struct tab *tab)
|
|
{
|
|
if (tab == g.active_tab)
|
|
return;
|
|
|
|
g.last_tab = g.active_tab;
|
|
g.active_tab = tab;
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static bool
|
|
app_goto_tab (int tab_index)
|
|
{
|
|
int i = 0;
|
|
LIST_FOR_EACH (struct tab, iter, g.tabs)
|
|
if (i++ == tab_index)
|
|
{
|
|
app_switch_tab (iter);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- Actions -----------------------------------------------------------------
|
|
|
|
static int
|
|
action_resolve (const char *name)
|
|
{
|
|
for (int i = 0; i < ACTION_USER_0; i++)
|
|
if (!strcasecmp_ascii (g_action_names[i], name))
|
|
return i;
|
|
|
|
// We could put this lookup first, and accordingly adjust
|
|
// app_init_bindings() to do action_resolve(action_name(action)),
|
|
// however the ability to override internal actions seems pointless.
|
|
for (size_t i = 0; i < g.action_names.len; i++)
|
|
if (!strcasecmp_ascii (g.action_names.vector[i], name))
|
|
return ACTION_USER_0 + i;
|
|
return -1;
|
|
}
|
|
|
|
static const char *
|
|
action_name (enum action action)
|
|
{
|
|
if (action < ACTION_USER_0)
|
|
return g_action_names[action];
|
|
|
|
size_t user_action = action - ACTION_USER_0;
|
|
hard_assert (user_action < g.action_names.len);
|
|
return g.action_names.vector[user_action];
|
|
}
|
|
|
|
static const char *
|
|
action_description (enum action action)
|
|
{
|
|
if (action < ACTION_USER_0)
|
|
return g_action_descriptions[action];
|
|
|
|
size_t user_action = action - ACTION_USER_0;
|
|
hard_assert (user_action < g.action_descriptions.len);
|
|
return g.action_descriptions.vector[user_action];
|
|
}
|
|
|
|
static const char *
|
|
action_command (enum action action)
|
|
{
|
|
if (action < ACTION_USER_0)
|
|
return NULL;
|
|
|
|
size_t user_action = action - ACTION_USER_0;
|
|
hard_assert (user_action < g.action_commands.len);
|
|
return g.action_commands.vector[user_action];
|
|
}
|
|
|
|
// --- User input handling -----------------------------------------------------
|
|
|
|
static void
|
|
mpd_client_vsend_command (struct mpd_client *self, va_list ap)
|
|
{
|
|
struct strv v = strv_make ();
|
|
const char *command;
|
|
while ((command = va_arg (ap, const char *)))
|
|
strv_append (&v, command);
|
|
mpd_client_send_commandv (self, v.vector);
|
|
strv_free (&v);
|
|
}
|
|
|
|
/// Send a command to MPD without caring about the response
|
|
static bool mpd_client_send_simple (struct mpd_client *self, ...)
|
|
ATTRIBUTE_SENTINEL;
|
|
|
|
static void
|
|
mpd_on_simple_response (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
(void) data;
|
|
(void) user_data;
|
|
|
|
if (!response->success)
|
|
print_error ("%s: %s", "command failed", response->message_text);
|
|
}
|
|
|
|
static bool
|
|
mpd_client_send_simple (struct mpd_client *self, ...)
|
|
{
|
|
if (self->state != MPD_CONNECTED)
|
|
return false;
|
|
|
|
va_list ap;
|
|
va_start (ap, self);
|
|
mpd_client_vsend_command (self, ap);
|
|
va_end (ap);
|
|
|
|
mpd_client_add_task (self, mpd_on_simple_response, NULL);
|
|
mpd_client_idle (self, 0);
|
|
return true;
|
|
}
|
|
|
|
#define MPD_SIMPLE(...) \
|
|
mpd_client_send_simple (&g.client, __VA_ARGS__, NULL)
|
|
|
|
static bool
|
|
app_setvol (int value)
|
|
{
|
|
char *volume = xstrdup_printf ("%d", MAX (0, MIN (100, value)));
|
|
bool result = g.volume >= 0 && MPD_SIMPLE ("setvol", volume);
|
|
free (volume);
|
|
return result;
|
|
}
|
|
|
|
static void
|
|
app_on_mpd_command_editor_end (bool confirmed)
|
|
{
|
|
struct mpd_client *c = &g.client;
|
|
if (!confirmed)
|
|
return;
|
|
|
|
size_t len;
|
|
char *u8 = (char *) u32_to_u8 (g.editor.line, g.editor.len + 1, NULL, &len);
|
|
mpd_client_send_command_raw (c, u8);
|
|
free (u8);
|
|
|
|
mpd_client_add_task (c, mpd_on_simple_response, NULL);
|
|
mpd_client_idle (c, 0);
|
|
}
|
|
|
|
static size_t
|
|
incremental_search_match (const ucs4_t *needle, size_t len,
|
|
const ucs4_t *chars, size_t chars_len)
|
|
{
|
|
// XXX: this is slow and simplistic, but unistring is awkward to use
|
|
size_t best = 0;
|
|
for (size_t start = 0; start < chars_len; start++)
|
|
{
|
|
size_t i = 0;
|
|
for (; i < len && start + i < chars_len; i++)
|
|
if (uc_tolower (needle[i]) != uc_tolower (chars[start + i]))
|
|
break;
|
|
best = MAX (best, i);
|
|
}
|
|
return best;
|
|
}
|
|
|
|
static void
|
|
incremental_search_on_changed (void)
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
if (!tab->item_count)
|
|
return;
|
|
|
|
size_t best = 0, current = 0, index = MAX (tab->item_selected, 0), i = 0;
|
|
while (i++ < tab->item_count)
|
|
{
|
|
struct str s = str_make ();
|
|
LIST_FOR_EACH (struct widget, w, tab->on_item_layout (index).head)
|
|
{
|
|
str_append (&s, w->text);
|
|
widget_destroy (w);
|
|
}
|
|
|
|
size_t len;
|
|
ucs4_t *text = u8_to_u32 ((const uint8_t *) s.str, s.len, NULL, &len);
|
|
str_free (&s);
|
|
current = incremental_search_match
|
|
(g.editor.line, g.editor.len, text, len);
|
|
free (text);
|
|
if (best < current)
|
|
{
|
|
best = current;
|
|
tab->item_selected = index;
|
|
app_move_selection (0);
|
|
}
|
|
index = (index + 1) % tab->item_count;
|
|
}
|
|
}
|
|
|
|
static void
|
|
incremental_search_on_end (bool confirmed)
|
|
{
|
|
(void) confirmed;
|
|
// Required callback, nothing to do here.
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static bool
|
|
run_command (const char *command, struct str *output, struct error **e)
|
|
{
|
|
char *adjusted = xstrdup_printf ("2>&1 %s", command);
|
|
print_debug ("running command: %s", adjusted);
|
|
|
|
FILE *fp = popen (adjusted, "r");
|
|
free (adjusted);
|
|
if (!fp)
|
|
return error_set (e, "%s", strerror (errno));
|
|
|
|
char buf[BUFSIZ];
|
|
size_t len;
|
|
while ((len = fread (buf, 1, sizeof buf, fp)) == sizeof buf)
|
|
str_append_data (output, buf, len);
|
|
str_append_data (output, buf, len);
|
|
|
|
int status = pclose (fp);
|
|
if (status < 0)
|
|
return error_set (e, "%s", strerror (errno));
|
|
if (WIFEXITED (status) && WEXITSTATUS (status))
|
|
return error_set (e, "exit status %d", WEXITSTATUS (status));
|
|
if (WIFSIGNALED (status))
|
|
return error_set (e, "terminated on signal %d", WTERMSIG (status));
|
|
if (WIFSTOPPED (status))
|
|
return error_set (e, "stopped on signal %d", WSTOPSIG (status));
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
app_process_action_command (enum action action)
|
|
{
|
|
const char *command = action_command (action);
|
|
if (!command)
|
|
return false;
|
|
|
|
struct str output = str_make ();
|
|
struct error *error = NULL;
|
|
(void) run_command (command, &output, &error);
|
|
str_enforce_utf8 (&output);
|
|
|
|
struct strv lines = strv_make ();
|
|
cstr_split (output.str, "\r\n", false, &lines);
|
|
str_free (&output);
|
|
while (lines.len && !*lines.vector[lines.len - 1])
|
|
free (strv_steal (&lines, lines.len - 1));
|
|
for (size_t i = 0; i < lines.len; i++)
|
|
print_debug ("output: %s", lines.vector[i]);
|
|
strv_free (&lines);
|
|
|
|
if (error)
|
|
{
|
|
print_error ("\"%s\": %s", action_description (action), error->message);
|
|
error_free (error);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
app_mpd_toggle (const char *name)
|
|
{
|
|
const char *s = str_map_find (&g.playback_info, name);
|
|
bool value = s && strcmp (s, "0");
|
|
return MPD_SIMPLE (name, value ? "0" : "1");
|
|
}
|
|
|
|
static bool
|
|
app_process_action (enum action action)
|
|
{
|
|
// First let the tab try to handle this
|
|
struct tab *tab = g.active_tab;
|
|
if (tab->on_action && tab->on_action (action))
|
|
{
|
|
xui_invalidate ();
|
|
return true;
|
|
}
|
|
|
|
switch (action)
|
|
{
|
|
case ACTION_NONE:
|
|
return true;
|
|
case ACTION_QUIT:
|
|
app_quit ();
|
|
return true;
|
|
case ACTION_REDRAW:
|
|
clear ();
|
|
xui_invalidate ();
|
|
return true;
|
|
|
|
case ACTION_ABORT:
|
|
// It is a pseudomode, avoid surprising the user
|
|
if (tab->item_mark > -1)
|
|
{
|
|
tab->item_mark = -1;
|
|
xui_invalidate ();
|
|
return true;
|
|
}
|
|
return false;
|
|
case ACTION_MPD_COMMAND:
|
|
line_editor_start (&g.editor, ':');
|
|
g.editor.on_end = app_on_mpd_command_editor_end;
|
|
xui_invalidate ();
|
|
app_hide_message ();
|
|
return true;
|
|
|
|
case ACTION_MULTISELECT:
|
|
if (!tab->can_multiselect
|
|
|| !tab->item_count || tab->item_selected < 0)
|
|
return false;
|
|
|
|
xui_invalidate ();
|
|
if (tab->item_mark > -1)
|
|
tab->item_mark = -1;
|
|
else
|
|
tab->item_mark = tab->item_selected;
|
|
return true;
|
|
case ACTION_INCREMENTAL_SEARCH:
|
|
line_editor_start (&g.editor, '/');
|
|
g.editor.on_changed = incremental_search_on_changed;
|
|
g.editor.on_end = incremental_search_on_end;
|
|
xui_invalidate ();
|
|
app_hide_message ();
|
|
return true;
|
|
|
|
case ACTION_TAB_LAST:
|
|
if (!g.last_tab)
|
|
return false;
|
|
app_switch_tab (g.last_tab);
|
|
return true;
|
|
case ACTION_TAB_HELP:
|
|
app_switch_tab (g.help_tab);
|
|
return true;
|
|
case ACTION_TAB_PREVIOUS:
|
|
if (g.active_tab == g.help_tab)
|
|
return false;
|
|
if (!g.active_tab->prev)
|
|
app_switch_tab (g.help_tab);
|
|
else
|
|
app_switch_tab (g.active_tab->prev);
|
|
return true;
|
|
case ACTION_TAB_NEXT:
|
|
if (g.active_tab == g.help_tab)
|
|
app_switch_tab (g.tabs);
|
|
else if (g.active_tab->next)
|
|
app_switch_tab (g.active_tab->next);
|
|
else
|
|
return false;
|
|
return true;
|
|
|
|
case ACTION_MPD_TOGGLE:
|
|
if (g.state == PLAYER_PLAYING) return MPD_SIMPLE ("pause", "1");
|
|
if (g.state == PLAYER_PAUSED) return MPD_SIMPLE ("pause", "0");
|
|
return MPD_SIMPLE ("play");
|
|
case ACTION_MPD_STOP: return MPD_SIMPLE ("stop");
|
|
case ACTION_MPD_PREVIOUS: return MPD_SIMPLE ("previous");
|
|
case ACTION_MPD_NEXT: return MPD_SIMPLE ("next");
|
|
case ACTION_MPD_FORWARD: return MPD_SIMPLE ("seekcur", "+10");
|
|
case ACTION_MPD_BACKWARD: return MPD_SIMPLE ("seekcur", "-10");
|
|
case ACTION_MPD_REPEAT: return app_mpd_toggle ("repeat");
|
|
case ACTION_MPD_RANDOM: return app_mpd_toggle ("random");
|
|
case ACTION_MPD_SINGLE: return app_mpd_toggle ("single");
|
|
case ACTION_MPD_CONSUME: return app_mpd_toggle ("consume");
|
|
case ACTION_MPD_UPDATE_DB: return MPD_SIMPLE ("update");
|
|
|
|
case ACTION_MPD_VOLUME_UP: return app_setvol (g.volume + 5);
|
|
case ACTION_MPD_VOLUME_DOWN: return app_setvol (g.volume - 5);
|
|
|
|
#ifdef WITH_PULSE
|
|
case ACTION_PULSE_VOLUME_UP: return pulse_volume_set (&g.pulse, +5);
|
|
case ACTION_PULSE_VOLUME_DOWN: return pulse_volume_set (&g.pulse, -5);
|
|
case ACTION_PULSE_MUTE: return pulse_volume_mute (&g.pulse);
|
|
#endif // WITH_PULSE
|
|
|
|
// XXX: these two should rather be parametrized
|
|
case ACTION_SCROLL_UP: return app_scroll (-3);
|
|
case ACTION_SCROLL_DOWN: return app_scroll (+3);
|
|
case ACTION_CENTER_CURSOR: return app_center_cursor ();
|
|
|
|
case ACTION_GOTO_TOP:
|
|
if (tab->item_count)
|
|
{
|
|
g.active_tab->item_selected = 0;
|
|
app_ensure_selection_visible ();
|
|
xui_invalidate ();
|
|
}
|
|
return true;
|
|
case ACTION_GOTO_BOTTOM:
|
|
if (tab->item_count)
|
|
{
|
|
g.active_tab->item_selected =
|
|
MAX (0, (int) g.active_tab->item_count - 1);
|
|
app_ensure_selection_visible ();
|
|
xui_invalidate ();
|
|
}
|
|
return true;
|
|
|
|
case ACTION_GOTO_ITEM_PREVIOUS: return app_move_selection (-1);
|
|
case ACTION_GOTO_ITEM_NEXT: return app_move_selection (1);
|
|
|
|
case ACTION_GOTO_PAGE_PREVIOUS:
|
|
app_scroll (-app_visible_items ());
|
|
return app_move_selection (-app_visible_items ());
|
|
case ACTION_GOTO_PAGE_NEXT:
|
|
app_scroll (app_visible_items ());
|
|
return app_move_selection (app_visible_items ());
|
|
|
|
case ACTION_GOTO_VIEW_TOP:
|
|
g.active_tab->item_selected = g.active_tab->item_top;
|
|
return app_move_selection (0);
|
|
case ACTION_GOTO_VIEW_CENTER:
|
|
g.active_tab->item_selected = g.active_tab->item_top;
|
|
return app_move_selection (MAX (0, app_visible_items () / 2 - 1));
|
|
case ACTION_GOTO_VIEW_BOTTOM:
|
|
g.active_tab->item_selected = g.active_tab->item_top;
|
|
return app_move_selection (MAX (0, app_visible_items () - 1));
|
|
|
|
default:
|
|
if (app_process_action_command (action))
|
|
return true;
|
|
|
|
print_error ("\"%s\" is not allowed here", action_description (action));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static bool
|
|
app_editor_process_action (enum action action)
|
|
{
|
|
xui_invalidate ();
|
|
switch (action)
|
|
{
|
|
case ACTION_ABORT:
|
|
line_editor_abort (&g.editor, false);
|
|
g.editor.on_end = NULL;
|
|
return true;
|
|
case ACTION_EDITOR_CONFIRM:
|
|
line_editor_abort (&g.editor, true);
|
|
g.editor.on_end = NULL;
|
|
return true;
|
|
default:
|
|
print_error ("\"%s\" is not allowed here", action_description (action));
|
|
return false;
|
|
|
|
case ACTION_EDITOR_B_CHAR:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_B_CHAR);
|
|
case ACTION_EDITOR_F_CHAR:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_F_CHAR);
|
|
case ACTION_EDITOR_B_WORD:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_B_WORD);
|
|
case ACTION_EDITOR_F_WORD:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_F_WORD);
|
|
case ACTION_EDITOR_HOME:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_HOME);
|
|
case ACTION_EDITOR_END:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_END);
|
|
|
|
case ACTION_EDITOR_UPCASE_WORD:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_UPCASE_WORD);
|
|
case ACTION_EDITOR_DOWNCASE_WORD:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_DOWNCASE_WORD);
|
|
case ACTION_EDITOR_CAPITALIZE_WORD:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_CAPITALIZE_WORD);
|
|
|
|
case ACTION_EDITOR_B_DELETE:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_B_DELETE);
|
|
case ACTION_EDITOR_F_DELETE:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_F_DELETE);
|
|
case ACTION_EDITOR_B_KILL_WORD:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_B_KILL_WORD);
|
|
case ACTION_EDITOR_B_KILL_LINE:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_B_KILL_LINE);
|
|
case ACTION_EDITOR_F_KILL_LINE:
|
|
return line_editor_action (&g.editor, LINE_EDITOR_F_KILL_LINE);
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
// Carefully chosen to limit the possibility of ever hitting termo keymods.
|
|
enum { APP_KEYMOD_DOUBLE_CLICK = 1 << 15 };
|
|
|
|
static bool
|
|
app_process_left_mouse_click (struct widget *w, int x, int y, int modifiers)
|
|
{
|
|
switch (w->id)
|
|
{
|
|
case WIDGET_BUTTON:
|
|
app_process_action (w->userdata);
|
|
break;
|
|
case WIDGET_GAUGE:
|
|
{
|
|
// TODO: We should avoid queuing up too many.
|
|
float position = (float) x / w->width;
|
|
if (g.song_duration >= 1)
|
|
{
|
|
char *where = xstrdup_printf ("%f", position * g.song_duration);
|
|
MPD_SIMPLE ("seekcur", where);
|
|
free (where);
|
|
}
|
|
break;
|
|
}
|
|
case WIDGET_TAB:
|
|
{
|
|
struct tab *tab = g.help_tab;
|
|
int i = 0;
|
|
LIST_FOR_EACH (struct tab, iter, g.tabs)
|
|
if (++i == w->userdata)
|
|
tab = iter;
|
|
|
|
app_switch_tab (tab);
|
|
break;
|
|
}
|
|
case WIDGET_LIST:
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
int row_index = y / g_xui.vunit;
|
|
if (row_index < 0
|
|
|| row_index >= (int) tab->item_count - tab->item_top)
|
|
return false;
|
|
|
|
if (!(modifiers & TERMO_KEYMOD_SHIFT))
|
|
tab->item_mark = -1;
|
|
else if (!tab->can_multiselect || tab->item_selected < 0)
|
|
return false;
|
|
else if (tab->item_mark < 0)
|
|
tab->item_mark = tab->item_selected;
|
|
|
|
tab->item_selected = row_index + tab->item_top;
|
|
app_ensure_selection_visible ();
|
|
xui_invalidate ();
|
|
|
|
if (modifiers & APP_KEYMOD_DOUBLE_CLICK)
|
|
app_process_action (ACTION_CHOOSE);
|
|
break;
|
|
}
|
|
case WIDGET_SCROLLBAR:
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
int visible_items = app_visible_items ();
|
|
tab->item_top = (double) y / w->height
|
|
* (int) tab->item_count - visible_items / 2;
|
|
xui_invalidate ();
|
|
app_fix_view_range ();
|
|
break;
|
|
}
|
|
case WIDGET_MESSAGE:
|
|
app_hide_message ();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
app_process_mouse (termo_mouse_event_t type, int x, int y, int button,
|
|
int modifiers)
|
|
{
|
|
// XXX: Terminals don't let us know which button has been released,
|
|
// so we can't press buttons at that point. We'd need a special "click"
|
|
// event handler that could be handled better under X11.
|
|
if (type == TERMO_MOUSE_RELEASE)
|
|
{
|
|
g.ui_dragging = WIDGET_NONE;
|
|
return true;
|
|
}
|
|
|
|
if (type == TERMO_MOUSE_DRAG)
|
|
{
|
|
if (g.ui_dragging != WIDGET_GAUGE
|
|
&& g.ui_dragging != WIDGET_SCROLLBAR)
|
|
return true;
|
|
|
|
struct widget *target = app_widget_by_id (g.ui_dragging);
|
|
x -= target->x;
|
|
y -= target->y;
|
|
return app_process_left_mouse_click (target, x, y, modifiers);
|
|
}
|
|
|
|
if (g.editor.line)
|
|
{
|
|
line_editor_abort (&g.editor, false);
|
|
xui_invalidate ();
|
|
}
|
|
|
|
struct widget *target = NULL;
|
|
LIST_FOR_EACH (struct widget, w, g_xui.widgets)
|
|
if (x >= w->x && x < w->x + w->width
|
|
&& y >= w->y && y < w->y + w->height)
|
|
target = w;
|
|
if (!target)
|
|
return false;
|
|
|
|
x -= target->x;
|
|
y -= target->y;
|
|
switch (button)
|
|
{
|
|
case 1:
|
|
g.ui_dragging = target->id;
|
|
return app_process_left_mouse_click (target, x, y, modifiers);
|
|
case 4:
|
|
switch (target->id)
|
|
{
|
|
case WIDGET_LIST:
|
|
return app_process_action (ACTION_SCROLL_UP);
|
|
case WIDGET_VOLUME:
|
|
return app_process_action (
|
|
#ifdef WITH_PULSE
|
|
g.pulse_control_requested ? ACTION_PULSE_VOLUME_UP :
|
|
#endif // WITH_PULSE
|
|
ACTION_MPD_VOLUME_UP);
|
|
case WIDGET_GAUGE:
|
|
return app_process_action (ACTION_MPD_FORWARD);
|
|
}
|
|
break;
|
|
case 5:
|
|
switch (target->id)
|
|
{
|
|
case WIDGET_LIST:
|
|
return app_process_action (ACTION_SCROLL_DOWN);
|
|
case WIDGET_VOLUME:
|
|
return app_process_action (
|
|
#ifdef WITH_PULSE
|
|
g.pulse_control_requested ? ACTION_PULSE_VOLUME_DOWN :
|
|
#endif // WITH_PULSE
|
|
ACTION_MPD_VOLUME_DOWN);
|
|
case WIDGET_GAUGE:
|
|
return app_process_action (ACTION_MPD_BACKWARD);
|
|
}
|
|
break;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static struct binding
|
|
{
|
|
termo_key_t decoded; ///< Decoded key definition
|
|
enum action action; ///< Action to take
|
|
int order; ///< Order for stable sorting
|
|
}
|
|
*g_normal_keys, *g_editor_keys;
|
|
static size_t g_normal_keys_len, g_editor_keys_len;
|
|
|
|
static struct binding_default
|
|
{
|
|
const char *key; ///< Key definition
|
|
enum action action; ///< Action to take
|
|
}
|
|
g_normal_defaults[] =
|
|
{
|
|
{ "q", ACTION_QUIT },
|
|
{ "C-l", ACTION_REDRAW },
|
|
{ "Escape", ACTION_ABORT },
|
|
{ "M-Tab", ACTION_TAB_LAST },
|
|
{ "F1", ACTION_TAB_HELP },
|
|
{ "S-Tab", ACTION_TAB_PREVIOUS },
|
|
{ "Tab", ACTION_TAB_NEXT },
|
|
{ "C-Left", ACTION_TAB_PREVIOUS },
|
|
{ "C-Right", ACTION_TAB_NEXT },
|
|
{ "C-PageUp", ACTION_TAB_PREVIOUS },
|
|
{ "C-PageDown", ACTION_TAB_NEXT },
|
|
|
|
{ "o", ACTION_GOTO_PLAYING },
|
|
{ "Home", ACTION_GOTO_TOP },
|
|
{ "End", ACTION_GOTO_BOTTOM },
|
|
{ "M-<", ACTION_GOTO_TOP },
|
|
{ "M->", ACTION_GOTO_BOTTOM },
|
|
{ "g", ACTION_GOTO_TOP },
|
|
{ "G", ACTION_GOTO_BOTTOM },
|
|
{ "S-Up", ACTION_MOVE_UP },
|
|
{ "S-Down", ACTION_MOVE_DOWN },
|
|
{ "Up", ACTION_GOTO_ITEM_PREVIOUS },
|
|
{ "Down", ACTION_GOTO_ITEM_NEXT },
|
|
{ "k", ACTION_GOTO_ITEM_PREVIOUS },
|
|
{ "j", ACTION_GOTO_ITEM_NEXT },
|
|
{ "PageUp", ACTION_GOTO_PAGE_PREVIOUS },
|
|
{ "PageDown", ACTION_GOTO_PAGE_NEXT },
|
|
{ "C-p", ACTION_GOTO_ITEM_PREVIOUS },
|
|
{ "C-n", ACTION_GOTO_ITEM_NEXT },
|
|
{ "C-b", ACTION_GOTO_PAGE_PREVIOUS },
|
|
{ "C-f", ACTION_GOTO_PAGE_NEXT },
|
|
{ "C-y", ACTION_SCROLL_UP },
|
|
{ "C-e", ACTION_SCROLL_DOWN },
|
|
{ "z", ACTION_CENTER_CURSOR },
|
|
|
|
{ "H", ACTION_GOTO_VIEW_TOP },
|
|
{ "M", ACTION_GOTO_VIEW_CENTER },
|
|
{ "L", ACTION_GOTO_VIEW_BOTTOM },
|
|
|
|
// Not sure how to set these up, they're pretty arbitrary so far
|
|
{ "Enter", ACTION_CHOOSE },
|
|
{ "Delete", ACTION_DELETE },
|
|
{ "d", ACTION_DELETE },
|
|
{ "?", ACTION_DESCRIBE },
|
|
{ "M-Up", ACTION_UP },
|
|
{ "Backspace", ACTION_UP },
|
|
{ "v", ACTION_MULTISELECT },
|
|
{ "C-s", ACTION_INCREMENTAL_SEARCH },
|
|
{ "/", ACTION_MPD_SEARCH },
|
|
{ "a", ACTION_MPD_ADD },
|
|
{ "r", ACTION_MPD_REPLACE },
|
|
{ ":", ACTION_MPD_COMMAND },
|
|
|
|
{ "<", ACTION_MPD_PREVIOUS },
|
|
{ ">", ACTION_MPD_NEXT },
|
|
{ "Left", ACTION_MPD_PREVIOUS },
|
|
{ "Right", ACTION_MPD_NEXT },
|
|
{ "M-Left", ACTION_MPD_BACKWARD },
|
|
{ "M-Right", ACTION_MPD_FORWARD },
|
|
{ "h", ACTION_MPD_PREVIOUS },
|
|
{ "l", ACTION_MPD_NEXT },
|
|
{ "Space", ACTION_MPD_TOGGLE },
|
|
{ "C-Space", ACTION_MPD_STOP },
|
|
{ "u", ACTION_MPD_UPDATE_DB },
|
|
{ "+", ACTION_MPD_VOLUME_UP },
|
|
{ "-", ACTION_MPD_VOLUME_DOWN },
|
|
},
|
|
g_editor_defaults[] =
|
|
{
|
|
{ "C-g", ACTION_ABORT },
|
|
{ "Escape", ACTION_ABORT },
|
|
{ "Enter", ACTION_EDITOR_CONFIRM },
|
|
|
|
{ "Left", ACTION_EDITOR_B_CHAR },
|
|
{ "Right", ACTION_EDITOR_F_CHAR },
|
|
{ "C-b", ACTION_EDITOR_B_CHAR },
|
|
{ "C-f", ACTION_EDITOR_F_CHAR },
|
|
{ "M-b", ACTION_EDITOR_B_WORD },
|
|
{ "M-f", ACTION_EDITOR_F_WORD },
|
|
{ "Home", ACTION_EDITOR_HOME },
|
|
{ "End", ACTION_EDITOR_END },
|
|
{ "C-a", ACTION_EDITOR_HOME },
|
|
{ "C-e", ACTION_EDITOR_END },
|
|
|
|
{ "M-u", ACTION_EDITOR_UPCASE_WORD },
|
|
{ "M-l", ACTION_EDITOR_DOWNCASE_WORD },
|
|
{ "M-c", ACTION_EDITOR_CAPITALIZE_WORD },
|
|
|
|
{ "C-h", ACTION_EDITOR_B_DELETE },
|
|
{ "DEL", ACTION_EDITOR_B_DELETE },
|
|
{ "Backspace", ACTION_EDITOR_B_DELETE },
|
|
{ "C-d", ACTION_EDITOR_F_DELETE },
|
|
{ "Delete", ACTION_EDITOR_F_DELETE },
|
|
{ "C-u", ACTION_EDITOR_B_KILL_LINE },
|
|
{ "C-k", ACTION_EDITOR_F_KILL_LINE },
|
|
{ "C-w", ACTION_EDITOR_B_KILL_WORD },
|
|
};
|
|
|
|
static int
|
|
app_binding_cmp (const void *a, const void *b)
|
|
{
|
|
const struct binding *aa = a, *bb = b;
|
|
int cmp = termo_keycmp (g_xui.tk, &aa->decoded, &bb->decoded);
|
|
return cmp ? cmp : bb->order - aa->order;
|
|
}
|
|
|
|
static bool
|
|
app_next_binding (struct str_map_iter *iter, termo_key_t *key, int *action)
|
|
{
|
|
struct config_item *v;
|
|
while ((v = str_map_iter_next (iter)))
|
|
{
|
|
*action = ACTION_NONE;
|
|
if (*termo_strpkey_utf8 (g_xui.tk,
|
|
iter->link->key, key, TERMO_FORMAT_ALTISMETA))
|
|
print_error ("%s: invalid binding", iter->link->key);
|
|
else if (v->type == CONFIG_ITEM_NULL)
|
|
return true;
|
|
else if (v->type != CONFIG_ITEM_STRING)
|
|
print_error ("%s: bindings must be strings", iter->link->key);
|
|
else if ((*action = action_resolve (v->value.string.str)) >= 0)
|
|
return true;
|
|
else
|
|
print_error ("%s: unknown action: %s",
|
|
iter->link->key, v->value.string.str);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static struct binding *
|
|
app_init_bindings (const char *keymap,
|
|
struct binding_default *defaults, size_t defaults_len, size_t *result_len)
|
|
{
|
|
ARRAY (struct binding, a)
|
|
ARRAY_INIT_SIZED (a, defaults_len);
|
|
|
|
// Order for stable sorting
|
|
size_t order = 0;
|
|
|
|
termo_key_t decoded;
|
|
for (size_t i = 0; i < defaults_len; i++)
|
|
{
|
|
hard_assert (!*termo_strpkey_utf8 (g_xui.tk,
|
|
defaults[i].key, &decoded, TERMO_FORMAT_ALTISMETA));
|
|
a[a_len++] = (struct binding) { decoded, defaults[i].action, order++ };
|
|
}
|
|
|
|
struct config_item *root = config_item_get (g.config.root, keymap, NULL);
|
|
if (root && root->type == CONFIG_ITEM_OBJECT)
|
|
{
|
|
struct str_map_iter iter = str_map_iter_make (&root->value.object);
|
|
ARRAY_RESERVE (a, iter.map->len);
|
|
|
|
int action;
|
|
while (app_next_binding (&iter, &decoded, &action))
|
|
a[a_len++] = (struct binding) { decoded, action, order++ };
|
|
}
|
|
|
|
// Use the helper field to use the last mappings of identical bindings
|
|
size_t out = 0;
|
|
qsort (a, a_len, sizeof *a, app_binding_cmp);
|
|
for (size_t in = 0; in < a_len; in++)
|
|
{
|
|
a[in].order = 0;
|
|
if (!out
|
|
|| termo_keycmp (g_xui.tk, &a[in].decoded, &a[out - 1].decoded))
|
|
a[out++] = a[in];
|
|
}
|
|
|
|
*result_len = out;
|
|
return a;
|
|
}
|
|
|
|
static char *
|
|
app_strfkey (const termo_key_t *key)
|
|
{
|
|
// For display purposes, this is highly desirable
|
|
int flags = termo_get_flags (g_xui.tk);
|
|
termo_set_flags (g_xui.tk, flags | TERMO_FLAG_SPACESYMBOL);
|
|
termo_key_t fixed = *key;
|
|
termo_canonicalise (g_xui.tk, &fixed);
|
|
termo_set_flags (g_xui.tk, flags);
|
|
|
|
char buf[16] = "";
|
|
termo_strfkey_utf8 (g_xui.tk,
|
|
buf, sizeof buf, &fixed, TERMO_FORMAT_ALTISMETA);
|
|
return xstrdup (buf);
|
|
}
|
|
|
|
static bool
|
|
app_process_termo_event (termo_key_t *event)
|
|
{
|
|
char *formatted = app_strfkey (event);
|
|
print_debug ("%s", formatted);
|
|
free (formatted);
|
|
|
|
bool handled = false;
|
|
if ((handled = event->type == TERMO_TYPE_FOCUS))
|
|
{
|
|
xui_invalidate ();
|
|
// Senseless fall-through
|
|
}
|
|
|
|
struct binding dummy = { *event, 0, 0 }, *binding;
|
|
if (g.editor.line)
|
|
{
|
|
if (event->type == TERMO_TYPE_KEY
|
|
|| event->type == TERMO_TYPE_FUNCTION
|
|
|| event->type == TERMO_TYPE_KEYSYM)
|
|
app_hide_message ();
|
|
|
|
if ((binding = bsearch (&dummy, g_editor_keys, g_editor_keys_len,
|
|
sizeof *binding, app_binding_cmp)))
|
|
return app_editor_process_action (binding->action);
|
|
if (event->type != TERMO_TYPE_KEY || event->modifiers != 0)
|
|
return handled;
|
|
|
|
line_editor_insert (&g.editor, event->code.codepoint);
|
|
xui_invalidate ();
|
|
return true;
|
|
}
|
|
if ((binding = bsearch (&dummy, g_normal_keys, g_normal_keys_len,
|
|
sizeof *binding, app_binding_cmp)))
|
|
return app_process_action (binding->action);
|
|
|
|
// TODO: parametrize actions, put this among other bindings
|
|
if (!(event->modifiers & ~TERMO_KEYMOD_ALT)
|
|
&& event->code.codepoint >= '0'
|
|
&& event->code.codepoint <= '9')
|
|
{
|
|
int n = event->code.codepoint - '0';
|
|
if (app_goto_tab ((n == 0 ? 10 : n) - 1))
|
|
return true;
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
// --- Current tab -------------------------------------------------------------
|
|
|
|
static struct tab g_current_tab;
|
|
|
|
static struct layout
|
|
current_tab_on_item_layout (size_t item_index)
|
|
{
|
|
// TODO: configurable output, maybe dynamically sized columns
|
|
compact_map_t map = item_list_get (&g.playlist, item_index);
|
|
const char *artist = compact_map_find (map, "artist");
|
|
const char *title = compact_map_find (map, "title");
|
|
|
|
chtype attrs = (int) item_index == g.song ? A_BOLD : 0;
|
|
struct layout l = {};
|
|
if (artist && title)
|
|
{
|
|
char *joined = xstrdup_printf ("%s - %s", artist, title);
|
|
app_push_fill (&l, g.ui->label (attrs, joined));
|
|
free (joined);
|
|
}
|
|
else
|
|
app_push_fill (&l, g.ui->label (attrs, compact_map_find (map, "file")));
|
|
|
|
int duration = -1;
|
|
mpd_read_time (compact_map_find (map, "duration"), &duration, NULL);
|
|
mpd_read_time (compact_map_find (map, "time"), &duration, NULL);
|
|
|
|
char *s = duration < 0 ? xstrdup ("-") : app_time_string (duration);
|
|
app_push (&l, g.ui->padding (attrs, 1, 1));
|
|
app_push (&l, g.ui->label (attrs, s));
|
|
free (s);
|
|
|
|
return l;
|
|
}
|
|
|
|
static void
|
|
mpd_on_move_response (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
(void) data;
|
|
|
|
*(bool *) user_data = false;
|
|
if (!response->success)
|
|
print_error ("%s: %s", "command failed", response->message_text);
|
|
}
|
|
|
|
static void
|
|
current_tab_move (int from, int to)
|
|
{
|
|
compact_map_t map;
|
|
const char *id;
|
|
if (!(map = item_list_get (&g.playlist, from))
|
|
|| !(id = compact_map_find (map, "id")))
|
|
return;
|
|
|
|
char *target_str = xstrdup_printf ("%d", to);
|
|
mpd_client_send_command (&g.client, "moveid", id, target_str, NULL);
|
|
free (target_str);
|
|
}
|
|
|
|
static bool
|
|
current_tab_move_selection (int diff)
|
|
{
|
|
static bool already_moving;
|
|
if (already_moving || diff == 0)
|
|
return true;
|
|
|
|
struct mpd_client *c = &g.client;
|
|
if (c->state != MPD_CONNECTED)
|
|
return false;
|
|
|
|
struct tab *tab = &g_current_tab;
|
|
struct tab_range range = tab_selection_range (tab);
|
|
if (range.from + diff < 0
|
|
|| range.upto + diff >= (int) tab->item_count)
|
|
return false;
|
|
|
|
mpd_client_list_begin (c);
|
|
if (diff < 0)
|
|
for (int i = range.from; i <= range.upto; i++)
|
|
current_tab_move (i, i + diff);
|
|
else
|
|
for (int i = range.upto; i >= range.from; i--)
|
|
current_tab_move (i, i + diff);
|
|
mpd_client_list_end (c);
|
|
|
|
mpd_client_add_task (c, mpd_on_move_response, &already_moving);
|
|
mpd_client_idle (c, 0);
|
|
return already_moving = true;
|
|
}
|
|
|
|
static bool
|
|
current_tab_on_action (enum action action)
|
|
{
|
|
struct tab *tab = &g_current_tab;
|
|
compact_map_t map = item_list_get (&g.playlist, tab->item_selected);
|
|
switch (action)
|
|
{
|
|
const char *id;
|
|
case ACTION_GOTO_PLAYING:
|
|
if (g.song < 0 || (size_t) g.song >= tab->item_count)
|
|
return false;
|
|
|
|
tab->item_selected = g.song;
|
|
app_ensure_selection_visible ();
|
|
return true;
|
|
case ACTION_MOVE_UP:
|
|
return current_tab_move_selection (-1);
|
|
case ACTION_MOVE_DOWN:
|
|
return current_tab_move_selection (+1);
|
|
case ACTION_CHOOSE:
|
|
tab->item_mark = -1;
|
|
return map && (id = compact_map_find (map, "id"))
|
|
&& MPD_SIMPLE ("playid", id);
|
|
case ACTION_DESCRIBE:
|
|
if (!map || !(id = compact_map_find (map, "file")))
|
|
return false;
|
|
|
|
app_show_message (xstrdup ("Path: "), xstrdup (id));
|
|
return true;
|
|
case ACTION_DELETE:
|
|
{
|
|
struct mpd_client *c = &g.client;
|
|
struct tab_range range = tab_selection_range (tab);
|
|
if (range.from < 0 || c->state != MPD_CONNECTED)
|
|
return false;
|
|
|
|
mpd_client_list_begin (c);
|
|
for (int i = range.from; i <= range.upto; i++)
|
|
{
|
|
if ((map = item_list_get (&g.playlist, i))
|
|
&& (id = compact_map_find (map, "id")))
|
|
mpd_client_send_command (c, "deleteid", id, NULL);
|
|
}
|
|
mpd_client_list_end (c);
|
|
mpd_client_add_task (c, mpd_on_simple_response, NULL);
|
|
mpd_client_idle (c, 0);
|
|
return true;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static void
|
|
current_tab_update (void)
|
|
{
|
|
g_current_tab.item_count = g.playlist.len;
|
|
g_current_tab.item_mark =
|
|
MIN ((int) g.playlist.len - 1, g_current_tab.item_mark);
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static struct tab *
|
|
current_tab_init (void)
|
|
{
|
|
struct tab *super = &g_current_tab;
|
|
tab_init (super, "Current");
|
|
super->can_multiselect = true;
|
|
super->on_action = current_tab_on_action;
|
|
super->on_item_layout = current_tab_on_item_layout;
|
|
return super;
|
|
}
|
|
|
|
// --- Library tab -------------------------------------------------------------
|
|
|
|
struct library_level
|
|
{
|
|
LIST_HEADER (struct library_level)
|
|
|
|
int item_top; ///< Stored state
|
|
int item_selected; ///< Stored state
|
|
char path[]; ///< Path of the level
|
|
};
|
|
|
|
enum
|
|
{
|
|
// This list is also ordered by ASCII and important for sorting
|
|
|
|
LIBRARY_ROOT = '/', ///< Root entry
|
|
LIBRARY_UP = '^', ///< Upper directory
|
|
LIBRARY_DIR = 'd', ///< Directory
|
|
LIBRARY_FILE = 'f', ///< File
|
|
LIBRARY_PLAYLIST = 'p', ///< Playlist (unsupported)
|
|
};
|
|
|
|
struct library_tab_item
|
|
{
|
|
int type; ///< Type of the item
|
|
int duration; ///< Duration or -1 if N/A or unknown
|
|
char *name; ///< Visible name
|
|
const char *path; ///< MPD path (follows the name)
|
|
};
|
|
|
|
static struct
|
|
{
|
|
struct tab super; ///< Parent class
|
|
struct str path; ///< Current path
|
|
struct library_level *above; ///< Upper levels
|
|
|
|
/// Current items
|
|
ARRAY (struct library_tab_item, items)
|
|
|
|
bool searching; ///< Search mode is active
|
|
}
|
|
g_library_tab;
|
|
|
|
static void
|
|
library_tab_add (int type, int duration, const char *name, const char *path)
|
|
{
|
|
// Slightly reduce memory overhead while retaining friendly access
|
|
size_t name_len = strlen (name), path_len = strlen (path);
|
|
char *combined = xmalloc (++name_len + ++path_len);
|
|
|
|
ARRAY_RESERVE (g_library_tab.items, 1);
|
|
g_library_tab.items[g_library_tab.items_len++] = (struct library_tab_item)
|
|
{
|
|
.type = type,
|
|
.duration = duration,
|
|
.name = memcpy (combined, name, name_len),
|
|
.path = memcpy (combined + name_len, path, path_len),
|
|
};
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static struct layout
|
|
library_tab_on_item_layout (size_t item_index)
|
|
{
|
|
hard_assert (item_index < g_library_tab.items_len);
|
|
|
|
struct library_tab_item *x = &g_library_tab.items[item_index];
|
|
const char *prefix, *name;
|
|
switch (x->type)
|
|
{
|
|
case LIBRARY_ROOT: prefix = "/"; name = ""; break;
|
|
case LIBRARY_UP: prefix = "/"; name = ".."; break;
|
|
case LIBRARY_DIR: prefix = "/"; name = x->name; break;
|
|
case LIBRARY_FILE: prefix = " "; name = x->name; break;
|
|
default: hard_assert (!"invalid item type");
|
|
}
|
|
|
|
chtype attrs = x->type != LIBRARY_FILE ? APP_ATTR (DIRECTORY) : 0;
|
|
struct layout l = {};
|
|
|
|
app_push (&l, g.ui->label (attrs, prefix));
|
|
app_push_fill (&l, g.ui->label (attrs, name));
|
|
|
|
if (x->duration >= 0)
|
|
{
|
|
char *s = app_time_string (x->duration);
|
|
app_push (&l, g.ui->padding (0, 1, 1));
|
|
app_push (&l, g.ui->label (attrs, s));
|
|
free (s);
|
|
}
|
|
return l;
|
|
}
|
|
|
|
static char
|
|
library_tab_header_type (const char *key)
|
|
{
|
|
if (!strcasecmp_ascii (key, "file")) return LIBRARY_FILE;
|
|
if (!strcasecmp_ascii (key, "directory")) return LIBRARY_DIR;
|
|
if (!strcasecmp_ascii (key, "playlist")) return LIBRARY_PLAYLIST;
|
|
return 0;
|
|
}
|
|
|
|
static void
|
|
library_tab_chunk (char type, const char *path, struct str_map *map)
|
|
{
|
|
// CUE files appear once as a directory and another time as a playlist,
|
|
// just skip them entirely
|
|
if (type == LIBRARY_PLAYLIST)
|
|
return;
|
|
|
|
const char *artist = str_map_find (map, "artist");
|
|
const char *title = str_map_find (map, "title");
|
|
char *name = (artist && title)
|
|
? xstrdup_printf ("%s - %s", artist, title)
|
|
: xstrdup (xbasename (path));
|
|
|
|
int duration = -1;
|
|
mpd_read_time (str_map_find (map, "duration"), &duration, NULL);
|
|
mpd_read_time (str_map_find (map, "time"), &duration, NULL);
|
|
library_tab_add (type, duration, name, path);
|
|
free (name);
|
|
}
|
|
|
|
static int
|
|
library_tab_compare (struct library_tab_item *a, struct library_tab_item *b)
|
|
{
|
|
if (a->type != b->type)
|
|
return a->type - b->type;
|
|
|
|
return app_casecmp ((uint8_t *) a->path, (uint8_t *) b->path);
|
|
}
|
|
|
|
static char *
|
|
library_tab_parent (void)
|
|
{
|
|
struct str *path = &g_library_tab.path;
|
|
if (!path->len)
|
|
return NULL;
|
|
|
|
char *last_slash;
|
|
if ((last_slash = strrchr (path->str, '/')))
|
|
return xstrndup (path->str, last_slash - path->str);
|
|
return xstrdup ("");
|
|
}
|
|
|
|
static bool
|
|
library_tab_is_above (const char *above, const char *subdir)
|
|
{
|
|
size_t above_len = strlen (above);
|
|
if (strncmp (above, subdir, above_len))
|
|
return false;
|
|
// The root is an empty string and is above anything other than itself
|
|
return subdir[above_len] == '/' || (*subdir && !*above);
|
|
}
|
|
|
|
static void
|
|
library_tab_change_level (const char *new_path)
|
|
{
|
|
struct str *path = &g_library_tab.path;
|
|
if (!strcmp (path->str, new_path))
|
|
return;
|
|
|
|
struct library_level *above;
|
|
if (library_tab_is_above (path->str, new_path))
|
|
{
|
|
above = xcalloc (1, sizeof *above + path->len + 1);
|
|
above->item_top = g_library_tab.super.item_top;
|
|
above->item_selected = g_library_tab.super.item_selected;
|
|
memcpy (above->path, path->str, path->len);
|
|
LIST_PREPEND (g_library_tab.above, above);
|
|
|
|
// Select the ".." entry to reflect Norton Commander
|
|
g_library_tab.super.item_top = 0;
|
|
g_library_tab.super.item_selected = 1;
|
|
}
|
|
else while ((above = g_library_tab.above)
|
|
&& !library_tab_is_above (above->path, new_path))
|
|
{
|
|
if (!strcmp (above->path, new_path))
|
|
{
|
|
g_library_tab.super.item_top = above->item_top;
|
|
g_library_tab.super.item_selected = above->item_selected;
|
|
}
|
|
g_library_tab.above = above->next;
|
|
free (above);
|
|
}
|
|
|
|
str_reset (path);
|
|
str_append (path, new_path);
|
|
|
|
cstr_set (&g_library_tab.super.header, NULL);
|
|
g_library_tab.super.item_mark = -1;
|
|
|
|
if (path->len)
|
|
g_library_tab.super.header = xstrdup_printf ("/%s", path->str);
|
|
}
|
|
|
|
static void
|
|
library_tab_reset (void)
|
|
{
|
|
for (size_t i = 0; i < g_library_tab.items_len; i++)
|
|
free (g_library_tab.items[i].name);
|
|
free (g_library_tab.items);
|
|
ARRAY_INIT (g_library_tab.items);
|
|
}
|
|
|
|
static void
|
|
library_tab_load_data (const struct strv *data)
|
|
{
|
|
library_tab_reset ();
|
|
|
|
char *parent = library_tab_parent ();
|
|
if (parent)
|
|
{
|
|
library_tab_add (LIBRARY_ROOT, -1, "", "");
|
|
library_tab_add (LIBRARY_UP, -1, "", parent);
|
|
free (parent);
|
|
}
|
|
|
|
struct str_map map = str_map_make (NULL);
|
|
map.key_xfrm = tolower_ascii_strxfrm;
|
|
|
|
char *key, *value, type;
|
|
for (size_t i = data->len; i--; )
|
|
if (!(key = mpd_parse_kv (data->vector[i], &value)))
|
|
continue;
|
|
else if (!(type = library_tab_header_type (key)))
|
|
str_map_set (&map, key, value);
|
|
else
|
|
{
|
|
library_tab_chunk (type, value, &map);
|
|
str_map_clear (&map);
|
|
}
|
|
str_map_free (&map);
|
|
|
|
struct library_tab_item *items = g_library_tab.items;
|
|
size_t len = g_library_tab.super.item_count = g_library_tab.items_len;
|
|
qsort (items, len, sizeof *items,
|
|
(int (*) (const void *, const void *)) library_tab_compare);
|
|
|
|
// XXX: this unmarks even if just the database updates
|
|
g_library_tab.super.item_mark = -1;
|
|
|
|
// Don't force the selection visible when there's no need to touch it
|
|
if (g_library_tab.super.item_selected >= (int) len)
|
|
app_move_selection (0);
|
|
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static void
|
|
library_tab_on_data (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
char *new_path = user_data;
|
|
if (!response->success)
|
|
{
|
|
print_error ("cannot read directory: %s", response->message_text);
|
|
free (new_path);
|
|
return;
|
|
}
|
|
|
|
g_library_tab.searching = false;
|
|
library_tab_change_level (new_path);
|
|
free (new_path);
|
|
|
|
library_tab_load_data (data);
|
|
}
|
|
|
|
static void
|
|
library_tab_reload (const char *new_path)
|
|
{
|
|
if (!new_path && g_library_tab.searching)
|
|
return; // TODO: perhaps we should call search_on_changed()
|
|
|
|
char *path = new_path
|
|
? xstrdup (new_path)
|
|
: xstrdup (g_library_tab.path.str);
|
|
|
|
struct mpd_client *c = &g.client;
|
|
mpd_client_send_command (c, "lsinfo", *path ? path : "/", NULL);
|
|
mpd_client_add_task (c, library_tab_on_data, path);
|
|
mpd_client_idle (c, 0);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
library_tab_on_search_data (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
char *filter = user_data;
|
|
if (!g_library_tab.searching)
|
|
goto out;
|
|
|
|
if (!response->success)
|
|
print_error ("cannot search: %s", response->message_text);
|
|
else
|
|
{
|
|
cstr_set (&g_library_tab.super.header,
|
|
xstrdup_printf ("%s: %s", "Global search", filter));
|
|
library_tab_load_data (data);
|
|
}
|
|
|
|
out:
|
|
free (filter);
|
|
}
|
|
|
|
static char *
|
|
mpd_quoted_filter_string (const char *value)
|
|
{
|
|
struct str quoted = str_make ();
|
|
str_append_c ("ed, '\'');
|
|
for (const char *p = value; *p; p++)
|
|
{
|
|
if (mpd_client_must_escape_in_quote (*p))
|
|
str_append_c ("ed, '\\');
|
|
str_append_c ("ed, *p);
|
|
}
|
|
str_append_c ("ed, '\'');
|
|
return str_steal ("ed);
|
|
}
|
|
|
|
static void
|
|
search_on_changed (void)
|
|
{
|
|
struct mpd_client *c = &g.client;
|
|
|
|
size_t len;
|
|
char *u8 = (char *) u32_to_u8 (g.editor.line, g.editor.len + 1, NULL, &len);
|
|
mpd_client_list_begin (c);
|
|
mpd_client_send_command (c, "search", "any", u8, NULL);
|
|
|
|
// Just tag search doesn't consider filenames.
|
|
// Older MPD can do `search any X file X` but without the negation,
|
|
// which is necessary to avoid duplicates. Neither syntax supports OR.
|
|
// XXX: We should parse this, but it's probably not going to reach 100 soon,
|
|
// and it is not really documented what this should even look like.
|
|
if (strcmp (c->got_hello, "0.21.") > 1)
|
|
{
|
|
char *quoted = mpd_quoted_filter_string (u8);
|
|
char *expression = xstrdup_printf ("((!(any contains %s)) AND "
|
|
"(file contains %s))", quoted, quoted);
|
|
mpd_client_send_command (c, "search", expression, NULL);
|
|
free (expression);
|
|
free (quoted);
|
|
}
|
|
|
|
mpd_client_list_end (c);
|
|
mpd_client_add_task (c, library_tab_on_search_data, u8);
|
|
mpd_client_idle (c, 0);
|
|
}
|
|
|
|
static void
|
|
search_on_end (bool confirmed)
|
|
{
|
|
if (!confirmed)
|
|
library_tab_reload (g_library_tab.above->path);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static bool
|
|
library_tab_is_range_playable (struct tab_range range)
|
|
{
|
|
for (int i = range.from; i <= range.upto; i++)
|
|
{
|
|
struct library_tab_item *x = &g_library_tab.items[i];
|
|
if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool
|
|
library_tab_on_action (enum action action)
|
|
{
|
|
struct mpd_client *c = &g.client;
|
|
if (c->state != MPD_CONNECTED)
|
|
return false;
|
|
|
|
struct tab *tab = &g_library_tab.super;
|
|
struct tab_range range = tab_selection_range (tab);
|
|
if (range.from < 0)
|
|
return false;
|
|
|
|
struct library_tab_item *x = &g_library_tab.items[range.from];
|
|
switch (action)
|
|
{
|
|
case ACTION_CHOOSE:
|
|
// I can't think of a reasonable way of handling that
|
|
if (range.from != range.upto)
|
|
break;
|
|
|
|
switch (x->type)
|
|
{
|
|
case LIBRARY_ROOT:
|
|
case LIBRARY_UP:
|
|
case LIBRARY_DIR: library_tab_reload (x->path); break;
|
|
case LIBRARY_FILE: MPD_SIMPLE ("add", x->path); break;
|
|
default: hard_assert (!"invalid item type");
|
|
}
|
|
tab->item_mark = -1;
|
|
return true;
|
|
case ACTION_DESCRIBE:
|
|
if (!*x->path)
|
|
break;
|
|
|
|
app_show_message (xstrdup ("Path: "), xstrdup (x->path));
|
|
return true;
|
|
case ACTION_UP:
|
|
{
|
|
char *parent = library_tab_parent ();
|
|
if (parent)
|
|
{
|
|
library_tab_reload (parent);
|
|
free (parent);
|
|
}
|
|
return parent != NULL;
|
|
}
|
|
case ACTION_MPD_SEARCH:
|
|
{
|
|
line_editor_start (&g.editor, '/');
|
|
g.editor.on_changed = search_on_changed;
|
|
g.editor.on_end = search_on_end;
|
|
|
|
// We just need to be deeper but not match anything real,
|
|
// in order to keep the rest of the codebase functional as-is
|
|
if (!g_library_tab.searching)
|
|
{
|
|
char *fake_subdir = xstrdup_printf ("%s/", g_library_tab.path.str);
|
|
library_tab_change_level (fake_subdir);
|
|
free (fake_subdir);
|
|
}
|
|
|
|
cstr_set (&tab->header, xstrdup_printf ("Global search"));
|
|
g_library_tab.searching = true;
|
|
|
|
// Since we've already changed the header, empty the list,
|
|
// although to be consistent we should also ask to search for "",
|
|
// which dumps the database
|
|
struct strv empty = strv_make ();
|
|
library_tab_load_data (&empty);
|
|
strv_free (&empty);
|
|
|
|
xui_invalidate ();
|
|
return true;
|
|
}
|
|
case ACTION_MPD_ADD:
|
|
if (!library_tab_is_range_playable (range))
|
|
break;
|
|
|
|
for (int i = range.from; i <= range.upto; i++)
|
|
{
|
|
struct library_tab_item *x = &g_library_tab.items[i];
|
|
if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE)
|
|
MPD_SIMPLE ("add", x->path);
|
|
}
|
|
tab->item_mark = -1;
|
|
return true;
|
|
case ACTION_MPD_REPLACE:
|
|
if (!library_tab_is_range_playable (range))
|
|
break;
|
|
|
|
// Clears the playlist (which stops playback), add what user wanted
|
|
// to replace it with, and eventually restore playback;
|
|
// I can't think of a reliable alternative that omits the "play"
|
|
mpd_client_list_begin (c);
|
|
|
|
mpd_client_send_command (c, "clear", NULL);
|
|
for (int i = range.from; i <= range.upto; i++)
|
|
{
|
|
struct library_tab_item *x = &g_library_tab.items[i];
|
|
if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE)
|
|
mpd_client_send_command (c, "add", x->path, NULL);
|
|
}
|
|
if (g.state == PLAYER_PLAYING)
|
|
mpd_client_send_command (c, "play", NULL);
|
|
|
|
mpd_client_list_end (c);
|
|
mpd_client_add_task (c, mpd_on_simple_response, NULL);
|
|
mpd_client_idle (c, 0);
|
|
tab->item_mark = -1;
|
|
return true;
|
|
default:
|
|
break;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static struct tab *
|
|
library_tab_init (void)
|
|
{
|
|
g_library_tab.path = str_make ();
|
|
// g_library_tab.items is fine with zero initialisation
|
|
|
|
struct tab *super = &g_library_tab.super;
|
|
tab_init (super, "Library");
|
|
super->can_multiselect = true;
|
|
super->on_action = library_tab_on_action;
|
|
super->on_item_layout = library_tab_on_item_layout;
|
|
return super;
|
|
}
|
|
|
|
// --- Streams -----------------------------------------------------------------
|
|
|
|
// MPD can only parse m3u8 playlists, and only when it feels like doing so
|
|
|
|
struct stream_tab_task
|
|
{
|
|
struct poller_curl_task curl; ///< Superclass
|
|
struct str data; ///< Downloaded data
|
|
bool replace; ///< Should playlist be replaced?
|
|
struct curl_slist *alias_ok;
|
|
};
|
|
|
|
static bool
|
|
is_content_type (const char *content_type,
|
|
const char *expected_type, const char *expected_subtype)
|
|
{
|
|
char *type = NULL, *subtype = NULL;
|
|
bool result = http_parse_media_type (content_type, &type, &subtype, NULL)
|
|
&& !strcasecmp_ascii (type, expected_type)
|
|
&& !strcasecmp_ascii (subtype, expected_subtype);
|
|
free (type);
|
|
free (subtype);
|
|
return result;
|
|
}
|
|
|
|
static void
|
|
streams_tab_filter (char *line, regex_t *re, struct strv *out)
|
|
{
|
|
regmatch_t groups[2];
|
|
if (regexec (re, line, 2, groups, 0) == REG_NOMATCH)
|
|
return;
|
|
|
|
// It may happen that playlist files contain useless, invalid quotes,
|
|
// let's be liberal in what we accept
|
|
regoff_t start = groups[1].rm_so, end = groups[1].rm_eo;
|
|
while (end > start + 1 && line[start] == '"' && line[end - 1] == '"')
|
|
{
|
|
start++;
|
|
end--;
|
|
}
|
|
|
|
char *target = xstrndup (line + start, end - start);
|
|
if (utf8_validate (target, end - start))
|
|
strv_append_owned (out, target);
|
|
else
|
|
{
|
|
strv_append_owned (out, latin1_to_utf8 (target));
|
|
free (target);
|
|
}
|
|
}
|
|
|
|
static void
|
|
streams_tab_parse_playlist (const char *playlist, const char *content_type,
|
|
struct strv *out)
|
|
{
|
|
// We accept a lot of very broken stuff because this is the real world
|
|
struct strv lines = strv_make ();
|
|
cstr_split (playlist, "\r\n", true, &lines);
|
|
|
|
// Since this excludes '"', it should even work for XMLs (w/o entities)
|
|
const char *extract_re =
|
|
"(https?://([][a-z0-9._~:/?#@!$&'()*+,;=-]|%[a-f0-9]{2})+)";
|
|
if ((lines.len && !strcasecmp_ascii (lines.vector[0], "[playlist]"))
|
|
|| (content_type && is_content_type (content_type, "audio", "x-scpls")))
|
|
extract_re = "^File[^=]*=(.+)";
|
|
else if ((lines.len && !strcasecmp_ascii (lines.vector[0], "#EXTM3U"))
|
|
|| (content_type && is_content_type (content_type, "audio", "mpegurl"))
|
|
|| (content_type && is_content_type (content_type, "audio", "x-mpegurl")))
|
|
// This could be "^([^#].*)", however 1. we would need to resolve
|
|
// relative URIs, and 2. relative URIs probably mean a Media Playlist,
|
|
// which must be passed to MPD. The better thing to do here would be to
|
|
// reject anything with EXT-X-TARGETDURATION, and to resolve the URIs.
|
|
extract_re = "^(https?://.+)";
|
|
|
|
regex_t *re = regex_compile (extract_re, REG_EXTENDED, NULL);
|
|
hard_assert (re != NULL);
|
|
for (size_t i = 0; i < lines.len; i++)
|
|
streams_tab_filter (lines.vector[i], re, out);
|
|
regex_free (re);
|
|
strv_free (&lines);
|
|
}
|
|
|
|
static bool
|
|
streams_tab_extract_links (struct str *data, const char *content_type,
|
|
struct strv *out)
|
|
{
|
|
// Since playlists are also "audio/*", this seems like a sane thing to do
|
|
for (size_t i = 0; i < data->len; i++)
|
|
{
|
|
uint8_t c = data->str[i];
|
|
if (iscntrl_ascii (c) & (c != '\t') & (c != '\r') & (c != '\n'))
|
|
return false;
|
|
}
|
|
|
|
streams_tab_parse_playlist (data->str, content_type, out);
|
|
return out->len != 0;
|
|
}
|
|
|
|
static void
|
|
streams_tab_task_finalize (struct stream_tab_task *self)
|
|
{
|
|
curl_easy_cleanup (self->curl.easy);
|
|
curl_slist_free_all (self->alias_ok);
|
|
str_free (&self->data);
|
|
free (self);
|
|
}
|
|
|
|
static void
|
|
streams_tab_task_dispose (struct stream_tab_task *self)
|
|
{
|
|
hard_assert (poller_curl_remove (&g.poller_curl, self->curl.easy, NULL));
|
|
streams_tab_task_finalize (self);
|
|
}
|
|
|
|
static void
|
|
streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task)
|
|
{
|
|
struct stream_tab_task *self =
|
|
CONTAINER_OF (task, struct stream_tab_task, curl);
|
|
|
|
if (msg->data.result
|
|
&& msg->data.result != CURLE_WRITE_ERROR)
|
|
{
|
|
cstr_uncapitalize (self->curl.curl_error);
|
|
print_error ("%s", self->curl.curl_error);
|
|
goto dispose;
|
|
}
|
|
|
|
struct mpd_client *c = &g.client;
|
|
if (c->state != MPD_CONNECTED)
|
|
goto dispose;
|
|
|
|
CURL *easy = msg->easy_handle;
|
|
CURLcode res;
|
|
|
|
long code;
|
|
char *type, *uri;
|
|
if ((res = curl_easy_getinfo (easy, CURLINFO_RESPONSE_CODE, &code))
|
|
|| (res = curl_easy_getinfo (easy, CURLINFO_CONTENT_TYPE, &type))
|
|
|| (res = curl_easy_getinfo (easy, CURLINFO_EFFECTIVE_URL, &uri)))
|
|
{
|
|
print_error ("%s: %s",
|
|
"cURL info retrieval failed", curl_easy_strerror (res));
|
|
goto dispose;
|
|
}
|
|
// cURL is not willing to parse the ICY header, the code is zero then
|
|
if (code && code != 200)
|
|
{
|
|
print_error ("%s: %ld", "unexpected HTTP response", code);
|
|
goto dispose;
|
|
}
|
|
|
|
mpd_client_list_begin (c);
|
|
if (self->replace)
|
|
mpd_client_send_command (c, "clear", NULL);
|
|
|
|
struct strv links = strv_make ();
|
|
if (!streams_tab_extract_links (&self->data, type, &links))
|
|
strv_append (&links, uri);
|
|
for (size_t i = 0; i < links.len; i++)
|
|
mpd_client_send_command (c, "add", links.vector[i], NULL);
|
|
if (self->replace && g.state == PLAYER_PLAYING)
|
|
mpd_client_send_command (c, "play", NULL);
|
|
|
|
strv_free (&links);
|
|
mpd_client_list_end (c);
|
|
mpd_client_add_task (c, mpd_on_simple_response, NULL);
|
|
mpd_client_idle (c, 0);
|
|
|
|
dispose:
|
|
streams_tab_task_dispose (self);
|
|
}
|
|
|
|
static size_t
|
|
write_callback (char *ptr, size_t size, size_t nmemb, void *user_data)
|
|
{
|
|
struct str *buf = user_data;
|
|
str_append_data (buf, ptr, size * nmemb);
|
|
|
|
// Invoke CURLE_WRITE_ERROR when we've received enough data for a playlist
|
|
if (buf->len >= (1 << 16))
|
|
return 0;
|
|
|
|
return size * nmemb;
|
|
}
|
|
|
|
static bool
|
|
streams_tab_process (const char *uri, bool replace, struct error **e)
|
|
{
|
|
// TODO: streams_tab_task_dispose() on that running task
|
|
if (g.poller_curl.registered)
|
|
{
|
|
print_error ("waiting for the last stream to time out");
|
|
return false;
|
|
}
|
|
|
|
struct stream_tab_task *task = xcalloc (1, sizeof *task);
|
|
hard_assert (poller_curl_spawn (&task->curl, NULL));
|
|
|
|
CURL *easy = task->curl.easy;
|
|
task->data = str_make ();
|
|
task->replace = replace;
|
|
task->alias_ok = curl_slist_append (NULL, "ICY 200 OK");
|
|
|
|
CURLcode res;
|
|
if ((res = curl_easy_setopt (easy, CURLOPT_FOLLOWLOCATION, 1L))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_NOPROGRESS, 1L))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_TIMEOUT, 10L))
|
|
// Not checking anything, we just want some data, any data
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYPEER, 0L))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYHOST, 0L))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_URL, uri))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_HTTP200ALIASES, task->alias_ok))
|
|
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_VERBOSE, (long) g_debug_mode))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_DEBUGFUNCTION, print_curl_debug))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_WRITEDATA, &task->data))
|
|
|| (res = curl_easy_setopt (easy, CURLOPT_WRITEFUNCTION, write_callback)))
|
|
{
|
|
error_set (e, "%s: %s", "cURL setup failed", curl_easy_strerror (res));
|
|
streams_tab_task_finalize (task);
|
|
return false;
|
|
}
|
|
|
|
task->curl.on_done = streams_tab_on_downloaded;
|
|
hard_assert (poller_curl_add (&g.poller_curl, task->curl.easy, NULL));
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
streams_tab_on_action (enum action action)
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
if (tab->item_selected < 0 || !tab->item_count)
|
|
return false;
|
|
|
|
// For simplicity the URL is the string following the stream name
|
|
const char *uri = 1 + strchr (g.streams.vector[tab->item_selected], 0);
|
|
|
|
struct error *e = NULL;
|
|
switch (action)
|
|
{
|
|
case ACTION_MPD_REPLACE:
|
|
streams_tab_process (uri, true, &e);
|
|
break;
|
|
case ACTION_CHOOSE:
|
|
case ACTION_MPD_ADD:
|
|
streams_tab_process (uri, false, &e);
|
|
break;
|
|
case ACTION_DESCRIBE:
|
|
app_show_message (xstrdup (uri), NULL);
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
if (e)
|
|
{
|
|
print_error ("%s", e->message);
|
|
error_free (e);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static struct layout
|
|
streams_tab_on_item_layout (size_t item_index)
|
|
{
|
|
struct layout l = {};
|
|
app_push_fill (&l, g.ui->label (0, g.streams.vector[item_index]));
|
|
return l;
|
|
}
|
|
|
|
static struct tab *
|
|
streams_tab_init (void)
|
|
{
|
|
static struct tab super;
|
|
tab_init (&super, "Streams");
|
|
super.on_action = streams_tab_on_action;
|
|
super.on_item_layout = streams_tab_on_item_layout;
|
|
super.item_count = g.streams.len;
|
|
return &super;
|
|
}
|
|
|
|
// --- Info tab ----------------------------------------------------------------
|
|
|
|
struct info_tab_plugin
|
|
{
|
|
LIST_HEADER (struct info_tab_plugin)
|
|
|
|
char *path; ///< Filesystem path to plugin
|
|
char *description; ///< What the plugin does
|
|
};
|
|
|
|
static struct info_tab_plugin *
|
|
info_tab_plugin_load (const char *path)
|
|
{
|
|
// Shell quoting is less annoying than process management.
|
|
struct str escaped = str_make ();
|
|
shell_quote (path, &escaped);
|
|
|
|
struct str description = str_make ();
|
|
struct error *error = NULL;
|
|
(void) run_command (escaped.str, &description, &error);
|
|
str_free (&escaped);
|
|
if (error)
|
|
{
|
|
print_error ("%s: %s", path, error->message);
|
|
error_free (error);
|
|
str_free (&description);
|
|
return NULL;
|
|
}
|
|
|
|
char *newline = strpbrk (description.str, "\r\n");
|
|
if (newline)
|
|
{
|
|
description.len = newline - description.str;
|
|
*newline = '\0';
|
|
}
|
|
str_enforce_utf8 (&description);
|
|
if (!description.len)
|
|
{
|
|
print_error ("%s: %s", path, "missing description");
|
|
str_free (&description);
|
|
return NULL;
|
|
}
|
|
|
|
struct info_tab_plugin *plugin = xcalloc (1, sizeof *plugin);
|
|
plugin->path = xstrdup (path);
|
|
plugin->description = str_steal (&description);
|
|
return plugin;
|
|
}
|
|
|
|
static void
|
|
info_tab_plugin_load_dir (struct str_map *basename_to_path, const char *dirname)
|
|
{
|
|
DIR *dir = opendir (dirname);
|
|
if (!dir)
|
|
{
|
|
print_debug ("opendir: %s: %s", dirname, strerror (errno));
|
|
return;
|
|
}
|
|
|
|
struct dirent *entry = NULL;
|
|
while ((entry = readdir (dir)))
|
|
{
|
|
struct stat st = {};
|
|
char *path = xstrdup_printf ("%s/%s", dirname, entry->d_name);
|
|
if (stat (path, &st) || !S_ISREG (st.st_mode))
|
|
{
|
|
free (path);
|
|
continue;
|
|
}
|
|
|
|
// Empty files silently erase formerly found basenames.
|
|
if (!st.st_size)
|
|
cstr_set (&path, NULL);
|
|
|
|
str_map_set (basename_to_path, entry->d_name, path);
|
|
}
|
|
closedir (dir);
|
|
}
|
|
|
|
static int
|
|
strv_sort_cb (const void *a, const void *b)
|
|
{
|
|
return strcmp (*(const char **) a, *(const char **) b);
|
|
}
|
|
|
|
static struct info_tab_plugin *
|
|
info_tab_plugin_load_all (void)
|
|
{
|
|
struct str_map basename_to_path = str_map_make (free);
|
|
struct strv paths = strv_make ();
|
|
get_xdg_data_dirs (&paths);
|
|
strv_append (&paths, PROJECT_DATADIR);
|
|
for (size_t i = paths.len; i--; )
|
|
{
|
|
char *dirname =
|
|
xstrdup_printf ("%s/" PROGRAM_NAME "/info", paths.vector[i]);
|
|
info_tab_plugin_load_dir (&basename_to_path, dirname);
|
|
free (dirname);
|
|
}
|
|
strv_free (&paths);
|
|
|
|
struct strv sorted = strv_make ();
|
|
struct str_map_iter iter = str_map_iter_make (&basename_to_path);
|
|
while (str_map_iter_next (&iter))
|
|
strv_append (&sorted, iter.link->key);
|
|
qsort (sorted.vector, sorted.len, sizeof *sorted.vector, strv_sort_cb);
|
|
|
|
struct info_tab_plugin *result = NULL;
|
|
for (size_t i = sorted.len; i--; )
|
|
{
|
|
const char *path = str_map_find (&basename_to_path, sorted.vector[i]);
|
|
struct info_tab_plugin *plugin = info_tab_plugin_load (path);
|
|
if (plugin)
|
|
LIST_PREPEND (result, plugin);
|
|
}
|
|
str_map_free (&basename_to_path);
|
|
strv_free (&sorted);
|
|
return result;
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
struct info_tab_item
|
|
{
|
|
char *prefix; ///< Fixed-width prefix column or NULL
|
|
char *text; ///< Text or NULL
|
|
bool formatted; ///< Interpret inline formatting marks?
|
|
struct info_tab_plugin *plugin; ///< Activatable plugin
|
|
};
|
|
|
|
static void
|
|
info_tab_item_free (struct info_tab_item *self)
|
|
{
|
|
cstr_set (&self->prefix, NULL);
|
|
cstr_set (&self->text, NULL);
|
|
}
|
|
|
|
static struct
|
|
{
|
|
struct tab super; ///< Parent class
|
|
struct info_tab_item *items; ///< Items array
|
|
size_t items_alloc; ///< How many items are allocated
|
|
|
|
struct info_tab_plugin *plugins; ///< Plugins
|
|
|
|
int plugin_songid; ///< Song ID or -1
|
|
pid_t plugin_pid; ///< Running plugin's process ID or -1
|
|
int plugin_stdout; ///< pid != -1: read end of stdout
|
|
struct poller_fd plugin_event; ///< pid != -1: stdout is readable
|
|
struct str plugin_output; ///< pid != -1: buffer, otherwise result
|
|
}
|
|
g_info_tab;
|
|
|
|
static chtype
|
|
info_tab_format_decode_toggle (char c)
|
|
{
|
|
switch (c)
|
|
{
|
|
case '\x01':
|
|
return A_BOLD;
|
|
case '\x02':
|
|
#ifdef A_ITALIC
|
|
return A_ITALIC;
|
|
#else
|
|
return A_UNDERLINE;
|
|
#endif
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
static void
|
|
info_tab_format (struct layout *l, const char *text)
|
|
{
|
|
chtype attrs = 0;
|
|
for (const char *p = text; *p; p++)
|
|
{
|
|
chtype toggled = info_tab_format_decode_toggle (*p);
|
|
if (!toggled)
|
|
continue;
|
|
|
|
if (p != text)
|
|
{
|
|
char *slice = xstrndup (text, p - text);
|
|
app_push (l, g.ui->label (attrs, slice));
|
|
free (slice);
|
|
}
|
|
|
|
attrs ^= toggled;
|
|
text = p + 1;
|
|
}
|
|
if (*text)
|
|
app_push (l, g.ui->label (attrs, text));
|
|
}
|
|
|
|
static struct layout
|
|
info_tab_on_item_layout (size_t item_index)
|
|
{
|
|
struct info_tab_item *item = &g_info_tab.items[item_index];
|
|
struct layout l = {};
|
|
if (item->prefix)
|
|
{
|
|
char *prefix = xstrdup_printf ("%s:", item->prefix);
|
|
app_push (&l, g.ui->label (A_BOLD, prefix))
|
|
->width = 8 * g_xui.hunit;
|
|
app_push (&l, g.ui->padding (0, 0.5, 1));
|
|
}
|
|
|
|
if (item->plugin)
|
|
app_push (&l, g.ui->label (A_BOLD, item->plugin->description));
|
|
else if (!item->text || !*item->text)
|
|
app_push (&l, g.ui->padding (0, 1, 1));
|
|
else if (item->formatted)
|
|
info_tab_format (&l, item->text);
|
|
else
|
|
app_push (&l, g.ui->label (0, item->text));
|
|
|
|
if (l.tail)
|
|
l.tail->width = -1;
|
|
return l;
|
|
}
|
|
|
|
static struct info_tab_item *
|
|
info_tab_prepare (void)
|
|
{
|
|
if (g_info_tab.super.item_count == g_info_tab.items_alloc)
|
|
g_info_tab.items = xreallocarray (g_info_tab.items,
|
|
sizeof *g_info_tab.items, (g_info_tab.items_alloc <<= 1));
|
|
|
|
struct info_tab_item *item =
|
|
&g_info_tab.items[g_info_tab.super.item_count++];
|
|
memset (item, 0, sizeof *item);
|
|
return item;
|
|
}
|
|
|
|
static void
|
|
info_tab_add (compact_map_t data, const char *field)
|
|
{
|
|
struct info_tab_item *item = info_tab_prepare ();
|
|
item->prefix = xstrdup (field);
|
|
item->text = xstrdup0 (compact_map_find (data, field));
|
|
}
|
|
|
|
static void
|
|
info_tab_update (void)
|
|
{
|
|
while (g_info_tab.super.item_count)
|
|
info_tab_item_free (&g_info_tab.items[--g_info_tab.super.item_count]);
|
|
|
|
compact_map_t map = item_list_get (&g.playlist, g.song);
|
|
if (!map)
|
|
return;
|
|
|
|
info_tab_add (map, "Title");
|
|
info_tab_add (map, "Artist");
|
|
info_tab_add (map, "Album");
|
|
info_tab_add (map, "Track");
|
|
info_tab_add (map, "Genre");
|
|
// We actually receive it as "file", but the key is also used for display
|
|
info_tab_add (map, "File");
|
|
|
|
if (g_info_tab.plugins)
|
|
{
|
|
(void) info_tab_prepare ();
|
|
LIST_FOR_EACH (struct info_tab_plugin, plugin, g_info_tab.plugins)
|
|
info_tab_prepare ()->plugin = plugin;
|
|
}
|
|
|
|
if (g_info_tab.plugin_pid != -1)
|
|
{
|
|
(void) info_tab_prepare ();
|
|
info_tab_prepare ()->text = xstrdup ("Processing...");
|
|
return;
|
|
}
|
|
|
|
const char *songid = compact_map_find (map, "Id");
|
|
if (songid && atoi (songid) == g_info_tab.plugin_songid
|
|
&& g_info_tab.plugin_output.len)
|
|
{
|
|
struct strv lines = strv_make ();
|
|
cstr_split (g_info_tab.plugin_output.str, "\r\n", false, &lines);
|
|
|
|
(void) info_tab_prepare ();
|
|
for (size_t i = 0; i < lines.len; i++)
|
|
{
|
|
struct info_tab_item *item = info_tab_prepare ();
|
|
item->formatted = true;
|
|
item->text = lines.vector[i];
|
|
}
|
|
free (lines.vector);
|
|
}
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
info_tab_plugin_abort (void)
|
|
{
|
|
if (g_info_tab.plugin_pid == -1)
|
|
return;
|
|
|
|
// XXX: our methods of killing are very crude, we hope to improve;
|
|
// at least install a SIGCHLD handler to collect zombies
|
|
(void) kill (-g_info_tab.plugin_pid, SIGTERM);
|
|
|
|
int status = 0;
|
|
while (waitpid (g_info_tab.plugin_pid, &status, WNOHANG) == -1
|
|
&& errno == EINTR)
|
|
;
|
|
if (WIFEXITED (status) && WEXITSTATUS (status) != EXIT_SUCCESS)
|
|
print_error ("plugin reported failure");
|
|
|
|
g_info_tab.plugin_pid = -1;
|
|
poller_fd_reset (&g_info_tab.plugin_event);
|
|
xclose (g_info_tab.plugin_stdout);
|
|
g_info_tab.plugin_stdout = -1;
|
|
}
|
|
|
|
static void
|
|
info_tab_on_plugin_stdout (const struct pollfd *fd, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
struct str *buf = &g_info_tab.plugin_output;
|
|
switch (socket_io_try_read (fd->fd, buf))
|
|
{
|
|
case SOCKET_IO_OK:
|
|
str_enforce_utf8 (buf);
|
|
return;
|
|
case SOCKET_IO_ERROR:
|
|
print_error ("error reading from plugin: %s", strerror (errno));
|
|
// Fall-through
|
|
case SOCKET_IO_EOF:
|
|
info_tab_plugin_abort ();
|
|
info_tab_update ();
|
|
xui_invalidate ();
|
|
}
|
|
}
|
|
|
|
static void
|
|
info_tab_plugin_run (struct info_tab_plugin *plugin, compact_map_t map)
|
|
{
|
|
info_tab_plugin_abort ();
|
|
if (!map)
|
|
return;
|
|
|
|
const char *songid = compact_map_find (map, "Id");
|
|
const char *title = compact_map_find (map, "Title");
|
|
const char *artist = compact_map_find (map, "Artist");
|
|
const char *album = compact_map_find (map, "Album");
|
|
if (!songid || !title || !artist)
|
|
{
|
|
print_error ("unknown song title or artist");
|
|
return;
|
|
}
|
|
|
|
int stdout_pipe[2];
|
|
if (pipe (stdout_pipe))
|
|
{
|
|
print_error ("%s: %s", "pipe", strerror (errno));
|
|
return;
|
|
}
|
|
|
|
enum { READ, WRITE };
|
|
set_cloexec (stdout_pipe[READ]);
|
|
set_cloexec (stdout_pipe[WRITE]);
|
|
|
|
const char *argv[] =
|
|
{ xbasename (plugin->path), title, artist, album, NULL };
|
|
|
|
pid_t child = fork ();
|
|
switch (child)
|
|
{
|
|
case -1:
|
|
print_error ("%s: %s", "fork", strerror (errno));
|
|
xclose (stdout_pipe[READ]);
|
|
xclose (stdout_pipe[WRITE]);
|
|
return;
|
|
case 0:
|
|
if (setpgid (0, 0) == -1 || !freopen ("/dev/null", "r", stdin)
|
|
|| dup2 (stdout_pipe[WRITE], STDOUT_FILENO) == -1
|
|
|| dup2 (stdout_pipe[WRITE], STDERR_FILENO) == -1)
|
|
_exit (EXIT_FAILURE);
|
|
|
|
signal (SIGPIPE, SIG_DFL);
|
|
|
|
(void) execv (plugin->path, (char **) argv);
|
|
fprintf (stderr, "%s\n", strerror (errno));
|
|
_exit (EXIT_FAILURE);
|
|
default:
|
|
// Resolve the race, even though it isn't critical for us
|
|
(void) setpgid (child, child);
|
|
|
|
g_info_tab.plugin_songid = atoi (songid);
|
|
g_info_tab.plugin_pid = child;
|
|
set_blocking ((g_info_tab.plugin_stdout = stdout_pipe[READ]), false);
|
|
xclose (stdout_pipe[WRITE]);
|
|
|
|
struct poller_fd *event = &g_info_tab.plugin_event;
|
|
*event = poller_fd_make (&g.poller, g_info_tab.plugin_stdout);
|
|
event->dispatcher = info_tab_on_plugin_stdout;
|
|
str_reset (&g_info_tab.plugin_output);
|
|
poller_fd_set (&g_info_tab.plugin_event, POLLIN);
|
|
}
|
|
}
|
|
|
|
static bool
|
|
info_tab_on_action (enum action action)
|
|
{
|
|
struct tab *tab = g.active_tab;
|
|
if (tab->item_selected < 0
|
|
|| tab->item_selected >= (int) tab->item_count)
|
|
return false;
|
|
|
|
struct info_tab_item *item = &g_info_tab.items[tab->item_selected];
|
|
if (!item->plugin)
|
|
return false;
|
|
|
|
switch (action)
|
|
{
|
|
case ACTION_DESCRIBE:
|
|
app_show_message (xstrdup ("Path: "), xstrdup (item->plugin->path));
|
|
return true;
|
|
case ACTION_CHOOSE:
|
|
info_tab_plugin_run (item->plugin, item_list_get (&g.playlist, g.song));
|
|
info_tab_update ();
|
|
xui_invalidate ();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static struct tab *
|
|
info_tab_init (void)
|
|
{
|
|
g_info_tab.items =
|
|
xcalloc ((g_info_tab.items_alloc = 16), sizeof *g_info_tab.items);
|
|
|
|
g_info_tab.plugins = info_tab_plugin_load_all ();
|
|
g_info_tab.plugin_songid = -1;
|
|
g_info_tab.plugin_pid = -1;
|
|
g_info_tab.plugin_stdout = -1;
|
|
g_info_tab.plugin_output = str_make ();
|
|
|
|
struct tab *super = &g_info_tab.super;
|
|
tab_init (super, "Info");
|
|
super->on_action = info_tab_on_action;
|
|
super->on_item_layout = info_tab_on_item_layout;
|
|
return super;
|
|
}
|
|
|
|
// --- Help tab ----------------------------------------------------------------
|
|
|
|
static struct
|
|
{
|
|
struct tab super; ///< Parent class
|
|
ARRAY (enum action, actions) ///< Actions for content
|
|
struct strv lines; ///< Visible content
|
|
}
|
|
g_help_tab;
|
|
|
|
static bool
|
|
help_tab_on_action (enum action action)
|
|
{
|
|
struct tab *tab = &g_help_tab.super;
|
|
if (tab->item_selected < 0
|
|
|| tab->item_selected >= (int) g_help_tab.actions_len)
|
|
return false;
|
|
|
|
enum action a = g_help_tab.actions[tab->item_selected];
|
|
if (!a)
|
|
return false;
|
|
|
|
if (action == ACTION_DESCRIBE)
|
|
{
|
|
app_show_message (xstrdup ("Configuration name: "),
|
|
xstrdup (action_name (a)));
|
|
return true;
|
|
}
|
|
if (action != ACTION_CHOOSE || a == ACTION_CHOOSE /* avoid recursion */)
|
|
return false;
|
|
|
|
// XXX: We can't propagate failure to ring the terminal/X11 bell, but we
|
|
// don't want to let our caller show a bad "can't do that" message either.
|
|
return app_process_action (a), true;
|
|
}
|
|
|
|
static void
|
|
help_tab_assign_action (enum action action)
|
|
{
|
|
hard_assert (g_help_tab.lines.len > g_help_tab.actions_len);
|
|
|
|
size_t to_push = g_help_tab.lines.len - g_help_tab.actions_len;
|
|
ARRAY_RESERVE (g_help_tab.actions, to_push);
|
|
for (size_t i = 1; i < to_push; i++)
|
|
g_help_tab.actions[g_help_tab.actions_len++] = ACTION_NONE;
|
|
g_help_tab.actions[g_help_tab.actions_len++] = action;
|
|
}
|
|
|
|
static void
|
|
help_tab_group (struct binding *keys, size_t len, struct strv *out,
|
|
bool bound[], size_t action_count)
|
|
{
|
|
for (enum action i = 0; i < action_count; i++)
|
|
{
|
|
struct strv ass = strv_make ();
|
|
for (size_t k = 0; k < len; k++)
|
|
if (keys[k].action == i)
|
|
strv_append_owned (&ass, app_strfkey (&keys[k].decoded));
|
|
if (ass.len)
|
|
{
|
|
char *joined = strv_join (&ass, ", ");
|
|
strv_append_owned (out, xstrdup_printf
|
|
(" %s%c%s", action_description (i), 0, joined));
|
|
free (joined);
|
|
|
|
bound[i] = true;
|
|
help_tab_assign_action (i);
|
|
}
|
|
strv_free (&ass);
|
|
}
|
|
}
|
|
|
|
static void
|
|
help_tab_unbound (struct strv *out, bool bound[], size_t action_count)
|
|
{
|
|
for (enum action i = 0; i < action_count; i++)
|
|
if (!bound[i])
|
|
{
|
|
strv_append_owned (out,
|
|
xstrdup_printf (" %s%c", action_description (i), 0));
|
|
help_tab_assign_action (i);
|
|
}
|
|
}
|
|
|
|
static struct layout
|
|
help_tab_on_item_layout (size_t item_index)
|
|
{
|
|
hard_assert (item_index < g_help_tab.lines.len);
|
|
const char *line = g_help_tab.lines.vector[item_index];
|
|
|
|
struct layout l = {};
|
|
app_push_fill (&l, g.ui->label (*line == ' ' ? 0 : A_BOLD, line));
|
|
|
|
const char *definition = strchr (line, 0) + 1;
|
|
if (*line == ' ' && *definition)
|
|
{
|
|
app_push (&l, g.ui->padding (0, 0.5, 1));
|
|
app_push_fill (&l, g.ui->label (0, definition));
|
|
}
|
|
return l;
|
|
}
|
|
|
|
static struct tab *
|
|
help_tab_init (void)
|
|
{
|
|
ARRAY_INIT (g_help_tab.actions);
|
|
struct strv *lines = &g_help_tab.lines;
|
|
*lines = strv_make ();
|
|
|
|
size_t bound_len = ACTION_USER_0 + g.action_names.len;
|
|
bool *bound = xcalloc (bound_len, sizeof *bound);
|
|
bound[ACTION_NONE] = true;
|
|
|
|
strv_append (lines, "Normal mode actions");
|
|
help_tab_group (g_normal_keys, g_normal_keys_len, lines, bound, bound_len);
|
|
strv_append (lines, "");
|
|
|
|
strv_append (lines, "Editor mode actions");
|
|
help_tab_group (g_editor_keys, g_editor_keys_len, lines, bound, bound_len);
|
|
strv_append (lines, "");
|
|
|
|
bool have_unbound = false;
|
|
for (size_t i = 0; i < bound_len; i++)
|
|
if (!bound[i])
|
|
have_unbound = true;
|
|
|
|
if (have_unbound)
|
|
{
|
|
strv_append (lines, "Unbound actions");
|
|
help_tab_unbound (lines, bound, bound_len);
|
|
strv_append (lines, "");
|
|
}
|
|
free (bound);
|
|
|
|
struct tab *super = &g_help_tab.super;
|
|
tab_init (super, "Help");
|
|
super->on_action = help_tab_on_action;
|
|
super->on_item_layout = help_tab_on_item_layout;
|
|
super->item_count = lines->len;
|
|
return super;
|
|
}
|
|
|
|
// --- Debug tab ---------------------------------------------------------------
|
|
|
|
struct debug_item
|
|
{
|
|
char *text; ///< Logged line
|
|
int64_t timestamp; ///< Timestamp
|
|
chtype attrs; ///< Line attributes
|
|
};
|
|
|
|
static struct
|
|
{
|
|
struct tab super; ///< Parent class
|
|
ARRAY (struct debug_item, items) ///< Items
|
|
bool active; ///< The tab is present
|
|
}
|
|
g_debug_tab;
|
|
|
|
static struct layout
|
|
debug_tab_on_item_layout (size_t item_index)
|
|
{
|
|
hard_assert (item_index < g_debug_tab.items_len);
|
|
struct debug_item *item = &g_debug_tab.items[item_index];
|
|
|
|
char buf[16];
|
|
struct tm tm;
|
|
time_t when = item->timestamp / 1000;
|
|
strftime (buf, sizeof buf, "%T", localtime_r (&when, &tm));
|
|
|
|
char *prefix = xstrdup_printf
|
|
("%s.%03d", buf, (int) (item->timestamp % 1000));
|
|
|
|
struct layout l = {};
|
|
app_push (&l, g.ui->label (0, prefix));
|
|
app_push (&l, g.ui->padding (item->attrs, 0.5, 1));
|
|
app_push_fill (&l, g.ui->label (item->attrs, item->text));
|
|
free (prefix);
|
|
return l;
|
|
}
|
|
|
|
static void
|
|
debug_tab_push (char *message, chtype attrs)
|
|
{
|
|
ARRAY_RESERVE (g_debug_tab.items, 1);
|
|
struct debug_item *item = &g_debug_tab.items[g_debug_tab.items_len++];
|
|
g_debug_tab.super.item_count = g_debug_tab.items_len;
|
|
item->text = message;
|
|
item->attrs = attrs;
|
|
item->timestamp = clock_msec (CLOCK_REALTIME);
|
|
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static struct tab *
|
|
debug_tab_init (void)
|
|
{
|
|
ARRAY_INIT (g_debug_tab.items);
|
|
g_debug_tab.active = true;
|
|
|
|
struct tab *super = &g_debug_tab.super;
|
|
tab_init (super, "Debug");
|
|
super->on_item_layout = debug_tab_on_item_layout;
|
|
return super;
|
|
}
|
|
|
|
// --- Spectrum analyser -------------------------------------------------------
|
|
|
|
#ifdef WITH_FFTW
|
|
|
|
static void
|
|
spectrum_redraw (void)
|
|
{
|
|
// A full refresh would be too computationally expensive,
|
|
// let's hack around it in this case
|
|
struct widget *spectrum = app_widget_by_id (WIDGET_SPECTRUM);
|
|
if (spectrum)
|
|
spectrum->on_render (spectrum);
|
|
|
|
poller_idle_set (&g_xui.flip_event);
|
|
}
|
|
|
|
// When any problem occurs with the FIFO, we'll just give up on it completely
|
|
static void
|
|
spectrum_discard_fifo (void)
|
|
{
|
|
if (g.spectrum_fd != -1)
|
|
{
|
|
poller_fd_reset (&g.spectrum_event);
|
|
xclose (g.spectrum_fd);
|
|
g.spectrum_fd = -1;
|
|
|
|
spectrum_free (&g.spectrum);
|
|
xui_invalidate ();
|
|
}
|
|
}
|
|
|
|
static void
|
|
spectrum_on_fifo_readable (const struct pollfd *pfd, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
struct spectrum *s = &g.spectrum;
|
|
|
|
bool update = false;
|
|
ssize_t n;
|
|
restart:
|
|
while ((n = read (pfd->fd,
|
|
s->buffer + s->buffer_len, s->buffer_size - s->buffer_len)) > 0)
|
|
if ((s->buffer_len += n) == s->buffer_size)
|
|
{
|
|
update = true;
|
|
spectrum_sample (s);
|
|
s->buffer_len = 0;
|
|
}
|
|
|
|
if (!n)
|
|
spectrum_discard_fifo ();
|
|
else if (errno == EINTR)
|
|
goto restart;
|
|
else if (errno != EAGAIN)
|
|
{
|
|
print_error ("spectrum: %s", strerror (errno));
|
|
spectrum_discard_fifo ();
|
|
}
|
|
else if (update)
|
|
spectrum_redraw ();
|
|
}
|
|
|
|
// When playback is stopped, we need to feed the analyser some zeroes ourselves.
|
|
// We could also just hide it. Hard to say which is simpler or better.
|
|
static void
|
|
spectrum_clear (void)
|
|
{
|
|
if (g.spectrum_fd != -1)
|
|
{
|
|
struct spectrum *s = &g.spectrum;
|
|
memset (s->buffer, 0, s->buffer_size);
|
|
spectrum_sample (s);
|
|
spectrum_sample (s);
|
|
s->buffer_len = 0;
|
|
|
|
spectrum_redraw ();
|
|
}
|
|
}
|
|
|
|
static void
|
|
spectrum_setup_fifo (void)
|
|
{
|
|
const char *spectrum_path =
|
|
get_config_string (g.config.root, "settings.spectrum_path");
|
|
const char *spectrum_format =
|
|
get_config_string (g.config.root, "settings.spectrum_format");
|
|
struct config_item *spectrum_bars =
|
|
config_item_get (g.config.root, "settings.spectrum_bars", NULL);
|
|
struct config_item *spectrum_fps =
|
|
config_item_get (g.config.root, "settings.spectrum_fps", NULL);
|
|
if (!spectrum_path)
|
|
return;
|
|
|
|
struct error *e = NULL;
|
|
char *path = resolve_filename
|
|
(spectrum_path, resolve_relative_config_filename);
|
|
|
|
if (!path)
|
|
print_error ("spectrum: %s", "FIFO path could not be resolved");
|
|
else if (!g_xui.locale_is_utf8)
|
|
print_error ("spectrum: %s", "UTF-8 locale required");
|
|
else if (!spectrum_init (&g.spectrum, (char *) spectrum_format,
|
|
spectrum_bars->value.integer, spectrum_fps->value.integer, &e))
|
|
{
|
|
print_error ("spectrum: %s", e->message);
|
|
error_free (e);
|
|
}
|
|
else if ((g.spectrum_fd = open (path, O_RDONLY | O_NONBLOCK)) == -1)
|
|
{
|
|
print_error ("spectrum: %s: %s", path, strerror (errno));
|
|
spectrum_free (&g.spectrum);
|
|
}
|
|
else
|
|
{
|
|
g.spectrum_event = poller_fd_make (&g.poller, g.spectrum_fd);
|
|
g.spectrum_event.dispatcher = spectrum_on_fifo_readable;
|
|
poller_fd_set (&g.spectrum_event, POLLIN);
|
|
}
|
|
|
|
free (path);
|
|
}
|
|
|
|
#else // ! WITH_FFTW
|
|
#define spectrum_setup_fifo() BLOCK_START BLOCK_END
|
|
#define spectrum_clear() BLOCK_START BLOCK_END
|
|
#define spectrum_discard_fifo() BLOCK_START BLOCK_END
|
|
#endif // ! WITH_FFTW
|
|
|
|
// --- PulseAudio --------------------------------------------------------------
|
|
|
|
#ifdef WITH_PULSE
|
|
|
|
static bool
|
|
mpd_find_output (const struct strv *data, const char *wanted)
|
|
{
|
|
// The plugin field is new in MPD 0.21, by default take any output
|
|
unsigned long n, accept = 1;
|
|
for (size_t i = data->len; i--; )
|
|
{
|
|
char *key, *value;
|
|
if (!(key = mpd_parse_kv (data->vector[i], &value)))
|
|
continue;
|
|
|
|
if (!strcasecmp_ascii (key, "outputid"))
|
|
{
|
|
if (accept)
|
|
return true;
|
|
|
|
accept = 1;
|
|
}
|
|
else if (!strcasecmp_ascii (key, "plugin"))
|
|
accept &= !strcmp (value, wanted);
|
|
else if (!strcasecmp_ascii (key, "outputenabled")
|
|
&& xstrtoul (&n, value, 10))
|
|
accept &= n == 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static void
|
|
mpd_on_outputs_response (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
// TODO: check whether an action is actually necessary
|
|
pulse_free (&g.pulse);
|
|
if (response->success && !mpd_find_output (data, "pulse"))
|
|
print_debug ("MPD has no PulseAudio output to control");
|
|
else
|
|
{
|
|
pulse_init (&g.pulse, &g.poller);
|
|
g.pulse.on_update = xui_invalidate;
|
|
}
|
|
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static void
|
|
pulse_update (void)
|
|
{
|
|
struct mpd_client *c = &g.client;
|
|
if (!g.pulse_control_requested)
|
|
return;
|
|
|
|
// The read permission is sufficient for this command
|
|
mpd_client_send_command (c, "outputs", NULL);
|
|
mpd_client_add_task (c, mpd_on_outputs_response, NULL);
|
|
mpd_client_idle (c, 0);
|
|
}
|
|
|
|
static void
|
|
pulse_disable (void)
|
|
{
|
|
pulse_free (&g.pulse);
|
|
xui_invalidate ();
|
|
}
|
|
|
|
#else // ! WITH_PULSE
|
|
#define pulse_update() BLOCK_START BLOCK_END
|
|
#define pulse_disable() BLOCK_START BLOCK_END
|
|
#endif // ! WITH_PULSE
|
|
|
|
// --- MPD interface -----------------------------------------------------------
|
|
|
|
static void
|
|
mpd_update_playlist_time (void)
|
|
{
|
|
g.playlist_time = 0;
|
|
|
|
// It would also be possible to retrieve this from "stats" -> "playtime"
|
|
unsigned long n;
|
|
for (size_t i = 0; i < g.playlist.len; i++)
|
|
{
|
|
compact_map_t map = item_list_get (&g.playlist, i);
|
|
const char *time = compact_map_find (map, "time");
|
|
if (time && xstrtoul (&n, time, 10))
|
|
g.playlist_time += n;
|
|
}
|
|
}
|
|
|
|
static void
|
|
mpd_set_elapsed_timer (int msec_past_second)
|
|
{
|
|
int delay_msec = 1000 - msec_past_second; // Until the next round second
|
|
if (!g.elapsed_poll)
|
|
{
|
|
poller_timer_set (&g.elapsed_event, delay_msec);
|
|
// Remember when the last round second was, relative to monotonic time
|
|
g.elapsed_since = clock_msec (CLOCK_BEST) - msec_past_second;
|
|
return;
|
|
}
|
|
|
|
// We may receive an earlier time, this seems to compensate for it well
|
|
// (I haven't seen it trigger more than 50ms too early)
|
|
delay_msec += 100;
|
|
|
|
// When playback stalls, avoid busy looping with the server
|
|
int elapsed_msec = g.song_elapsed * 1000 + msec_past_second;
|
|
if (elapsed_msec == g.elapsed_since)
|
|
delay_msec = MAX (delay_msec, 500);
|
|
|
|
// In polling mode, we're interested in progress rather than stability.
|
|
// We can reuse both the poller_timer struct and the timestamp field.
|
|
poller_timer_set (&g.elapsed_event, delay_msec);
|
|
g.elapsed_since = elapsed_msec;
|
|
}
|
|
|
|
static void
|
|
mpd_update_playback_state (void)
|
|
{
|
|
struct str_map *map = &g.playback_info;
|
|
g.song_elapsed = g.song_duration = g.volume = g.song = -1;
|
|
uint32_t last_playlist_version = g.playlist_version;
|
|
g.playlist_version = 0;
|
|
|
|
const char *state;
|
|
g.state = PLAYER_STOPPED;
|
|
if ((state = str_map_find (map, "state")))
|
|
{
|
|
if (!strcmp (state, "play")) g.state = PLAYER_PLAYING;
|
|
if (!strcmp (state, "pause")) g.state = PLAYER_PAUSED;
|
|
}
|
|
if (g.state == PLAYER_STOPPED)
|
|
{
|
|
spectrum_clear ();
|
|
}
|
|
|
|
// Values in "time" are always rounded. "elapsed", introduced in MPD 0.16,
|
|
// is in millisecond precision and "duration" as well, starting with 0.20.
|
|
// Prefer the more precise values but use what we have.
|
|
const char *time = str_map_find (map, "time");
|
|
const char *elapsed = str_map_find (map, "elapsed");
|
|
const char *duration = str_map_find (map, "duration");
|
|
|
|
struct strv fields = strv_make ();
|
|
if (time)
|
|
{
|
|
cstr_split (time, ":", false, &fields);
|
|
if (fields.len >= 1 && !elapsed) elapsed = fields.vector[0];
|
|
if (fields.len >= 2 && !duration) duration = fields.vector[1];
|
|
}
|
|
|
|
int msec_past_second = 0;
|
|
mpd_read_time (elapsed, &g.song_elapsed, &msec_past_second);
|
|
mpd_read_time (duration, &g.song_duration, NULL);
|
|
strv_free (&fields);
|
|
|
|
poller_timer_reset (&g.elapsed_event);
|
|
if (g.state == PLAYER_PLAYING)
|
|
mpd_set_elapsed_timer (msec_past_second);
|
|
else
|
|
g.elapsed_since = -1;
|
|
|
|
// The server sends -1 when nothing is being played right now
|
|
unsigned long n;
|
|
if (xstrtoul_map (map, "volume", &n)) g.volume = n;
|
|
|
|
if (xstrtoul_map (map, "playlist", &n)) g.playlist_version = n;
|
|
if (xstrtoul_map (map, "song", &n)) g.song = n;
|
|
|
|
if (g.playlist_version != last_playlist_version)
|
|
mpd_update_playlist_time ();
|
|
|
|
xui_invalidate ();
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
mpd_process_info_data (const struct strv *data)
|
|
{
|
|
struct str_map *map = &g.playback_info;
|
|
|
|
// First there's the status, followed by playlist items chunked by "file"
|
|
unsigned long n; char *key, *value;
|
|
for (size_t i = 0; i < data->len - 1 && data->vector[i]; i++)
|
|
{
|
|
if (!(key = mpd_parse_kv (data->vector[i], &value)))
|
|
continue;
|
|
if (!strcasecmp_ascii (key, "playlistlength")
|
|
&& xstrtoul (&n, value, 10))
|
|
item_list_resize (&g.playlist, n);
|
|
str_map_set (map, key, xstrdup (value));
|
|
}
|
|
|
|
// It's much better to process the playlist from the back
|
|
struct str_map item = str_map_make (NULL);
|
|
item.key_xfrm = tolower_ascii_strxfrm;
|
|
for (size_t i = data->len - 1; i-- && data->vector[i]; )
|
|
{
|
|
if (!(key = mpd_parse_kv (data->vector[i], &value)))
|
|
continue;
|
|
str_map_set (&item, key, value);
|
|
if (!strcasecmp_ascii (key, "file"))
|
|
{
|
|
if (xstrtoul_map (&item, "pos", &n))
|
|
item_list_set (&g.playlist, n, &item);
|
|
str_map_clear (&item);
|
|
}
|
|
}
|
|
str_map_free (&item);
|
|
}
|
|
|
|
/// Find a song by its id in the current playlist. Expensive, rarely called.
|
|
static ssize_t
|
|
mpd_find_pos_of_id (const char *desired_id)
|
|
{
|
|
compact_map_t map;
|
|
const char *id;
|
|
for (size_t i = 0; i < g.playlist.len; i++)
|
|
{
|
|
if ((map = item_list_get (&g.playlist, i))
|
|
&& (id = compact_map_find (map, "id"))
|
|
&& !strcmp (id, desired_id))
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
static const char *
|
|
mpd_id_of_pos (int pos)
|
|
{
|
|
compact_map_t map = item_list_get (&g.playlist, pos);
|
|
return map ? compact_map_find (map, "id") : NULL;
|
|
}
|
|
|
|
static void
|
|
mpd_process_info (const struct strv *data)
|
|
{
|
|
struct tab *tab = &g_current_tab;
|
|
char *prev_sel_id = xstrdup0 (mpd_id_of_pos (tab->item_selected));
|
|
char *prev_mark_id = xstrdup0 (mpd_id_of_pos (tab->item_mark));
|
|
char *fallback_id = NULL;
|
|
|
|
struct tab_range r = tab_selection_range (g.active_tab);
|
|
if (r.upto >= 0)
|
|
{
|
|
if (!(fallback_id = xstrdup0 (mpd_id_of_pos (r.upto + 1))))
|
|
fallback_id = xstrdup0 (mpd_id_of_pos (r.from - 1));
|
|
}
|
|
|
|
mpd_process_info_data (data);
|
|
|
|
const char *sel_id = mpd_id_of_pos (tab->item_selected);
|
|
const char *mark_id = mpd_id_of_pos (tab->item_mark);
|
|
|
|
if (prev_mark_id && (!mark_id || strcmp (prev_mark_id, mark_id)))
|
|
tab->item_mark = mpd_find_pos_of_id (prev_mark_id);
|
|
if (prev_sel_id && (!sel_id || strcmp (prev_sel_id, sel_id)))
|
|
{
|
|
if ((tab->item_selected = mpd_find_pos_of_id (prev_sel_id)) < 0)
|
|
{
|
|
tab->item_mark = -1;
|
|
if (fallback_id)
|
|
tab->item_selected = mpd_find_pos_of_id (fallback_id);
|
|
}
|
|
app_move_selection (0);
|
|
}
|
|
|
|
free (prev_sel_id);
|
|
free (prev_mark_id);
|
|
free (fallback_id);
|
|
}
|
|
|
|
static void
|
|
mpd_on_info_response (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
// TODO: preset an error player state?
|
|
str_map_clear (&g.playback_info);
|
|
if (!response->success)
|
|
print_error ("%s: %s",
|
|
"MPD status retrieval failed", response->message_text);
|
|
else if (!data->len)
|
|
print_debug ("empty MPD status response");
|
|
else
|
|
mpd_process_info (data);
|
|
|
|
mpd_update_playback_state ();
|
|
current_tab_update ();
|
|
info_tab_update ();
|
|
}
|
|
|
|
static void
|
|
mpd_on_elapsed_time_tick (void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
// Compute how much time has elapsed since the last round second
|
|
int64_t diff_msec = clock_msec (CLOCK_BEST) - g.elapsed_since;
|
|
int elapsed_sec = diff_msec / 1000;
|
|
int elapsed_msec = diff_msec % 1000;
|
|
|
|
g.song_elapsed += elapsed_sec;
|
|
g.elapsed_since += elapsed_sec * 1000;
|
|
|
|
// Try to get called on the next round second of playback
|
|
poller_timer_set (&g.elapsed_event, 1000 - elapsed_msec);
|
|
|
|
xui_invalidate ();
|
|
}
|
|
|
|
static void
|
|
mpd_request_info (void)
|
|
{
|
|
struct mpd_client *c = &g.client;
|
|
|
|
mpd_client_list_ok_begin (c);
|
|
mpd_client_send_command (c, "status", NULL);
|
|
char *last_version = xstrdup_printf ("%" PRIu32, g.playlist_version);
|
|
mpd_client_send_command (c, "plchanges", last_version, NULL);
|
|
free (last_version);
|
|
mpd_client_list_end (c);
|
|
mpd_client_add_task (c, mpd_on_info_response, NULL);
|
|
mpd_client_idle (c, 0);
|
|
}
|
|
|
|
static void
|
|
mpd_on_elapsed_time_tick_poll (void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
// As soon as the reply arrives, we (may) set the timer again
|
|
mpd_request_info ();
|
|
}
|
|
|
|
static void
|
|
mpd_on_events (unsigned subsystems, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
struct mpd_client *c = &g.client;
|
|
|
|
if (subsystems & MPD_SUBSYSTEM_DATABASE)
|
|
library_tab_reload (NULL);
|
|
if (subsystems & MPD_SUBSYSTEM_OUTPUT)
|
|
pulse_update ();
|
|
|
|
if (subsystems & (MPD_SUBSYSTEM_PLAYER | MPD_SUBSYSTEM_OPTIONS
|
|
| MPD_SUBSYSTEM_PLAYLIST | MPD_SUBSYSTEM_MIXER | MPD_SUBSYSTEM_UPDATE))
|
|
mpd_request_info ();
|
|
else
|
|
mpd_client_idle (c, 0);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static void
|
|
mpd_queue_reconnect (void)
|
|
{
|
|
poller_timer_set (&g.connect_event, 5 * 1000);
|
|
}
|
|
|
|
// On an error, MPD discards the rest of our enqueuing commands--work it around
|
|
static void mpd_enqueue_step (size_t start_offset);
|
|
|
|
static void
|
|
mpd_on_enqueue_response (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
(void) data;
|
|
intptr_t start_offset = (intptr_t) user_data;
|
|
|
|
if (response->success)
|
|
strv_reset (&g.enqueue);
|
|
else
|
|
{
|
|
// Their addition may also overflow, but YOLO
|
|
hard_assert (start_offset >= 0 && response->list_offset >= 0);
|
|
|
|
print_error ("%s: %s", response->message_text,
|
|
g.enqueue.vector[start_offset + response->list_offset]);
|
|
mpd_enqueue_step (start_offset + response->list_offset + 1);
|
|
}
|
|
}
|
|
|
|
static void
|
|
mpd_enqueue_step (size_t start_offset)
|
|
{
|
|
struct mpd_client *c = &g.client;
|
|
if (start_offset >= g.enqueue.len)
|
|
{
|
|
strv_reset (&g.enqueue);
|
|
return;
|
|
}
|
|
|
|
// TODO: might want to consider using addid and autoplaying
|
|
mpd_client_list_begin (c);
|
|
for (size_t i = start_offset; i < g.enqueue.len; i++)
|
|
mpd_client_send_command (c, "add", g.enqueue.vector[i], NULL);
|
|
mpd_client_list_end (c);
|
|
mpd_client_add_task (c, mpd_on_enqueue_response, (void *) start_offset);
|
|
mpd_client_idle (c, 0);
|
|
}
|
|
|
|
static void
|
|
mpd_on_ready (void)
|
|
{
|
|
mpd_request_info ();
|
|
library_tab_reload (NULL);
|
|
spectrum_setup_fifo ();
|
|
pulse_update ();
|
|
mpd_enqueue_step (0);
|
|
}
|
|
|
|
static void
|
|
mpd_on_password_response (const struct mpd_response *response,
|
|
const struct strv *data, void *user_data)
|
|
{
|
|
(void) data;
|
|
(void) user_data;
|
|
struct mpd_client *c = &g.client;
|
|
|
|
if (response->success)
|
|
mpd_on_ready ();
|
|
else
|
|
{
|
|
print_error ("%s: %s",
|
|
"MPD authentication failed", response->message_text);
|
|
mpd_client_send_command (c, "close", NULL);
|
|
}
|
|
}
|
|
|
|
static void
|
|
mpd_on_connected (void *user_data)
|
|
{
|
|
(void) user_data;
|
|
struct mpd_client *c = &g.client;
|
|
|
|
const char *password =
|
|
get_config_string (g.config.root, "settings.password");
|
|
if (password)
|
|
{
|
|
mpd_client_send_command (c, "password", password, NULL);
|
|
mpd_client_add_task (c, mpd_on_password_response, NULL);
|
|
}
|
|
else
|
|
mpd_on_ready ();
|
|
}
|
|
|
|
static void
|
|
mpd_on_failure (void *user_data)
|
|
{
|
|
(void) user_data;
|
|
// This is also triggered both by a failed connect and a clean disconnect
|
|
print_debug ("connection to MPD failed");
|
|
mpd_queue_reconnect ();
|
|
|
|
str_map_clear (&g.playback_info);
|
|
item_list_resize (&g.playlist, 0);
|
|
|
|
mpd_update_playback_state ();
|
|
current_tab_update ();
|
|
info_tab_update ();
|
|
|
|
spectrum_discard_fifo ();
|
|
pulse_disable ();
|
|
}
|
|
|
|
static void
|
|
mpd_on_io_hook (void *user_data, bool outgoing, const char *line)
|
|
{
|
|
(void) user_data;
|
|
if (outgoing)
|
|
debug_tab_push (xstrdup_printf ("<< %s", line), APP_ATTR (OUTGOING));
|
|
else
|
|
debug_tab_push (xstrdup_printf (">> %s", line), APP_ATTR (INCOMING));
|
|
}
|
|
|
|
static void
|
|
app_on_reconnect (void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
struct mpd_client *c = &g.client;
|
|
c->on_failure = mpd_on_failure;
|
|
c->on_connected = mpd_on_connected;
|
|
c->on_event = mpd_on_events;
|
|
|
|
if (g_debug_mode)
|
|
c->on_io_hook = mpd_on_io_hook;
|
|
|
|
// We accept hostname/IPv4/IPv6 in pseudo-URL format, as well as sockets
|
|
char *address = xstrdup (get_config_string (g.config.root,
|
|
"settings.address")), *p = address, *host = address, *port = "6600";
|
|
|
|
// Unwrap IPv6 addresses in format_host_port_pair() format
|
|
char *right_bracket = strchr (p, ']');
|
|
if (p[0] == '[' && right_bracket)
|
|
{
|
|
*right_bracket = '\0';
|
|
host = p + 1;
|
|
p = right_bracket + 1;
|
|
}
|
|
|
|
char *colon = strchr (p, ':');
|
|
if (colon)
|
|
{
|
|
*colon = '\0';
|
|
port = colon + 1;
|
|
}
|
|
|
|
struct error *e = NULL;
|
|
if (!mpd_client_connect (c, host, port, &e))
|
|
{
|
|
print_error ("%s: %s", "cannot connect to MPD", e->message);
|
|
error_free (e);
|
|
mpd_queue_reconnect ();
|
|
}
|
|
free (address);
|
|
xui_invalidate ();
|
|
}
|
|
|
|
// --- TUI ---------------------------------------------------------------------
|
|
|
|
static struct widget *
|
|
tui_make_button (chtype attrs, const char *label, enum action a)
|
|
{
|
|
struct widget *w = tui_make_label (attrs, 0, label);
|
|
w->id = WIDGET_BUTTON;
|
|
w->userdata = a;
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
tui_render_gauge (struct widget *self)
|
|
{
|
|
struct row_buffer buf = row_buffer_make ();
|
|
if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1)
|
|
goto out;
|
|
|
|
float ratio = (float) g.song_elapsed / g.song_duration;
|
|
if (ratio < 0) ratio = 0;
|
|
if (ratio > 1) ratio = 1;
|
|
|
|
// Always compute it in exactly eight times the resolution,
|
|
// because sometimes Unicode is even useful
|
|
int len_left = ratio * self->width * 8 + 0.5;
|
|
|
|
static const char *partials[] = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉" };
|
|
int remainder = len_left % 8;
|
|
len_left /= 8;
|
|
|
|
const char *partial = NULL;
|
|
if (g.use_partial_boxes)
|
|
partial = partials[remainder];
|
|
else
|
|
len_left += remainder >= (int) 4;
|
|
|
|
int len_right = self->width - len_left;
|
|
row_buffer_space (&buf, len_left, APP_ATTR (ELAPSED));
|
|
if (partial && len_right-- > 0)
|
|
row_buffer_append (&buf, partial, APP_ATTR (REMAINS));
|
|
row_buffer_space (&buf, len_right, APP_ATTR (REMAINS));
|
|
|
|
out:
|
|
tui_flush_buffer (self, &buf);
|
|
}
|
|
|
|
// TODO: Perhaps it should save the number within.
|
|
static struct widget *
|
|
tui_make_gauge (chtype attrs)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = tui_render_gauge;
|
|
w->attrs = attrs;
|
|
w->width = -1;
|
|
w->height = 1;
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
tui_render_spectrum (struct widget *self)
|
|
{
|
|
// Don't mess up the line editor caret, when it's shown
|
|
int last_x, last_y;
|
|
getyx (stdscr, last_y, last_x);
|
|
|
|
struct row_buffer buf = row_buffer_make ();
|
|
#ifdef WITH_FFTW
|
|
row_buffer_append (&buf, g.spectrum.rendered, self->attrs);
|
|
#endif // WITH_FFTW
|
|
tui_flush_buffer (self, &buf);
|
|
|
|
move (last_y, last_x);
|
|
}
|
|
|
|
static struct widget *
|
|
tui_make_spectrum (chtype attrs, int width)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = tui_render_spectrum;
|
|
w->attrs = attrs;
|
|
w->width = width;
|
|
w->height = 1;
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
tui_render_scrollbar (struct widget *self)
|
|
{
|
|
// This assumes that we can write to the one-before-last column,
|
|
// i.e. that it's not covered by any double-wide character (and that
|
|
// ncurses comes to the right results when counting characters).
|
|
struct tab *tab = g.active_tab;
|
|
int visible_items = app_visible_items ();
|
|
|
|
hard_assert (tab->item_count != 0);
|
|
if (!g.use_partial_boxes)
|
|
{
|
|
struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 1);
|
|
for (int row = 0; row < visible_items; row++)
|
|
{
|
|
move (self->y + row, self->x);
|
|
if (row < bar.start || row >= bar.start + bar.length)
|
|
addch (' ' | self->attrs);
|
|
else
|
|
addch (' ' | self->attrs | A_REVERSE);
|
|
}
|
|
return;
|
|
}
|
|
|
|
struct scrollbar bar = app_compute_scrollbar (tab, visible_items * 8, 8);
|
|
bar.length += bar.start;
|
|
|
|
int start_part = bar.start % 8; bar.start /= 8;
|
|
int end_part = bar.length % 8; bar.length /= 8;
|
|
|
|
// Even with this, the solid part must be at least one character high
|
|
static const char *partials[] = { "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁" };
|
|
|
|
for (int row = 0; row < visible_items; row++)
|
|
{
|
|
chtype attrs = self->attrs;
|
|
if (row > bar.start && row <= bar.length)
|
|
attrs ^= A_REVERSE;
|
|
|
|
const char *c = " ";
|
|
if (row == bar.start) c = partials[start_part];
|
|
if (row == bar.length) c = partials[end_part];
|
|
|
|
move (self->y + row, self->x);
|
|
|
|
struct row_buffer buf = row_buffer_make ();
|
|
row_buffer_append (&buf, c, attrs);
|
|
row_buffer_flush (&buf);
|
|
row_buffer_free (&buf);
|
|
}
|
|
}
|
|
|
|
static struct widget *
|
|
tui_make_scrollbar (chtype attrs)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = tui_render_scrollbar;
|
|
w->attrs = attrs;
|
|
w->width = 1;
|
|
return w;
|
|
}
|
|
|
|
static struct widget *
|
|
tui_make_list (void)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->width = -1;
|
|
w->height = g.active_tab->item_count;
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
tui_render_editor (struct widget *self)
|
|
{
|
|
struct row_buffer buf = row_buffer_make ();
|
|
const struct line_editor *e = &g.editor;
|
|
int width = self->width;
|
|
if (e->prompt)
|
|
{
|
|
hard_assert (e->prompt < 127);
|
|
row_buffer_append_c (&buf, e->prompt, self->attrs);
|
|
width--;
|
|
}
|
|
|
|
int following = 0;
|
|
for (size_t i = e->point; i < e->len; i++)
|
|
following += e->w[i];
|
|
|
|
int preceding = 0;
|
|
size_t start = e->point;
|
|
while (start && preceding < width / 2)
|
|
preceding += e->w[--start];
|
|
|
|
// There can be one extra space at the end of the line but this way we
|
|
// don't need to care about non-spacing marks following full-width chars
|
|
while (start && width - preceding - following > 2 /* widest char */)
|
|
preceding += e->w[--start];
|
|
|
|
// XXX: we should also show < > indicators for overflow but it'd probably
|
|
// considerably complicate this algorithm
|
|
for (; start < e->len; start++)
|
|
row_buffer_append_c (&buf, e->line[start], self->attrs);
|
|
tui_flush_buffer (self, &buf);
|
|
|
|
// FIXME: This should be at the end of of tui_render().
|
|
int caret = !!e->prompt + preceding;
|
|
move (self->y, self->x + caret);
|
|
curs_set (1);
|
|
}
|
|
|
|
static struct widget *
|
|
tui_make_editor (chtype attrs)
|
|
{
|
|
// TODO: This should ideally measure the text, and copy it to w->text.
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = tui_render_editor;
|
|
w->attrs = attrs;
|
|
w->width = -1;
|
|
w->height = 1;
|
|
return w;
|
|
}
|
|
|
|
static struct app_ui app_tui_ui =
|
|
{
|
|
.padding = tui_make_padding,
|
|
.label = app_make_label,
|
|
.button = tui_make_button,
|
|
.gauge = tui_make_gauge,
|
|
.spectrum = tui_make_spectrum,
|
|
.scrollbar = tui_make_scrollbar,
|
|
.list = tui_make_list,
|
|
.editor = tui_make_editor,
|
|
};
|
|
|
|
// --- X11 ---------------------------------------------------------------------
|
|
|
|
#ifdef WITH_X11
|
|
|
|
// On a 20x20 raster to make it feasible to design on paper.
|
|
#define X11_STOP {INFINITY, INFINITY}
|
|
static const XPointDouble
|
|
x11_icon_previous[] =
|
|
{
|
|
{10, 0}, {0, 10}, {10, 20}, X11_STOP,
|
|
{20, 0}, {10, 10}, {20, 20}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_pause[] =
|
|
{
|
|
{1, 0}, {7, 0}, {7, 20}, {1, 20}, X11_STOP,
|
|
{13, 0}, {19, 0}, {19, 20}, {13, 20}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_play[] =
|
|
{
|
|
{0, 0}, {20, 10}, {0, 20}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_stop[] =
|
|
{
|
|
{0, 0}, {20, 0}, {20, 20}, {0, 20}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_next[] =
|
|
{
|
|
{0, 0}, {10, 10}, {0, 20}, X11_STOP,
|
|
{10, 0}, {20, 10}, {10, 20}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_repeat[] =
|
|
{
|
|
{0, 12}, {0, 6}, {3, 3}, {13, 3}, {13, 0}, {20, 4.5},
|
|
{13, 9}, {13, 6}, {3, 6}, {3, 10}, X11_STOP,
|
|
{0, 15.5}, {7, 11}, {7, 14}, {17, 14}, {17, 10}, {20, 8},
|
|
{20, 14}, {17, 17}, {7, 17}, {7, 20}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_random[] =
|
|
{
|
|
{0, 6}, {0, 3}, {5, 3}, {6, 4.5}, {4, 7.5}, {3, 6}, X11_STOP,
|
|
{9, 15.5}, {11, 12.5}, {12, 14}, {13, 14}, {13, 11}, {20, 15.5},
|
|
{13, 20}, {13, 17}, {10, 17}, X11_STOP,
|
|
{0, 17}, {0, 14}, {3, 14}, {10, 3}, {13, 3}, {13, 0}, {20, 4.5},
|
|
{13, 9}, {13, 6}, {12, 6}, {5, 17}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_single[] =
|
|
{
|
|
{7, 6}, {7, 4}, {9, 2}, {12, 2}, {12, 15}, {14, 15}, {14, 18},
|
|
{7, 18}, {7, 15}, {9, 15}, {9, 6}, X11_STOP, X11_STOP,
|
|
},
|
|
x11_icon_consume[] =
|
|
{
|
|
{0, 13}, {0, 7}, {4, 3}, {10, 3}, {14, 7}, {5, 10}, {14, 13},
|
|
{10, 17}, {4, 17}, X11_STOP,
|
|
{16, 12}, {16, 8}, {20, 8}, {20, 12}, X11_STOP, X11_STOP,
|
|
};
|
|
|
|
static const XPointDouble *
|
|
x11_icon_for_action (enum action action)
|
|
{
|
|
switch (action)
|
|
{
|
|
case ACTION_MPD_PREVIOUS:
|
|
return x11_icon_previous;
|
|
case ACTION_MPD_TOGGLE:
|
|
return g.state == PLAYER_PLAYING ? x11_icon_pause : x11_icon_play;
|
|
case ACTION_MPD_STOP:
|
|
return x11_icon_stop;
|
|
case ACTION_MPD_NEXT:
|
|
return x11_icon_next;
|
|
case ACTION_MPD_REPEAT:
|
|
return x11_icon_repeat;
|
|
case ACTION_MPD_RANDOM:
|
|
return x11_icon_random;
|
|
case ACTION_MPD_SINGLE:
|
|
return x11_icon_single;
|
|
case ACTION_MPD_CONSUME:
|
|
return x11_icon_consume;
|
|
default:
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
static void
|
|
x11_render_button (struct widget *self)
|
|
{
|
|
x11_render_padding (self);
|
|
|
|
const XPointDouble *icon = x11_icon_for_action (self->userdata);
|
|
if (!icon)
|
|
{
|
|
x11_render_label (self);
|
|
return;
|
|
}
|
|
|
|
size_t total = 0;
|
|
for (size_t i = 0; icon[i].x != INFINITY || icon[i - 1].x != INFINITY; i++)
|
|
total++;
|
|
|
|
// TODO: There should be an attribute for buttons, to handle this better.
|
|
XRenderColor color = *x11_fg (self);
|
|
if (!(self->attrs & A_BOLD))
|
|
{
|
|
color.alpha /= 2;
|
|
color.red /= 2;
|
|
color.green /= 2;
|
|
color.blue /= 2;
|
|
}
|
|
|
|
Picture source = XRenderCreateSolidFill (g_xui.dpy, &color);
|
|
const XRenderPictFormat *format
|
|
= XRenderFindStandardFormat (g_xui.dpy, PictStandardA8);
|
|
|
|
int x = self->x, y = self->y + (self->height - self->width) / 2;
|
|
XPointDouble buffer[total], *p = buffer;
|
|
for (size_t i = 0; i < total; i++)
|
|
if (icon[i].x != INFINITY)
|
|
{
|
|
p->x = x + icon[i].x / 20.0 * self->width;
|
|
p->y = y + icon[i].y / 20.0 * self->width;
|
|
p++;
|
|
}
|
|
else if (p != buffer)
|
|
{
|
|
XRenderCompositeDoublePoly (g_xui.dpy, PictOpOver,
|
|
source, g_xui.x11_pixmap_picture, format,
|
|
0, 0, 0, 0, buffer, p - buffer, EvenOddRule);
|
|
p = buffer;
|
|
}
|
|
XRenderFreePicture (g_xui.dpy, source);
|
|
}
|
|
|
|
static struct widget *
|
|
x11_make_button (chtype attrs, const char *label, enum action a)
|
|
{
|
|
struct widget *w = x11_make_label (attrs, 0, label);
|
|
w->id = WIDGET_BUTTON;
|
|
w->userdata = a;
|
|
|
|
if (x11_icon_for_action (a))
|
|
{
|
|
w->on_render = x11_render_button;
|
|
|
|
// It should be padded by the caller horizontally.
|
|
w->height = g_xui.vunit;
|
|
w->width = w->height * 3 / 4;
|
|
}
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
x11_render_gauge (struct widget *self)
|
|
{
|
|
x11_render_padding (self);
|
|
if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1)
|
|
return;
|
|
|
|
int part = (float) g.song_elapsed / g.song_duration * self->width;
|
|
XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
|
|
x11_bg_attrs (APP_ATTR (ELAPSED)),
|
|
self->x,
|
|
self->y + self->height / 8,
|
|
part,
|
|
self->height * 3 / 4);
|
|
XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
|
|
x11_bg_attrs (APP_ATTR (REMAINS)),
|
|
self->x + part,
|
|
self->y + self->height / 8,
|
|
self->width - part,
|
|
self->height * 3 / 4);
|
|
}
|
|
|
|
// TODO: Perhaps it should save the number within.
|
|
static struct widget *
|
|
x11_make_gauge (chtype attrs)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = x11_render_gauge;
|
|
w->attrs = attrs;
|
|
w->width = -1;
|
|
w->height = g_xui.vunit;
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
x11_render_spectrum (struct widget *self)
|
|
{
|
|
x11_render_padding (self);
|
|
|
|
#ifdef WITH_FFTW
|
|
XRectangle rectangles[g.spectrum.bars];
|
|
int step = self->width / N_ELEMENTS (rectangles);
|
|
for (int i = 0; i < g.spectrum.bars; i++)
|
|
{
|
|
int height = round ((self->height - 2) * g.spectrum.spectrum[i]);
|
|
rectangles[i] = (XRectangle)
|
|
{
|
|
self->x + i * step,
|
|
self->y + self->height - 1 - height,
|
|
step,
|
|
height,
|
|
};
|
|
}
|
|
|
|
XRenderFillRectangles (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
|
|
x11_fg (self), rectangles, N_ELEMENTS (rectangles));
|
|
#endif // WITH_FFTW
|
|
|
|
// Enable the spectrum_redraw() hack.
|
|
XRectangle r = { self->x, self->y, self->width, self->height };
|
|
XUnionRectWithRegion (&r, g_xui.x11_clip, g_xui.x11_clip);
|
|
}
|
|
|
|
static struct widget *
|
|
x11_make_spectrum (chtype attrs, int width)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = x11_render_spectrum;
|
|
w->attrs = attrs;
|
|
w->width = width * g_xui.vunit / 2;
|
|
w->height = g_xui.vunit;
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
x11_render_scrollbar (struct widget *self)
|
|
{
|
|
x11_render_padding (self);
|
|
|
|
struct tab *tab = g.active_tab;
|
|
struct scrollbar bar =
|
|
app_compute_scrollbar (tab, app_visible_items_height (), g_xui.vunit);
|
|
|
|
XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
|
|
x11_fg_attrs (self->attrs),
|
|
self->x,
|
|
self->y + bar.start,
|
|
self->width,
|
|
bar.length);
|
|
}
|
|
|
|
static struct widget *
|
|
x11_make_scrollbar (chtype attrs)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = x11_render_scrollbar;
|
|
w->attrs = attrs;
|
|
w->width = g_xui.vunit / 2;
|
|
return w;
|
|
}
|
|
|
|
static struct widget *
|
|
x11_make_list (void)
|
|
{
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = x11_render_padding;
|
|
return w;
|
|
}
|
|
|
|
static void
|
|
x11_render_editor (struct widget *self)
|
|
{
|
|
x11_render_padding (self);
|
|
|
|
struct x11_font *font = x11_widget_font (self);
|
|
XftColor color = { .color = *x11_fg (self) };
|
|
|
|
// A simplistic adaptation of tui_render_editor() follows.
|
|
const struct line_editor *e = &g.editor;
|
|
int x = self->x;
|
|
if (e->prompt)
|
|
{
|
|
hard_assert (e->prompt < 127);
|
|
x += x11_font_draw (font, &color, x, self->y,
|
|
(char[2]) { e->prompt, 0 }) + g_xui.vunit / 4;
|
|
}
|
|
|
|
// TODO: Make this scroll around the caret, and fade like labels.
|
|
size_t len;
|
|
ucs4_t *buf = xcalloc (e->len + 1, sizeof *buf);
|
|
u32_cpy (buf, e->line, e->point);
|
|
char *a = (char *) u32_to_u8 (buf, u32_strlen (buf) + 1, NULL, &len);
|
|
u32_cpy (buf, e->line + e->point, e->len - e->point + 1);
|
|
char *b = (char *) u32_to_u8 (buf, u32_strlen (buf) + 1, NULL, &len);
|
|
free (buf);
|
|
|
|
x += x11_font_draw (font, &color, x, self->y, a);
|
|
int caret = x;
|
|
x += x11_font_draw (font, &color, x, self->y, b);
|
|
free (a);
|
|
free (b);
|
|
|
|
XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
|
|
&color.color, caret, self->y, 2, self->height);
|
|
}
|
|
|
|
static struct widget *
|
|
x11_make_editor (chtype attrs)
|
|
{
|
|
// TODO: This should ideally measure the text, and copy it to w->text.
|
|
struct widget *w = xcalloc (1, sizeof *w + 1);
|
|
w->on_render = x11_render_editor;
|
|
w->attrs = attrs;
|
|
w->width = -1;
|
|
w->height = g_xui.vunit;
|
|
return w;
|
|
}
|
|
|
|
static struct app_ui app_x11_ui =
|
|
{
|
|
.padding = x11_make_padding,
|
|
.label = app_make_label,
|
|
.button = x11_make_button,
|
|
.gauge = x11_make_gauge,
|
|
.spectrum = x11_make_spectrum,
|
|
.scrollbar = x11_make_scrollbar,
|
|
.list = x11_make_list,
|
|
.editor = x11_make_editor,
|
|
|
|
.have_icons = true,
|
|
};
|
|
|
|
#endif // WITH_X11
|
|
|
|
// --- Signals -----------------------------------------------------------------
|
|
|
|
static int g_signal_pipe[2]; ///< A pipe used to signal... signals
|
|
|
|
/// Program termination has been requested by a signal
|
|
static volatile sig_atomic_t g_termination_requested;
|
|
/// The window has changed in size
|
|
static volatile sig_atomic_t g_winch_received;
|
|
|
|
static void
|
|
signals_postpone_handling (char id)
|
|
{
|
|
int original_errno = errno;
|
|
if (write (g_signal_pipe[1], &id, 1) == -1)
|
|
soft_assert (errno == EAGAIN);
|
|
errno = original_errno;
|
|
}
|
|
|
|
static void
|
|
signals_superhandler (int signum)
|
|
{
|
|
switch (signum)
|
|
{
|
|
case SIGWINCH:
|
|
g_winch_received = true;
|
|
signals_postpone_handling ('w');
|
|
break;
|
|
case SIGINT:
|
|
case SIGTERM:
|
|
g_termination_requested = true;
|
|
signals_postpone_handling ('t');
|
|
break;
|
|
default:
|
|
hard_assert (!"unhandled signal");
|
|
}
|
|
}
|
|
|
|
static void
|
|
signals_setup_handlers (void)
|
|
{
|
|
if (pipe (g_signal_pipe) == -1)
|
|
exit_fatal ("%s: %s", "pipe", strerror (errno));
|
|
|
|
set_cloexec (g_signal_pipe[0]);
|
|
set_cloexec (g_signal_pipe[1]);
|
|
|
|
// So that the pipe cannot overflow; it would make write() block within
|
|
// the signal handler, which is something we really don't want to happen.
|
|
// The same holds true for read().
|
|
set_blocking (g_signal_pipe[0], false);
|
|
set_blocking (g_signal_pipe[1], false);
|
|
|
|
signal (SIGPIPE, SIG_IGN);
|
|
|
|
struct sigaction sa;
|
|
sa.sa_flags = SA_RESTART;
|
|
sa.sa_handler = signals_superhandler;
|
|
sigemptyset (&sa.sa_mask);
|
|
|
|
if (sigaction (SIGWINCH, &sa, NULL) == -1
|
|
|| sigaction (SIGINT, &sa, NULL) == -1
|
|
|| sigaction (SIGTERM, &sa, NULL) == -1)
|
|
exit_fatal ("sigaction: %s", strerror (errno));
|
|
}
|
|
|
|
// --- Initialisation, event handling ------------------------------------------
|
|
|
|
static bool g_verbose_mode = false;
|
|
|
|
static void
|
|
app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
char id = 0;
|
|
(void) read (fd->fd, &id, 1);
|
|
|
|
if (g_termination_requested && !g.quitting)
|
|
app_quit ();
|
|
|
|
// It would be awkward to set up SIGWINCH conditionally,
|
|
// so have it as a handler within UIs.
|
|
if (g_winch_received)
|
|
{
|
|
g_winch_received = false;
|
|
if (g_xui.ui->winch)
|
|
g_xui.ui->winch ();
|
|
}
|
|
}
|
|
|
|
static void
|
|
app_on_message_timer (void *user_data)
|
|
{
|
|
(void) user_data;
|
|
|
|
app_hide_message ();
|
|
}
|
|
|
|
static void
|
|
app_log_handler (void *user_data, const char *quote, const char *fmt,
|
|
va_list ap)
|
|
{
|
|
// We certainly don't want to end up in a possibly infinite recursion
|
|
static bool in_processing;
|
|
if (in_processing)
|
|
return;
|
|
|
|
in_processing = true;
|
|
|
|
struct str message = str_make ();
|
|
str_append (&message, quote);
|
|
size_t quote_len = message.len;
|
|
str_append_vprintf (&message, fmt, ap);
|
|
|
|
// Show it prettified to the user, then maybe log it elsewhere as well.
|
|
// TODO: Review locale encoding vs UTF-8 in the entire program.
|
|
message.str[0] = toupper_ascii (message.str[0]);
|
|
app_show_message (xstrndup (message.str, quote_len),
|
|
xstrdup (message.str + quote_len));
|
|
|
|
if (g_verbose_mode && (g_xui.ui != &tui_ui || !isatty (STDERR_FILENO)))
|
|
fprintf (stderr, "%s\n", message.str);
|
|
if (g_debug_tab.active)
|
|
debug_tab_push (str_steal (&message),
|
|
user_data == NULL ? 0 : g.attrs[(intptr_t) user_data].attrs);
|
|
str_free (&message);
|
|
|
|
in_processing = false;
|
|
}
|
|
|
|
static void
|
|
app_init_poller_events (void)
|
|
{
|
|
g.signal_event = poller_fd_make (&g.poller, g_signal_pipe[0]);
|
|
g.signal_event.dispatcher = app_on_signal_pipe_readable;
|
|
poller_fd_set (&g.signal_event, POLLIN);
|
|
|
|
g.message_timer = poller_timer_make (&g.poller);
|
|
g.message_timer.dispatcher = app_on_message_timer;
|
|
|
|
g.connect_event = poller_timer_make (&g.poller);
|
|
g.connect_event.dispatcher = app_on_reconnect;
|
|
poller_timer_set (&g.connect_event, 0);
|
|
|
|
g.elapsed_event = poller_timer_make (&g.poller);
|
|
g.elapsed_event.dispatcher = g.elapsed_poll
|
|
? mpd_on_elapsed_time_tick_poll
|
|
: mpd_on_elapsed_time_tick;
|
|
}
|
|
|
|
static void
|
|
app_init_ui (bool requested_x11)
|
|
{
|
|
xui_preinit ();
|
|
|
|
g_normal_keys = app_init_bindings ("normal",
|
|
g_normal_defaults, N_ELEMENTS (g_normal_defaults), &g_normal_keys_len);
|
|
g_editor_keys = app_init_bindings ("editor",
|
|
g_editor_defaults, N_ELEMENTS (g_editor_defaults), &g_editor_keys_len);
|
|
|
|
// It doesn't work 100% (e.g. incompatible with undelining in urxvt)
|
|
// TODO: make this configurable
|
|
g.use_partial_boxes = g_xui.locale_is_utf8;
|
|
|
|
#ifdef WITH_X11
|
|
g_xui.x11_fontname = get_config_string (g.config.root, "settings.x11_font");
|
|
#endif // WITH_X11
|
|
|
|
xui_start (&g.poller, requested_x11, g.attrs, N_ELEMENTS (g.attrs));
|
|
|
|
#ifdef WITH_X11
|
|
if (g_xui.ui == &x11_ui)
|
|
g.ui = &app_x11_ui;
|
|
else
|
|
#endif // WITH_X11
|
|
g.ui = &app_tui_ui;
|
|
}
|
|
|
|
static void
|
|
app_init_enqueue (char *argv[], int argc)
|
|
{
|
|
// TODO: MPD is unwilling to play directories, so perhaps recurse ourselves
|
|
char cwd[4096] = "";
|
|
for (int i = 0; i < argc; i++)
|
|
{
|
|
// This is a super-trivial method of URL detection, however anything
|
|
// contaning the scheme and authority delimiters in a sequence is most
|
|
// certainly not a filesystem path, and thus it will work as expected.
|
|
// Error handling may be done by MPD.
|
|
const char *path_or_URL = argv[i];
|
|
if (*path_or_URL == '/' || strstr (path_or_URL, "://"))
|
|
strv_append (&g.enqueue, path_or_URL);
|
|
else if (!*cwd && !getcwd (cwd, sizeof cwd))
|
|
exit_fatal ("getcwd: %s", strerror (errno));
|
|
else
|
|
strv_append_owned (&g.enqueue,
|
|
xstrdup_printf ("%s/%s", cwd, path_or_URL));
|
|
}
|
|
}
|
|
|
|
int
|
|
main (int argc, char *argv[])
|
|
{
|
|
static const struct opt opts[] =
|
|
{
|
|
{ 'd', "debug", NULL, 0, "run in debug mode" },
|
|
#ifdef WITH_X11
|
|
{ 'x', "x11", NULL, 0, "use X11 even when run from a terminal" },
|
|
#endif // WITH_X11
|
|
{ 'h', "help", NULL, 0, "display this help and exit" },
|
|
{ 'v', "verbose", NULL, 0, "log messages on standard error" },
|
|
{ 'V', "version", NULL, 0, "output version information and exit" },
|
|
{ 0, NULL, NULL, 0, NULL }
|
|
};
|
|
|
|
bool requested_x11 = false;
|
|
struct opt_handler oh
|
|
= opt_handler_make (argc, argv, opts, "[URL | PATH]...", "MPD client.");
|
|
|
|
int c;
|
|
while ((c = opt_handler_get (&oh)) != -1)
|
|
switch (c)
|
|
{
|
|
case 'd':
|
|
g_debug_mode = true;
|
|
break;
|
|
case 'x':
|
|
requested_x11 = true;
|
|
break;
|
|
case 'v':
|
|
g_verbose_mode = true;
|
|
break;
|
|
case 'h':
|
|
opt_handler_usage (&oh, stdout);
|
|
exit (EXIT_SUCCESS);
|
|
case 'V':
|
|
printf (PROGRAM_NAME " " PROGRAM_VERSION "\n");
|
|
exit (EXIT_SUCCESS);
|
|
default:
|
|
print_error ("wrong options");
|
|
opt_handler_usage (&oh, stderr);
|
|
exit (EXIT_FAILURE);
|
|
}
|
|
|
|
argc -= optind;
|
|
argv += optind;
|
|
opt_handler_free (&oh);
|
|
|
|
// We only need to convert to and from the terminal encoding
|
|
if (!setlocale (LC_CTYPE, ""))
|
|
print_warning ("failed to set the locale");
|
|
|
|
app_init_context ();
|
|
app_init_enqueue (argv, argc);
|
|
app_load_configuration ();
|
|
signals_setup_handlers ();
|
|
app_init_poller_events ();
|
|
app_init_ui (requested_x11);
|
|
|
|
if (g_debug_mode)
|
|
app_prepend_tab (debug_tab_init ());
|
|
|
|
// Redirect all messages from liberty to a special tab so they're not lost
|
|
g_log_message_real = app_log_handler;
|
|
|
|
app_prepend_tab (info_tab_init ());
|
|
if (g.streams.len)
|
|
app_prepend_tab (streams_tab_init ());
|
|
app_prepend_tab (library_tab_init ());
|
|
app_prepend_tab (current_tab_init ());
|
|
app_switch_tab ((g.help_tab = help_tab_init ()));
|
|
|
|
// TODO: the help tab should be the default for new users only,
|
|
// so provide a configuration option to flip this
|
|
if (argc)
|
|
app_switch_tab (&g_current_tab);
|
|
|
|
g.polling = true;
|
|
while (g.polling)
|
|
poller_run (&g.poller);
|
|
|
|
xui_stop ();
|
|
g_log_message_real = log_message_stdio;
|
|
app_free_context ();
|
|
return 0;
|
|
}
|