Checkpoint ongoing work.
authorNot Zed <notzed@gmail.com>
Sun, 7 Jan 2024 23:35:59 +0000 (10:05 +1030)
committerNot Zed <notzed@gmail.com>
Sun, 7 Jan 2024 23:35:59 +0000 (10:05 +1030)
 Updates to player gui.
 Added internals documentation.

Makefile
README
TODO
dbindex.h
http-monitor.c
music-player.c
player.html

index fdb8bab..57b111f 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,10 +1,10 @@
 
 # FIXME: dependencies n shit
 
-FFMPEG=/opt/ffmpeg/4.0
-LMDB=/home/notzed/src/openldap/libraries/liblmdb
+#FFMPEG=/opt/ffmpeg/4.0
+#LMDB=/home/notzed/src/openldap/libraries/liblmdb
 EZE=../libeze
-WARN=-Wno-deprecated-declarations -Wno-parentheses -Wno-unused-but-set-variable -Wno-pointer-sign
+WARN=-Wno-unused-but-set-variable -Wno-pointer-sign -Wmissing-prototypes
 
 pkgs=ffmpeg lmdb blkid asound espeak
 
@@ -56,8 +56,8 @@ dbmarshal.c: blobs.o $(EZE)/ez-blob-compiler
        $(EZE)/ez-blob-compiler $< DBDISK_DESC DBFILE_DESC DBLIST_DESC > $@~
        mv $@~ $@
 
-dbmarshal.o: dbmarshal.c
-       $(CC) $(CFLAGS) -Wno-unused -c -o $@ $<
+dbmarshal.o: dbmarshal.c dbmarshal.h
+       $(CC) $(CFLAGS) -Wno-unused -Wno-missing-prototypes -c -o $@ $<
 
 clean:
        rm -f $(PROGS)
@@ -117,6 +117,12 @@ http-monitor: http-monitor.o dbindex.o notify.o dbmarshal.o blobs.o ../libeze/li
 
 dbindex.o: dbmarshal.h
 
+proto: proto.o dbmarshal.o blobs.o
+proto.o: proto.c dbindex.c
+
+test-index: test-index.o dbmarshal.o blobs.o
+test-index.o: test-index.c dbindex.c
+
 %.d: %.c
        @rm -rf $@
        @cc -MM -MT "$*.o" $< -o $@~ $(CFLAGS) 2>/dev/null
diff --git a/README b/README
index b7572c6..f5de658 100644 (file)
--- a/README
+++ b/README
@@ -90,3 +90,59 @@ Operations on Coming Up
 
 * Remove (user playlist, queue)
   Remove from playlist
+
+
+internals
+---------
+
+Database implemented in lmdb using ez-blob for serialisation.
+
+pseudo-SQL equivalent.
+
+All primary keys are 'auto increment start=1'.
+
+create table disk (
+       id int rimary key,
+       uuid text unique,
+       label text,
+       type text
+       mount text
+)
+
+create table file (
+       id int primary key,
+       diskid int not null references disk(id),
+       size bigint,
+       mtime bigint,
+       duration bigint,
+
+       path text unique,
+       dir text,
+       title text,
+       artist text,
+)
+
+create index file_by_title on file(dir);
+create index file_by_title on file(title);
+create index file_by_title on file(artist);
+
+create table list (
+       id int primary key,
+       int size,
+       name text unique,
+       desc text
+)
+
+create table file_by_list (
+       listid int not null references list(id),
+       seq int not null,
+       fileid int not null,
+       unique (listid, seq)
+)
+
+create table list_by_file (
+       fileid int not null references file(id),
+       listid int not null references list(id),
+       seq int not null,
+       unique (listid, seq)
+)
diff --git a/TODO b/TODO
index 98db9f7..a62d457 100644 (file)
--- a/TODO
+++ b/TODO
@@ -1,31 +1,4 @@
 
-o bugs
- - something wrong with playlist and direct-play state/reverse lookup?
-
-
-o Multiple playlists
- - leverage the shuffle code
- - add a playlist secondary index?
-
- create table shuffle {
-   seq int,
-   index int,
-   playlist int,
-   primary key (seq),
-   foreign key seq references file(id),
-   index on (index)
- }
-
-table playlist {
-  id int,
-  shuffleid int,
-
-  text name,
-
-  foreign key id references shuffle(index),
-  foreign key shuffleid references shuffle(index)
-}
-
 o check end of file processing
  - seems to truncate the last frame?
 
@@ -38,3 +11,106 @@ o internals
   - add items as scanned?
   - when you add an item to a playlist, randomly swap it's order with an existing item
   X problem is all-playlist is ordered by path so will typically require very large update anyway
+
+o enhancements
+ - some sort of play stats
+  - plays, seeks, skips
+  - date?
+  - by playlist?
+  - or just log each play?
+
+o playlist
+  - coming up
+    - list in order that would be played
+      - play now
+      - user list
+      - juke box
+      - default
+  - should coming up have history?
+  - how to visit 'play now' history?
+
+o display only a single list
+  - tab-like buttons to switch between which list is being shown?
+  - search as one option, or implied if search query not empty?
+  - how to switch lists?  how to enqueue?
+
+[all] [jukebox] [...]
+[search | query]
+
+ [ entries ]
+ [ entries ]
+ [ entries ]
+ [ entries ]
+
+  - entry buttons
+    - add to playlist (what context?)
+    - goto this list next?
+
+  * naah this is a bit pants
+
+[coming up] [search | query | clear]
+
+ - how to switch to a playlist?
+
+
+o list views
+  @ coming up list
+    - includes the playing now, jukebox, default logic
+    - only allows 'jump to' for entries
+    - search?
+  @ search
+    - add to list (which?)
+    - play now
+  @ playlist
+    - playlist editing
+    - delete, ordering?
+    - pagination?
+    - search?
+    - search + add?
+
+maybe
+
+ [coming up] [all?] [list 1] [list 2] [list n]
+ [search: ]
+
+  - search applies to current list
+  - entries displayed in play order?  or?
+  X still no context for list add
+
+simplify!  blah, still want playlists though
+
+ playlist
+ jukebox
+ default
+
+
+o should playlists remember their location?
+  - switch to playlist just continues
+  - jukebox how??
+
+
+ARGH - just need to fucking work it out.
+
+
+!BASICS!
+--------
+
+Core function, just have a default playlist and a jukebox.
+
+ jukebox
+ default
+
+Search
+ - all songs
+ - only option is to queue
+
+The List
+ - is either coming up or search results
+ - coming up shows jukebox or default list
+
+ Coming up
+  - play button will jump to that position in list
+
+ Search Results
+  - play button adds to jukebox playlist
+
index 4e2b4db..6b53ef5 100644 (file)
--- a/dbindex.h
+++ b/dbindex.h
@@ -123,6 +123,9 @@ void dbindex_abort(dbtxn *tx);
 int dbstate_get(dbtxn *tx, dbindex *db, dbstate *s);
 int dbstate_put(dbtxn *tx, dbindex *db, dbstate *s);
 
