chiark / gitweb /
Right click play playlist option
[disorder] / disobedience / playlists.c
1 /*
2  * This file is part of DisOrder
3  * Copyright (C) 2008, 2009 Richard Kettlewell
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful, but
11  * WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  * General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License
16  * along with this program; if not, write to the Free Software
17  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
18  * USA
19  */
20 /** @file disobedience/playlists.c
21  * @brief Playlist support for Disobedience
22  *
23  * The playlists management window contains:
24  * - the playlist picker (a list of all playlists) TODO should be a tree!
25  * - an add button
26  * - a delete button
27  * - the playlist editor (a d+d-capable view of the currently picked playlist)
28  * - a close button   TODO
29  *
30  * This file also maintains the playlist menu, allowing playlists to be
31  * activated from the main window's menu.
32  *
33  * Internally we maintain the playlist list, which is just the current list of
34  * playlists.  Changes to this are reflected in the playlist menu and the
35  * playlist picker.
36  *
37  */
38 #include "disobedience.h"
39 #include "queue-generic.h"
40 #include "popup.h"
41 #include "validity.h"
42
43 #if PLAYLISTS
44
45 static void playlist_list_received_playlists(void *v,
46                                              const char *err,
47                                              int nvec, char **vec);
48 static void playlist_editor_fill(const char *event,
49                                  void *eventdata,
50                                  void *callbackdata);
51 static int playlist_playall_sensitive(void *extra);
52 static void playlist_playall_activate(GtkMenuItem *menuitem,
53                                       gpointer user_data);
54
55 /** @brief Playlist editing window */
56 static GtkWidget *playlist_window;
57
58 /** @brief Columns for the playlist editor */
59 static const struct queue_column playlist_columns[] = {
60   { "Artist", column_namepart, "artist", COL_EXPAND|COL_ELLIPSIZE },
61   { "Album",  column_namepart, "album",  COL_EXPAND|COL_ELLIPSIZE },
62   { "Title",  column_namepart, "title",  COL_EXPAND|COL_ELLIPSIZE },
63   { "Length", column_length,   0,        COL_RIGHT }
64 };
65
66 /** @brief Pop-up menu for playlist editor */
67 // TODO some of these may not be generic enough yet - check!
68 static struct menuitem playlist_menuitems[] = {
69   { "Track properties", ql_properties_activate, ql_properties_sensitive, 0, 0 },
70   { "Play track", ql_play_activate, ql_play_sensitive, 0, 0 },
71   { "Play playlist", playlist_playall_activate, playlist_playall_sensitive, 0, 0 },
72   { "Remove track from queue", ql_remove_activate, ql_remove_sensitive, 0, 0 },
73   { "Select all tracks", ql_selectall_activate, ql_selectall_sensitive, 0, 0 },
74   { "Deselect all tracks", ql_selectnone_activate, ql_selectnone_sensitive, 0, 0 },
75 };
76
77 /** @brief Queuelike for editing a playlist */
78 static struct queuelike ql_playlist = {
79   .name = "playlist",
80   .columns = playlist_columns,
81   .ncolumns = sizeof playlist_columns / sizeof *playlist_columns,
82   .menuitems = playlist_menuitems,
83   .nmenuitems = sizeof playlist_menuitems / sizeof *playlist_menuitems,
84   //.drop = playlist_drop  //TODO
85 };
86
87 /* Maintaining the list of playlists ---------------------------------------- */
88
89 /** @brief Current list of playlists or NULL */
90 char **playlists;
91
92 /** @brief Count of playlists */
93 int nplaylists;
94
95 /** @brief Schedule an update to the list of playlists
96  *
97  * Called periodically and when a playlist is created or deleted.
98  */
99 static void playlist_list_update(const char attribute((unused)) *event,
100                                  void attribute((unused)) *eventdata,
101                                  void attribute((unused)) *callbackdata) {
102   disorder_eclient_playlists(client, playlist_list_received_playlists, 0);
103 }
104
105 /** @brief qsort() callback for playlist name comparison */
106 static int playlistcmp(const void *ap, const void *bp) {
107   const char *a = *(char **)ap, *b = *(char **)bp;
108   const char *ad = strchr(a, '.'), *bd = strchr(b, '.');
109   int c;
110
111   /* Group owned playlists by owner */
112   if(ad && bd) {
113     const int adn = ad - a, bdn = bd - b;
114     if((c = strncmp(a, b, adn < bdn ? adn : bdn)))
115       return c;
116     /* Lexical order within playlists of a single owner */
117     return strcmp(ad + 1, bd + 1);
118   }
119
120   /* Owned playlists after shared ones */
121   if(ad) {
122     return 1;
123   } else if(bd) {
124     return -1;
125   }
126
127   /* Lexical order of shared playlists */
128   return strcmp(a, b);
129 }
130
131 /** @brief Called with a new list of playlists */
132 static void playlist_list_received_playlists(void attribute((unused)) *v,
133                                              const char *err,
134                                              int nvec, char **vec) {
135   if(err) {
136     playlists = 0;
137     nplaylists = -1;
138     /* Probably means server does not support playlists */
139   } else {
140     playlists = vec;
141     nplaylists = nvec;
142     qsort(playlists, nplaylists, sizeof (char *), playlistcmp);
143   }
144   /* Tell our consumers */
145   event_raise("playlists-updated", 0);
146 }
147
148 /* Playlists menu ----------------------------------------------------------- */
149
150 static void playlist_menu_playing(void attribute((unused)) *v,
151                                   const char *err) {
152   if(err)
153     popup_protocol_error(0, err);
154 }
155
156 /** @brief Play received playlist contents
157  *
158  * Passed as a completion callback by menu_activate_playlist().
159  */
160 static void playlist_menu_received_content(void attribute((unused)) *v,
161                                            const char *err,
162                                            int nvec, char **vec) {
163   if(err) {
164     popup_protocol_error(0, err);
165     return;
166   }
167   for(int n = 0; n < nvec; ++n)
168     disorder_eclient_play(client, vec[n], playlist_menu_playing, NULL);
169 }
170
171 /** @brief Called to activate a playlist
172  *
173  * Called when the menu item for a playlist is clicked.
174  */
175 static void playlist_menu_activate(GtkMenuItem *menuitem,
176                                    gpointer attribute((unused)) user_data) {
177   GtkLabel *label = GTK_LABEL(GTK_BIN(menuitem)->child);
178   const char *playlist = gtk_label_get_text(label);
179
180   disorder_eclient_playlist_get(client, playlist_menu_received_content,
181                                 playlist, NULL);
182 }
183
184 /** @brief Called when the playlists change
185  *
186  * Naively refills the menu.  The results might be unsettling if the menu is
187  * currently open, but this is hopefuly fairly rare.
188  */
189 static void playlist_menu_changed(const char attribute((unused)) *event,
190                                   void attribute((unused)) *eventdata,
191                                   void attribute((unused)) *callbackdata) {
192   if(!playlists_menu)
193     return;                             /* OMG too soon */
194   GtkMenuShell *menu = GTK_MENU_SHELL(playlists_menu);
195   while(menu->children)
196     gtk_container_remove(GTK_CONTAINER(menu), GTK_WIDGET(menu->children->data));
197   /* NB nplaylists can be -1 as well as 0 */
198   for(int n = 0; n < nplaylists; ++n) {
199     GtkWidget *w = gtk_menu_item_new_with_label(playlists[n]);
200     g_signal_connect(w, "activate", G_CALLBACK(playlist_menu_activate), 0);
201     gtk_widget_show(w);
202     gtk_menu_shell_append(menu, w);
203   }
204   gtk_widget_set_sensitive(menu_playlists_widget,
205                            nplaylists > 0);
206   gtk_widget_set_sensitive(menu_editplaylists_widget,
207                            nplaylists >= 0);
208 }
209
210 /* Popup to create a new playlist ------------------------------------------- */
211
212 /** @brief New-playlist popup */
213 static GtkWidget *playlist_new_window;
214
215 /** @brief Text entry in new-playlist popup */
216 static GtkWidget *playlist_new_entry;
217
218 /** @brief Label for displaying feedback on what's wrong */
219 static GtkWidget *playlist_new_info;
220
221 /** @brief "Shared" radio button */
222 static GtkWidget *playlist_new_shared;
223
224 /** @brief "Public" radio button */
225 static GtkWidget *playlist_new_public;
226
227 /** @brief "Private" radio button */
228 static GtkWidget *playlist_new_private;
229
230 /** @brief Get entered new-playlist details
231  * @param namep Where to store entered name (or NULL)
232  * @param fullnamep Where to store computed full name (or NULL)
233  * @param sharep Where to store 'shared' flag (or NULL)
234  * @param publicp Where to store 'public' flag (or NULL)
235  * @param privatep Where to store 'private' flag (or NULL)
236  */
237 static void playlist_new_details(char **namep,
238                                  char **fullnamep,
239                                  gboolean *sharedp,
240                                  gboolean *publicp,
241                                  gboolean *privatep) {
242   gboolean shared, public, private;
243   g_object_get(playlist_new_shared, "active", &shared, (char *)NULL);
244   g_object_get(playlist_new_public, "active", &public, (char *)NULL);
245   g_object_get(playlist_new_private, "active", &private, (char *)NULL);
246   char *gname = gtk_editable_get_chars(GTK_EDITABLE(playlist_new_entry),
247                                        0, -1); /* name owned by calle */
248   char *name = xstrdup(gname);
249   g_free(gname);
250   if(sharedp) *sharedp = shared;
251   if(publicp) *publicp = public;
252   if(privatep) *privatep = private;
253   if(namep) *namep = name;
254   if(fullnamep) {
255     if(*sharedp) *fullnamep = *namep;
256     else byte_xasprintf(fullnamep, "%s.%s", config->username, name);
257   }
258 }
259
260 /** @brief Called when the newly created playlist has unlocked */
261 static void playlist_new_unlocked(void attribute((unused)) *v, const char *err) {
262   if(err)
263     popup_protocol_error(0, err);
264   /* Pop down the creation window */
265   gtk_widget_destroy(playlist_new_window);
266 }
267
268 /** @brief Called when the new playlist has been created */
269 static void playlist_new_created(void attribute((unused)) *v, const char *err) {
270   if(err) {
271     popup_protocol_error(0, err);
272     return;
273   }
274   disorder_eclient_playlist_unlock(client, playlist_new_unlocked, NULL);
275   // TODO arrange for the new playlist to be selected
276 }
277
278 /** @brief Called when the proposed new playlist's contents have been retrieved
279  *
280  * ...or rather, normally, when it's been reported that it does not exist.
281  */
282 static void playlist_new_retrieved(void *v, const char *err,
283                                    int nvec,
284                                    char attribute((unused)) **vec) {
285   char *fullname = v;
286   if(!err && nvec != -1)
287     /* A rare case but not in principle impossible */
288     err = "A playlist with that name already exists.";
289   if(err) {
290     popup_protocol_error(0, err);
291     disorder_eclient_playlist_unlock(client, playlist_new_unlocked, fullname);
292     return;
293   }
294   gboolean shared, public, private;
295   playlist_new_details(0, 0, &shared, &public, &private);
296   disorder_eclient_playlist_set_share(client, playlist_new_created, fullname,
297                                       public ? "public"
298                                       : private ? "private"
299                                       : "shared",
300                                       fullname);
301 }
302
303 /** @brief Called when the proposed new playlist has been locked */
304 static void playlist_new_locked(void *v, const char *err) {
305   char *fullname = v;
306   if(err) {
307     popup_protocol_error(0, err);
308     return;
309   }
310   disorder_eclient_playlist_get(client, playlist_new_retrieved,
311                                 fullname, fullname);
312 }
313
314 /** @brief Called when 'ok' is clicked in new-playlist popup */
315 static void playlist_new_ok(GtkButton attribute((unused)) *button,
316                             gpointer attribute((unused)) userdata) {
317   gboolean shared, public, private;
318   char *name, *fullname;
319   playlist_new_details(&name, &fullname, &shared, &public, &private);
320
321   /* We need to:
322    * - lock the playlist
323    * - check it doesn't exist
324    * - set sharing (which will create it empty
325    * - unlock it
326    *
327    * TODO we should freeze the window while this is going on to stop a second
328    * click.
329    */
330   disorder_eclient_playlist_lock(client, playlist_new_locked, fullname,
331                                  fullname);
332 }
333
334 /** @brief Called when 'cancel' is clicked in new-playlist popup */
335 static void playlist_new_cancel(GtkButton attribute((unused)) *button,
336                                 gpointer attribute((unused)) userdata) {
337   gtk_widget_destroy(playlist_new_window);
338 }
339
340 /** @brief Buttons for new-playlist popup */
341 static struct button playlist_new_buttons[] = {
342   {
343     .stock = GTK_STOCK_OK,
344     .clicked = playlist_new_ok,
345     .tip = "Create new playlist"
346   },
347   {
348     .stock = GTK_STOCK_CANCEL,
349     .clicked = playlist_new_cancel,
350     .tip = "Do not create new playlist"
351   }
352 };
353 #define NPLAYLIST_NEW_BUTTONS (sizeof playlist_new_buttons / sizeof *playlist_new_buttons)
354
355 /** @brief Test whether the new-playlist window settings are valid
356  * @return NULL on success or an error string if not
357  */
358 static const char *playlist_new_valid(void) {
359   gboolean shared, public, private;
360   char *name, *fullname;
361   playlist_new_details(&name, &fullname, &shared, &public, &private);
362   if(!(shared || public || private))
363     return "No type set.";
364   if(!*name)
365     return "";
366   /* See if the result is valid */
367   if(!valid_username(name)
368      || playlist_parse_name(fullname, NULL, NULL))
369     return "Not a valid playlist name.";
370   /* See if the result clashes with an existing name.  This is not a perfect
371    * check, the playlist might be created after this point but before we get a
372    * chance to disable the "OK" button.  However when we try to create the
373    * playlist we will first try to retrieve it, with a lock held, so we
374    * shouldn't end up overwriting anything. */
375   for(int n = 0; n < nplaylists; ++n)
376     if(!strcmp(playlists[n], fullname)) {
377       if(shared)
378         return "A shared playlist with that name already exists.";
379       else
380         return "You already have a playlist with that name.";
381     }
382   /* As far as we can tell creation would work */
383   return NULL;
384 }
385
386 /** @brief Called to update new playlist window state
387  *
388  * This is called whenever one the text entry or radio buttons changed, and
389  * also when the set of known playlists changes.  It determines whether the new
390  * playlist would be creatable and sets the sensitivity of the OK button
391  * and info display accordingly.
392  */
393 static void playlist_new_changed(const char attribute((unused)) *event,
394                                  void attribute((unused)) *eventdata,
395                                  void attribute((unused)) *callbackdata) {
396   if(!playlist_new_window)
397     return;
398   const char *reason = playlist_new_valid();
399   gtk_widget_set_sensitive(playlist_new_buttons[0].widget,
400                            !reason);
401   gtk_label_set_text(GTK_LABEL(playlist_new_info), reason);
402 }
403
404 /** @brief Called when some radio button in the new-playlist popup changes */
405 static void playlist_new_button_toggled(GtkToggleButton attribute((unused)) tb,
406                                         gpointer attribute((unused)) userdata) {
407   playlist_new_changed(0,0,0);
408 }
409
410 /** @brief Called when the text entry field in the new-playlist popup changes */
411 static void playlist_new_entry_edited(GtkEditable attribute((unused)) *editable,
412                                       gpointer attribute((unused)) user_data) {
413   playlist_new_changed(0,0,0);
414 }
415
416 /** @brief Pop up a new window to enter the playlist name and details */
417 static void playlist_new_playlist(void) {
418   assert(playlist_new_window == NULL);
419   playlist_new_window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
420   g_signal_connect(playlist_new_window, "destroy",
421                    G_CALLBACK(gtk_widget_destroyed), &playlist_new_window);
422   gtk_window_set_title(GTK_WINDOW(playlist_new_window), "Create new playlist");
423   /* Window will be modal, suppressing access to other windows */
424   gtk_window_set_modal(GTK_WINDOW(playlist_new_window), TRUE);
425   gtk_window_set_transient_for(GTK_WINDOW(playlist_new_window),
426                                GTK_WINDOW(playlist_window));
427
428   /* Window contents will use a table (grid) layout */
429   GtkWidget *table = gtk_table_new(3, 3, FALSE/*!homogeneous*/);
430
431   /* First row: playlist name */
432   gtk_table_attach_defaults(GTK_TABLE(table),
433                             gtk_label_new("Playlist name"),
434                             0, 1, 0, 1);
435   playlist_new_entry = gtk_entry_new();
436   g_signal_connect(playlist_new_entry, "changed",
437                    G_CALLBACK(playlist_new_entry_edited), NULL);
438   gtk_table_attach_defaults(GTK_TABLE(table),
439                             playlist_new_entry,
440                             1, 3, 0, 1);
441
442   /* Second row: radio buttons to choose type */
443   playlist_new_shared = gtk_radio_button_new_with_label(NULL, "shared");
444   playlist_new_public
445     = gtk_radio_button_new_with_label_from_widget(GTK_RADIO_BUTTON(playlist_new_shared),
446                                                   "public");
447   playlist_new_private
448     = gtk_radio_button_new_with_label_from_widget(GTK_RADIO_BUTTON(playlist_new_shared),
449                                                   "private");
450   g_signal_connect(playlist_new_shared, "toggled",
451                    G_CALLBACK(playlist_new_button_toggled), NULL);
452   g_signal_connect(playlist_new_public, "toggled",
453                    G_CALLBACK(playlist_new_button_toggled), NULL);
454   g_signal_connect(playlist_new_private, "toggled",
455                    G_CALLBACK(playlist_new_button_toggled), NULL);
456   gtk_table_attach_defaults(GTK_TABLE(table), playlist_new_shared, 0, 1, 1, 2);
457   gtk_table_attach_defaults(GTK_TABLE(table), playlist_new_public, 1, 2, 1, 2);
458   gtk_table_attach_defaults(GTK_TABLE(table), playlist_new_private, 2, 3, 1, 2);
459
460   /* Third row: info bar saying why not */
461   playlist_new_info = gtk_label_new("");
462   gtk_table_attach_defaults(GTK_TABLE(table), playlist_new_info,
463                             0, 3, 2, 3);
464
465   /* Fourth row: ok/cancel buttons */
466   GtkWidget *hbox = create_buttons_box(playlist_new_buttons,
467                                        NPLAYLIST_NEW_BUTTONS,
468                                        gtk_hbox_new(FALSE, 0));
469   gtk_table_attach_defaults(GTK_TABLE(table), hbox, 0, 3, 3, 4);
470
471   gtk_container_add(GTK_CONTAINER(playlist_new_window),
472                     frame_widget(table, NULL));
473
474   /* Set initial state of OK button */
475   playlist_new_changed(0,0,0);
476
477   /* TODO: return should = OK, escape should = cancel */
478   
479   /* Display the window */
480   gtk_widget_show_all(playlist_new_window);
481 }
482
483 /* Playlist picker ---------------------------------------------------------- */
484
485 /** @brief Delete button */
486 static GtkWidget *playlist_picker_delete_button;
487
488 /** @brief Tree model for list of playlists */
489 static GtkListStore *playlist_picker_list;
490
491 /** @brief Selection for list of playlists */
492 static GtkTreeSelection *playlist_picker_selection;
493
494 /** @brief Currently selected playlist */
495 static const char *playlist_picker_selected;
496
497 /** @brief (Re-)populate the playlist picker tree model */
498 static void playlist_picker_fill(const char attribute((unused)) *event,
499                                  void attribute((unused)) *eventdata,
500                                  void attribute((unused)) *callbackdata) {
501   GtkTreeIter iter[1];
502
503   if(!playlist_window)
504     return;
505   if(!playlist_picker_list)
506     playlist_picker_list = gtk_list_store_new(1, G_TYPE_STRING);
507   const char *was_selected = playlist_picker_selected;
508   gtk_list_store_clear(playlist_picker_list); /* clears playlists_selected */
509   for(int n = 0; n < nplaylists; ++n) {
510     gtk_list_store_insert_with_values(playlist_picker_list, iter,
511                                       n                /*position*/,
512                                       0, playlists[n], /* column 0 */
513                                       -1);             /* no more cols */
514     /* Reselect the selected playlist */
515     if(was_selected && !strcmp(was_selected, playlists[n]))
516       gtk_tree_selection_select_iter(playlist_picker_selection, iter);
517   }
518   /* TODO deselecting then reselecting the current playlist resets the playlist
519    * editor, which trashes the user's selection. */
520 }
521
522 /** @brief Called when the selection might have changed */
523 static void playlist_picker_selection_changed(GtkTreeSelection attribute((unused)) *treeselection,
524                                               gpointer attribute((unused)) user_data) {
525   GtkTreeIter iter;
526   char *gselected, *selected;
527   
528   /* Identify the current selection */
529   if(gtk_tree_selection_get_selected(playlist_picker_selection, 0, &iter)) {
530     gtk_tree_model_get(GTK_TREE_MODEL(playlist_picker_list), &iter,
531                        0, &gselected, -1);
532     selected = xstrdup(gselected);
533     g_free(gselected);
534   } else
535     selected = 0;
536   /* Set button sensitivity according to the new state */
537   if(selected)
538     gtk_widget_set_sensitive(playlist_picker_delete_button, 1);
539   else
540     gtk_widget_set_sensitive(playlist_picker_delete_button, 0);
541   /* Eliminate no-change cases */
542   if(!selected && !playlist_picker_selected)
543     return;
544   if(selected
545      && playlist_picker_selected
546      && !strcmp(selected, playlist_picker_selected))
547     return;
548   /* Record the new state */
549   playlist_picker_selected = selected;
550   /* Re-initalize the queue */
551   ql_new_queue(&ql_playlist, NULL);
552   playlist_editor_fill(NULL, (void *)playlist_picker_selected, NULL);
553 }
554
555 /** @brief Called when the 'add' button is pressed */
556 static void playlist_picker_add(GtkButton attribute((unused)) *button,
557                                 gpointer attribute((unused)) userdata) {
558   /* Unselect whatever is selected TODO why?? */
559   gtk_tree_selection_unselect_all(playlist_picker_selection);
560   playlist_new_playlist();
561 }
562
563 /** @brief Called when playlist deletion completes */
564 static void playlists_picker_delete_completed(void attribute((unused)) *v,
565                                               const char *err) {
566   if(err)
567     popup_protocol_error(0, err);
568 }
569
570 /** @brief Called when the 'Delete' button is pressed */
571 static void playlist_picker_delete(GtkButton attribute((unused)) *button,
572                                    gpointer attribute((unused)) userdata) {
573   GtkWidget *yesno;
574   int res;
575
576   if(!playlist_picker_selected)
577     return;                             /* shouldn't happen */
578   yesno = gtk_message_dialog_new(GTK_WINDOW(playlist_window),
579                                  GTK_DIALOG_MODAL,
580                                  GTK_MESSAGE_QUESTION,
581                                  GTK_BUTTONS_YES_NO,
582                                  "Do you really want to delete playlist %s?"
583                                  " This action cannot be undone.",
584                                  playlist_picker_selected);
585   res = gtk_dialog_run(GTK_DIALOG(yesno));
586   gtk_widget_destroy(yesno);
587   if(res == GTK_RESPONSE_YES) {
588     disorder_eclient_playlist_delete(client,
589                                      playlists_picker_delete_completed,
590                                      playlist_picker_selected,
591                                      NULL);
592   }
593 }
594
595 /** @brief Table of buttons below the playlist list */
596 static struct button playlist_picker_buttons[] = {
597   {
598     GTK_STOCK_ADD,
599     playlist_picker_add,
600     "Create a new playlist",
601     0
602   },
603   {
604     GTK_STOCK_REMOVE,
605     playlist_picker_delete,
606     "Delete a playlist",
607     0
608   },
609 };
610 #define NPLAYLIST_PICKER_BUTTONS (sizeof playlist_picker_buttons / sizeof *playlist_picker_buttons)
611
612 /** @brief Create the list of playlists for the edit playlists window */
613 static GtkWidget *playlist_picker_create(void) {
614   /* Create the list of playlist and populate it */
615   playlist_picker_fill(NULL, NULL, NULL);
616   /* Create the tree view */
617   GtkWidget *tree = gtk_tree_view_new_with_model(GTK_TREE_MODEL(playlist_picker_list));
618   /* ...and the renderers for it */
619   GtkCellRenderer *cr = gtk_cell_renderer_text_new();
620   GtkTreeViewColumn *col = gtk_tree_view_column_new_with_attributes("Playlist",
621                                                                     cr,
622                                                                     "text", 0,
623                                                                     NULL);
624   gtk_tree_view_append_column(GTK_TREE_VIEW(tree), col);
625   /* Get the selection for the view; set its mode; arrange for a callback when
626    * it changes */
627   playlist_picker_selected = NULL;
628   playlist_picker_selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(tree));
629   gtk_tree_selection_set_mode(playlist_picker_selection, GTK_SELECTION_BROWSE);
630   g_signal_connect(playlist_picker_selection, "changed",
631                    G_CALLBACK(playlist_picker_selection_changed), NULL);
632
633   /* Create the control buttons */
634   GtkWidget *buttons = create_buttons_box(playlist_picker_buttons,
635                                           NPLAYLIST_PICKER_BUTTONS,
636                                           gtk_hbox_new(FALSE, 1));
637   playlist_picker_delete_button = playlist_picker_buttons[1].widget;
638
639   playlist_picker_selection_changed(NULL, NULL);
640
641   /* Buttons live below the list */
642   GtkWidget *vbox = gtk_vbox_new(FALSE, 0);
643   gtk_box_pack_start(GTK_BOX(vbox), scroll_widget(tree), TRUE/*expand*/, TRUE/*fill*/, 0);
644   gtk_box_pack_start(GTK_BOX(vbox), buttons, FALSE/*expand*/, FALSE, 0);
645
646   return vbox;
647 }
648
649 /* Playlist editor ---------------------------------------------------------- */
650
651 /** @brief Called with new tracks for the playlist */
652 static void playlists_editor_received_tracks(void *v,
653                                              const char *err,
654                                              int nvec, char **vec) {
655   const char *playlist = v;
656   if(err) {
657     popup_protocol_error(0, err);
658     return;
659   }
660   if(!playlist_picker_selected
661      || strcmp(playlist, playlist_picker_selected)) {
662     /* The tracks are for the wrong playlist - something must have changed
663      * while the fetch command was in flight.  We just ignore this callback,
664      * the right answer will be requested and arrive in due course. */
665     return;
666   }
667   if(nvec == -1)
668     /* No such playlist, presumably we'll get a deleted event shortly */
669     return;
670   /* Translate the list of tracks into queue entries */
671   struct queue_entry *newq, **qq = &newq;
672   hash *h = hash_new(sizeof(int));
673   for(int n = 0; n < nvec; ++n) {
674     struct queue_entry *q = xmalloc(sizeof *q);
675     q->track = vec[n];
676     /* Synthesize a unique ID so that the selection survives updates.  Tracks
677      * can appear more than once in the queue so we can't use raw track names,
678      * so we add a serial number to the start. */
679     int *serialp = hash_find(h, vec[n]), serial = serialp ? *serialp : 0;
680     byte_xasprintf((char **)&q->id, "%d-%s", serial++, vec[n]);
681     hash_add(h, vec[0], &serial, HASH_INSERT_OR_REPLACE);
682     *qq = q;
683     qq = &q->next;
684   }
685   *qq = NULL;
686   ql_new_queue(&ql_playlist, newq);
687 }
688
689 /** @brief (Re-)populate the playlist tree model */
690 static void playlist_editor_fill(const char attribute((unused)) *event,
691                                  void *eventdata,
692                                  void attribute((unused)) *callbackdata) {
693   const char *modified_playlist = eventdata;
694   if(!playlist_window)
695     return;
696   if(!playlist_picker_selected)
697     return;
698   if(!strcmp(playlist_picker_selected, modified_playlist))
699     disorder_eclient_playlist_get(client, playlists_editor_received_tracks,
700                                   playlist_picker_selected,
701                                   (void *)playlist_picker_selected);
702 }
703
704 static GtkWidget *playlists_editor_create(void) {
705   assert(ql_playlist.view == NULL);     /* better not be set up already */
706   GtkWidget *w = init_queuelike(&ql_playlist);
707   /* Initially empty */
708   return w;
709 }
710
711 /* Playlist editor right-click menu ---------------------------------------- */
712
713 /** @brief Called to determine whether the playlist is playable */
714 static int playlist_playall_sensitive(void attribute((unused)) *extra) {
715   /* If there's no playlist obviously we can't play it */
716   if(!playlist_picker_selected)
717     return FALSE;
718   /* If it's empty we can't play it */
719   if(!ql_playlist.q)
720     return FALSE;
721   /* Otherwise we can */
722   return TRUE;
723 }
724
725 /** @brief Called to play the selected playlist */
726 static void playlist_playall_activate(GtkMenuItem attribute((unused)) *menuitem,
727                                       gpointer attribute((unused)) user_data) {
728   if(!playlist_picker_selected)
729     return;
730   /* Re-use the menu-based activation callback */
731   disorder_eclient_playlist_get(client, playlist_menu_received_content,
732                                 playlist_picker_selected, NULL);
733 }
734
735 /* Playlists window --------------------------------------------------------- */
736
737 /** @brief Keypress handler */
738 static gboolean playlist_window_keypress(GtkWidget attribute((unused)) *widget,
739                                          GdkEventKey *event,
740                                          gpointer attribute((unused)) user_data) {
741   if(event->state)
742     return FALSE;
743   switch(event->keyval) {
744   case GDK_Escape:
745     gtk_widget_destroy(playlist_window);
746     return TRUE;
747   default:
748     return FALSE;
749   }
750 }
751
752 /** @brief Called when the playlist window is destroyed */
753 static void playlist_window_destroyed(GtkWidget attribute((unused)) *widget,
754                                       GtkWidget **widget_pointer) {
755   destroy_queuelike(&ql_playlist);
756   *widget_pointer = NULL;
757 }
758
759 /** @brief Pop up the playlists window
760  *
761  * Called when the playlists menu item is selected
762  */
763 void playlist_window_create(gpointer attribute((unused)) callback_data,
764                             guint attribute((unused)) callback_action,
765                             GtkWidget attribute((unused)) *menu_item) {
766   /* If the window already exists, raise it */
767   if(playlist_window) {
768     gtk_window_present(GTK_WINDOW(playlist_window));
769     return;
770   }
771   /* Create the window */
772   playlist_window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
773   gtk_widget_set_style(playlist_window, tool_style);
774   g_signal_connect(playlist_window, "destroy",
775                    G_CALLBACK(playlist_window_destroyed), &playlist_window);
776   gtk_window_set_title(GTK_WINDOW(playlist_window), "Playlists Management");
777   /* TODO loads of this is very similar to (copied from!) users.c - can we
778    * de-dupe? */
779   /* Keyboard shortcuts */
780   g_signal_connect(playlist_window, "key-press-event",
781                    G_CALLBACK(playlist_window_keypress), 0);
782   /* default size is too small */
783   gtk_window_set_default_size(GTK_WINDOW(playlist_window), 512, 240);
784
785   GtkWidget *hbox = gtk_hbox_new(FALSE, 0);
786   gtk_box_pack_start(GTK_BOX(hbox), playlist_picker_create(),
787                      FALSE/*expand*/, FALSE, 0);
788   gtk_box_pack_start(GTK_BOX(hbox), gtk_event_box_new(),
789                      FALSE/*expand*/, FALSE, 2);
790   gtk_box_pack_start(GTK_BOX(hbox), playlists_editor_create(),
791                      TRUE/*expand*/, TRUE/*fill*/, 0);
792
793   gtk_container_add(GTK_CONTAINER(playlist_window), frame_widget(hbox, NULL));
794   gtk_widget_show_all(playlist_window);
795 }
796
797 /** @brief Initialize playlist support */
798 void playlists_init(void) {
799   /* We re-get all playlists upon any change... */
800   event_register("playlist-created", playlist_list_update, 0);
801   event_register("playlist-deleted", playlist_list_update, 0);
802   /* ...and on reconnection */
803   event_register("log-connected", playlist_list_update, 0);
804   /* ...and from time to time */
805   event_register("periodic-slow", playlist_list_update, 0);
806   /* ...and at startup */
807   playlist_list_update(0, 0, 0);
808
809   /* Update the playlists menu when the set of playlists changes */
810   event_register("playlists-updated", playlist_menu_changed, 0);
811   /* Update the new-playlist OK button when the set of playlists changes */
812   event_register("playlists-updated", playlist_new_changed, 0);
813   /* Update the list of playlists in the edit window when the set changes */
814   event_register("playlists-updated", playlist_picker_fill, 0);
815   /* Update the displayed playlist when it is modified */
816   event_register("playlist-modified", playlist_editor_fill, 0);
817 }
818
819 #endif
820
821 /*
822 Local Variables:
823 c-basic-offset:2
824 comment-column:40
825 fill-column:79
826 indent-tabs-mode:nil
827 End:
828 */