Compare commits

..

No commits in common. "8aac4ae0a8705641ee55772292d0ae25d529271a" and "b6dd94072080d29b356d2c22d9f317deac55331d" have entirely different histories.

7 changed files with 37 additions and 550 deletions

View File

@ -104,7 +104,6 @@ foreach (extra m)
endforeach ()
# Generate a configuration file
include (GNUInstallDirs)
configure_file (${PROJECT_SOURCE_DIR}/config.h.in
${PROJECT_BINARY_DIR}/config.h)
include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR})
@ -124,10 +123,10 @@ target_link_libraries (${PROJECT_NAME} ${Unistring_LIBRARIES}
add_threads (${PROJECT_NAME})
# Installation
include (GNUInstallDirs)
install (TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR})
install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR})
install (DIRECTORY contrib DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})
install (DIRECTORY info DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})
# Generate documentation from text markup
find_program (ASCIIDOCTOR_EXECUTABLE asciidoctor)

10
NEWS
View File

@ -1,13 +1,3 @@
Unreleased
* Added ability to look up song lyrics,
using a new scriptable extension interface for the Info tab
* Made the X11 interface support italic fonts
* Added Readline-like M-u, M-l, M-c editor bindings
2.0.0 (2022-09-03)
* Added an optional X11 user interface

View File

@ -18,7 +18,6 @@ Features
Most stuff is there. I've been using the program exclusively for many years.
Among other things, it can display and change PulseAudio volume directly
to cover the use case of remote control, it has a fast spectrum visualiser,
it can be extended with plugins to fetch lyrics or other song-related info,
and both its appearance and key bindings can be customized.
Note that currently only the filesystem browsing mode is implemented,
@ -41,7 +40,7 @@ Building
Build dependencies: CMake, pkg-config, asciidoctor or asciidoc,
liberty (included), termo (included) +
Runtime dependencies: ncursesw, libunistring, cURL +
Optional runtime dependencies: fftw3, libpulse, x11, xft, Perl + cURL (lyrics)
Optional runtime dependencies: fftw3, libpulse, x11, xft
$ git clone --recursive https://git.janouch.name/p/nncmpp.git
$ mkdir nncmpp/build

View File

@ -4,9 +4,6 @@
#define PROGRAM_NAME "${PROJECT_NAME}"
#define PROGRAM_VERSION "${PROJECT_VERSION}"
// We use the XDG Base Directory Specification, but may be installed anywhere.
#define PROJECT_DATADIR "${CMAKE_INSTALL_FULL_DATADIR}"
#cmakedefine HAVE_RESIZETERM
#cmakedefine WITH_FFTW
#cmakedefine WITH_PULSE

View File

