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