git.strcat.st

/strcat/jam.git/ - summarytreelogarchive

subject
we on git now nurga
commit
988aa7bfa61215dfa60c6a06180a38d037fcef15
date
2026-04-17T19:53:38Z
message
diff
 .gitignore |    1 +
 Makefile   |   32 +
 README.md  |   45 +
 jam.c      | 2836 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 jamrc      |   24 +
 5 files changed, 2938 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b883a94
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+jam
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..daeec29
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+CC ?= cc
+PREFIX ?= /usr
+DESTDIR ?=
+
+CFLAGS ?= -O2 -Wall -Wextra -Wpedantic
+LDFLAGS ?=
+LDLIBS ?= -lasound
+
+FFMPEG_CFLAGS ?=
+FFMPEG_LIBS ?=
+
+ifneq ($(strip $(FFMPEG_LIBS)),)
+CFLAGS += -DHAVE_FFMPEG $(FFMPEG_CFLAGS)
+LDLIBS += $(FFMPEG_LIBS)
+endif
+
+all: jam
+
+jam: jam.c
+	$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LDLIBS)
+
+install: jam
+	mkdir -p $(DESTDIR)$(PREFIX)/bin
+	cp -f jam $(DESTDIR)$(PREFIX)/bin/jam
+
+uninstall:
+	rm -f $(DESTDIR)$(PREFIX)/bin/jam
+
+clean:
+	rm -f jam
+
+.PHONY: all install uninstall clean
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..67d4640
--- /dev/null
+++ b/README.md
@@ -0,0 +1,45 @@
+JAM - Jank (ASS) Alsa Music-player
+
+dependencies:
+  alsa
+  ffmpeg 	# optional, for mp3/m4a/flac/etc support
+
+build:
+  make
+  # with ffmpeg support
+  make FFMPEG_LIBS="-lavformat -lavcodec -lswresample -lavutil"
+
+make (un)install:
+  make install
+  make uninstall
+
+usage:
+  jam song.wav
+  jam .
+  jam dir/*
+  jam -R -r 48000 -c 2 -v 80 -s 10 song.pcm
+  cat song.pcm | jam -R -r 44100 -c 2
+
+arguments:
+  -r RATE	sample rate			[default 44100]
+  -c CHANNELS	channels			[default 2    ]
+  -v VOLUME	0-100				[default 100  ]
+  -s SECONDS	seek start			[wav only     ]
+  -d DEVICE	alsa device			[or --device  ]
+  -R		raw PCM 16-b little endian	[no header    ]
+
+default keybinds:
+  space	pause/resume
+  h	seek -5s
+  l	seek +5s
+  k	volume +5%
+  j	volume -5%
+  n	next file
+  p	previous file
+  r	toggle repeat
+  s	toggle shuffle
+  /	search
+  q	quit
+
+configuration:
+	refer to jamrc
diff --git a/jam.c b/jam.c
new file mode 100644
index 0000000..54482a0
--- /dev/null
+++ b/jam.c
@@ -0,0 +1,2836 @@
+#include <sys/types.h>
+#include <sys/stat.h>
+
+#include <alsa/asoundlib.h>
+#include <ctype.h>
+#include <dirent.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <termios.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/ioctl.h>
+
+#ifdef HAVE_FFMPEG
+#include <libavcodec/avcodec.h>
+#include <libavformat/avformat.h>
+#include <libavutil/channel_layout.h>
+#include <libavutil/version.h>
+#include <libavutil/opt.h>
+#include <libswresample/swresample.h>
+#include <libswresample/version.h>
+#endif
+
+#define DEFAULT_RATE		44100
+#define DEFAULT_CHANNELS	2
+#define DEFAULT_VOLUME		100
+#define BUFFER_FRAMES		1024
+#define SEEK_SECONDS		5.0
+#define MAX_LINE		80
+
+static volatile sig_atomic_t g_stop = 0;
+static volatile sig_atomic_t g_quit = 0;
+static char g_search_path[PATH_MAX];
+
+struct wav_info {
+	unsigned int	rate;
+	unsigned int	channels;
+	unsigned int	bits;
+	off_t		data_offset;
+	off_t		data_size;
+	bool		ok;
+};
+
+struct keyseq {
+	char		seq[16];
+	size_t		len;
+};
+
+struct keylist {
+	struct keyseq	seqs[8];
+	int		count;
+};
+
+struct keymap {
+	struct keylist	quit;
+	struct keylist	pause;
+	struct keylist	vol_down;
+	struct keylist	vol_up;
+	struct keylist	seek_back;
+	struct keylist	seek_fwd;
+	struct keylist	next;
+	struct keylist	prev;
+	struct keylist	repeat;
+	struct keylist	shuffle;
+	struct keylist	search;
+};
+
+struct player_state {
+	snd_pcm_t	*pcm;
+	struct keymap	*keys;
+	bool		*repeat;
+	bool		*shuffle;
+	bool		paused;
+	bool		seekable;
+	unsigned int	rate;
+	unsigned int	channels;
+	unsigned int	bits;
+	int		volume;
+	off_t		data_offset;
+	off_t		data_size;
+	off_t		data_pos;
+	size_t		frame_bytes;
+};
+
+enum play_action {
+	PLAY_DONE = 0,
+	PLAY_NEXT,
+	PLAY_PREV,
+	PLAY_SEARCH,
+	PLAY_QUIT,
+	PLAY_ERR
+};
+
+enum key_action {
+	KA_NONE = 0,
+	KA_QUIT,
+	KA_PAUSE,
+	KA_VOL_DOWN,
+	KA_VOL_UP,
+	KA_SEEK_BACK,
+	KA_SEEK_FWD,
+	KA_NEXT,
+	KA_PREV,
+	KA_REPEAT,
+	KA_SHUFFLE,
+	KA_SEARCH
+};
+
+static const char *progname;
+
+static void	on_sigint(int);
+static uint32_t	rd_u32le(const unsigned char *);
+static uint16_t	rd_u16le(const unsigned char *);
+static struct	wav_info	parse_wav_header(int);
+static int	set_raw_mode_fd(int, struct termios *);
+static void	restore_term_fd(int, const struct termios *);
+static double	now_sec(void);
+static void	format_time(char *, size_t, double);
+static void	apply_volume(int16_t *, size_t, int);
+static void	format_status_line(char *, size_t, const char *,
+		const struct player_state *);
+static void	print_status(const char *, const struct player_state *);
+static int	open_pcm(snd_pcm_t **, const char *, unsigned int, unsigned int);
+static int	seek_to(struct player_state *, int, double);
+static void	usage(void);
+static int	cmp_str(const void *, const void *);
+static int	add_path(char ***, int *, int *, const char *);
+static int	add_dir_files(char ***, int *, int *, const char *);
+static int	collect_paths(char ***, int *, int, char **, int);
+static int	reset_paths(char ***, int *, const char *);
+static int	normalize_device_opt(int *, char ***);
+static void	trim_line(char *);
+static void	keylist_clear(struct keylist *);
+static void	keylist_add(struct keylist *, const char *, size_t);
+static void	keymap_defaults(struct keymap *);
+static int	token_to_seq(const char *, char *, size_t *);
+static int	parse_fkey(const char *);
+static void	add_fkey(struct keylist *, int);
+static void	parse_key_list(const char *, struct keylist *);
+static enum	key_action match_keyseq(const struct keymap *, const char *, size_t);
+static bool	keybuf_is_prefix(const struct keymap *, const char *, size_t);
+static bool	keybuf_is_prefix_longer(const struct keymap *, const char *, size_t);
+static enum 	key_action keybuf_consume(const struct keymap *,
+		char *, size_t *);
+static enum	key_action read_action(const struct keymap *, int,
+		char *, size_t *);
+static enum	play_action handle_key(struct player_state *, enum key_action,
+		int *, bool);
+static int	load_config(char *, size_t, unsigned int *, unsigned int *,
+		int *, bool *, bool *, struct keymap *);
+static enum	play_action play_pcm(struct player_state *, int, int,
+		const char *, bool, bool);
+static void	keymap_lists(const struct keymap *, const struct keylist ***,
+		size_t *);
+static void	close_pcm(snd_pcm_t *);
+static void	maybe_update_ui(const char *, const struct player_state *,
+		double *);
+static double	bytes_per_sec(const struct player_state *);
+static double	bytes_to_sec(off_t, const struct player_state *);
+enum search_result {
+	SEARCH_NONE = 0,
+	SEARCH_CANCEL,
+	SEARCH_SUBMIT
+};
+struct search_state {
+	bool		active;
+	bool		rendered;
+	bool		dirty;
+	bool		tab_active;
+	bool		explicit_path;
+	size_t		tab_len;
+	int		tab_index;
+	size_t		len;
+	char		buf[PATH_MAX];
+	char		tab_query[PATH_MAX];
+};
+static void	search_start(struct search_state *);
+static void	search_end(const char *, const struct player_state *,
+		struct search_state *, double *);
+static enum search_result search_handle_input(struct search_state *, int,
+		const char *, char *, size_t);
+static void	search_render(const char *, const struct player_state *,
+		struct search_state *, double *);
+static int	fuzzy_score(const char *, const char *);
+static int	best_match_in_dir(const char *, const char *, char *, size_t,
+		bool *);
+static int	nth_match_in_dir(const char *, const char *, int, char *, size_t,
+		bool *);
+static int	resolve_search_path(const char *, const char *, char *, size_t);
+static void	compute_base_dir(const char *, char *, size_t);
+static int	term_cols(void);
+static void	recompute_explicit_path(struct search_state *);
+static void	get_search_display(const struct search_state *, char *, size_t);
+static bool	build_full_path(const char *, const char *, char *, size_t);
+static int	join_path(const char *, const char *, char *, size_t);
+#ifdef HAVE_FFMPEG
+static int64_t	sec_to_ts(double, AVRational);
+static const char *format_tag_name(char *, size_t, const char *,
+			    AVFormatContext *);
+static enum play_action	play_ffmpeg(struct player_state *, const char *, int,
+			    bool, const char *);
+static enum play_action ffmpeg_cleanup(enum play_action, struct player_state *,
+			    SwrContext **, AVCodecContext **, AVFormatContext **,
+			    AVPacket **, AVFrame **, uint8_t **);
+#endif
+static size_t	sanitize_utf8_trunc(const char *, size_t, char *, size_t);
+static void	save_config_volume(int);
+
+static void
+on_sigint(int sig)
+{
+	(void)sig;
+	g_stop = 1;
+	g_quit = 1;
+}
+
+static uint32_t
+rd_u32le(const unsigned char *p)
+{
+	return (uint32_t)p[0] | ((uint32_t)p[1] << 8) |
+	    ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
+}
+
+static uint16_t
+rd_u16le(const unsigned char *p)
+{
+	return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
+}
+
+static struct wav_info
+parse_wav_header(int fd)
+{
+	struct wav_info info = {0};
+	unsigned char hdr[12];
+	unsigned char chunk_hdr[8];
+	bool fmt_ok	= false;
+	bool data_ok	= false;
+	off_t off;
+	ssize_t n;
+	uint32_t csize;
+
+	n = pread(fd, hdr, sizeof(hdr), 0);
+	if (n != (ssize_t)sizeof(hdr))
+		return info;
+	if (memcmp(hdr, "RIFF", 4) != 0 || memcmp(hdr + 8, "WAVE", 4) != 0)
+		return info;
+
+	off = 12;
+	for (;;) {
+		n = pread(fd, chunk_hdr, sizeof(chunk_hdr), off);
+		if (n != (ssize_t)sizeof(chunk_hdr))
+			break;
+		csize = rd_u32le(chunk_hdr + 4);
+		if (memcmp(chunk_hdr, "fmt ", 4) == 0) {
+			unsigned char fmt[16];
+			uint16_t audio_format;
+
+			if (csize < 16)
+				break;
+			n = pread(fd, fmt, sizeof(fmt), off + 8);
+			if (n != (ssize_t)sizeof(fmt))
+				break;
+			audio_format = rd_u16le(fmt + 0);
+			info.channels	= rd_u16le(fmt + 2);
+			info.rate	= rd_u32le(fmt + 4);
+			info.bits	= rd_u16le(fmt + 14);
+			if (audio_format != 1)
+				break;
+			fmt_ok = true;
+		} else if (memcmp(chunk_hdr, "data", 4) == 0) {
+			info.data_offset	= off + 8;
+			info.data_size		= csize;
+			data_ok = true;
+		}
+		off += 8 + csize + (csize & 1);
+		if (fmt_ok && data_ok)
+			break;
+	}
+
+	info.ok = fmt_ok && data_ok;
+	return info;
+}
+
+static int
+set_raw_mode_fd(int fd, struct termios *orig)
+{
+	struct termios raw;
+	int flags;
+
+	if (fd < 0 || !isatty(fd))
+		return 0;
+	if (tcgetattr(fd, orig) != 0)
+		return -1;
+	raw = *orig;
+	raw.c_lflag &= (tcflag_t)~(ECHO | ICANON | ISIG);
+	raw.c_cc[VMIN] = 0;
+	raw.c_cc[VTIME] = 0;
+	if (tcsetattr(fd, TCSANOW, &raw) != 0)
+		return -1;
+	flags = fcntl(fd, F_GETFL, 0);
+	if (flags >= 0)
+		fcntl(fd, F_SETFL, flags | O_NONBLOCK);
+	return 0;
+}
+
+static void
+restore_term_fd(int fd, const struct termios *orig)
+{
+	if (fd >= 0 && isatty(fd))
+		tcsetattr(fd, TCSANOW, orig);
+}
+
+static double
+now_sec(void)
+{
+	struct timespec ts;
+
+	clock_gettime(CLOCK_MONOTONIC, &ts);
+	return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
+}
+
+static void
+format_time(char *buf, size_t len, double sec)
+{
+	int m;
+	int s;
+
+	if (sec < 0)
+		sec = 0;
+	m = (int)(sec / 60.0);
+	s = (int)(sec) % 60;
+	snprintf(buf, len, "%02d:%02d", m, s);
+}
+
+static void
+apply_volume(int16_t *samples, size_t count, int volume)
+{
+	int v;
+	size_t i;
+
+	if (volume >= 100)
+		return;
+	v = volume;
+	for (i = 0; i < count; ++i) {
+		int32_t x;
+
+		x = samples[i];
+		x = (x * v) / 100;
+		if (x > 32767)
+			x = 32767;
+		if (x < -32768)
+			x = -32768;
+		samples[i] = (int16_t)x;
+	}
+}
+
+static void
+format_status_line(char *line, size_t line_len, const char *name,
+    const struct player_state *st)
+{
+	char posbuf[16];
+	char durbuf[16];
+	char tail[128];
+	char namebuf[256];
+	const char *state;
+	const char *rep;
+	const char *shf;
+	const char *base;
+	const char *slash;
+	double pos;
+	int tlen;
+	int max_name;
+	size_t blen;
+
+	if (line == NULL || line_len == 0)
+		return;
+	line[0] = '\0';
+	namebuf[0] = '\0';
+
+	pos = 0.0;
+	if (st->rate && st->channels && st->bits) {
+		off_t bytes;
+		double bps;
+
+		bytes = st->data_pos - st->data_offset;
+		bps = (double)st->rate * (double)st->channels *
+		    (double)(st->bits / 8);
+		if (bps > 0)
+			pos = (double)bytes / bps;
+	}
+	format_time(posbuf, sizeof(posbuf), pos);
+
+	if (st->data_size > 0) {
+		double bps;
+		double dur;
+
+		bps = (double)st->rate * (double)st->channels *
+		    (double)(st->bits / 8);
+		dur = (bps > 0) ? (double)st->data_size / bps : 0.0;
+		format_time(durbuf, sizeof(durbuf), dur);
+	} else
+		snprintf(durbuf, sizeof(durbuf), "--:--");
+
+	state	= st->paused ? "paused" : "playing";
+	rep	= (st->repeat && *st->repeat) ? "[repeat] " : "";
+	shf	= (st->shuffle && *st->shuffle) ? "[shuffle] " : "";
+	base	= name ? name : "-";
+	slash	= strrchr(base, '/');
+	if (slash && slash[1])
+		base = slash + 1;
+
+	tlen = snprintf(tail, sizeof(tail), "  %s/%s  vol %3d%%  %s%s%s",
+	    posbuf, durbuf, st->volume, shf, rep, state);
+	if (tlen < 0)
+		return;
+	if (tlen >= (int)sizeof(tail))
+		tlen = (int)sizeof(tail) - 1;
+
+	max_name = term_cols() - tlen;
+	if (max_name < 0)
+		max_name = 0;
+
+	blen = strlen(base);
+	if ((int)blen > max_name) {
+		size_t max_keep;
+
+		if (max_name <= 0) {
+			namebuf[0] = '\0';
+		} else if (max_name < 3) {
+			sanitize_utf8_trunc(base, (size_t)max_name,
+			    namebuf, sizeof(namebuf));
+		} else {
+			max_keep = (size_t)max_name - 3;
+			sanitize_utf8_trunc(base, max_keep,
+			    namebuf, sizeof(namebuf));
+			strncat(namebuf, "...",
+			    sizeof(namebuf) - strlen(namebuf) - 1);
+		}
+	} else {
+		sanitize_utf8_trunc(base, blen, namebuf, sizeof(namebuf));
+	}
+	base = namebuf;
+
+	snprintf(line, line_len, "%s%s", base, tail);
+}
+
+static void
+print_status(const char *name, const struct player_state *st)
+{
+	char line[512];
+	format_status_line(line, sizeof(line), name, st);
+	fprintf(stderr, "\r%s\x1b[K", line);
+	fflush(stderr);
+}
+
+static int
+open_pcm(snd_pcm_t **pcm, const char *device, unsigned int rate,
+    unsigned int channels)
+{
+	const char *dev;
+	int rc;
+
+	dev = (device && device[0]) ? device : "default";
+	rc = snd_pcm_open(pcm, dev, SND_PCM_STREAM_PLAYBACK, 0);
+	if (rc < 0)
+		return rc;
+
+	rc = snd_pcm_set_params(*pcm, SND_PCM_FORMAT_S16_LE,
+	    SND_PCM_ACCESS_RW_INTERLEAVED, channels, rate, 1, 500000);
+	return rc;
+}
+
+static void
+close_pcm(snd_pcm_t *pcm)
+{
+	if (pcm == NULL)
+		return;
+	if (g_quit)
+		snd_pcm_drop(pcm);
+	else
+		snd_pcm_drain(pcm);
+	snd_pcm_close(pcm);
+}
+
+static void
+maybe_update_ui(const char *name, const struct player_state *st, double *last_ui)
+{
+	double now;
+
+	if (last_ui == NULL)
+		return;
+	now = now_sec();
+	if (now - *last_ui > 0.1) {
+		print_status(name, st);
+		*last_ui = now;
+	}
+}
+
+static double
+bytes_per_sec(const struct player_state *st)
+{
+	if (st == NULL || st->rate == 0 || st->channels == 0 || st->bits == 0)
+		return 0.0;
+	return (double)st->rate * (double)st->channels *
+	    (double)(st->bits / 8);
+}
+
+static double
+bytes_to_sec(off_t bytes, const struct player_state *st)
+{
+	double bps;
+
+	bps = bytes_per_sec(st);
+	if (bps <= 0.0)
+		return 0.0;
+	return (double)bytes / bps;
+}
+
+static int
+term_cols(void)
+{
+	return MAX_LINE;
+}
+
+static int
+join_path(const char *dir, const char *base, char *out, size_t out_sz)
+{
+	size_t dlen;
+
+	if (out == NULL || out_sz == 0 || dir == NULL || base == NULL)
+		return -1;
+	dlen = strlen(dir);
+	if (dlen == 0) {
+		if (snprintf(out, out_sz, "%s", base) >= (int)out_sz)
+			return -1;
+		return 0;
+	}
+	if (dir[dlen - 1] == '/')
+		return (snprintf(out, out_sz, "%s%s", dir, base) < (int)out_sz)
+		    ? 0 : -1;
+	return (snprintf(out, out_sz, "%s/%s", dir, base) < (int)out_sz)
+	    ? 0 : -1;
+}
+
+static bool
+build_full_path(const char *dir, const char *name, char *out, size_t out_sz)
+{
+	size_t dlen;
+	size_t nlen;
+	size_t pos;
+
+	if (out == NULL || out_sz == 0 || dir == NULL || name == NULL)
+		return false;
+	dlen = strlen(dir);
+	nlen = strlen(name);
+	pos = 0;
+
+	if (dlen == 0) {
+		if (nlen + 1 > out_sz)
+			return false;
+		memcpy(out, name, nlen);
+		out[nlen] = '\0';
+		return true;
+	}
+
+	if (dlen + nlen + 2 > out_sz)
+		return false;
+	memcpy(out, dir, dlen);
+	pos = dlen;
+	if (out[pos - 1] != '/')
+		out[pos++] = '/';
+	memcpy(out + pos, name, nlen);
+	out[pos + nlen] = '\0';
+	return true;
+}
+
+static void
+search_start(struct search_state *s)
+{
+	if (s == NULL)
+		return;
+	s->active = true;
+	s->rendered = false;
+	s->dirty = true;
+	s->tab_active = false;
+	s->explicit_path = false;
+	s->tab_len = 0;
+	s->tab_index = 0;
+	s->len = 0;
+	s->buf[0] = '\0';
+	s->tab_query[0] = '\0';
+}
+
+static void
+search_end(const char *name, const struct player_state *st,
+    struct search_state *s, double *last_ui)
+{
+	if (s == NULL || !s->active)
+		return;
+	if (s->rendered) {
+		fprintf(stderr, "\r\x1b[K\x1b[1A\r\x1b[K");
+		fflush(stderr);
+	}
+	s->active = false;
+	s->rendered = false;
+	s->dirty = false;
+	if (last_ui)
+		*last_ui = 0.0;
+	print_status(name, st);
+}
+
+static void
+recompute_explicit_path(struct search_state *s)
+{
+	if (s == NULL)
+		return;
+	if (s->len == 0) {
+		s->explicit_path = false;
+		return;
+	}
+	if (s->buf[0] == '~' || strchr(s->buf, '/') != NULL)
+		s->explicit_path = true;
+	else
+		s->explicit_path = false;
+}
+
+static void
+get_search_display(const struct search_state *s, char *out, size_t out_sz)
+{
+	const char *home;
+	const char *disp;
+	const char *slash;
+
+	if (out == NULL || out_sz == 0)
+		return;
+	out[0] = '\0';
+	if (s == NULL)
+		return;
+	if (!s->explicit_path) {
+		disp = s->buf;
+		slash = strrchr(disp, '/');
+		if (slash && slash[1])
+			disp = slash + 1;
+		snprintf(out, out_sz, "%s", disp);
+		return;
+	}
+	disp = s->buf;
+	home = getenv("HOME");
+	if (home && home[0]) {
+		size_t hlen = strlen(home);
+
+		if (strncmp(disp, home, hlen) == 0) {
+			if (disp[hlen] == '/' || disp[hlen] == '\0') {
+				snprintf(out, out_sz, "~%s", disp + hlen);
+				return;
+			}
+		}
+	}
+	snprintf(out, out_sz, "%s", disp);
+}
+
+static int
+fuzzy_score(const char *pattern, const char *text)
+{
+	size_t pi = 0;
+	size_t ti = 0;
+	size_t plen;
+	size_t tlen;
+	int score = 0;
+	int last = -1;
+
+	if (pattern == NULL || text == NULL)
+		return -1;
+	plen = strlen(pattern);
+	tlen = strlen(text);
+	if (plen == 0)
+		return 0;
+
+	for (pi = 0; pi < plen; ++pi) {
+		char pc = (char)tolower((unsigned char)pattern[pi]);
+		bool found = false;
+
+		while (ti < tlen) {
+			char tc = (char)tolower((unsigned char)text[ti]);
+
+			if (tc == pc) {
+				if (last < 0)
+					score += (int)(ti * 2);
+				else
+					score += (int)(ti - (size_t)last - 1);
+				last = (int)ti;
+				ti++;
+				found = true;
+				break;
+			}
+			ti++;
+		}
+		if (!found)
+			return -1;
+	}
+	if (last >= 0)
+		score += (int)(tlen - (size_t)last - 1);
+	return score;
+}
+
+static int
+best_match_in_dir(const char *dir, const char *pattern, char *out, size_t out_sz,
+    bool *out_is_dir)
+{
+	DIR *d;
+	struct dirent *ent;
+	int best_score = -1;
+	char best_name[NAME_MAX + 1];
+	bool best_is_dir = false;
+
+	if (out == NULL || out_sz == 0)
+		return -1;
+	out[0] = '\0';
+	if (out_is_dir)
+		*out_is_dir = false;
+
+	d = opendir(dir);
+	if (d == NULL)
+		return -1;
+
+	while ((ent = readdir(d)) != NULL) {
+		int score;
+		bool is_dir = false;
+
+		if (strcmp(ent->d_name, ".") == 0 ||
+		    strcmp(ent->d_name, "..") == 0)
+			continue;
+		if (pattern == NULL || pattern[0] == '\0')
+			score = 0;
+		else
+			score = fuzzy_score(pattern, ent->d_name);
+		if (score < 0)
+			continue;
+		if ((pattern == NULL || pattern[0] != '.') &&
+		    ent->d_name[0] == '.')
+			continue;
+
+#ifdef DT_DIR
+		if (ent->d_type == DT_DIR)
+			is_dir = true;
+		else if (ent->d_type == DT_UNKNOWN)
+#endif
+		{
+			char full[PATH_MAX];
+			struct stat st;
+
+			if (strlen(dir) + 1 + strlen(ent->d_name) >= sizeof(full))
+				continue;
+			if (!build_full_path(dir, ent->d_name, full,
+			    sizeof(full)))
+				continue;
+			if (stat(full, &st) == 0 && S_ISDIR(st.st_mode))
+				is_dir = true;
+		}
+
+		if (best_score < 0 || score < best_score ||
+		    (score == best_score &&
+		    strcasecmp(ent->d_name, best_name) < 0)) {
+			best_score = score;
+			strncpy(best_name, ent->d_name, sizeof(best_name) - 1);
+			best_name[sizeof(best_name) - 1] = '\0';
+			best_is_dir = is_dir;
+		}
+	}
+	closedir(d);
+
+	if (best_score < 0)
+		return -1;
+	strncpy(out, best_name, out_sz - 1);
+	out[out_sz - 1] = '\0';
+	if (out_is_dir)
+		*out_is_dir = best_is_dir;
+	return 0;
+}
+
+static int
+nth_match_in_dir(const char *dir, const char *pattern, int index, char *out,
+    size_t out_sz, bool *out_is_dir)
+{
+	struct candidate {
+		char name[NAME_MAX + 1];
+		int score;
+		bool is_dir;
+	};
+	struct candidate *list = NULL;
+	size_t count = 0;
+	size_t cap = 0;
+	DIR *d;
+	struct dirent *ent;
+	int rc = -1;
+	int i;
+
+	if (out == NULL || out_sz == 0 || index < 0)
+		return -1;
+	out[0] = '\0';
+	if (out_is_dir)
+		*out_is_dir = false;
+
+	d = opendir(dir);
+	if (d == NULL)
+		return -1;
+
+	while ((ent = readdir(d)) != NULL) {
+		int score;
+		bool is_dir = false;
+
+		if (strcmp(ent->d_name, ".") == 0 ||
+		    strcmp(ent->d_name, "..") == 0)
+			continue;
+		if ((pattern == NULL || pattern[0] != '.') &&
+		    ent->d_name[0] == '.')
+			continue;
+
+		if (pattern == NULL || pattern[0] == '\0')
+			score = 0;
+		else
+			score = fuzzy_score(pattern, ent->d_name);
+		if (score < 0)
+			continue;
+
+#ifdef DT_DIR
+		if (ent->d_type == DT_DIR)
+			is_dir = true;
+		else if (ent->d_type == DT_UNKNOWN)
+#endif
+		{
+			char full[PATH_MAX];
+			struct stat st;
+
+			if (!build_full_path(dir, ent->d_name, full,
+			    sizeof(full)))
+				continue;
+			if (stat(full, &st) == 0 && S_ISDIR(st.st_mode))
+				is_dir = true;
+		}
+
+		if (count >= cap) {
+			size_t ncap = (cap == 0) ? 16 : cap * 2;
+			struct candidate *nl =
+			    realloc(list, ncap * sizeof(*list));
+			if (nl == NULL) {
+				free(list);
+				closedir(d);
+				return -1;
+			}
+			list = nl;
+			cap = ncap;
+		}
+		strncpy(list[count].name, ent->d_name,
+		    sizeof(list[count].name) - 1);
+		list[count].name[sizeof(list[count].name) - 1] = '\0';
+		list[count].score = score;
+		list[count].is_dir = is_dir;
+		count++;
+	}
+	closedir(d);
+	if (count == 0) {
+		free(list);
+		return -1;
+	}
+
+	for (i = 0; i < (int)count - 1; ++i) {
+		int j;
+
+		for (j = i + 1; j < (int)count; ++j) {
+			struct candidate tmp;
+			bool swap = false;
+
+			if (list[j].score < list[i].score)
+				swap = true;
+			else if (list[j].score == list[i].score &&
+			    strcasecmp(list[j].name, list[i].name) < 0)
+				swap = true;
+			if (!swap)
+				continue;
+			tmp = list[i];
+			list[i] = list[j];
+			list[j] = tmp;
+		}
+	}
+
+	index = index % (int)count;
+	if (index < 0)
+		index = 0;
+	strncpy(out, list[index].name, out_sz - 1);
+	out[out_sz - 1] = '\0';
+	if (out_is_dir)
+		*out_is_dir = list[index].is_dir;
+	rc = 0;
+	free(list);
+	return rc;
+}
+
+static int
+resolve_search_path(const char *query, const char *base_dir, char *out,
+    size_t out_sz)
+{
+	char expanded[PATH_MAX];
+	char dir[PATH_MAX];
+	char base[PATH_MAX];
+	char *slash;
+	struct stat st;
+	bool is_dir = false;
+	bool is_abs = false;
+	bool has_slash = false;
+
+	if (out == NULL || out_sz == 0)
+		return -1;
+	out[0] = '\0';
+	if (query == NULL || query[0] == '\0')
+		return -1;
+
+	if (strchr(query, '/') != NULL)
+		has_slash = true;
+	if (query[0] == '/' || query[0] == '~')
+		is_abs = true;
+
+	if (query[0] == '~') {
+		const char *home = getenv("HOME");
+
+		if (home && home[0]) {
+			if (query[1] == '/' || query[1] == '\0')
+				snprintf(expanded, sizeof(expanded), "%s%s",
+				    home, query + 1);
+			else
+				snprintf(expanded, sizeof(expanded), "%s", query);
+		} else
+			snprintf(expanded, sizeof(expanded), "%s", query);
+	} else {
+		if (is_abs)
+			snprintf(expanded, sizeof(expanded), "%s", query);
+		else if ((has_slash || query[0] == '.') &&
+		    base_dir != NULL && base_dir[0] != '\0') {
+			if (join_path(base_dir, query, expanded,
+			    sizeof(expanded)) < 0)
+				snprintf(expanded, sizeof(expanded), "%s", query);
+		} else if (has_slash || base_dir == NULL || base_dir[0] == '\0') {
+			snprintf(expanded, sizeof(expanded), "%s", query);
+		}
+		else if (join_path(base_dir, query, expanded,
+		    sizeof(expanded)) < 0)
+			snprintf(expanded, sizeof(expanded), "%s", query);
+	}
+
+	if (stat(expanded, &st) == 0) {
+		snprintf(out, out_sz, "%s", expanded);
+		return 0;
+	}
+
+	snprintf(dir, sizeof(dir), "%s", expanded);
+	slash = strrchr(dir, '/');
+	if (slash != NULL) {
+		*slash = '\0';
+		snprintf(base, sizeof(base), "%s", slash + 1);
+		if (dir[0] == '\0')
+			snprintf(dir, sizeof(dir), "/");
+	} else {
+		snprintf(dir, sizeof(dir), ".");
+		snprintf(base, sizeof(base), "%s", expanded);
+	}
+
+	(void)best_match_in_dir(dir, base, base, sizeof(base), &is_dir);
+	if (base[0] == '\0')
+		return -1;
+	if (join_path(dir, base, out, out_sz) < 0)
+		return -1;
+	return 0;
+}
+
+static enum search_result
+search_handle_input(struct search_state *s, int ctrl_fd,
+    const char *base_dir, char *out_path, size_t out_len)
+{
+	unsigned char buf[64];
+	ssize_t r;
+	ssize_t i;
+
+	if (s == NULL || !s->active)
+		return SEARCH_NONE;
+	r = (ctrl_fd >= 0) ? read(ctrl_fd, buf, sizeof(buf)) : -1;
+	if (r < 0) {
+		if (errno == EAGAIN || errno == EWOULDBLOCK)
+			return SEARCH_NONE;
+		return SEARCH_NONE;
+	}
+	for (i = 0; i < r; ++i) {
+		unsigned char c = buf[i];
+
+		if (c == 0x1b) {
+			s->dirty = true;
+			s->tab_active = false;
+			return SEARCH_CANCEL;
+		}
+		if (c == '\r' || c == '\n') {
+			if (out_path && out_len > 0 &&
+			    resolve_search_path(s->buf, base_dir, out_path,
+			    out_len) == 0) {
+				s->dirty = true;
+				s->tab_active = false;
+				return SEARCH_SUBMIT;
+			}
+			s->dirty = true;
+			s->tab_active = false;
+			return SEARCH_CANCEL;
+		}
+		if (c == '\t') {
+			char dir[PATH_MAX];
+			char base[PATH_MAX];
+			char match[PATH_MAX];
+			char rawdir[PATH_MAX];
+			char prefix[PATH_MAX];
+			char *slash;
+			bool is_dir = false;
+			int ok;
+
+			if (!s->tab_active) {
+				snprintf(s->tab_query, sizeof(s->tab_query),
+				    "%s", s->buf);
+				s->tab_len = strlen(s->tab_query);
+				s->tab_index = 0;
+				s->tab_active = true;
+			} else {
+				s->tab_index++;
+			}
+
+			snprintf(rawdir, sizeof(rawdir), "%s", s->tab_query);
+			slash = strrchr(rawdir, '/');
+			if (slash != NULL) {
+				size_t plen = (size_t)(slash - rawdir + 1);
+
+				if (plen >= sizeof(prefix))
+					plen = sizeof(prefix) - 1;
+				memcpy(prefix, rawdir, plen);
+				prefix[plen] = '\0';
+
+				*slash = '\0';
+				snprintf(base, sizeof(base), "%s", slash + 1);
+				if (rawdir[0] == '\0')
+					snprintf(rawdir, sizeof(rawdir), "/");
+			} else {
+				prefix[0] = '\0';
+				snprintf(base, sizeof(base), "%s", s->tab_query);
+			}
+
+			if (rawdir[0] == '\0') {
+				if (base_dir && base_dir[0])
+					snprintf(dir, sizeof(dir), "%s", base_dir);
+				else
+					snprintf(dir, sizeof(dir), ".");
+			} else if (rawdir[0] == '~') {
+				const char *home = getenv("HOME");
+
+				if (home && home[0]) {
+					if (rawdir[1] == '/' || rawdir[1] == '\0')
+						snprintf(dir, sizeof(dir), "%s%s",
+						    home, rawdir + 1);
+					else
+						snprintf(dir, sizeof(dir), "%s",
+						    rawdir);
+				} else
+					snprintf(dir, sizeof(dir), "%s", rawdir);
+			} else if (rawdir[0] == '/') {
+				snprintf(dir, sizeof(dir), "%s", rawdir);
+			} else if (base_dir && base_dir[0]) {
+				if (join_path(base_dir, rawdir, dir,
+				    sizeof(dir)) < 0)
+					snprintf(dir, sizeof(dir), "%s", rawdir);
+			} else {
+				snprintf(dir, sizeof(dir), "%s", rawdir);
+			}
+
+			ok = nth_match_in_dir(dir, base, s->tab_index, match,
+			    sizeof(match), &is_dir);
+			if (ok == 0) {
+				if (snprintf(s->buf, sizeof(s->buf), "%s%s%s",
+				    prefix, match, is_dir ? "/" : "") >=
+				    (int)sizeof(s->buf)) {
+					s->buf[sizeof(s->buf) - 1] = '\0';
+				}
+				s->len = strlen(s->buf);
+				s->dirty = true;
+				recompute_explicit_path(s);
+			}
+			continue;
+		}
+		if (c == 0x7f || c == 0x08) {
+			if (s->len > 0) {
+				s->len--;
+				s->buf[s->len] = '\0';
+				s->dirty = true;
+				s->tab_active = false;
+				recompute_explicit_path(s);
+			}
+			continue;
+		}
+		if (c < 0x20 || c == 0x7f)
+			continue;
+		if (s->len + 1 < sizeof(s->buf)) {
+			s->buf[s->len++] = (char)c;
+			s->buf[s->len] = '\0';
+			s->dirty = true;
+			s->tab_active = false;
+			if (c == '/' || (c == '~' && s->len == 1))
+				s->explicit_path = true;
+		}
+	}
+	return SEARCH_NONE;
+}
+
+static void
+search_render(const char *name, const struct player_state *st,
+    struct search_state *s, double *last_ui)
+{
+	char line[512];
+	char disp[PATH_MAX];
+	char disp_trunc[PATH_MAX];
+	int cols;
+	int avail;
+	bool force = false;
+
+	if (s == NULL || !s->active)
+		return;
+	if (s->dirty)
+		force = true;
+	if (last_ui == NULL)
+		return;
+	if (!force && (now_sec() - *last_ui) <= 0.1)
+		return;
+
+	format_status_line(line, sizeof(line), name, st);
+	get_search_display(s, disp, sizeof(disp));
+	cols = term_cols();
+	avail = cols - (int)strlen("search: ");
+	if (avail < 0)
+		avail = 0;
+	sanitize_utf8_trunc(disp, (size_t)avail, disp_trunc,
+	    sizeof(disp_trunc));
+	if (s->rendered)
+		fprintf(stderr, "\x1b[1A");
+	fprintf(stderr, "\r%s\x1b[K\nsearch: %s\x1b[K", line, disp_trunc);
+	fflush(stderr);
+
+	s->rendered = true;
+	s->dirty = false;
+	*last_ui = now_sec();
+}
+
+static void
+compute_base_dir(const char *path, char *out, size_t out_sz)
+{
+	char tmp[PATH_MAX];
+	char *slash;
+
+	if (out == NULL || out_sz == 0)
+		return;
+	if (path == NULL || path[0] == '\0') {
+		snprintf(out, out_sz, ".");
+		return;
+	}
+	snprintf(tmp, sizeof(tmp), "%s", path);
+	slash = strrchr(tmp, '/');
+	if (slash != NULL) {
+		*slash = '\0';
+		if (tmp[0] == '\0')
+			snprintf(out, out_sz, "/");
+		else
+			snprintf(out, out_sz, "%s", tmp);
+	} else {
+		snprintf(out, out_sz, ".");
+	}
+}
+static int
+seek_to(struct player_state *st, int fd, double sec)
+{
+	double bps;
+	off_t target;
+	off_t rel;
+
+	if (!st->seekable || st->data_size <= 0)
+		return -1;
+	bps = (double)st->rate * (double)st->channels *
+	    (double)(st->bits / 8);
+	if (bps <= 0)
+		return -1;
+	target = st->data_offset + (off_t)(sec * bps);
+	if (target < st->data_offset)
+		target = st->data_offset;
+	if (target > st->data_offset + st->data_size)
+		target = st->data_offset + st->data_size;
+	if (st->frame_bytes > 0) {
+		rel = target - st->data_offset;
+		rel = (rel / (off_t)st->frame_bytes) * (off_t)st->frame_bytes;
+		target = st->data_offset + rel;
+	}
+	if (lseek(fd, target, SEEK_SET) < 0)
+		return -1;
+	st->data_pos = target;
+	snd_pcm_drop(st->pcm);
+	snd_pcm_prepare(st->pcm);
+	return 0;
+}
+
+static void
+usage(void)
+{
+	fprintf(stderr,
+	    "usage: %s [-R] [-d device] [-r rate] [-c channels] [-s seconds] "
+	    "[-v volume] [file|-]\n",
+	    progname ? progname : "jam");
+	exit(1);
+}
+
+static int
+cmp_str(const void *a, const void *b)
+{
+	const char *const *sa = a;
+	const char *const *sb = b;
+
+	return strcmp(*sa, *sb);
+}
+
+static int
+add_path(char ***list, int *count, int *cap, const char *path)
+{
+	char **nl;
+	int ncap;
+
+	if (*count >= *cap) {
+		ncap = (*cap == 0) ? 16 : (*cap * 2);
+		nl = realloc(*list, (size_t)ncap * sizeof(char *));
+		if (nl == NULL)
+			return -1;
+		*list = nl;
+		*cap = ncap;
+	}
+	(*list)[*count] = strdup(path);
+	if ((*list)[*count] == NULL)
+		return -1;
+	(*count)++;
+	return 0;
+}
+
+static int
+add_dir_files(char ***list, int *count, int *cap, const char *dirpath)
+{
+	struct dirent *ent;
+	DIR *d;
+
+	d = opendir(dirpath);
+	if (d == NULL)
+		return -1;
+	while ((ent = readdir(d)) != NULL) {
+		char *full;
+		struct stat st;
+		size_t len;
+
+		if (strcmp(ent->d_name, ".") == 0 ||
+		    strcmp(ent->d_name, "..") == 0)
+			continue;
+		len = strlen(dirpath) + strlen(ent->d_name) + 2;
+		full = malloc(len);
+		if (full == NULL) {
+			closedir(d);
+			return -1;
+		}
+		snprintf(full, len, "%s/%s", dirpath, ent->d_name);
+
+		if (stat(full, &st) == 0 && S_ISREG(st.st_mode)) {
+			if (add_path(list, count, cap, full) < 0) {
+				free(full);
+				closedir(d);
+				return -1;
+			}
+		}
+		free(full);
+	}
+	closedir(d);
+	return 0;
+}
+
+static int
+collect_paths(char ***out, int *out_count, int argc, char **argv, int start)
+{
+	char **list = NULL;
+	int count = 0;
+	int cap = 0;
+	int i;
+	bool saw_stdin = false;
+
+	for (i = start; i < argc; ++i) {
+		const char *p = argv[i];
+		struct stat st;
+
+		if (strcmp(p, "-") == 0) {
+			saw_stdin = true;
+			if (add_path(&list, &count, &cap, p) < 0)
+				goto fail;
+			continue;
+		}
+		if (stat(p, &st) == 0 && S_ISDIR(st.st_mode)) {
+			if (add_dir_files(&list, &count, &cap, p) < 0)
+				goto fail;
+		} else {
+			if (add_path(&list, &count, &cap, p) < 0)
+				goto fail;
+		}
+	}
+	if (count > 1 && !saw_stdin)
+		qsort(list, (size_t)count, sizeof(char *), cmp_str);
+	*out = list;
+	*out_count = count;
+	return 0;
+fail:
+	if (list != NULL) {
+		for (i = 0; i < count; ++i)
+			free(list[i]);
+		free(list);
+	}
+	return -1;
+}
+
+static int
+reset_paths(char ***paths, int *path_count, const char *path)
+{
+	char **list = NULL;
+	int count = 0;
+	char *argv[1];
+	int i;
+
+	if (paths == NULL || path_count == NULL || path == NULL)
+		return -1;
+
+	argv[0] = (char *)path;
+	if (collect_paths(&list, &count, 1, argv, 0) < 0)
+		return -1;
+
+	if (*paths != NULL) {
+		for (i = 0; i < *path_count; ++i)
+			free((*paths)[i]);
+		free(*paths);
+	}
+	*paths = list;
+	*path_count = count;
+	return 0;
+}
+
+static int
+normalize_device_opt(int *argc, char ***argv)
+{
+	int i;
+
+	for (i = 1; i < *argc; ++i) {
+		if (strcmp((*argv)[i], "--device") == 0)
+			(*argv)[i] = (char *)"-d";
+	}
+	return 0;
+}
+
+static void
+trim_line(char *s)
+{
+	size_t len;
+	char *p;
+
+	len = strlen(s);
+	while (len > 0 && (s[len - 1] == '\n' || s[len - 1] == '\r' ||
+	    s[len - 1] == ' ' || s[len - 1] == '\t'))
+		s[--len] = '\0';
+	p = s;
+	while (*p == ' ' || *p == '\t')
+		p++;
+	if (p != s)
+		memmove(s, p, strlen(p) + 1);
+}
+
+static void
+keylist_clear(struct keylist *kl)
+{
+	if (kl == NULL)
+		return;
+	kl->count = 0;
+}
+
+static void
+keylist_add(struct keylist *kl, const char *seq, size_t len)
+{
+	if (kl == NULL || seq == NULL || len == 0)
+		return;
+	if (kl->count >= (int)(sizeof(kl->seqs) / sizeof(kl->seqs[0])))
+		return;
+	if (len >= sizeof(kl->seqs[kl->count].seq))
+		len = sizeof(kl->seqs[kl->count].seq) - 1;
+	memcpy(kl->seqs[kl->count].seq, seq, len);
+	kl->seqs[kl->count].seq[len] = '\0';
+	kl->seqs[kl->count].len = len;
+	kl->count++;
+}
+
+static void
+keymap_defaults(struct keymap *km)
+{
+	if (km == NULL)
+		return;
+	keylist_clear(&km->quit);
+	keylist_clear(&km->pause);
+	keylist_clear(&km->vol_down);
+	keylist_clear(&km->vol_up);
+	keylist_clear(&km->seek_back);
+	keylist_clear(&km->seek_fwd);
+	keylist_clear(&km->next);
+	keylist_clear(&km->prev);
+	keylist_clear(&km->repeat);
+	keylist_clear(&km->shuffle);
+	keylist_clear(&km->search);
+
+	keylist_add(&km->quit,		"q",	1);
+	keylist_add(&km->pause,		" ",	1);
+	keylist_add(&km->vol_down,	"j",	1);
+	keylist_add(&km->vol_up,	"k",	1);
+	keylist_add(&km->seek_back,	"h",	1);
+	keylist_add(&km->seek_fwd,	"l",	1);
+	keylist_add(&km->next,		"n",	1);
+	keylist_add(&km->prev,		"p",	1);
+	keylist_add(&km->repeat,	"r",	1);
+	keylist_add(&km->shuffle,	"s",	1);
+	keylist_add(&km->search,	"/",	1);
+}
+
+static void
+keymap_lists(const struct keymap *km, const struct keylist ***out, size_t *count)
+{
+	static const struct keylist *lists[11];
+
+	if (out == NULL || count == NULL) {
+		return;
+	}
+	if (km == NULL) {
+		*out = NULL;
+		*count = 0;
+		return;
+	}
+	lists[0]	= &km->quit;
+	lists[1]	= &km->pause;
+	lists[2]	= &km->vol_down;
+	lists[3]	= &km->vol_up;
+	lists[4]	= &km->seek_back;
+	lists[5]	= &km->seek_fwd;
+	lists[6]	= &km->next;
+	lists[7]	= &km->prev;
+	lists[8]	= &km->repeat;
+	lists[9]	= &km->shuffle;
+	lists[10]	= &km->search;
+
+	*out	= lists;
+	*count	= sizeof(lists) / sizeof(lists[0]);
+}
+
+static const struct {
+	const char	*name;
+	const char	*seq;
+	size_t		len;
+} keynames[] = {
+	{ "space",	" ",		1 },
+	{ "esc",	"\x1b",		1 },
+	{ "escape",	"\x1b",		1 },
+	{ "enter",	"\n",		1 },
+	{ "return",	"\n",		1 },
+	{ "tab",	"\t",		1 },
+	{ "backspace",	"\x7f",		1 },
+	{ "del",	"\x1b[3~",	4 },
+	{ "delete",	"\x1b[3~",	4 },
+	{ "ins",	"\x1b[2~",	4 },
+	{ "insert",	"\x1b[2~",	4 },
+	{ "home",	"\x1b[H",	3 },
+	{ "end",	"\x1b[F",	3 },
+	{ "pgup",	"\x1b[5~",	4 },
+	{ "pageup",	"\x1b[5~",	4 },
+	{ "pgdn",	"\x1b[6~",	4 },
+	{ "pagedown",	"\x1b[6~", 	4 },
+	{ "up",		"\x1b[A",	3 },
+	{ "down",	"\x1b[B",	3 },
+	{ "right",	"\x1b[C",	3 },
+	{ "left",	"\x1b[D",	3 }
+};
+
+static int
+token_to_seq(const char *tok, char *out, size_t *out_len)
+{
+	char low[32];
+	size_t n;
+	size_t i;
+	char c;
+
+	if (tok == NULL || out == NULL || out_len == NULL)
+		return -1;
+	if (tok[0] == '\0')
+		return -1;
+	if (strlen(tok) == 1) {
+		out[0] = tok[0];
+		*out_len = 1;
+		return 0;
+	}
+	n = strlen(tok);
+	if (n >= sizeof(low))
+		n = sizeof(low) - 1;
+	for (i = 0; i < n; ++i)
+		low[i] = (char)tolower((unsigned char)tok[i]);
+	low[n] = '\0';
+
+	if (strncmp(low, "alt+", 4) == 0 && tok[4] && !tok[5]) {
+		c = tok[4];
+		out[0] = 0x1b;
+		out[1] = c;
+		*out_len = 2;
+		return 0;
+	}
+
+	for (i = 0; i < sizeof(keynames) / sizeof(keynames[0]); ++i) {
+		if (strcmp(low, keynames[i].name) == 0) {
+			memcpy(out, keynames[i].seq, keynames[i].len);
+			*out_len = keynames[i].len;
+			return 0;
+		}
+	}
+	if (strncmp(low, "ctrl+", 5) == 0 && low[5] && !low[6]) {
+		c = tok[5];
+		if (c >= 'A' && c <= 'Z')
+			c = (char)(c - 'A' + 'a');
+		if (c >= 'a' && c <= 'z') {
+			out[0] = (char)(c - 'a' + 1);
+			*out_len = 1;
+			return 0;
+		}
+	}
+	return -1;
+}
+
+static int
+parse_fkey(const char *tok)
+{
+	char low[16];
+	size_t n;
+	size_t i;
+	char *end;
+	long v;
+
+	if (tok == NULL)
+		return -1;
+	n = strlen(tok);
+	if (n < 2)
+		return -1;
+	if (n >= sizeof(low))
+		n = sizeof(low) - 1;
+	for (i = 0; i < n; ++i)
+		low[i] = (char)tolower((unsigned char)tok[i]);
+	low[n] = '\0';
+	if (low[0] != 'f')
+		return -1;
+	v = strtol(low + 1, &end, 10);
+	if (*end != '\0')
+		return -1;
+	if (v < 1 || v > 24)
+		return -1;
+	return (int)v;
+}
+
+static void
+add_fkey(struct keylist *kl, int fn)
+{
+	static const struct {
+		const char	*seq;
+		size_t		len;
+	} ftab[][2] = {
+		{ { "\x1bOP", 3 }, { "\x1b[11~", 5 } },	/* F1 */
+		{ { "\x1bOQ", 3 }, { "\x1b[12~", 5 } },	/* F2 */
+		{ { "\x1bOR", 3 }, { "\x1b[13~", 5 } },	/* F3 */
+		{ { "\x1bOS", 3 }, { "\x1b[14~", 5 } },	/* F4 */
+		{ { "\x1b[15~", 5 }, { NULL, 0 } },	/* F5 */
+		{ { "\x1b[17~", 5 }, { NULL, 0 } },	/* F6 */
+		{ { "\x1b[18~", 5 }, { NULL, 0 } },	/* F7 */
+		{ { "\x1b[19~", 5 }, { NULL, 0 } },	/* F8 */
+		{ { "\x1b[20~", 5 }, { NULL, 0 } },	/* F9 */
+		{ { "\x1b[21~", 5 }, { NULL, 0 } },	/* F10 */
+		{ { "\x1b[23~", 5 }, { NULL, 0 } },	/* F11 */
+		{ { "\x1b[24~", 5 }, { NULL, 0 } },	/* F12 */
+		{ { "\x1b[25~", 5 }, { NULL, 0 } },	/* F13 */
+		{ { "\x1b[26~", 5 }, { NULL, 0 } },	/* F14 */
+		{ { "\x1b[28~", 5 }, { NULL, 0 } },	/* F15 */
+		{ { "\x1b[29~", 5 }, { NULL, 0 } },	/* F16 */
+		{ { "\x1b[31~", 5 }, { NULL, 0 } },	/* F17 */
+		{ { "\x1b[32~", 5 }, { NULL, 0 } },	/* F18 */
+		{ { "\x1b[33~", 5 }, { NULL, 0 } },	/* F19 */
+		{ { "\x1b[34~", 5 }, { NULL, 0 } },	/* F20 */
+		{ { "\x1b[42~", 5 }, { NULL, 0 } },	/* F21 */
+		{ { "\x1b[43~", 5 }, { NULL, 0 } },	/* F22 */
+		{ { "\x1b[44~", 5 }, { NULL, 0 } },	/* F23 */
+		{ { "\x1b[45~", 5 }, { NULL, 0 } }	/* F24 */
+	};
+	int idx;
+
+	if (kl == NULL)
+		return;
+	if (fn < 1 || fn > 24)
+		return;
+	idx = fn - 1;
+	keylist_add(kl, ftab[idx][0].seq, ftab[idx][0].len);
+	if (ftab[idx][1].seq != NULL)
+		keylist_add(kl, ftab[idx][1].seq, ftab[idx][1].len);
+}
+
+static void
+parse_key_list(const char *val, struct keylist *kl)
+{
+	const char *p;
+
+	if (val == NULL || kl == NULL)
+		return;
+	keylist_clear(kl);
+
+	p = val;
+	while (*p) {
+		char tok[64];
+		size_t tlen = 0;
+
+		while (*p == ' ' || *p == '\t' || *p == ',')
+			p++;
+		if (*p == '\0')
+			break;
+		if (*p == '"') {
+			p++;
+			while (*p && *p != '"' && tlen + 1 < sizeof(tok))
+				tok[tlen++] = *p++;
+			if (*p == '"')
+				p++;
+		} else {
+			while (*p && *p != ',' && *p != ' ' && *p != '\t' &&
+			    tlen + 1 < sizeof(tok))
+				tok[tlen++] = *p++;
+		}
+		tok[tlen] = '\0';
+		if (tlen > 0) {
+			char seq[16];
+			size_t slen = 0;
+			int fn;
+
+			fn = parse_fkey(tok);
+			if (fn > 0) {
+				add_fkey(kl, fn);
+			} else if (token_to_seq(tok, seq, &slen) == 0) {
+				keylist_add(kl, seq, slen);
+			}
+		}
+	}
+}
+
+static enum key_action
+match_keyseq(const struct keymap *km, const char *buf, size_t len)
+{
+	const struct keylist **lists = NULL;
+	size_t list_count = 0;
+	const enum key_action actions[] = {
+		KA_QUIT, KA_PAUSE, KA_VOL_DOWN, KA_VOL_UP, KA_SEEK_BACK,
+		KA_SEEK_FWD, KA_NEXT, KA_PREV, KA_REPEAT, KA_SHUFFLE,
+		KA_SEARCH
+	};
+	size_t i;
+	int j;
+
+	if (km == NULL || buf == NULL || len == 0)
+		return KA_NONE;
+	keymap_lists(km, &lists, &list_count);
+	if (list_count != sizeof(actions) / sizeof(actions[0]))
+		return KA_NONE;
+	for (i = 0; i < list_count; ++i) {
+		const struct keylist *kl = lists[i];
+
+		for (j = 0; j < kl->count; ++j) {
+			if (kl->seqs[j].len == len &&
+			    memcmp(kl->seqs[j].seq, buf, len) == 0)
+				return actions[i];
+		}
+	}
+	return KA_NONE;
+}
+
+static bool
+keybuf_is_prefix(const struct keymap *km, const char *buf, size_t len)
+{
+	const struct keylist **lists = NULL;
+	size_t list_count = 0;
+	size_t i;
+	int j;
+
+	if (km == NULL || buf == NULL || len == 0)
+		return false;
+	keymap_lists(km, &lists, &list_count);
+	for (i = 0; i < list_count; ++i) {
+		const struct keylist *kl = lists[i];
+
+		for (j = 0; j < kl->count; ++j) {
+			if (kl->seqs[j].len >= len &&
+			    memcmp(kl->seqs[j].seq, buf, len) == 0)
+				return true;
+		}
+	}
+	return false;
+}
+
+static bool
+keybuf_is_prefix_longer(const struct keymap *km, const char *buf, size_t len)
+{
+	const struct keylist **lists = NULL;
+	size_t list_count = 0;
+	size_t i;
+	int j;
+
+	if (km == NULL || buf == NULL || len == 0)
+		return false;
+	keymap_lists(km, &lists, &list_count);
+	for (i = 0; i < list_count; ++i) {
+		const struct keylist *kl = lists[i];
+
+		for (j = 0; j < kl->count; ++j) {
+			if (kl->seqs[j].len > len &&
+			    memcmp(kl->seqs[j].seq, buf, len) == 0)
+				return true;
+		}
+	}
+	return false;
+}
+
+static enum key_action
+keybuf_consume(const struct keymap *km, char *buf, size_t *len)
+{
+	enum key_action act;
+
+	while (*len > 0) {
+		act = match_keyseq(km, buf, *len);
+		if (act != KA_NONE) {
+			if (keybuf_is_prefix_longer(km, buf, *len))
+				return KA_NONE;
+			*len = 0;
+			return act;
+		}
+		if (keybuf_is_prefix(km, buf, *len))
+			return KA_NONE;
+		memmove(buf, buf + 1, *len - 1);
+		(*len)--;
+	}
+	return KA_NONE;
+}
+
+static enum key_action
+read_action(const struct keymap *km, int ctrl_fd, char *buf, size_t *len)
+{
+	char tmp[16];
+	ssize_t r;
+	ssize_t i;
+	enum key_action act;
+	static double last_input = 0.0;
+	double now;
+
+	now = now_sec();
+	r = (ctrl_fd >= 0) ? read(ctrl_fd, tmp, sizeof(tmp)) : -1;
+	if (r > 0) {
+		last_input = now;
+		for (i = 0; i < r; ++i) {
+			if (*len < 16) {
+				buf[*len] = tmp[i];
+				(*len)++;
+			} else {
+				memmove(buf, buf + 1, 15);
+				buf[15] = tmp[i];
+			}
+			act = keybuf_consume(km, buf, len);
+			if (act != KA_NONE)
+				return act;
+		}
+	} else if (r < 0 && errno != EAGAIN && errno != EWOULDBLOCK)
+		return KA_NONE;
+
+	if (*len > 0 && (now - last_input) > 0.05) {
+		act = match_keyseq(km, buf, *len);
+		if (act != KA_NONE) {
+			*len = 0;
+			return act;
+		}
+		*len = 0;
+	}
+	return keybuf_consume(km, buf, len);
+}
+
+static enum play_action
+handle_key(struct player_state *st, enum key_action ka, int *seek_dir,
+    bool allow_nextprev)
+{
+	if (ka == KA_NONE)
+		return PLAY_DONE;
+
+	switch (ka) {
+	case KA_QUIT:
+		g_quit = 1;
+		return PLAY_QUIT;
+	case KA_PAUSE:
+		st->paused = !st->paused;
+		if (snd_pcm_pause(st->pcm, st->paused ? 1 : 0) < 0) {
+			snd_pcm_drop(st->pcm);
+			snd_pcm_prepare(st->pcm);
+		}
+		break;
+	case KA_VOL_DOWN:
+		st->volume -= 5;
+		if (st->volume < 0)
+			st->volume = 0;
+		break;
+	case KA_VOL_UP:
+		st->volume += 5;
+		if (st->volume > 100)
+			st->volume = 100;
+		break;
+	case KA_SEEK_BACK:
+		if (seek_dir)
+			*seek_dir = -1;
+		break;
+	case KA_SEEK_FWD:
+		if (seek_dir)
+			*seek_dir = 1;
+		break;
+	case KA_NEXT:
+		if (allow_nextprev)
+			return PLAY_NEXT;
+		break;
+	case KA_PREV:
+		if (allow_nextprev)
+			return PLAY_PREV;
+		break;
+	case KA_REPEAT:
+		if (st->repeat)
+			*st->repeat = !*st->repeat;
+		break;
+	case KA_SHUFFLE:
+		if (st->shuffle)
+			*st->shuffle = !*st->shuffle;
+		break;
+	case KA_SEARCH:
+		break;
+	default:
+		break;
+	}
+	return PLAY_DONE;
+}
+
+static int
+load_config(char *device, size_t device_len, unsigned int *rate,
+    unsigned int *channels, int *volume, bool *repeat, bool *shuffle,
+    struct keymap *keys)
+{
+	FILE *f;
+	char line[512];
+	char path[512];
+	const char *home;
+
+	home = getenv("HOME");
+	if (home == NULL || home[0] == '\0')
+		return 0;
+	snprintf(path, sizeof(path), "%s/.jamrc", home);
+	f = fopen(path, "r");
+	if (f == NULL)
+		return 0;
+
+	while (fgets(line, sizeof(line), f)) {
+		char *eq;
+		char *key;
+		char *val;
+		unsigned int v;
+		int iv;
+
+		trim_line(line);
+		if (line[0] == '\0' || line[0] == '#')
+			continue;
+		eq = strchr(line, '=');
+		if (eq == NULL)
+			continue;
+		*eq = '\0';
+	key = line;
+	val = eq + 1;
+		trim_line(key);
+		trim_line(val);
+
+		if (strcmp(key, "rate") == 0) {
+			v = (unsigned int)atoi(val);
+			if (v > 0)
+				*rate = v;
+		} else if (strcmp(key, "channels") == 0) {
+			v = (unsigned int)atoi(val);
+			if (v > 0)
+				*channels = v;
+		} else if (strcmp(key, "volume") == 0) {
+			iv = atoi(val);
+			if (iv < 0)
+				iv = 0;
+			if (iv > 100)
+				iv = 100;
+			*volume = iv;
+		} else if (strcmp(key, "device") == 0) {
+			if (device != NULL && device_len > 0) {
+				strncpy(device, val, device_len - 1);
+				device[device_len - 1] = '\0';
+			}
+		} else if (strcmp(key, "repeat") == 0) {
+			*repeat = (strcmp(val, "1") == 0 ||
+			    strcmp(val, "true") == 0 ||
+			    strcmp(val, "yes") == 0);
+		} else if (strcmp(key, "shuffle") == 0) {
+			*shuffle = (strcmp(val, "1") == 0 ||
+			    strcmp(val, "true") == 0 ||
+			    strcmp(val, "yes") == 0);
+		} else if (keys != NULL) {
+			if (strcmp(key, "key_quit") == 0)
+				parse_key_list(val, &keys->quit);
+			else if (strcmp(key, "key_pause") == 0)
+				parse_key_list(val, &keys->pause);
+			else if (strcmp(key, "key_vol_down") == 0)
+				parse_key_list(val, &keys->vol_down);
+			else if (strcmp(key, "key_vol_up") == 0)
+				parse_key_list(val, &keys->vol_up);
+			else if (strcmp(key, "key_seek_back") == 0)
+				parse_key_list(val, &keys->seek_back);
+			else if (strcmp(key, "key_seek_fwd") == 0)
+				parse_key_list(val, &keys->seek_fwd);
+			else if (strcmp(key, "key_next") == 0)
+				parse_key_list(val, &keys->next);
+			else if (strcmp(key, "key_prev") == 0)
+				parse_key_list(val, &keys->prev);
+			else if (strcmp(key, "key_repeat") == 0)
+				parse_key_list(val, &keys->repeat);
+			else if (strcmp(key, "key_shuffle") == 0)
+				parse_key_list(val, &keys->shuffle);
+			else if (strcmp(key, "key_search") == 0)
+				parse_key_list(val, &keys->search);
+		}
+	}
+	fclose(f);
+	return 0;
+}
+
+static void
+save_config_volume(int volume)
+{
+	FILE *in;
+	FILE *out;
+	char line[512];
+	char path[512];
+	char tmp[512];
+	const char *home;
+	bool wrote = false;
+	struct stat st;
+	int fd;
+
+	home = getenv("HOME");
+	if (home == NULL || home[0] == '\0')
+		return;
+	snprintf(path, sizeof(path), "%s/.jamrc", home);
+
+	in = fopen(path, "r");
+	if (in == NULL) {
+		out = fopen(path, "w");
+		if (out == NULL)
+			return;
+		fprintf(out, "volume=%d\n", volume);
+		fclose(out);
+		return;
+	}
+
+	if (stat(path, &st) != 0)
+		st.st_mode = 0600;
+
+	snprintf(tmp, sizeof(tmp), "%s/.jamrc.tmpXXXXXX", home);
+	fd = mkstemp(tmp);
+	if (fd < 0) {
+		fclose(in);
+		return;
+	}
+	(void)fchmod(fd, st.st_mode & 0777);
+	out = fdopen(fd, "w");
+	if (out == NULL) {
+		close(fd);
+		unlink(tmp);
+		fclose(in);
+		return;
+	}
+
+	while (fgets(line, sizeof(line), in)) {
+		char work[512];
+		char *eq;
+		char *key;
+		char *val;
+
+		strncpy(work, line, sizeof(work) - 1);
+		work[sizeof(work) - 1] = '\0';
+		trim_line(work);
+		if (work[0] == '\0' || work[0] == '#') {
+			fputs(line, out);
+			continue;
+		}
+		eq = strchr(work, '=');
+		if (eq == NULL) {
+			fputs(line, out);
+			continue;
+		}
+		*eq = '\0';
+		key = work;
+		val = eq + 1;
+		trim_line(key);
+		trim_line(val);
+
+		if (strcmp(key, "volume") == 0) {
+			fprintf(out, "volume=%d\n", volume);
+			wrote = true;
+			continue;
+		}
+		fputs(line, out);
+	}
+
+	if (!wrote)
+		fprintf(out, "volume=%d\n", volume);
+
+	fclose(in);
+	fflush(out);
+	fclose(out);
+	(void)rename(tmp, path);
+}
+
+static enum play_action
+play_pcm(struct player_state *st, int fd, int ctrl_fd, const char *name,
+    bool allow_seek, bool allow_nextprev)
+{
+	uint8_t *buf;
+	enum key_action ka;
+	enum play_action act;
+	size_t buf_bytes;
+	size_t buf_frames;
+	size_t frames;
+	size_t offset;
+	size_t keylen = 0;
+	char keybuf[16];
+	size_t out_frames;
+	ssize_t nread;
+	snd_pcm_sframes_t wrote;
+	double last_ui;
+	int seek_dir;
+	struct search_state search = {0};
+	char base_dir[PATH_MAX];
+
+	buf_frames = BUFFER_FRAMES;
+	buf_bytes = buf_frames * st->frame_bytes;
+	buf = malloc(buf_bytes);
+	if (buf == NULL)
+		return PLAY_ERR;
+
+	last_ui = 0.0;
+	act = PLAY_DONE;
+
+	compute_base_dir(name, base_dir, sizeof(base_dir));
+	while (!g_stop) {
+		seek_dir = 0;
+		if (search.active) {
+			enum search_result sr =
+			    search_handle_input(&search, ctrl_fd, base_dir,
+			    g_search_path, sizeof(g_search_path));
+			if (sr == SEARCH_SUBMIT) {
+				search_end(name, st, &search, &last_ui);
+				act = PLAY_SEARCH;
+				goto out;
+			}
+			if (sr == SEARCH_CANCEL)
+				search_end(name, st, &search, &last_ui);
+		} else {
+			ka = read_action(st->keys, ctrl_fd, keybuf, &keylen);
+			if (ka == KA_SEARCH && ctrl_fd >= 0) {
+				search_start(&search);
+			} else {
+				act = handle_key(st, ka, &seek_dir,
+				    allow_nextprev);
+				if (act == PLAY_QUIT || act == PLAY_NEXT ||
+				    act == PLAY_PREV)
+					goto out;
+			}
+		}
+		if (seek_dir != 0 && allow_seek) {
+			double pos = bytes_to_sec(st->data_pos - st->data_offset,
+			    st);
+
+			seek_to(st, fd, pos +
+			    (seek_dir < 0 ? -SEEK_SECONDS : SEEK_SECONDS));
+		}
+
+		if (st->paused) {
+			struct timespec ts = {0, 100000000};
+
+			nanosleep(&ts, NULL);
+		} else {
+			nread = read(fd, buf, buf_bytes);
+			if (nread == 0) {
+				act = PLAY_DONE;
+				goto out;
+			}
+			if (nread < 0) {
+				if (errno == EINTR)
+					continue;
+				act = PLAY_ERR;
+				goto out;
+			}
+			st->data_pos += nread;
+
+			frames = (size_t)nread / st->frame_bytes;
+			apply_volume((int16_t *)buf, frames * st->channels,
+			    st->volume);
+
+			offset = 0;
+			out_frames = frames;
+			while (out_frames > 0) {
+				wrote = snd_pcm_writei(st->pcm, buf + offset,
+				    out_frames);
+				if (wrote < 0) {
+					if (wrote == -EPIPE) {
+						snd_pcm_prepare(st->pcm);
+						continue;
+					}
+					act = PLAY_ERR;
+					goto out;
+				}
+				offset += (size_t)wrote * st->frame_bytes;
+				out_frames -= (size_t)wrote;
+			}
+		}
+
+		if (search.active)
+			search_render(name, st, &search, &last_ui);
+		else
+			maybe_update_ui(name, st, &last_ui);
+
+		if (st->data_size > 0 &&
+		    st->data_pos >= st->data_offset + st->data_size) {
+			act = PLAY_DONE;
+			goto out;
+		}
+	}
+
+out:
+	free(buf);
+	return act;
+}
+
+#ifdef HAVE_FFMPEG
+static int64_t
+sec_to_ts(double sec, AVRational tb)
+{
+	return (int64_t)(sec / av_q2d(tb));
+}
+
+static const char *
+format_tag_name(char *out, size_t len, const char *path, AVFormatContext *fmt)
+{
+	const char *base;
+	const char *slash;
+	AVDictionaryEntry *title;
+	AVDictionaryEntry *artist;
+
+	if (out == NULL || len == 0)
+		return path;
+	out[0] = '\0';
+	base = path ? path : "-";
+	slash = strrchr(base, '/');
+	if (slash && slash[1])
+		base = slash + 1;
+
+	title = av_dict_get(fmt->metadata, "title", NULL, 0);
+	artist = av_dict_get(fmt->metadata, "artist", NULL, 0);
+	if (title && title->value && title->value[0]) {
+		if (artist && artist->value && artist->value[0])
+			snprintf(out, len, "%s - %s", artist->value,
+			    title->value);
+		else
+			snprintf(out, len, "%s", title->value);
+		return out;
+	}
+	snprintf(out, len, "%s", base);
+	return out;
+}
+
+static enum play_action
+ffmpeg_cleanup(enum play_action act, struct player_state *st, SwrContext **swr,
+    AVCodecContext **dec, AVFormatContext **fmt, AVPacket **pkt,
+    AVFrame **frm, uint8_t **outbuf)
+{
+	if (pkt)
+		av_packet_free(pkt);
+	if (frm)
+		av_frame_free(frm);
+	if (outbuf)
+		av_freep(outbuf);
+	if (st && st->pcm)
+		close_pcm(st->pcm);
+	if (swr && *swr)
+		swr_free(swr);
+	if (dec && *dec)
+		avcodec_free_context(dec);
+	if (fmt && *fmt)
+		avformat_close_input(fmt);
+	return act;
+}
+
+static enum play_action
+play_ffmpeg(struct player_state *st, const char *path, int ctrl_fd,
+    bool allow_nextprev, const char *device)
+{
+	AVFormatContext *fmt = NULL;
+	AVCodecContext *dec = NULL;
+	AVPacket *pkt = NULL;
+	AVFrame *frm = NULL;
+	SwrContext *swr = NULL;
+	AVStream *stm;
+	const AVCodec *codec;
+	char namebuf[256];
+	const char *disp;
+	enum key_action ka;
+	enum play_action act;
+	char keybuf[16];
+	size_t keylen = 0;
+	double last_ui;
+	uint8_t *outbuf = NULL;
+	int outbuf_size = 0;
+	int stream;
+	int out_rate;
+	int out_ch;
+	int ret;
+	double duration_sec;
+	int seek_dir;
+#if LIBAVUTIL_VERSION_MAJOR >= 57
+	AVChannelLayout out_layout;
+#else
+	int64_t out_layout;
+#endif
+	struct search_state search = {0};
+	char base_dir[PATH_MAX];
+
+	st->pcm = NULL;
+	av_log_set_level(AV_LOG_QUIET);
+
+	if (avformat_open_input(&fmt, path, NULL, NULL) < 0)
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	if (avformat_find_stream_info(fmt, NULL) < 0) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+
+	stream = av_find_best_stream(fmt, AVMEDIA_TYPE_AUDIO, -1, -1, NULL, 0);
+	if (stream < 0) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+
+	stm = fmt->streams[stream];
+	codec = avcodec_find_decoder(stm->codecpar->codec_id);
+	if (codec == NULL) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+
+	dec = avcodec_alloc_context3(codec);
+	if (dec == NULL) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+	if (avcodec_parameters_to_context(dec, stm->codecpar) < 0) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+	if (avcodec_open2(dec, codec, NULL) < 0) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+
+#if LIBAVUTIL_VERSION_MAJOR >= 57
+	out_rate = dec->sample_rate;
+	out_ch = dec->ch_layout.nb_channels > 0 ?
+	    dec->ch_layout.nb_channels : 2;
+	out_layout = dec->ch_layout;
+
+	if (out_layout.nb_channels == 0)
+		av_channel_layout_default(&out_layout, out_ch);
+
+#if LIBSWRESAMPLE_VERSION_MAJOR >= 4
+	if (swr_alloc_set_opts2(&swr, &out_layout, AV_SAMPLE_FMT_S16,
+	    out_rate, &out_layout, dec->sample_fmt, dec->sample_rate,
+	    0, NULL) < 0) {
+		avcodec_free_context(&dec);
+		avformat_close_input(&fmt);
+		return PLAY_ERR;
+	}
+#else
+	swr = swr_alloc_set_opts(NULL, out_layout.u.mask, AV_SAMPLE_FMT_S16,
+	    out_rate, out_layout.u.mask, dec->sample_fmt, dec->sample_rate,
+	    0, NULL);
+#endif
+#else
+	out_rate = dec->sample_rate;
+	out_ch = dec->channels > 0 ? dec->channels : 2;
+	out_layout = dec->channel_layout;
+
+	if (out_layout == 0)
+		out_layout = av_get_default_channel_layout(out_ch);
+
+	swr = swr_alloc_set_opts(NULL, out_layout, AV_SAMPLE_FMT_S16,
+	    out_rate, out_layout, dec->sample_fmt, dec->sample_rate, 0, NULL);
+#endif
+	if (swr == NULL || swr_init(swr) < 0) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+
+	st->rate	= (unsigned int)out_rate;
+	st->channels	= (unsigned int)out_ch;
+	st->bits	= 16;
+	st->frame_bytes	= (size_t)out_ch * 2;
+	st->data_offset	= 0;
+	st->data_pos	= 0;
+
+	duration_sec = 0.0;
+	if (stm->duration != AV_NOPTS_VALUE)
+		duration_sec = (double)stm->duration * av_q2d(stm->time_base);
+	else if (fmt->duration > 0)
+		duration_sec = (double)fmt->duration / (double)AV_TIME_BASE;
+	if (duration_sec > 0) {
+		double bps = bytes_per_sec(st);
+
+		st->data_size = (bps > 0.0) ? (off_t)(duration_sec * bps) : 0;
+	} else
+		st->data_size = 0;
+
+	ret = open_pcm(&st->pcm, device, st->rate, st->channels);
+	if (ret < 0) {
+		warnx("alsa: %s", snd_strerror(ret));
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+
+	disp = format_tag_name(namebuf, sizeof(namebuf), path, fmt);
+	pkt = av_packet_alloc();
+	frm = av_frame_alloc();
+	if (pkt == NULL || frm == NULL) {
+		return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec, &fmt,
+		    &pkt, &frm, &outbuf);
+	}
+
+	last_ui = 0.0;
+
+	compute_base_dir(path, base_dir, sizeof(base_dir));
+	while (!g_stop) {
+		seek_dir = 0;
+		if (search.active) {
+			enum search_result sr =
+			    search_handle_input(&search, ctrl_fd, base_dir,
+			    g_search_path, sizeof(g_search_path));
+			if (sr == SEARCH_SUBMIT) {
+				search_end(disp, st, &search, &last_ui);
+				return ffmpeg_cleanup(PLAY_SEARCH, st, &swr, &dec,
+				    &fmt, &pkt, &frm, &outbuf);
+			}
+			if (sr == SEARCH_CANCEL)
+				search_end(disp, st, &search, &last_ui);
+		} else {
+			ka = read_action(st->keys, ctrl_fd, keybuf, &keylen);
+			if (ka == KA_SEARCH && ctrl_fd >= 0) {
+				search_start(&search);
+			} else {
+				act = handle_key(st, ka, &seek_dir,
+				    allow_nextprev);
+				if (act == PLAY_QUIT || act == PLAY_NEXT ||
+				    act == PLAY_PREV) {
+					return ffmpeg_cleanup(act, st, &swr, &dec,
+					    &fmt, &pkt, &frm, &outbuf);
+				}
+			}
+		}
+		if (seek_dir != 0) {
+			double bps = bytes_per_sec(st);
+			double pos = bytes_to_sec(st->data_pos, st);
+
+			pos += (seek_dir < 0) ? -SEEK_SECONDS : SEEK_SECONDS;
+			if (pos < 0)
+				pos = 0;
+			int64_t ts = sec_to_ts(pos, stm->time_base);
+
+			if (av_seek_frame(fmt, stream, ts,
+			    AVSEEK_FLAG_BACKWARD) >= 0) {
+				avcodec_flush_buffers(dec);
+				snd_pcm_drop(st->pcm);
+				snd_pcm_prepare(st->pcm);
+				st->data_pos = (bps > 0.0) ? (off_t)(pos * bps) : 0;
+			}
+		}
+
+		if (st->paused) {
+			struct timespec ts = {0, 100000000};
+
+			nanosleep(&ts, NULL);
+			if (search.active)
+				search_render(disp, st, &search, &last_ui);
+			else
+				maybe_update_ui(disp, st, &last_ui);
+			continue;
+		}
+
+		ret = av_read_frame(fmt, pkt);
+		if (ret < 0)
+			avcodec_send_packet(dec, NULL);
+		else if (pkt->stream_index == stream)
+			avcodec_send_packet(dec, pkt);
+		av_packet_unref(pkt);
+
+		for (;;) {
+			ret = avcodec_receive_frame(dec, frm);
+			if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
+				break;
+			if (ret < 0) {
+				return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec,
+				    &fmt, &pkt, &frm, &outbuf);
+			}
+
+			int out_samples = av_rescale_rnd(
+			    swr_get_delay(swr, dec->sample_rate) +
+			    frm->nb_samples, out_rate, dec->sample_rate,
+			    AV_ROUND_UP);
+			int needed = av_samples_get_buffer_size(NULL, out_ch,
+			    out_samples, AV_SAMPLE_FMT_S16, 1);
+
+			if (needed > outbuf_size) {
+				av_freep(&outbuf);
+				outbuf = av_malloc((size_t)needed);
+				outbuf_size = needed;
+			}
+			if (outbuf == NULL) {
+				return ffmpeg_cleanup(PLAY_ERR, st, &swr, &dec,
+				    &fmt, &pkt, &frm, &outbuf);
+			}
+
+			uint8_t *out_planes[] = { outbuf };
+			int converted = swr_convert(swr, out_planes, out_samples,
+			    (const uint8_t **)frm->data, frm->nb_samples);
+			if (converted < 0)
+				continue;
+
+			size_t out_bytes = (size_t)converted * st->frame_bytes;
+			apply_volume((int16_t *)outbuf,
+			    (size_t)converted * st->channels, st->volume);
+
+			size_t frames = (size_t)converted;
+			size_t offset = 0;
+			while (frames > 0) {
+				snd_pcm_sframes_t wrote =
+				    snd_pcm_writei(st->pcm, outbuf + offset,
+				    frames);
+				if (wrote < 0) {
+					if (wrote == -EPIPE) {
+						snd_pcm_prepare(st->pcm);
+						continue;
+					}
+					break;
+				}
+				offset += (size_t)wrote * st->frame_bytes;
+				frames -= (size_t)wrote;
+			}
+
+			st->data_pos += (off_t)out_bytes;
+
+			if (search.active)
+				search_render(disp, st, &search, &last_ui);
+			else
+				maybe_update_ui(disp, st, &last_ui);
+		}
+
+		if (ret == AVERROR_EOF)
+			break;
+	}
+
+	return ffmpeg_cleanup(PLAY_DONE, st, &swr, &dec, &fmt,
+	    &pkt, &frm, &outbuf);
+}
+#endif
+
+static size_t
+sanitize_utf8_trunc(const char *s, size_t max_bytes, char *out, size_t out_sz)
+{
+	size_t i = 0;
+	size_t o = 0;
+
+	if (out_sz == 0)
+		return 0;
+	if (s == NULL) {
+		out[0] = '\0';
+		return 0;
+	}
+
+	while (s[i] != '\0' && i < max_bytes && o + 1 < out_sz) {
+		unsigned char c = (unsigned char)s[i];
+		size_t seq_len = 0;
+		size_t k;
+		bool ok = true;
+
+		if (c < 0x20 || c == 0x7f) {
+			out[o++] = '?';
+			i++;
+			continue;
+		}
+		if (c < 0x80) {
+			out[o++] = (char)c;
+			i++;
+			continue;
+		}
+
+		if (c >= 0xC2 && c <= 0xDF)
+			seq_len = 2;
+		else if (c >= 0xE0 && c <= 0xEF)
+			seq_len = 3;
+		else if (c >= 0xF0 && c <= 0xF4)
+			seq_len = 4;
+		else
+			seq_len = 1;
+
+		if (seq_len == 1 || i + seq_len > max_bytes) {
+			out[o++] = '?';
+			i++;
+			continue;
+		}
+		for (k = 1; k < seq_len; ++k) {
+			unsigned char cc = (unsigned char)s[i + k];
+			if ((cc & 0xC0) != 0x80) {
+				ok = false;
+				break;
+			}
+		}
+		if (!ok || o + seq_len >= out_sz) {
+			out[o++] = '?';
+			i++;
+			continue;
+		}
+		memcpy(out + o, s + i, seq_len);
+		o += seq_len;
+		i += seq_len;
+	}
+
+	out[o] = '\0';
+	return o;
+}
+
+int
+main(int argc, char *argv[])
+{
+	char		**paths = NULL;
+	struct termios	orig_term;
+	struct keymap	keys;
+	char		device[128];
+	int		path_count = 0;
+	int		ctrl_fd = -1;
+	bool		term_set = false;
+	unsigned int	rate = DEFAULT_RATE;
+	unsigned int	channels = DEFAULT_CHANNELS;
+	unsigned int	bits = 16;
+	int		volume = DEFAULT_VOLUME;
+	double		seek_start = 0.0;
+	bool		raw = false;
+	bool		repeat = false;
+	bool		shuffle = false;
+	int		cur_index = 0;
+	bool		use_stdin;
+	int		ch;
+	int		exit_code = 0;
+
+	progname = argv[0];
+	if (progname == NULL || progname[0] == '\0')
+		progname = "jam";
+
+	device[0] = '\0';
+	memset(&orig_term, 0, sizeof(orig_term));
+	keymap_defaults(&keys);
+	load_config(device, sizeof(device), &rate, &channels, &volume,
+	    &repeat, &shuffle, &keys);
+	normalize_device_opt(&argc, &argv);
+
+	while ((ch = getopt(argc, argv, "Rd:r:c:v:s:h")) != -1) {
+		switch (ch) {
+		case 'R':
+			raw = true;
+			break;
+		case 'd':
+			if (optarg && optarg[0]) {
+				strncpy(device, optarg, sizeof(device) - 1);
+				device[sizeof(device) - 1] = '\0';
+			}
+			break;
+		case 'r':
+			rate = (unsigned int)atoi(optarg);
+			break;
+		case 'c':
+			channels = (unsigned int)atoi(optarg);
+			break;
+		case 'v':
+			volume = atoi(optarg);
+			if (volume < 0)
+				volume = 0;
+			if (volume > 100)
+				volume = 100;
+			break;
+		case 's':
+			seek_start = atof(optarg);
+			if (seek_start < 0)
+				seek_start = 0;
+			break;
+		case 'h':
+		default:
+			usage();
+		}
+	}
+	argc -= optind;
+	argv += optind;
+
+	if (argc > 0) {
+		if (collect_paths(&paths, &path_count, argc, argv, 0) < 0) {
+			warnx("failed to read paths");
+			exit_code = 1;
+			goto out;
+		}
+	}
+
+	use_stdin = (path_count == 0 || strcmp(paths[0], "-") == 0);
+	if (use_stdin) {
+		ctrl_fd = open("/dev/tty", O_RDONLY);
+	} else if (isatty(STDIN_FILENO))
+		ctrl_fd = STDIN_FILENO;
+
+	if (set_raw_mode_fd(ctrl_fd, &orig_term) == 0)
+		term_set = true;
+
+	signal(SIGINT, on_sigint);
+	signal(SIGTERM, on_sigint);
+
+	srand((unsigned int)time(NULL));
+
+	if (use_stdin && !raw) {
+		warnx("stdin requires -R (raw PCM 16-bit LE)");
+		exit_code = 1;
+		goto out;
+	}
+
+	while (!g_stop) {
+		const char *path = use_stdin ? "-" : paths[cur_index];
+		const char *name = use_stdin ? "-" : path;
+		struct player_state st = {0};
+		enum play_action act = PLAY_DONE;
+		int rc;
+		int fd = STDIN_FILENO;
+
+		st.volume = volume;
+		st.repeat = &repeat;
+		st.shuffle = &shuffle;
+		st.keys = &keys;
+
+		if (!use_stdin) {
+			fd = open(path, O_RDONLY);
+			if (fd < 0) {
+				warn("%s", path);
+				exit_code = 1;
+				act = PLAY_ERR;
+				goto loop_done;
+			}
+		}
+
+		if (raw) {
+			st.rate		= rate;
+			st.channels	= channels;
+			st.bits		= bits;
+			st.frame_bytes	= (size_t)channels * (bits / 8);
+			st.seekable	= !use_stdin;
+			st.data_offset	= 0;
+			st.data_size	= 0;
+			st.data_pos	= 0;
+
+			rc = open_pcm(&st.pcm, device, rate, channels);
+			if (rc < 0) {
+				warnx("alsa: %s", snd_strerror(rc));
+				exit_code = 1;
+				act = PLAY_ERR;
+				goto loop_done;
+			}
+			act = play_pcm(&st, fd, ctrl_fd, name, false,
+			    (!use_stdin && path_count > 1));
+			close_pcm(st.pcm);
+		} else {
+			struct wav_info info = {0};
+
+			if (!use_stdin)
+				info = parse_wav_header(fd);
+			if (!use_stdin && info.ok && info.bits == 16) {
+				rate = info.rate;
+				channels = info.channels;
+				bits = info.bits;
+				if (lseek(fd, info.data_offset, SEEK_SET) < 0) {
+					warn("lseek");
+					exit_code = 1;
+					act = PLAY_ERR;
+					goto loop_done;
+				}
+
+				st.rate		= rate;
+				st.channels	= channels;
+				st.bits		= bits;
+				st.frame_bytes	= (size_t)channels * (bits / 8);
+				st.seekable	= true;
+				st.data_offset	= info.data_offset;
+				st.data_size	= info.data_size;
+				st.data_pos	= info.data_offset;
+
+				if (seek_start > 0)
+					seek_to(&st, fd, seek_start);
+
+				rc = open_pcm(&st.pcm, device, rate, channels);
+				if (rc < 0) {
+					warnx("alsa: %s", snd_strerror(rc));
+					exit_code = 1;
+					act = PLAY_ERR;
+					goto loop_done;
+				}
+				act = play_pcm(&st, fd, ctrl_fd, name, true,
+				    (!use_stdin && path_count > 1));
+				close_pcm(st.pcm);
+			} else {
+#ifdef HAVE_FFMPEG
+				if (!use_stdin) {
+					act = play_ffmpeg(&st, path, ctrl_fd,
+					    (path_count > 1), device);
+				} else {
+					warnx("stdin requires -R (raw PCM 16-bit LE)");
+					exit_code = 1;
+					act = PLAY_ERR;
+					goto loop_done;
+				}
+#else
+				warnx("unsupported format (rebuild with ffmpeg)");
+				exit_code = 1;
+				act = PLAY_ERR;
+				goto loop_done;
+#endif
+			}
+		}
+
+loop_done:
+		if (!use_stdin)
+			close(fd);
+
+		volume = st.volume;
+
+	if (act == PLAY_ERR)
+		exit_code = 1;
+	if (act == PLAY_QUIT || act == PLAY_ERR)
+		break;
+	if (act == PLAY_SEARCH) {
+		if (reset_paths(&paths, &path_count, g_search_path) < 0) {
+			warnx("failed to read paths");
+			exit_code = 1;
+			break;
+		}
+		cur_index = 0;
+		use_stdin = (path_count == 0 || strcmp(paths[0], "-") == 0);
+		if (use_stdin && !raw) {
+			warnx("stdin requires -R (raw PCM 16-bit LE)");
+			exit_code = 1;
+			break;
+		}
+		continue;
+	}
+		if (use_stdin)
+			break;
+
+		if (repeat && act == PLAY_DONE)
+			continue;
+		if (shuffle && path_count > 1) {
+			cur_index = rand() % path_count;
+			continue;
+		}
+		if (act == PLAY_PREV) {
+			if (cur_index > 0)
+				cur_index--;
+			else
+				cur_index = 0;
+		} else {
+			if (cur_index + 1 < path_count)
+				cur_index++;
+			else
+				break;
+		}
+	}
+
+out:
+	fprintf(stderr, "\n");
+	if (term_set)
+		restore_term_fd(ctrl_fd, &orig_term);
+	if (ctrl_fd >= 0 && ctrl_fd != STDIN_FILENO)
+		close(ctrl_fd);
+	if (paths != NULL) {
+		int i;
+
+		for (i = 0; i < path_count; ++i)
+			free(paths[i]);
+		free(paths);
+	}
+	if (!g_quit)
+		save_config_volume(volume);
+	return exit_code;
+}
diff --git a/jamrc b/jamrc
new file mode 100644
index 0000000..66eabd4
--- /dev/null
+++ b/jamrc
@@ -0,0 +1,24 @@
+rate=44100
+channels=2
+volume=85
+device=default
+repeat=0
+shuffle=0
+
+# keybinds
+# value(s) can be single character (case-sensitive) or key name, comma-separated.
+# key names:    space, esc, enter, tab, backspace, up, down, left, right, home, end, pgup, pgdn, ins, del
+# modifiers:    ctrl+a..ctrl+z, alt+space
+# func keys:    F1..F24
+
+key_quit=       "q",    "esc",  "ctrl+c"
+key_pause=      "p",    "space"
+key_vol_down=   "j",    "-",    "down"
+key_vol_up=     "k",    "+",    "up"
+key_seek_back=  "h",    "left"
+key_seek_fwd=   "l",    "right"
+key_next=       "n",    "F8",   "pgdn"
+key_prev=       "p",    "F7",   "pgup"
+key_repeat=     "r",    "F2"
+key_shuffle=    "s",    "F3"
+key_search=     "/",    "F4"