+//int dbstate_get_list(dbtxn *tx, dbindex *db, dbid_t listid, dblistcursor *c);
+//int dbstate_put_list(dbtxn *tx, dbindex *db, dbid_t listid, dblistcursor *c);
+
 dbdisk *dbdisk_get(dbtxn *tx, dbindex *db, int diskid);
 dbdisk *dbdisk_get_uuid(dbtxn *tx, dbindex *db, const char *uuid);
 void dbdisk_free(dbdisk *d);
index 2a1fdbb..e8460ec 100644 (file)
@@ -87,6 +87,12 @@ static int blobio_load(struct ez_blobio *io, const char *path, struct obstack *o
        return res;
 }
 
+static void dump_params(struct ez_httprequest *r) {
+       printf("request: '%s'\n", r->url);
+       for (struct ez_pair *w = ez_list_head(&r->params), *n = ez_node_succ(w);n;w=n,n = ez_node_succ(w))
+               printf(" %s=%s\n", w->name, w->value);
+}
+
 static struct ez_pair *find_param(struct ez_httprequest *r, const char *name) {
        for (struct ez_pair *w = ez_list_head(&r->params), *n = ez_node_succ(w);n;w=n,n = ez_node_succ(w)) {
                if (strcasecmp(w->name, name) == 0)
@@ -132,13 +138,50 @@ static void duration_value(struct obstack *os, uint64_t ms) {
        obstack_printf(os, "\"%02d:%02d:%02d\"", (int)h, (int)m, (int)s);
 }
 
+// Might be good enough assuming 'nice' data.
+static void write_value_json(struct obstack *os, const char *s) {
+       char c;
+
+       obstack_1grow(os, '"');
+       while ((c = *s++)) {
+               switch (c) {
+               case '\b':
+                       obstack_grow(os, "\\b", 2);
+                       break;
+               case '\f':
+                       obstack_grow(os, "\\f", 2);
+                       break;
+               case '\n':
+                       obstack_grow(os, "\\n", 2);
+                       break;
+               case '\r':
+                       obstack_grow(os, "\\r", 2);
+                       break;
+               case '\t':
+                       obstack_grow(os, "\\t", 2);
+                       break;
+               case '\\':
+               case '"':
+                       obstack_1grow(os, '\\');
+               default:
+                       obstack_1grow(os, c);
+                       break;
+               }
+       }
+       obstack_1grow(os, '"');
+}
+
 static void write_file_json(struct obstack *os, dbfile *file) {
        obstack_printf(os, "\"id\":\"%d\"", file->id);
 
-       if (file->title)
-               obstack_printf(os, ",\"title\":\"%s\"", file->title);
-       if (file->artist)
-               obstack_printf(os, ",\"artist\":\"%s\"", file->artist);
+       if (file->title) {
+               obstack_sgrow(os, ",\"title\":");
+               write_value_json(os, file->title);
+       }
+       if (file->artist) {
+               obstack_sgrow(os, ",\"artist\":");
+               write_value_json(os, file->artist);
+       }
 
        obstack_sgrow(os, ",\"length\":");
        duration_value(os, file->duration / 1000);
@@ -162,7 +205,8 @@ static void write_state_json(dbindex *db, struct obstack *os) {
                obstack_1grow(os, '{');
                write_file_json(os, file);
 
-               obstack_printf(os, ",\"path\":\"%s\"", file->path);
+               obstack_sgrow(os, ",\"path\":");
+               write_value_json(os, file->path);
                obstack_printf(os, ",\"status\":\"%s\"", (state.state & 2) ? "paused" : "playing");
 
                obstack_printf(os, ",\"listid\":\"%u\"", state.current.listid);
@@ -197,7 +241,12 @@ static int write_lists_json(dbindex *db, struct obstack *io) {
 
                obstack_1grow(io, '{');
                obstack_printf(io, "\"id\":\"%d\"", list->id);
-               obstack_printf(io, ",\"name\":\"%s\"", list->name);
+               obstack_sgrow(io, ",\"name\":");
+               write_value_json(io, list->name);
+               if (list->desc) {
+                       obstack_sgrow(io, ",\"desc\":");
+                       write_value_json(io, list->desc);
+               }
                obstack_printf(io, ",\"size\":\"%d\"", list->size);
                obstack_1grow(io, '}');
        }
@@ -217,31 +266,41 @@ static void write_list_json(dbindex *db, struct obstack *io, int listid, int seq
        dbscan scan;
        dbfile *file;
        dbtxn *tx = dbindex_begin(db, NULL, 1);
-       dblistcursor list = { .listid = listid, .seq = seq, .fileid = fileid };
+       dblistcursor cursor = { .listid = listid, .seq = seq, .fileid = fileid };
+       dblist *list = dblist_get(tx, db, listid);
 
-       // TODO: get list properly and seq
+       // FIXME: get list properly and seq
+       if (!list)
+               goto fail;
 
        obstack_1grow(io, '{');
-       obstack_sgrow(io, "\"list_name\":\"shuffle\"");
+
+       obstack_sgrow(io, "\"name\":");
+       write_value_json(io, list->name);
+       if (list->desc) {
+               obstack_sgrow(io, ",\"desc\":");
+               write_value_json(io, list->desc);
+       }
        obstack_sgrow(io, ",\"items\": [");
 
-       file = dbscan_list_entry(tx, &scan, db, &list);
+       file = dbscan_list_entry(tx, &scan, db, &cursor);
        for (int i=0;file && i<10;i++) {
                obstack_sgrow(io, i > 0 ? ",{" : "{");
                write_file_json(io, file);
 
-               obstack_printf(io, ",\"listid\":\"%u\"", list.listid);
-               obstack_printf(io, ",\"seq\":\"%u\"", list.seq);
+               obstack_printf(io, ",\"listid\":\"%u\"", cursor.listid);
+               obstack_printf(io, ",\"seq\":\"%u\"", cursor.seq);
 
                obstack_1grow(io, '}');
 
                dbfile_free(file);
-               file = dbscan_list_entry_next(&scan, &list);
+               file = dbscan_list_entry_next(&scan, &cursor);
        }
        dbfile_free(file);
        obstack_sgrow(io, "]}");
 
        dbscan_free(&scan);
+fail:
        dbindex_abort(tx);
 }
 
@@ -421,24 +480,23 @@ static int handle_list(struct ez_httprequest *req, struct ez_httpresponse *rep)
        int seq = param_int(req, "s", 0); // TODO: should be in /x/list/{listid}/{seq} ?
 
        printf("%s %s, listid=%d seq=%d fid=%d\n", req->method, req->url, lid, seq, fid);
-
        if (strcmp(req->method, "GET") == 0) {
                write_list_json(db, os, lid, seq, fid);
                return set_data_json(rep);
        } else if (strcmp(req->method, "POST") == 0) {
                struct dblistcursor info = { .listid = lid, .fileid = fid };
-               // /x/list/{listid} post fid=?  ignore seq
+               // /x/list/{listid} post f=?  ignore seq
                if (fid == 0)
                        goto fail0;
 
                dbtxn *tx = dbindex_begin(db, NULL, 0);
                if (!tx)
                        goto fail0;
-               if (dblist_add_file(tx, db, &info))
-                       goto fail1;
                dblist *list = dblist_get(tx, db, lid);
                if (!list)
                        goto fail1;
+               if (dblist_add_file(tx, db, &info))
+                       goto fail1;
 
                if (dbindex_commit(tx))
                        goto fail2;
@@ -475,22 +533,27 @@ static int handle_search(struct ez_httprequest *req, struct ez_httpresponse *rep
        return set_data_json(rep);
 }
 
-// goto specific file /goto?f={file.id}
+// goto specific file /goto?f={file.id}[&l={list.id}]
+// fid lid   action
+//   0  !0   start playing list  (FIXME)
+//  !0   0   'play now' (play next?)
+//  !0  !0   play file in list
+//
 // TODO: playlist
 static int handle_goto(struct ez_httprequest *req, struct ez_httpresponse *rep) {
        int fid = param_int(req, "f", 0);
+       int lid = param_int(req, "l", 0);
 
-       // TODO: look it up in the db?
+       if (fid == 0 && lid == 0) {
+               httpresponse_set_response(rep, 404, "Not Found");
+               return 0;
+       } else {
+               struct notify_goto gogo = { .info.fileid = fid, .info.listid = lid };
 
-       if (fid != 0) {
-               struct notify_goto gogo = { .info.fileid = fid };
+               notify_msg_send(player, (fid != 0 && lid == 0) ? NOTIFY_PLAY_NOW : NOTIFY_PLAY_GOTO, 0, &gogo);
 
-               notify_msg_send(player, NOTIFY_PLAY_GOTO, 0, &gogo);
                httpresponse_set_response(rep, 202, "Requested");
                return 0;
-       } else {
-               httpresponse_set_response(rep, 404, "Not Found");
-               return 0;
        }
 }
 
index 299c1ec..ce09607 100644 (file)
@@ -17,6 +17,7 @@
    <http://www.gnu.org/licenses/>.
 */
 
+#include <libavcodec/avcodec.h>
 #include <libavformat/avformat.h>
 #include <libswresample/swresample.h>
 #include <libavfilter/avfilter.h>
@@ -414,11 +415,13 @@ static int audio_init_filter(struct audio_player *ap) {
 
        AVCodecContext *avctx = ap->cc;
        AVRational time_base = ap->audio->time_base;
+       char layout[32];
 
-       sprintf(tmp, "time_base=%d/%d:sample_rate=%d:sample_fmt=%s:channel_layout=0x%"PRIx64,
+       res = av_channel_layout_describe(&avctx->ch_layout, layout, sizeof(layout));
+       sprintf(tmp, "time_base=%d/%d:sample_rate=%d:sample_fmt=%s:channel_layout=0x%s",
                time_base.num, time_base.den, avctx->sample_rate,
                av_get_sample_fmt_name(avctx->sample_fmt),
-               avctx->channel_layout);
+               layout);
        fprintf(stderr, "asource: %s\n", tmp);
        res = avfilter_graph_create_filter(&ap->asource_ctx, asource, "src", tmp, NULL, ap->fg);
        if (res < 0)
@@ -546,8 +549,9 @@ int audio_close_media(struct audio_player *ap) {
 
 static int audio_init_media(struct audio_player *ap, const char *path) {
        int res;
-       AVCodec *codec;
+       const AVCodec *codec;
        int audioid;
+       char layout[32];
 
        printf("audio_init_media '%s'\n", path);
 
@@ -580,9 +584,11 @@ static int audio_init_media(struct audio_player *ap, const char *path) {
        if (res < 0)
                goto fail;
 
+       res = av_channel_layout_describe(&ap->cc->ch_layout, layout, sizeof(layout));
+
        printf("codec rate: %d\n", ap->cc->sample_rate);
-       printf("codec channels: %d\n", ap->cc->channels);
-       printf("codec layout: %lu\n", ap->cc->channel_layout);
+       printf("codec channels: %d\n", ap->cc->ch_layout.nb_channels);
+       printf("codec layout: %s\n", layout);
        printf("codec format: %s\n", av_get_sample_fmt_name(ap->cc->sample_fmt));
 
        res = audio_init_pcm(ap);
@@ -658,6 +664,13 @@ static void dump_cursor(const char *what, dblistcursor *info) {
     - DEFAULT
 
   Once a file finishes the search begins at USER always?
+
+  This is shit.
+
+  Playlist needs to be done differently.
+   - one list-cursor for each list
+   - current is just what is playing now
+   - when it finishes the list state is copied to current and advanced
  */
 static int audio_advance_file(struct audio_player *ap, dbfile *(*advance)(dbscan *scan, dblistcursor *info)) {
        int res;
@@ -666,7 +679,6 @@ static int audio_advance_file(struct audio_player *ap, dbfile *(*advance)(dbscan
        dbfile *file;
        dbtxn *tx = dbindex_begin(ap->index, NULL, 1);
        int retry = 0;
-       int trynext = 1;
 
        dbfile_free(ap->playing);
        ap->playing = NULL;
@@ -675,6 +687,9 @@ static int audio_advance_file(struct audio_player *ap, dbfile *(*advance)(dbscan
        if (ap->playing_state.list_mode == LIST_PLAYNOW)
                ap->playing_state.list_mode = LIST_USER;
 
+       // TODO: what what
+       ap->playing_state.list_mode = LIST_JUKEBOX;
+
        do {
                dblistcursor *active;
 
@@ -699,12 +714,7 @@ static int audio_advance_file(struct audio_player *ap, dbfile *(*advance)(dbscan
                printf("loop: %d\n", retry);
                file = dbscan_list_entry(tx, &scan, ap->index, active);
                if (file) {
-                       //printf("entry: %s\n", file->path);
-                       if (trynext) {
-                               dbfile_free(file);
-                               file = advance(&scan, active);
-                       }
-                       //printf("next: %s\n", file ? file->path : "<nil>");
+                       printf("entry: %s\n", file->path);
                } else {
                        // repeat?  reset?
                        printf("no entry found. scan.res =%d  db.res=%d\n", dbscan_result(&scan), dbindex_result(ap->index));
@@ -721,7 +731,19 @@ static int audio_advance_file(struct audio_player *ap, dbfile *(*advance)(dbscan
                                ap->playing_state.current = *active;
                                ap->playing = file;
                                audio_checkpoint_state(ap);
-                               break;
+
+                               // store advanced state as well
+                               file = dbscan_list_entry_next(&scan, active);
+                               if (file) {
+                                       dbfile_free(file);
+                               } else {
+                                       active->seq += 1;
+                                       active->fileid = 0;
+                               }
+                               printf("playing ok, next is lid=%d fid=%d seq=%d\n", active->listid, active->fileid, active->seq);
+                               dbscan_free(&scan);
+                               dbindex_abort(tx);
+                               return res;
                        }
 
                        dbfile_free(file);
@@ -735,17 +757,14 @@ static int audio_advance_file(struct audio_player *ap, dbfile *(*advance)(dbscan
                        case LIST_USER:
                                // if loop ... just loop
                                ap->playing_state.list_mode = LIST_JUKEBOX;
-                               trynext = 1;
                                break;
                        case LIST_JUKEBOX:
                                ap->playing_state.list_mode = LIST_DEFAULT;
-                               trynext = 0;
                                break;
                        case LIST_DEFAULT:
                                // loop the default list always
                                ap->playing_state.list_default.seq = 0;
                                ap->playing_state.list_default.fileid = 0;
-                               trynext = 0;
                                break;
                        }
                } else {
@@ -884,7 +903,7 @@ static int audio_play_enqueue(struct audio_player *ap, int fileid) {
                        // If we're not already in jukebox mode, set the jukebox state to point
                        // to the previous file so that 'next file' will find the right one
                        // FIXME: what about 'prev' file?
-                       // FIXME: find the actual seq in the db
+                       // FIXME: find the actual seq in the db (last entry before this is added)
                        // FIXME: this will drop the last song on restore, perhaps it should have a next_list_mode instead
                        //        this would also fix the same problem for play-now
 
@@ -920,7 +939,11 @@ static int audio_restore_state(struct audio_player *ap) {
        if ((res = dbstate_get(tx, ap->index, &ap->playing_state)) == 0 && ap->playing_state.state == 1) {
                dbfile *file = dbfile_get(tx, ap->index, ap->playing_state.current.fileid);
 
-               printf("restoring file %s\n", file->path);
+               printf("restoring lid=%d fid=%d seq=%d file %s\n",
+                       ap->playing_state.current.listid,
+                       ap->playing_state.current.fileid,
+                       ap->playing_state.current.seq,
+                       file->path);
                if (file) {
                        if (dbfile_full_path(tx, ap->index, file)) {
                                ap->playing = file;
@@ -930,7 +953,7 @@ static int audio_restore_state(struct audio_player *ap) {
                        } else {
                                dbfile_free(file);
                        }
-               }
+       }
        } else {
                // FIXME: check playing_state.state == 1 separate to error test above
                res = -1;
index d84788a..9bb7f4e 100644 (file)
      .media-button path {
         fill: white;
      }
+     /* radio/tabs */
+     .options {
+        display: flex;
+        flex-wrap: wrap;
+     }
+     .options input {
+        display: none;
+     }
+     .options input + label {
+        display: block;
+        padding: 4px 8px;
+        margin: 10px;
+        border: 1px solid black;
+        border-radius: 4px;
+        margin: 0px 2px;
+        background-color: lightgrey;
+        box-shadow: inset 0px 0px 3px rgba(0, 0, 0, 0.5);
+        min-width: 10em;
+     }
+     .options input:checked + label {
+        background-color: slategrey;
+     }
      /* scrolling lists */
      .track-list tbody {
         display: block;
 
      */
 
-     var playlistID = 1; // current list, from state?
-     var listStartID = 0; // "coming up" first entry
-     var allPlayLists = [];
-
-     // failed?
-     function requestGET(url, done) {
-       var r = new XMLHttpRequest();
-       r.onreadystatechange = function() {
-        //console.log(r);
-        if (r.readyState === XMLHttpRequest.DONE) {
-          done(r);
-        }
-       }
-       r.open("GET", url);
-       r.send();
-     }
-
-     function playerCommand(name) {
-       var r = new XMLHttpRequest();
-
-       console.log("command " + name);
-       r.onreadystatechange = function() {
-        console.log(r);
-        if (r.readyState === XMLHttpRequest.DONE) {
-          if (r.status === 0 || (r.status >= 200 && r.status < 400)) {
-            console.log("got it");
-          }
-        }
-       }
-       r.open("GET", "/x/" + name);
-       r.send();
-     }
-     // goto button, b.id = "goto-fileid"
-     function playerGotoClick(e) {
-       playerCommand("goto?f=" + e.currentTarget.id.substring(5));
-     }
-     function playerGotoKey(e) {
-       if (e.key == "Enter" || e.key == " ") {
-        playerCommand("goto?f=" + e.currentTarget.id.substring(5));
-       }
-     }
-
-     function playerCommandClick(e) {
-       playerCommand(e.currentTarget.id.substring(7));
-     }
-     function playerCommandKey(e) {
-       if (e.key == "Enter" || e.key == " ") {
-        playerCommand(e.currentTarget.id.substring(7));
-       }
-     }
-
-     function playerCommandClick2(e) {
-       playerCommand(e.currentTarget.getAttribute("query"));
-     }
-     function playerCommandKey2(e) {
-       if (e.key == "Enter" || e.key == " ") {
-        playerCommand(e.currentTarget.getAttribute("query"));
-       }
-     }
-
-     function playerSetPlaylists(items, list) {
-       let selected = items.value;
-
-       while (items.firstChild)
-        items.firstChild.remove();
-
-       for (var i=0; i<list.length; i++) {
-        let v = list[i];
-        let o = document.createElement("option");
-
-        o.value = v.id; // v.name?
-        o.textContent = v.name + " (" + v.size + ")";
-
-        items.appendChild(o);
-       }
-
-       if (selected) {
-        items.value = selected;
-       }
-     }
-
-     function playerUpdateLists() {
-       console.log("polling lists");
-       requestGET("/x/list", function(r) {
-        if (r.status === 200) {
-          let list = JSON.parse(r.response);
-
-          allPlayLists = list.items;
-
-          // TODO: put this somewhere
-          playerSetPlaylists(document.getElementById("lists"), allPlayLists);
-          //playerSetPlaylists(document.getElementById("next-lists"), list);
-        }
-       });
-     }
-     function createIcon(href) {
-       let b = document.createElementNS("http://www.w3.org/2000/svg", "svg");
-
-       b.classList.add('player-button');
-       b.tabIndex = 1;
-       b.setAttribute("viewBox", "-2 -2 28 28");
-       b.setAttribute("width", "3em");
-
-       let u = document.createElementNS("http://www.w3.org/2000/svg", "use");
-       u.setAttribute("href", href);
-       b.appendChild(u);
-       return b;
-     }
-     function playerAppendTracks(items, list, search) {
-       for (var i=0; i<list.length; i++) {
-        let v = list[i];
-        let row = document.createElement("tr");
-        let c = row.insertCell();
-
-        let d = document.createElement("div");
-        let t = document.createElement("div");
-        let a = document.createElement("div");
-
-        c.classList.add("track");
-
-        t.textContent = v.title;
-        t.classList.add("title");
-        a.textContent = v.artist;
-        a.classList.add("artist");
-
-        d.appendChild(t);
-        d.appendChild(a);
-        c.appendChild(d);
-
-        c = row.insertCell();
-        c.textContent = v.length;
-        c.classList.add("duration");
-
-        c = row.insertCell();
-        /*
-           let b = document.createElement("span");
-           b.id = "goto-" + v.id;
-           b.textContent = "\u23f5";// "\u25B6";
-           b.tabIndex = 50;
-           b.setAttribute("query", "goto?f=" + v.id);
-           b.addEventListener("click", playerCommandClick2);
-           b.addEventListener("keypress", playerCommandKey2);
-         */
-        /*
-           let b = document.createElementNS("http://www.w3.org/2000/svg", "svg");
-
-           b.tabIndex = 1;
-           b.setAttribute("query", "goto?f=" + v.id);
-           b.setAttribute("viewBox", "-2 -2 28 28");
-           b.setAttribute("width", "3em");
+      var listStartID = 0; // "coming up" first entry
+      var allPlayLists = [];
+      var playerStatus = null; // last status update from player
+      var playerStatusShown = null; // last time player status was updated?
+
+      // failed?
+      function requestGET(url, done) {
+       var r = new XMLHttpRequest();
+       r.onreadystatechange = function() {
+         //console.log(r);
+         if (r.readyState === XMLHttpRequest.DONE) {
+           done(r);
+         }
+       }
+       r.open("GET", url);
+       r.send();
+      }
+
+      function playerCommand(method, name) {
+       var r = new XMLHttpRequest();
+
+       console.log("command " + method + " /x/" + name);
+       r.onreadystatechange = function() {
+         console.log(r);
+         if (r.readyState === XMLHttpRequest.DONE) {
+           if (r.status === 0 || (r.status >= 200 && r.status < 400)) {
+             console.log("command " + name + " finished rc=" + r.status);
+           }
+         }
+       }
+       r.open(method, "/x/" + name);
+       r.send();
+      }
+
+      // goto button, b.id = "goto-fileid"
+      function playerGotoClick(e) {
+       playerCommand("GET", "goto?f=" + e.currentTarget.id.substring(5));
+      }
+      function playerGotoKey(e) {
+       if (e.key == "Enter" || e.key == " ") {
+         playerCommand("GET", "goto?f=" + e.currentTarget.id.substring(5));
+       }
+      }
+
+      function playerCommandClick(e) {
+       playerCommand("GET", e.currentTarget.id.substring(7));
+      }
+      function playerCommandKey(e) {
+       if (e.key == "Enter" || e.key == " ") {
+         playerCommand("GET", e.currentTarget.id.substring(7));
+       }
+      }
+
+      function playerCommandClick2(e) {
+       playerCommand("GET", e.currentTarget.getAttribute("query"));
+      }
+      function playerCommandKey2(e) {
+       if (e.key == "Enter" || e.key == " ") {
+         playerCommand("GET", e.currentTarget.getAttribute("query"));
+       }
+      }
+      function playerPostClick(e) {
+       playerCommand("POST", e.currentTarget.getAttribute("query"));
+      }
+      function playerPostKey(e) {
+       if (e.key == "Enter" || e.key == " ") {
+         playerCommand("POST", e.currentTarget.getAttribute("query"));
+       }
+      }
+
+      function clear(items) {
+       while (items.firstChild)
+         items.firstChild.remove();
+      }
+      // TBD
+      function playerSetPlaylists(items, list) {
+       let selected = items.value;
+
+       clear(items);
+
+       for (var i=0; i<list.length; i++) {
+         let v = list[i];
+         let o = document.createElement("option");
+
+         o.value = v.id; // v.name?
+         o.textContent = v.name + " (" + v.size + ")";
+
+         items.appendChild(o);
+       }
+
+       if (selected) {
+         items.value = selected;
+       }
+      }
+      // the tabby version
+      function playerSetPlaylistsX(items, list) {
+       clear(items);
+       console.log('status listid = ' + playerStatus.listid);
+       for (var i=0; i<list.length; i++) {
+         let v = list[i];
+         let input = document.createElement("input");
+         let label = document.createElement("label");
+
+         input.type = 'radio';
+         input.name = 'list-options';
+         input.id = 'list_' + v.id;
+         input.value = v.id;
+         input.checked = (v.id == playerStatus.listid);
+         items.appendChild(input);
+
+         label.htmlFor = 'list_' + v.id;
+         label.textContent = v.desc;
+
+         items.appendChild(label);
+         console.log(' item.id = ' + v.id);
+       }
+      }
+
+
+      function playerUpdateLists() {
+       console.log("polling lists");
+       requestGET("/x/list", function(r) {
+         if (r.status === 200) {
+           let list = JSON.parse(r.response);
+
+           allPlayLists = list.items;
+
+           // TODO: put this somewhere
+           // TBD
+           //playerSetPlaylists(document.getElementById("lists"), allPlayLists);
+           //playerSetPlaylists(document.getElementById("next-lists"), list);
+
+           playerSetPlaylistsX(document.getElementById("list-options"), allPlayLists);
+         }
+       });
+      }
+      function createIcon(href) {
+       let b = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+
+       b.classList.add('player-button');
+       b.tabIndex = 1;
+       b.setAttribute("viewBox", "-2 -2 28 28");
+       b.setAttribute("width", "3em");
+
+       let u = document.createElementNS("http://www.w3.org/2000/svg", "use");
+       u.setAttribute("href", href);
+       b.appendChild(u);
+       return b;
+      }
+      function playerAppendTracks(items, list, issearch) {
+       for (var i=0; i<list.length; i++) {
+         let v = list[i];
+         let row = document.createElement("tr");
+         let c = row.insertCell();
+
+         let d = document.createElement("div");
+         let t = document.createElement("div");
+         let a = document.createElement("div");
+
+         c.classList.add("track");
+
+         t.textContent = v.title;
+         t.classList.add("title");
+         a.textContent = v.artist;
+         a.classList.add("artist");
+
+         d.appendChild(t);
+         d.appendChild(a);
+         c.appendChild(d);
+
+         c = row.insertCell();
+         c.textContent = v.length;
+         c.classList.add("duration");
+
+         c = row.insertCell();
+
+         //if (!issearch) {
+         //  let b = createIcon("#icon-nope");
+         //  c.appendChild(b);
+         //}
+
+         if (issearch) {
+           let b = createIcon("#icon-playlist-add");
+           // FIXME: hardcoded jukebox list
+           b.setAttribute("query", "list/3?f=" + v.id);
+           c.appendChild(b);
+           b.addEventListener("click", playerPostClick);
+           b.addEventListener("keypress", playerPostKey);
+           c.appendChild(b);
+         } else {
+           let b = createIcon("#icon-play");
+           b.setAttribute("query", "goto?f=" + v.id + "&l=" + v.listid);
            b.addEventListener("click", playerCommandClick2);
            b.addEventListener("keypress", playerCommandKey2);
-
-           let u = document.createElementNS("http://www.w3.org/2000/svg", "use");
-           u.setAttribute("href", "#icon-play");
-           b.appendChild(u);
-         */
-
-        let b = createIcon("#icon-play");
-        b.setAttribute("query", "goto?f=" + v.id);
-        b.addEventListener("click", playerCommandClick2);
-        b.addEventListener("keypress", playerCommandKey2);
-
-        c.appendChild(b);
-
-        if (search) {
-          b = createIcon("#icon-playlist-add");
-          //b.setAttribute("query", "list/" + v.goto?f=" + v.id);
-          //b.addEventListener("click", playerCommandClick2);
-          //b.addEventListener("keypress", playerCommandKey2);
-          c.appendChild(b);
-        } else {
-          b = createIcon("#icon-nope");
-          c.appendChild(b);
-        }
-
-        c.classList.add("action");
-
-        row.classList.add((i & 1) ? "odd" : "even");
-
-        items.appendChild(row);
-       }
-     }
-
-     function playerUpdateList(fileid, seq) {
-       console.log("polling playlist");
-       requestGET("/x/list/" + playlistID + "?f=" + fileid + "?s=" + seq, function(r) {
-        if (r.status === 200) {
-          let table = document.getElementById("coming-up");
-          let items = document.getElementById("coming-up-list");
-          let list = JSON.parse(r.response);
-
-          while (items.firstChild)
-            items.firstChild.remove();
-
-          if (list.items.length > 0) {
-            table.caption = document.createElement("caption");
-            table.caption.textContent = "Coming up in playlist '" + list.list_name + "' …";
-          }
-
-          playerAppendTracks(items, list.items, 0);
-          /*
-             for (var i=0; i<list.list.length; i++) {
-             let v = list.list[i];
-             let row = document.createElement("tr");
-             let c = row.insertCell();
-
-             c.textContent = v.title;
-             c.classList.add("title");
-             c = row.insertCell();
-             c.textContent = v.artist;
-             c.classList.add("artist");
-             c = row.insertCell();
-             c.textContent = v.length;
-             c.classList.add("duration");
-
-             c = row.insertCell();
-             let b = document.createElement("span");
-             b.id = "goto-" + v.id;
-             b.textContent = "GOTO";
-             b.tabIndex = 50;
-             b.addEventListener("click", playerGotoClick);
-             b.addEventListener("keypress", playerGotoKey);
-             c.appendChild(b);
-
-             items.appendChild(row);
-             } */
-        }
-        listStartID = fileid;
-       });
-     }
-
-     function playerSearch(query) {
-       console.log("search: " + query);
-       requestGET("/x/search?q=" + encodeURIComponent(query), function(r) {
-        if (r.status === 200) {
-          let table = document.getElementById("search-results");
-          let items = document.getElementById("search-results-list");
-          let list = JSON.parse(r.response);
-
-          while (items.firstChild)
-            items.firstChild.remove();
-
-          //table.caption = document.createElement("caption");
-          //if (list.items.length > 0) {
-          //  table.caption.textContent = "Matches";
-          //} else {
-          //  table.caption.textContent = "No matches";
-         // }
-
-          playerAppendTracks(items, list.items, 1);
-          /*
-             for (var i=0; i<list.items.length; i++) {
-             let v = list.items[i];
-             let row = document.createElement("tr");
-             let c = row.insertCell();
-
-             c.textContent = v.title;
-             c = row.insertCell();
-             c.textContent = v.artist;
-             c = row.insertCell();
-             c.textContent = v.length;
-
-             c = row.insertCell();
-             let b = document.createElement("span");
-             b.id = "goto-" + v.id;
-             b.textContent = "GOTO";
-             b.tabIndex = 50;
-             b.addEventListener("click", playerGotoClick);
-             b.addEventListener("keypress", playerGotoKey);
-             c.appendChild(b);
-
-             // add to playlist button, or whatever
-
-             items.appendChild(row);
-             }*/
-        }
-       });
-     }
-
-     function playerPoll() {
-       var r = new XMLHttpRequest();
-
-       //console.log("polling");
-       r.onreadystatechange = function() {
-        //console.log(r);
-        if (r.readyState === XMLHttpRequest.DONE) {
-          if (r.status == 200) {
-            var s = JSON.parse(r.response);
-
-            document.getElementById("track-title").textContent = s.title;
-            document.getElementById("track-artist").textContent = s.artist;
-            document.getElementById("track-path").textContent = s.path;
-            document.getElementById("track-length").textContent = s.length;
-
-            /*
+           c.appendChild(b);
+         }
+
+         c.classList.add("action");
+
+         row.classList.add((i & 1) ? "odd" : "even");
+
+         items.appendChild(row);
+       }
+      }
+
+      function playerUpdateList(status) {
+       console.log("polling playlist");
+       requestGET("/x/list/" + status.listid + "?f=" + status.id + "?s=" + status.seq, function(r) {
+         if (r.status === 200) {
+           let table = document.getElementById("coming-up");
+           let items = document.getElementById("coming-up-list");
+           let list = JSON.parse(r.response);
+
+           while (items.firstChild)
+             items.firstChild.remove();
+
+           if (list.items.length > 0) {
+             table.caption = document.createElement("caption");
+             table.caption.textContent = "Coming up ...";
+           }
+
+           playerAppendTracks(items, list.items, 0);
+         }
+         // TODO: update based on this
+         playerStatusShown = status;
+         // TBD?
+         listStartID = status.id;
+       });
+      }
+
+      function playerUpdateSearch(query) {
+       console.log("search: " + query);
+       requestGET("/x/search?q=" + encodeURIComponent(query), function(r) {
+         if (r.status === 200) {
+           let table = document.getElementById("coming-up");
+           let items = document.getElementById("coming-up-list");
+           //let table = document.getElementById("search-results");
+           //let items = document.getElementById("search-results-list");
+           let list = JSON.parse(r.response);
+
+           while (items.firstChild)
+             items.firstChild.remove();
+
+           table.caption = document.createElement("caption");
+           if (list.items.length > 0) {
+             table.caption.textContent = "Matches (" + list.items.length + ")";
+           } else {
+             table.caption.textContent = "No matches";
+           }
+
+           playerAppendTracks(items, list.items, 1);
+         }
+       });
+      }
+      function playerUpdateListX() {
+       console.log("update search or playlist");
+       let query = document.getElementById("search-query").value;
+
+       if (query) {
+         playerUpdateSearch(query);
+       } else {
+         playerUpdateList(playerStatus);
+       }
+      }
+      function playerPoll() {
+       var r = new XMLHttpRequest();
+
+       //console.log("polling");
+       r.onreadystatechange = function() {
+         //console.log(r);
+         if (r.readyState === XMLHttpRequest.DONE) {
+           if (r.status == 200) {
+             var s = JSON.parse(r.response);
+
+             playerStatus = s;
+
+             document.getElementById("track-title").textContent = s.title;
+             document.getElementById("track-artist").textContent = s.artist;
+             document.getElementById("track-path").textContent = s.path;
+             document.getElementById("track-length").textContent = s.length;
+
+             /*
                let p = document.getElementById("track-position");
 
                if (s.status == "playing") {
                p.textContent = "(stopped)";
                }*/
 
-            document.getElementById("track-progress").style.width = (s.positionms * 100 / s.lengthms) + "%";
-            document.getElementById("track-position").textContent = s.position;
-            document.getElementById("track-length").textContent = s.length;
-
-            if (s.id != listStartID) {
-              playerUpdateList(s.id, s.seq);
-              playerUpdateLists();
-            }
-          } else {
-            document.getElementById("track-title").textContent = "?";
-            document.getElementById("track-artist").textContent = "?";
-            document.getElementById("track-length").textContent = "?";
-            document.getElementById("track-position").textContent = "?";
-          }
-
-          window.setTimeout(playerPoll, 5000);
-        }
-       }
-       r.open("GET", "/x/status");
-       r.send();
-     }
-
-     function playerCreateList(name) {
-       var r = new XMLHttpRequest();
-
-       console.log("create list: " + name);
-
-       r.onreadystatechange = function() {
-        console.log(r);
-        if (r.readyState === XMLHttpRequest.DONE) {
-          if (r.status === 0 || (r.status >= 200 && r.status < 400)) {
-            console.log("got it");
-            playerUpdateLists();
-          }
-        }
-       }
-       r.open("POST", "/x/list?name=" + encodeURIComponent(name));
-       r.send();
-     }
-
-     document.addEventListener("DOMContentLoaded", function() {
-       // query in attribute
-       Array.prototype.forEach.call(document.getElementsByClassName("player-button"), function(b) {
-        b.addEventListener("click", playerCommandClick2);
-        b.addEventListener("keypress", playerCommandKey2);
-       });
-
-       playerPoll();
-     });
+             document.getElementById("track-progress").style.width = (s.positionms * 100 / s.lengthms) + "%";
+             document.getElementById("track-position").textContent = s.position;
+             document.getElementById("track-length").textContent = s.length;
+
+             // TODO: use playerSttatusShown, or just pass it to playerUpdateListX()
+             if (s.id != listStartID) {
+               playerUpdateListX();
+               playerUpdateLists();
+             }
+           } else {
+             playerStatus = null;
+             document.getElementById("track-title").textContent = "?";
+             document.getElementById("track-artist").textContent = "?";
+             document.getElementById("track-length").textContent = "?";
+             document.getElementById("track-position").textContent = "?";
+           }
+
+           window.setTimeout(playerPoll, 1000);
+         }
+       }
+       r.open("GET", "/x/status");
+       r.send();
+      }
+
+      function playerCreateList(name) {
+       var r = new XMLHttpRequest();
+
+       console.log("create list: " + name);
+
+       r.onreadystatechange = function() {
+         console.log(r);
+         if (r.readyState === XMLHttpRequest.DONE) {
+           if (r.status === 0 || (r.status >= 200 && r.status < 400)) {
+             console.log("got it");
+             playerUpdateLists();
+           }
+         }
+       }
+       r.open("POST", "/x/list?name=" + encodeURIComponent(name));
+       r.send();
+      }
+
+      document.addEventListener("DOMContentLoaded", function() {
+       // query in attribute
+       Array.prototype.forEach.call(document.getElementsByClassName("player-button"), function(b) {
+         b.addEventListener("click", playerCommandClick2);
+         b.addEventListener("keypress", playerCommandKey2);
+       });
+
+       document.getElementById("track-bar").addEventListener("click", function(e) {
+         if (playerStatus) {
+           var seekms = Math.round(playerStatus.lengthms * e.offsetX / e.target.clientWidth);
+
+           playerCommand("GET", "seek?seek=" + (seekms/1000));
+         }
+       }, true);
+
+       playerPoll();
+      });
     </script>
   </head>
   <body>
          <rect class='button-border' width='24' height='24'/>
          <path d="M3 6c-.55 0-1 .45-1 1v13c0 1.1.9 2 2 2h13c.55 0 1-.45 1-1s-.45-1-1-1H5c-.55 0-1-.45-1-1V7c0-.55-.45-1-1-1zm17-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 9h-3v3c0 .55-.45 1-1 1s-1-.45-1-1v-3h-3c-.55 0-1-.45-1-1s.45-1 1-1h3V6c0-.55.45-1 1-1s1 .45 1 1v3h3c.55 0 1 .45 1 1s-.45 1-1 1z"/>
        </svg>
-       <svg id=icon-add-to-queue' class='media-button' xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
+       <svg id='icon-add-to-queue' class='media-button' xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
          <rect class='button-border' width='24' height='24'/>
          <path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v1c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-1h5c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm-1 14H4c-.55 0-1-.45-1-1V6c0-.55.45-1 1-1h16c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1zm-4-6c0 .55-.45 1-1 1h-2v2c0 .55-.45 1-1 1s-1-.45-1-1v-2H9c-.55 0-1-.45-1-1s.45-1 1-1h2V8c0-.55.45-1 1-1s1 .45 1 1v2h2c.55 0 1 .45 1 1z"/>
        </svg>
        </div>
        <div class='progress-bar' style='width:100%;display:grid;grid-template-columns:8em 1fr 8em;'>
          <span id='track-position' style='text-align:center;'></span>
-         <div id='track-progress' class='progress-indicator'></div>
+         <div id='track-bar'>
+           <div id='track-progress' class='progress-indicator'></div>
+         </div>
          <span id='track-length' style='text-align:centre;'></span>
        </div>
       </div>
       </div>
       -->
     </div>
+    <!-- all playlists -->
+    <div id='list-options' class='options' style='padding:1em;'></div>
+
     <div style='padding:1em;'>
       <input id='new-list-name' type='text'> <input type='button' value='Create Playlist' onClick='playerCreateList(document.getElementById("new-list-name").value)'>
-      <select id='lists'></select>
+      Search&nbsp;<input type='text' id='search-query' oninput='playerUpdateListX()'>
     </div>
     <div class='section'>
       <table id='coming-up' class='track-list'>
        </tbody>
       </table>
     </div>
+    <!--
     <div id='search' class='section'>
-      <!-- <input type='text' onchange='playerSearch(this.value)'> -->
+      <!-- <input type='text' onchange='playerSearch(this.value)'> --
       <table id='search-results' class='track-list'>
-       <caption>Search <input type='text' oninput='playerSearch(this.value)'></caption>
-       <!--
-            <thead>
-            <tr><th class='track'>Track<th class='duration'>Length<th class='action'>Action <select id='search-lists'><option>list a</option><option>list b</option></select></tr>
-            </thead> -->
+       <caption>Search <input id='search-query' type='text' oninput='playerUpdateListX()'></caption>
        <tbody id='search-results-list'>
        </tbody>
       </table>
-    </div>
+    </div> -->
   </body>
 </html>