Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import math
2from functools import lru_cache, partial
3from random import randint
4from typing import Any, cast, Dict, List, Tuple
6from fuzzywuzzy import fuzz
7from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango
9from ..adapters import AdapterManager, api_objects as API
10from ..config import AppConfiguration
11from ..ui import util
12from ..ui.common import (
13 IconButton,
14 LoadError,
15 SongListColumn,
16 SpinnerImage,
17)
20class EditPlaylistDialog(Gtk.Dialog):
21 def __init__(self, parent: Any, playlist: API.Playlist):
22 Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
24 # HEADER
25 self.header = Gtk.HeaderBar()
26 self._set_title(playlist.name)
28 cancel_button = Gtk.Button(label="Cancel")
29 cancel_button.connect("clicked", lambda _: self.close())
30 self.header.pack_start(cancel_button)
32 self.edit_button = Gtk.Button(label="Edit")
33 self.edit_button.get_style_context().add_class("suggested-action")
34 self.edit_button.connect(
35 "clicked", lambda *a: self.response(Gtk.ResponseType.APPLY)
36 )
37 self.header.pack_end(self.edit_button)
39 self.set_titlebar(self.header)
41 content_area = self.get_content_area()
42 content_grid = Gtk.Grid(column_spacing=10, row_spacing=10, margin=10)
44 make_label = lambda label_text: Gtk.Label(label_text, halign=Gtk.Align.END)
46 content_grid.attach(make_label("Playlist Name"), 0, 0, 1, 1)
47 self.name_entry = Gtk.Entry(text=playlist.name, hexpand=True)
48 self.name_entry.connect("changed", self._on_name_change)
49 content_grid.attach(self.name_entry, 1, 0, 1, 1)
51 content_grid.attach(make_label("Comment"), 0, 1, 1, 1)
52 self.comment_entry = Gtk.Entry(text=playlist.comment, hexpand=True)
53 content_grid.attach(self.comment_entry, 1, 1, 1, 1)
55 content_grid.attach(make_label("Public"), 0, 2, 1, 1)
56 self.public_switch = Gtk.Switch(active=playlist.public, halign=Gtk.Align.START)
57 content_grid.attach(self.public_switch, 1, 2, 1, 1)
59 delete_button = Gtk.Button(label="Delete")
60 delete_button.connect("clicked", lambda *a: self.response(Gtk.ResponseType.NO))
61 content_grid.attach(delete_button, 0, 3, 1, 2)
63 content_area.add(content_grid)
64 self.show_all()
66 def _on_name_change(self, entry: Gtk.Entry):
67 text = entry.get_text()
68 if len(text) > 0:
69 self._set_title(text)
70 self.edit_button.set_sensitive(len(text) > 0)
72 def _set_title(self, playlist_name: str):
73 self.header.props.title = f"Edit {playlist_name}"
75 def get_data(self) -> Dict[str, Any]:
76 return {
77 "name": self.name_entry.get_text(),
78 "comment": self.comment_entry.get_text(),
79 "public": self.public_switch.get_active(),
80 }
83class PlaylistsPanel(Gtk.Paned):
84 """Defines the playlists panel."""
86 __gsignals__ = {
87 "song-clicked": (
88 GObject.SignalFlags.RUN_FIRST,
89 GObject.TYPE_NONE,
90 (int, object, object),
91 ),
92 "refresh-window": (
93 GObject.SignalFlags.RUN_FIRST,
94 GObject.TYPE_NONE,
95 (object, bool),
96 ),
97 }
99 def __init__(self, *args, **kwargs):
100 Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
102 self.playlist_list = PlaylistList()
103 self.pack1(self.playlist_list, False, False)
105 self.playlist_detail_panel = PlaylistDetailPanel()
106 self.playlist_detail_panel.connect(
107 "song-clicked",
108 lambda _, *args: self.emit("song-clicked", *args),
109 )
110 self.playlist_detail_panel.connect(
111 "refresh-window",
112 lambda _, *args: self.emit("refresh-window", *args),
113 )
114 self.pack2(self.playlist_detail_panel, True, False)
116 def update(self, app_config: AppConfiguration = None, force: bool = False):
117 self.playlist_list.update(app_config=app_config, force=force)
118 self.playlist_detail_panel.update(app_config=app_config, force=force)
121class PlaylistList(Gtk.Box):
122 __gsignals__ = {
123 "refresh-window": (
124 GObject.SignalFlags.RUN_FIRST,
125 GObject.TYPE_NONE,
126 (object, bool),
127 ),
128 }
130 offline_mode = False
132 class PlaylistModel(GObject.GObject):
133 playlist_id = GObject.Property(type=str)
134 name = GObject.Property(type=str)
136 def __init__(self, playlist_id: str, name: str):
137 GObject.GObject.__init__(self)
138 self.playlist_id = playlist_id
139 self.name = name
141 def __init__(self):
142 Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
144 playlist_list_actions = Gtk.ActionBar()
146 self.new_playlist_button = IconButton("list-add-symbolic", label="New Playlist")
147 self.new_playlist_button.connect("clicked", self.on_new_playlist_clicked)
148 playlist_list_actions.pack_start(self.new_playlist_button)
150 self.list_refresh_button = IconButton(
151 "view-refresh-symbolic", "Refresh list of playlists"
152 )
153 self.list_refresh_button.connect("clicked", self.on_list_refresh_click)
154 playlist_list_actions.pack_end(self.list_refresh_button)
156 self.add(playlist_list_actions)
158 self.error_container = Gtk.Box()
159 self.add(self.error_container)
161 loading_new_playlist = Gtk.ListBox()
163 self.loading_indicator = Gtk.ListBoxRow(activatable=False, selectable=False)
164 loading_spinner = Gtk.Spinner(name="playlist-list-spinner", active=True)
165 self.loading_indicator.add(loading_spinner)
166 loading_new_playlist.add(self.loading_indicator)
168 self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False)
169 new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False)
171 self.new_playlist_entry = Gtk.Entry(name="playlist-list-new-playlist-entry")
172 self.new_playlist_entry.connect("activate", self.new_entry_activate)
173 new_playlist_box.add(self.new_playlist_entry)
175 new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
177 confirm_button = IconButton(
178 "object-select-symbolic",
179 "Create playlist",
180 name="playlist-list-new-playlist-confirm",
181 relief=True,
182 )
183 confirm_button.connect("clicked", self.confirm_button_clicked)
184 new_playlist_actions.pack_end(confirm_button, False, True, 0)
186 self.cancel_button = IconButton(
187 "process-stop-symbolic",
188 "Cancel create playlist",
189 name="playlist-list-new-playlist-cancel",
190 relief=True,
191 )
192 self.cancel_button.connect("clicked", self.cancel_button_clicked)
193 new_playlist_actions.pack_end(self.cancel_button, False, True, 0)
195 new_playlist_box.add(new_playlist_actions)
196 self.new_playlist_row.add(new_playlist_box)
198 loading_new_playlist.add(self.new_playlist_row)
199 self.add(loading_new_playlist)
201 list_scroll_window = Gtk.ScrolledWindow(min_content_width=220)
203 def create_playlist_row(model: PlaylistList.PlaylistModel) -> Gtk.ListBoxRow:
204 row = Gtk.ListBoxRow(
205 action_name="app.go-to-playlist",
206 action_target=GLib.Variant("s", model.playlist_id),
207 )
208 row.add(
209 Gtk.Label(
210 label=f"<b>{model.name}</b>",
211 use_markup=True,
212 margin=10,
213 halign=Gtk.Align.START,
214 ellipsize=Pango.EllipsizeMode.END,
215 )
216 )
217 row.show_all()
218 return row
220 self.playlists_store = Gio.ListStore()
221 self.list = Gtk.ListBox(name="playlist-list-listbox")
222 self.list.bind_model(self.playlists_store, create_playlist_row)
223 list_scroll_window.add(self.list)
224 self.pack_start(list_scroll_window, True, True, 0)
226 def update(self, app_config: AppConfiguration = None, force: bool = False):
227 if app_config:
228 self.offline_mode = app_config.offline_mode
229 self.new_playlist_button.set_sensitive(not app_config.offline_mode)
230 self.list_refresh_button.set_sensitive(not app_config.offline_mode)
231 self.new_playlist_row.hide()
232 self.update_list(app_config=app_config, force=force)
234 @util.async_callback(
235 AdapterManager.get_playlists,
236 before_download=lambda self: self.loading_indicator.show_all(),
237 on_failure=lambda self, e: self.loading_indicator.hide(),
238 )
239 def update_list(
240 self,
241 playlists: List[API.Playlist],
242 app_config: AppConfiguration = None,
243 force: bool = False,
244 order_token: int = None,
245 is_partial: bool = False,
246 ):
247 for c in self.error_container.get_children():
248 self.error_container.remove(c)
249 if is_partial:
250 load_error = LoadError(
251 "Playlist list",
252 "load playlists",
253 has_data=len(playlists) > 0,
254 offline_mode=self.offline_mode,
255 )
256 self.error_container.pack_start(load_error, True, True, 0)
257 self.error_container.show_all()
258 else:
259 self.error_container.hide()
261 new_store = []
262 selected_idx = None
263 for i, playlist in enumerate(playlists or []):
264 if (
265 app_config
266 and app_config.state
267 and app_config.state.selected_playlist_id == playlist.id
268 ):
269 selected_idx = i
271 new_store.append(PlaylistList.PlaylistModel(playlist.id, playlist.name))
273 util.diff_model_store(self.playlists_store, new_store)
275 # Preserve selection
276 if selected_idx is not None:
277 row = self.list.get_row_at_index(selected_idx)
278 self.list.select_row(row)
280 self.loading_indicator.hide()
282 # Event Handlers
283 # =========================================================================
284 def on_new_playlist_clicked(self, _):
285 self.new_playlist_entry.set_text("Untitled Playlist")
286 self.new_playlist_entry.grab_focus()
287 self.new_playlist_row.show()
289 def on_list_refresh_click(self, _):
290 self.update(force=True)
292 def new_entry_activate(self, entry: Gtk.Entry):
293 self.create_playlist(entry.get_text())
295 def cancel_button_clicked(self, _):
296 self.new_playlist_row.hide()
298 def confirm_button_clicked(self, _):
299 self.create_playlist(self.new_playlist_entry.get_text())
301 def create_playlist(self, playlist_name: str):
302 def on_playlist_created(_):
303 self.update(force=True)
305 self.loading_indicator.show()
306 playlist_ceate_future = AdapterManager.create_playlist(name=playlist_name)
307 playlist_ceate_future.add_done_callback(
308 lambda f: GLib.idle_add(on_playlist_created, f)
309 )
312class PlaylistDetailPanel(Gtk.Overlay):
313 __gsignals__ = {
314 "song-clicked": (
315 GObject.SignalFlags.RUN_FIRST,
316 GObject.TYPE_NONE,
317 (int, object, object),
318 ),
319 "refresh-window": (
320 GObject.SignalFlags.RUN_FIRST,
321 GObject.TYPE_NONE,
322 (object, bool),
323 ),
324 }
326 playlist_id = None
327 playlist_details_expanded = False
328 offline_mode = False
330 editing_playlist_song_list: bool = False
331 reordering_playlist_song_list: bool = False
333 def __init__(self):
334 Gtk.Overlay.__init__(self, name="playlist-view-overlay")
335 self.playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
337 playlist_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
339 self.playlist_artwork = SpinnerImage(
340 image_name="playlist-album-artwork",
341 spinner_name="playlist-artwork-spinner",
342 image_size=200,
343 )
344 playlist_info_box.add(self.playlist_artwork)
346 # Name, comment, number of songs, etc.
347 playlist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
349 playlist_details_box.pack_start(Gtk.Box(), True, False, 0)
351 self.playlist_indicator = self.make_label(name="playlist-indicator")
352 playlist_details_box.add(self.playlist_indicator)
354 self.playlist_name = self.make_label(name="playlist-name")
355 playlist_details_box.add(self.playlist_name)
357 self.playlist_comment = self.make_label(name="playlist-comment")
358 playlist_details_box.add(self.playlist_comment)
360 self.playlist_stats = self.make_label(name="playlist-stats")
361 playlist_details_box.add(self.playlist_stats)
363 self.play_shuffle_buttons = Gtk.Box(
364 orientation=Gtk.Orientation.HORIZONTAL,
365 name="playlist-play-shuffle-buttons",
366 )
368 self.play_all_button = IconButton(
369 "media-playback-start-symbolic",
370 label="Play All",
371 relief=True,
372 )
373 self.play_all_button.connect("clicked", self.on_play_all_clicked)
374 self.play_shuffle_buttons.pack_start(self.play_all_button, False, False, 0)
376 self.shuffle_all_button = IconButton(
377 "media-playlist-shuffle-symbolic",
378 label="Shuffle All",
379 relief=True,
380 )
381 self.shuffle_all_button.connect("clicked", self.on_shuffle_all_button)
382 self.play_shuffle_buttons.pack_start(self.shuffle_all_button, False, False, 5)
384 playlist_details_box.add(self.play_shuffle_buttons)
386 playlist_info_box.pack_start(playlist_details_box, True, True, 0)
388 # Action buttons & expand/collapse button
389 action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
390 self.playlist_action_buttons = Gtk.Box(
391 orientation=Gtk.Orientation.HORIZONTAL, spacing=10
392 )
394 self.download_all_button = IconButton(
395 "folder-download-symbolic", "Download all songs in the playlist"
396 )
397 self.download_all_button.connect(
398 "clicked", self.on_playlist_list_download_all_button_click
399 )
400 self.playlist_action_buttons.add(self.download_all_button)
402 self.playlist_edit_button = IconButton("document-edit-symbolic", "Edit paylist")
403 self.playlist_edit_button.connect("clicked", self.on_playlist_edit_button_click)
404 self.playlist_action_buttons.add(self.playlist_edit_button)
406 self.view_refresh_button = IconButton(
407 "view-refresh-symbolic", "Refresh playlist info"
408 )
409 self.view_refresh_button.connect("clicked", self.on_view_refresh_click)
410 self.playlist_action_buttons.add(self.view_refresh_button)
412 action_buttons_container.pack_start(
413 self.playlist_action_buttons, False, False, 10
414 )
416 action_buttons_container.pack_start(Gtk.Box(), True, True, 0)
418 expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
419 self.expand_collapse_button = IconButton(
420 "pan-up-symbolic", "Expand playlist details"
421 )
422 self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click)
423 expand_button_container.pack_end(self.expand_collapse_button, False, False, 0)
424 action_buttons_container.add(expand_button_container)
426 playlist_info_box.pack_end(action_buttons_container, False, False, 5)
428 self.playlist_box.add(playlist_info_box)
430 self.error_container = Gtk.Box()
431 self.playlist_box.add(self.error_container)
433 # Playlist songs list
434 self.playlist_song_scroll_window = Gtk.ScrolledWindow()
436 self.playlist_song_store = Gtk.ListStore(
437 bool, # clickable
438 str, # cache status
439 str, # title
440 str, # album
441 str, # artist
442 str, # duration
443 str, # song ID
444 )
446 @lru_cache(maxsize=1024)
447 def row_score(key: str, row_items: Tuple[str]) -> int:
448 return fuzz.partial_ratio(key, " ".join(row_items).lower())
450 def playlist_song_list_search_fn(
451 store: Gtk.ListStore,
452 col: int,
453 key: str,
454 treeiter: Gtk.TreeIter,
455 data: Any = None,
456 ) -> bool:
457 threshold = math.ceil(math.ceil(len(key) * 0.8) / len(key) * 100)
458 return row_score(key.lower(), tuple(store[treeiter][2:5])) < threshold
460 self.playlist_songs = Gtk.TreeView(
461 model=self.playlist_song_store,
462 reorderable=True,
463 margin_top=15,
464 enable_search=True,
465 )
466 self.playlist_songs.set_search_equal_func(playlist_song_list_search_fn)
467 selection = self.playlist_songs.get_selection()
468 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
469 selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
471 # Song status column.
472 renderer = Gtk.CellRendererPixbuf()
473 renderer.set_fixed_size(30, 35)
474 column = Gtk.TreeViewColumn("", renderer, icon_name=1)
475 column.set_resizable(True)
476 self.playlist_songs.append_column(column)
478 self.playlist_songs.append_column(SongListColumn("TITLE", 2, bold=True))
479 self.playlist_songs.append_column(SongListColumn("ALBUM", 3))
480 self.playlist_songs.append_column(SongListColumn("ARTIST", 4))
481 self.playlist_songs.append_column(
482 SongListColumn("DURATION", 5, align=1, width=40)
483 )
485 self.playlist_songs.connect("row-activated", self.on_song_activated)
486 self.playlist_songs.connect("button-press-event", self.on_song_button_press)
488 # Set up drag-and-drop on the song list for editing the order of the
489 # playlist.
490 self.playlist_song_store.connect(
491 "row-inserted", self.on_playlist_model_row_move
492 )
493 self.playlist_song_store.connect("row-deleted", self.on_playlist_model_row_move)
495 self.playlist_song_scroll_window.add(self.playlist_songs)
497 self.playlist_box.pack_start(self.playlist_song_scroll_window, True, True, 0)
498 self.add(self.playlist_box)
500 playlist_view_spinner = Gtk.Spinner(active=True)
501 playlist_view_spinner.start()
503 self.playlist_view_loading_box = Gtk.Alignment(
504 name="playlist-view-overlay", xalign=0.5, yalign=0.5, xscale=0.1, yscale=0.1
505 )
506 self.playlist_view_loading_box.add(playlist_view_spinner)
507 self.add_overlay(self.playlist_view_loading_box)
509 update_playlist_view_order_token = 0
511 def update(self, app_config: AppConfiguration, force: bool = False):
512 # Deselect everything if switching online to offline.
513 if self.offline_mode != app_config.offline_mode:
514 self.playlist_songs.get_selection().unselect_all()
516 self.offline_mode = app_config.offline_mode
517 if app_config.state.selected_playlist_id is None:
518 self.playlist_box.hide()
519 self.playlist_view_loading_box.hide()
520 else:
521 self.update_playlist_view_order_token += 1
522 self.playlist_box.show()
523 self.update_playlist_view(
524 app_config.state.selected_playlist_id,
525 app_config=app_config,
526 force=force,
527 order_token=self.update_playlist_view_order_token,
528 )
529 self.download_all_button.set_sensitive(not app_config.offline_mode)
530 self.playlist_edit_button.set_sensitive(not app_config.offline_mode)
531 self.view_refresh_button.set_sensitive(not app_config.offline_mode)
533 _current_song_ids: List[str] = []
535 @util.async_callback(
536 AdapterManager.get_playlist_details,
537 before_download=lambda self: self.show_loading_all(),
538 on_failure=lambda self, e: self.hide_loading_all(),
539 )
540 def update_playlist_view(
541 self,
542 playlist: API.Playlist,
543 app_config: AppConfiguration = None,
544 force: bool = False,
545 order_token: int = None,
546 is_partial: bool = False,
547 ):
548 if self.update_playlist_view_order_token != order_token:
549 return
551 # If the selected playlist has changed, then clear the selections in
552 # the song list.
553 if self.playlist_id != playlist.id:
554 self.playlist_songs.get_selection().unselect_all()
556 self.playlist_id = playlist.id
558 if app_config:
559 self.playlist_details_expanded = app_config.state.playlist_details_expanded
561 up_down = "up" if self.playlist_details_expanded else "down"
562 self.expand_collapse_button.set_icon(f"pan-{up_down}-symbolic")
563 self.expand_collapse_button.set_tooltip_text(
564 "Collapse" if self.playlist_details_expanded else "Expand"
565 )
567 # Update the info display.
568 self.playlist_name.set_markup(f"<b>{playlist.name}</b>")
569 self.playlist_name.set_tooltip_text(playlist.name)
571 if self.playlist_details_expanded:
572 self.playlist_artwork.get_style_context().remove_class("collapsed")
573 self.playlist_name.get_style_context().remove_class("collapsed")
574 self.playlist_box.show_all()
575 self.playlist_indicator.set_markup("PLAYLIST")
577 if playlist.comment:
578 self.playlist_comment.set_text(playlist.comment)
579 self.playlist_comment.set_tooltip_text(playlist.comment)
580 self.playlist_comment.show()
581 else:
582 self.playlist_comment.hide()
584 self.playlist_stats.set_markup(self._format_stats(playlist))
585 else:
586 self.playlist_artwork.get_style_context().add_class("collapsed")
587 self.playlist_name.get_style_context().add_class("collapsed")
588 self.playlist_box.show_all()
589 self.playlist_indicator.hide()
590 self.playlist_comment.hide()
591 self.playlist_stats.hide()
593 # Update the artwork.
594 self.update_playlist_artwork(playlist.cover_art, order_token=order_token)
596 for c in self.error_container.get_children():
597 self.error_container.remove(c)
598 if is_partial:
599 has_data = len(playlist.songs) > 0
600 load_error = LoadError(
601 "Playlist data",
602 "load playlist details",
603 has_data=has_data,
604 offline_mode=self.offline_mode,
605 )
606 self.error_container.pack_start(load_error, True, True, 0)
607 self.error_container.show_all()
608 if not has_data:
609 self.playlist_song_scroll_window.hide()
610 else:
611 self.error_container.hide()
612 self.playlist_song_scroll_window.show()
614 # Update the song list model. This requires some fancy diffing to
615 # update the list.
616 self.editing_playlist_song_list = True
618 # This doesn't look efficient, since it's doing a ton of passses over the data,
619 # but there is some annoying memory overhead for generating the stores to diff,
620 # so we are short-circuiting by checking to see if any of the the IDs have
621 # changed.
622 #
623 # The entire algorithm ends up being O(2n), but the first loop is very tight,
624 # and the expensive parts of the second loop are avoided if the IDs haven't
625 # changed.
626 song_ids, songs = [], []
627 if len(self._current_song_ids) != len(playlist.songs):
628 force = True
630 for i, c in enumerate(playlist.songs):
631 if i >= len(self._current_song_ids) or c.id != self._current_song_ids[i]:
632 force = True
633 song_ids.append(c.id)
634 songs.append(c)
636 new_songs_store = []
637 can_play_any_song = False
638 cached_status_icons = ("folder-download-symbolic", "view-pin-symbolic")
640 if force:
641 self._current_song_ids = song_ids
643 # Regenerate the store from the actual song data (this is more expensive
644 # because when coming from the cache, we are doing 2N fk requests to
645 # albums).
646 for status_icon, song in zip(
647 util.get_cached_status_icons(song_ids),
648 [cast(API.Song, s) for s in songs],
649 ):
650 playable = not self.offline_mode or status_icon in cached_status_icons
651 can_play_any_song |= playable
652 new_songs_store.append(
653 [
654 playable,
655 status_icon,
656 song.title,
657 album.name if (album := song.album) else None,
658 artist.name if (artist := song.artist) else None,
659 util.format_song_duration(song.duration),
660 song.id,
661 ]
662 )
663 else:
664 # Just update the clickable state and download state.
665 for status_icon, song_model in zip(
666 util.get_cached_status_icons(song_ids), self.playlist_song_store
667 ):
668 playable = not self.offline_mode or status_icon in cached_status_icons
669 can_play_any_song |= playable
670 new_songs_store.append([playable, status_icon, *song_model[2:]])
672 util.diff_song_store(self.playlist_song_store, new_songs_store)
674 self.play_all_button.set_sensitive(can_play_any_song)
675 self.shuffle_all_button.set_sensitive(can_play_any_song)
677 self.editing_playlist_song_list = False
679 self.playlist_view_loading_box.hide()
680 self.playlist_action_buttons.show_all()
682 @util.async_callback(
683 partial(AdapterManager.get_cover_art_uri, scheme="file"),
684 before_download=lambda self: self.playlist_artwork.set_loading(True),
685 on_failure=lambda self, e: self.playlist_artwork.set_loading(False),
686 )
687 def update_playlist_artwork(
688 self,
689 cover_art_filename: str,
690 app_config: AppConfiguration,
691 force: bool = False,
692 order_token: int = None,
693 is_partial: bool = False,
694 ):
695 if self.update_playlist_view_order_token != order_token:
696 return
698 self.playlist_artwork.set_from_file(cover_art_filename)
699 self.playlist_artwork.set_loading(False)
701 if self.playlist_details_expanded:
702 self.playlist_artwork.set_image_size(200)
703 else:
704 self.playlist_artwork.set_image_size(70)
706 # Event Handlers
707 # =========================================================================
708 def on_view_refresh_click(self, _):
709 self.update_playlist_view(
710 self.playlist_id,
711 force=True,
712 order_token=self.update_playlist_view_order_token,
713 )
715 def on_playlist_edit_button_click(self, _):
716 assert self.playlist_id
717 playlist = AdapterManager.get_playlist_details(self.playlist_id).result()
718 dialog = EditPlaylistDialog(self.get_toplevel(), playlist)
719 playlist_deleted = False
721 result = dialog.run()
722 # Using ResponseType.NO as the delete event.
723 if result not in (Gtk.ResponseType.APPLY, Gtk.ResponseType.NO):
724 dialog.destroy()
725 return
727 if result == Gtk.ResponseType.APPLY:
728 AdapterManager.update_playlist(self.playlist_id, **dialog.get_data())
729 elif result == Gtk.ResponseType.NO:
730 # Delete the playlist.
731 confirm_dialog = Gtk.MessageDialog(
732 transient_for=self.get_toplevel(),
733 message_type=Gtk.MessageType.WARNING,
734 buttons=Gtk.ButtonsType.NONE,
735 text="Confirm deletion",
736 )
737 confirm_dialog.add_buttons(
738 Gtk.STOCK_DELETE,
739 Gtk.ResponseType.YES,
740 Gtk.STOCK_CANCEL,
741 Gtk.ResponseType.CANCEL,
742 )
743 confirm_dialog.format_secondary_markup(
744 f'Are you sure you want to delete the "{playlist.name}" playlist?'
745 )
746 result = confirm_dialog.run()
747 confirm_dialog.destroy()
748 if result == Gtk.ResponseType.YES:
749 AdapterManager.delete_playlist(self.playlist_id)
750 playlist_deleted = True
751 else:
752 # In this case, we don't want to do any invalidation of
753 # anything.
754 dialog.destroy()
755 return
757 # Force a re-fresh of the view
758 self.emit(
759 "refresh-window",
760 {"selected_playlist_id": None if playlist_deleted else self.playlist_id},
761 True,
762 )
763 dialog.destroy()
765 def on_playlist_list_download_all_button_click(self, _):
766 def download_state_change(song_id: str):
767 GLib.idle_add(
768 lambda: self.update_playlist_view(
769 self.playlist_id, order_token=self.update_playlist_view_order_token
770 )
771 )
773 song_ids = [s[-1] for s in self.playlist_song_store]
774 AdapterManager.batch_download_songs(
775 song_ids,
776 before_download=download_state_change,
777 on_song_download_complete=download_state_change,
778 )
780 def on_play_all_clicked(self, _):
781 self.emit(
782 "song-clicked",
783 0,
784 [m[-1] for m in self.playlist_song_store],
785 {"force_shuffle_state": False, "active_playlist_id": self.playlist_id},
786 )
788 def on_shuffle_all_button(self, _):
789 self.emit(
790 "song-clicked",
791 randint(0, len(self.playlist_song_store) - 1),
792 [m[-1] for m in self.playlist_song_store],
793 {"force_shuffle_state": True, "active_playlist_id": self.playlist_id},
794 )
796 def on_expand_collapse_click(self, _):
797 self.emit(
798 "refresh-window",
799 {"playlist_details_expanded": not self.playlist_details_expanded},
800 False,
801 )
803 def on_song_activated(self, _, idx: Gtk.TreePath, col: Any):
804 if not self.playlist_song_store[idx[0]][0]:
805 return
806 # The song ID is in the last column of the model.
807 self.emit(
808 "song-clicked",
809 idx.get_indices()[0],
810 [m[-1] for m in self.playlist_song_store],
811 {"active_playlist_id": self.playlist_id},
812 )
814 def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool:
815 if event.button == 3: # Right click
816 clicked_path = tree.get_path_at_pos(event.x, event.y)
817 if not clicked_path:
818 return False
820 store, paths = tree.get_selection().get_selected_rows()
821 allow_deselect = False
823 def on_download_state_change(song_id: str):
824 GLib.idle_add(
825 lambda: self.update_playlist_view(
826 self.playlist_id,
827 order_token=self.update_playlist_view_order_token,
828 )
829 )
831 # Use the new selection instead of the old one for calculating what
832 # to do the right click on.
833 if clicked_path[0] not in paths:
834 paths = [clicked_path[0]]
835 allow_deselect = True
837 song_ids = [self.playlist_song_store[p][-1] for p in paths]
839 # Used to adjust for the header row.
840 bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
841 widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
843 def on_remove_songs_click(_):
844 assert self.playlist_id
845 delete_idxs = {p.get_indices()[0] for p in paths}
846 new_song_ids = [
847 model[-1]
848 for i, model in enumerate(self.playlist_song_store)
849 if i not in delete_idxs
850 ]
851 AdapterManager.update_playlist(
852 playlist_id=self.playlist_id, song_ids=new_song_ids
853 ).result()
854 self.update_playlist_view(
855 self.playlist_id,
856 force=True,
857 order_token=self.update_playlist_view_order_token,
858 )
860 remove_text = (
861 "Remove " + util.pluralize("song", len(song_ids)) + " from playlist"
862 )
863 util.show_song_popover(
864 song_ids,
865 event.x,
866 event.y + abs(bin_coords.by - widget_coords.wy),
867 tree,
868 self.offline_mode,
869 on_download_state_change=on_download_state_change,
870 on_remove_downloads_click=(
871 lambda: (
872 self.offline_mode
873 and self.playlist_songs.get_selection().unselect_all()
874 )
875 ),
876 extra_menu_items=[
877 (
878 Gtk.ModelButton(
879 text=remove_text, sensitive=not self.offline_mode
880 ),
881 on_remove_songs_click,
882 )
883 ],
884 on_playlist_state_change=lambda: self.emit("refresh-window", {}, True),
885 )
887 # If the click was on a selected row, don't deselect anything.
888 if not allow_deselect:
889 return True
891 return False
893 def on_playlist_model_row_move(self, *args):
894 # If we are programatically editing the song list, don't do anything.
895 if self.editing_playlist_song_list:
896 return
898 # We get both a delete and insert event, I think it's deterministic
899 # which one comes first, but just in case, we have this
900 # reordering_playlist_song_list flag.
901 if self.reordering_playlist_song_list:
902 self._update_playlist_order(self.playlist_id)
903 self.reordering_playlist_song_list = False
904 else:
905 self.reordering_playlist_song_list = True
907 # Helper Methods
908 # =========================================================================
909 def show_loading_all(self):
910 self.playlist_artwork.set_loading(True)
911 self.playlist_view_loading_box.show_all()
913 def hide_loading_all(self):
914 self.playlist_artwork.set_loading(False)
915 self.playlist_view_loading_box.hide()
917 def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label:
918 return Gtk.Label(
919 label=text,
920 name=name,
921 halign=Gtk.Align.START,
922 ellipsize=Pango.EllipsizeMode.END,
923 **params,
924 )
926 @util.async_callback(AdapterManager.get_playlist_details)
927 def _update_playlist_order(
928 self,
929 playlist: API.Playlist,
930 app_config: AppConfiguration,
931 **kwargs,
932 ):
933 self.playlist_view_loading_box.show_all()
934 update_playlist_future = AdapterManager.update_playlist(
935 playlist.id, song_ids=[s[-1] for s in self.playlist_song_store]
936 )
938 update_playlist_future.add_done_callback(
939 lambda f: GLib.idle_add(
940 lambda: self.update_playlist_view(
941 playlist.id,
942 force=True,
943 order_token=self.update_playlist_view_order_token,
944 )
945 )
946 )
948 def _format_stats(self, playlist: API.Playlist) -> str:
949 created_date_text = ""
950 if playlist.created:
951 created_date_text = f" on {playlist.created.strftime('%B %d, %Y')}"
952 created_text = f"Created by {playlist.owner}{created_date_text}"
954 lines = [
955 util.dot_join(
956 created_text,
957 f"{'Not v' if not playlist.public else 'V'}isible to others",
958 ),
959 util.dot_join(
960 "{} {}".format(
961 playlist.song_count,
962 util.pluralize("song", playlist.song_count or 0),
963 ),
964 util.format_sequence_duration(playlist.duration),
965 ),
966 ]
967 return "\n".join(lines)