@ -1,43 +0,0 @@
#!/usr/bin/env perl
# 10-azlyrics.pl: nncmpp info plugin to fetch song lyrics on AZLyrics
#
# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
# SPDX-License-Identifier: 0BSD
#
# Inspired by a similar ncmpc plugin.
use warnings;
use strict;
use utf8;
use open ':std', ':utf8';
unless (@ARGV) {
print "Look up on AZLyrics\n";
exit;
}
use Encode;
my ($title, $artist, $album) = map {decode_utf8($_)} @ARGV;
# TODO: An upgrade would be transliteration with, e.g., Text::Unidecode.
use Unicode::Normalize;
$artist = lc(NFD($artist) =~ s/^the\s+//ir =~ s/[^A-Za-z0-9]//gr);
$title = lc(NFD($title) =~ s/\(.*?\)//gr =~ s/[^A-Za-z0-9]//gr);
# TODO: Consider caching the results in a location like
# $XDG_CACHE_HOME/nncmpp/info/azlyrics/$artist-$title
my $found = 0;
if ($title ne '') {
open(my $curl, '-|', 'curl', '-sA', 'nncmpp/2.0',
"https://www.azlyrics.com/lyrics/$artist/$title.html") or die $!;
while (<$curl>) {
next unless /^<div>/ .. /^<\/div>/; s/<!--.*?-->//g; s/\s+$//gs;
$found = 1;
s/<\/?b>/\x01/g; s/<\/?i>/\x02/g; s/<br>/\n/; s/<.+?>//g;
s/&lt;/</g; s/&gt;/>/g; s/&quot;/"/g; s/&apos;/'/g; s/&amp;/&/g;
print;
}
close($curl) or die $?;
}
print "No lyrics have been found.\n" unless $found;

View File

@ -128,19 +128,6 @@ For this to work, *nncmpp* needs to access the right PulseAudio daemon--in case
your setup is unusual, consult the list of environment variables in
*pulseaudio*(1). MPD-compatibles are currently unsupported.
Info plugins
------------
You can invoke various plugins from the Info tab, for example to look up
song lyrics.
Plugins can be arbitrary scripts or binaries. When run without command line
arguments, a plugin outputs a user interface description of what it does.
When invoked by a user, it receives the following self-explanatory arguments:
_TITLE_ _ARTIST_ [_ALBUM_], and anything it writes to its standard output
or standard error stream is presented back to the user. Here, bold and italic
formatting can be toggled with ASCII control characters 1 (SOH) and 2 (STX),
respectively. Otherwise, all input and output makes use of the UTF-8 encoding.
Files
-----
*nncmpp* follows the XDG Base Directory Specification.
@ -148,14 +135,6 @@ Files
_~/.config/nncmpp/nncmpp.conf_::
The configuration file.
_~/.local/share/nncmpp/info/_::
_/usr/local/share/nncmpp/info/_::
_/usr/share/nncmpp/info/_::
Info plugins are loaded from these directories, in order,
then listed lexicographically.
Only the first occurence of a particular filename is used,
and empty files act as silent disablers.
Reporting bugs
--------------
Use https://git.janouch.name/p/nncmpp to report bugs, request features,

490
nncmpp.c
View File

@ -75,11 +75,10 @@ enum
#define HAVE_LIBERTY
#include "line-editor.c"
#include <dirent.h>
#include <locale.h>
#include <math.h>
#include <sys/ioctl.h>
#include <locale.h>
#include <termios.h>
#include <sys/ioctl.h>
// ncurses is notoriously retarded for input handling, we need something
// different if only to receive mouse events reliably.
@ -131,20 +130,6 @@ clock_msec (clockid_t clock)
return (int64_t) tp.tv_sec * 1000 + (int64_t) tp.tv_nsec / 1000000;
}
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)
{
@ -179,18 +164,6 @@ latin1_to_utf8 (const char *latin1)
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)
{
@ -345,8 +318,6 @@ poller_curl_on_socket_action (CURL *easy, curl_socket_t s, int what,
struct poller_curl_fd *fd;
if (!(fd = socket_data))
{
set_cloexec (s);
fd = xmalloc (sizeof *fd);
LIST_PREPEND (self->fds, fd);
@ -1397,7 +1368,6 @@ static struct app_context
XftDraw *xft_draw; ///< Xft rendering context
XftFont *xft_regular; ///< Regular font
XftFont *xft_bold; ///< Bold font
XftFont *xft_italic; ///< Italic font
char *x11_selection; ///< CLIPBOARD selection
XRenderColor x_fg[ATTRIBUTE_COUNT]; ///< Foreground per attribute
@ -4119,456 +4089,68 @@ streams_tab_init (void)
// --- 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);
FILE *fp = popen (escaped.str, "r");
str_free (&escaped);
if (!fp)
{
print_error ("%s: %s", path, strerror (errno));
return NULL;
}
struct str description = str_make ();
char buf[BUFSIZ];
size_t len;
while ((len = fread (buf, 1, sizeof buf, fp)) == sizeof buf)
str_append_data (&description, buf, len);
str_append_data (&description, buf, len);
if (pclose (fp))
{
str_free (&description);
print_error ("%s: %s", path, strerror (errno));
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)
{
str_free (&description);
print_error ("%s: %s", path, "missing 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
struct strv keys; ///< Data keys
struct strv values; ///< Data values
}
g_info_tab;
static chtype
info_tab_format_decode_toggle (char c)
{
switch (c)
{
case '\x01':
return A_BOLD;
case '\x02':
return A_ITALIC;
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];
const char *key = g_info_tab.keys.vector[item_index];
const char *value = g_info_tab.values.vector[item_index];
struct layout l = {};
if (item->prefix)
{
char *prefix = xstrdup_printf ("%s:", item->prefix);
char *prefix = xstrdup_printf ("%s:", key);
app_push (&l, g.ui->label (A_BOLD, prefix))
->width = 8 * g.ui_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;
app_push_fill (&l, g.ui->label (0, value));
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));
const char *value = compact_map_find (data, field);
if (!value) value = "";
strv_append (&g_info_tab.keys, field);
strv_append (&g_info_tab.values, value);
g_info_tab.super.item_count++;
}
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;
strv_reset (&g_info_tab.keys);
strv_reset (&g_info_tab.values);
g_info_tab.super.item_count = 0;
compact_map_t map;
if ((map = item_list_get (&g.playlist, g.song)))
{
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
// Yes, it is "file", but this is also 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 ();
app_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 ();
app_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 ();
g_info_tab.keys = strv_make ();
g_info_tab.values = strv_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;
}
@ -5795,7 +5377,7 @@ tui_on_tty_readable (const struct pollfd *fd, void *user_data)
poller_timer_reset (&g.tk_timer);
termo_advisereadable (g.tk);
termo_key_t event = {};
termo_key_t event;
int64_t event_ts = clock_msec (CLOCK_BEST);
termo_result_t res;
while ((res = termo_getkey (g.tk, &event)) == TERMO_RES_KEY)
@ -5866,11 +5448,7 @@ static XErrorHandler x11_default_error_handler;
static XftFont *
x11_font (struct widget *self)
{
if (self->attrs & A_BOLD)
return g.xft_bold;
if (self->attrs & A_ITALIC)
return g.xft_italic;
return g.xft_regular;
return (self->attrs & A_BOLD) ? g.xft_bold : g.xft_regular;
}
static XRenderColor *
@ -6351,7 +5929,6 @@ x11_destroy (void)
XftDrawDestroy (g.xft_draw);
XftFontClose (g.dpy, g.xft_regular);
XftFontClose (g.dpy, g.xft_bold);
XftFontClose (g.dpy, g.xft_italic);
cstr_set (&g.x11_selection, NULL);
poller_fd_reset (&g.x11_event);
@ -6853,11 +6430,8 @@ x11_init_fonts (void)
FcPattern *query_regular = FcNameParse ((const FcChar8 *) name);
FcPattern *query_bold = FcPatternDuplicate (query_regular);
FcPatternAdd (query_bold, FC_STYLE, (FcValue) {
.type = FcTypeString, .u.s = (FcChar8 *) "Bold" }, FcFalse);
FcPattern *query_italic = FcPatternDuplicate (query_regular);
FcPatternAdd (query_italic, FC_STYLE, (FcValue) {
.type = FcTypeString, .u.s = (FcChar8 *) "Italic" }, FcFalse);
FcPatternAdd (query_bold, FC_STYLE,
(FcValue) { .type = FcTypeString, .u.s = (FcChar8 *) "Bold" }, FcFalse);
FcPattern *regular = XftFontMatch (g.dpy, screen, query_regular, &result);
FcPatternDestroy (query_regular);
@ -6875,13 +6449,6 @@ x11_init_fonts (void)
FcPatternDestroy (bold);
if (!g.xft_bold)
g.xft_bold = XftFontCopy (g.dpy, g.xft_regular);
FcPattern *italic = XftFontMatch (g.dpy, screen, query_italic, &result);
FcPatternDestroy (query_italic);
if (italic && !(g.xft_italic = XftFontOpenPattern (g.dpy, italic)))
FcPatternDestroy (italic);
if (!g.xft_italic)
g.xft_italic = XftFontCopy (g.dpy, g.xft_regular);
}
static void
@ -7108,7 +6675,6 @@ app_log_handler (void *user_data, const char *quote, const char *fmt,
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));