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
1from random import randint
2from typing import Any, cast, List
4from gi.repository import Gdk, GLib, GObject, Gtk, Pango
6from sublime_music.adapters import AdapterManager, api_objects as API, Result
7from sublime_music.config import AppConfiguration
8from sublime_music.ui import util
10from .icon_button import IconButton
11from .load_error import LoadError
12from .song_list_column import SongListColumn
13from .spinner_image import SpinnerImage
16class AlbumWithSongs(Gtk.Box):
17 __gsignals__ = {
18 "song-selected": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
19 "song-clicked": (
20 GObject.SignalFlags.RUN_FIRST,
21 GObject.TYPE_NONE,
22 (int, object, object),
23 ),
24 }
26 offline_mode = True
28 def __init__(
29 self,
30 album: API.Album,
31 cover_art_size: int = 200,
32 show_artist_name: bool = True,
33 ):
34 Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
35 self.album = album
37 box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
38 artist_artwork = SpinnerImage(
39 loading=False,
40 image_name="artist-album-list-artwork",
41 spinner_name="artist-artwork-spinner",
42 image_size=cover_art_size,
43 )
44 # Account for 10px margin on all sides with "+ 20".
45 artist_artwork.set_size_request(cover_art_size + 20, cover_art_size + 20)
46 box.pack_start(artist_artwork, False, False, 0)
47 box.pack_start(Gtk.Box(), True, True, 0)
48 self.pack_start(box, False, False, 0)
50 def cover_art_future_done(f: Result):
51 artist_artwork.set_from_file(f.result())
52 artist_artwork.set_loading(False)
54 cover_art_filename_future = AdapterManager.get_cover_art_uri(
55 album.cover_art,
56 "file",
57 before_download=lambda: artist_artwork.set_loading(True),
58 )
59 cover_art_filename_future.add_done_callback(
60 lambda f: GLib.idle_add(cover_art_future_done, f)
61 )
63 album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
64 album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
66 # TODO (#43): deal with super long-ass titles
67 album_title_and_buttons.add(
68 Gtk.Label(
69 label=album.name,
70 name="artist-album-list-album-name",
71 halign=Gtk.Align.START,
72 ellipsize=Pango.EllipsizeMode.END,
73 )
74 )
76 self.play_btn = IconButton(
77 "media-playback-start-symbolic",
78 "Play all songs in this album",
79 sensitive=False,
80 )
81 self.play_btn.connect("clicked", self.play_btn_clicked)
82 album_title_and_buttons.pack_start(self.play_btn, False, False, 5)
84 self.shuffle_btn = IconButton(
85 "media-playlist-shuffle-symbolic",
86 "Shuffle all songs in this album",
87 sensitive=False,
88 )
89 self.shuffle_btn.connect("clicked", self.shuffle_btn_clicked)
90 album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
92 self.play_next_btn = IconButton(
93 "queue-front-symbolic",
94 "Play all of the songs in this album next",
95 sensitive=False,
96 )
97 album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
99 self.add_to_queue_btn = IconButton(
100 "queue-back-symbolic",
101 "Add all the songs in this album to the end of the play queue",
102 sensitive=False,
103 )
104 album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False, 5)
106 self.download_all_btn = IconButton(
107 "folder-download-symbolic",
108 "Download all songs in this album",
109 sensitive=False,
110 )
111 self.download_all_btn.connect("clicked", self.on_download_all_click)
112 album_title_and_buttons.pack_end(self.download_all_btn, False, False, 5)
114 album_details.add(album_title_and_buttons)
116 stats: List[Any] = [
117 album.artist.name if show_artist_name and album.artist else None,
118 album.year,
119 album.genre.name if album.genre else None,
120 util.format_sequence_duration(album.duration) if album.duration else None,
121 ]
123 album_details.add(
124 Gtk.Label(
125 label=util.dot_join(*stats),
126 halign=Gtk.Align.START,
127 margin_left=10,
128 )
129 )
131 self.loading_indicator_container = Gtk.Box()
132 album_details.add(self.loading_indicator_container)
134 self.error_container = Gtk.Box()
135 album_details.add(self.error_container)
137 # clickable, cache status, title, duration, song ID
138 self.album_song_store = Gtk.ListStore(bool, str, str, str, str)
140 self.album_songs = Gtk.TreeView(
141 model=self.album_song_store,
142 name="album-songs-list",
143 headers_visible=False,
144 margin_top=15,
145 margin_left=10,
146 margin_right=10,
147 margin_bottom=10,
148 )
149 selection = self.album_songs.get_selection()
150 selection.set_mode(Gtk.SelectionMode.MULTIPLE)
151 selection.set_select_function(lambda _, model, path, current: model[path[0]][0])
153 # Song status column.
154 renderer = Gtk.CellRendererPixbuf()
155 renderer.set_fixed_size(30, 35)
156 column = Gtk.TreeViewColumn("", renderer, icon_name=1)
157 column.set_resizable(True)
158 self.album_songs.append_column(column)
160 self.album_songs.append_column(SongListColumn("TITLE", 2, bold=True))
161 self.album_songs.append_column(SongListColumn("DURATION", 3, align=1, width=40))
163 self.album_songs.connect("row-activated", self.on_song_activated)
164 self.album_songs.connect("button-press-event", self.on_song_button_press)
165 self.album_songs.get_selection().connect(
166 "changed", self.on_song_selection_change
167 )
168 album_details.add(self.album_songs)
170 self.pack_end(album_details, True, True, 0)
172 self.update_album_songs(album.id)
174 # Event Handlers
175 # =========================================================================
176 def on_song_selection_change(self, event: Any):
177 if not self.album_songs.has_focus():
178 self.emit("song-selected")
180 def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any):
181 if not self.album_song_store[idx[0]][0]:
182 return
183 # The song ID is in the last column of the model.
184 self.emit(
185 "song-clicked",
186 idx.get_indices()[0],
187 [m[-1] for m in self.album_song_store],
188 {},
189 )
191 def on_song_button_press(self, tree: Any, event: Gdk.EventButton) -> bool:
192 if event.button == 3: # Right click
193 clicked_path = tree.get_path_at_pos(event.x, event.y)
194 if not clicked_path:
195 return False
197 store, paths = tree.get_selection().get_selected_rows()
198 allow_deselect = False
200 def on_download_state_change(song_id: str):
201 self.update_album_songs(self.album.id)
203 # Use the new selection instead of the old one for calculating what
204 # to do the right click on.
205 if clicked_path[0] not in paths:
206 paths = [clicked_path[0]]
207 allow_deselect = True
209 song_ids = [self.album_song_store[p][-1] for p in paths]
211 # Used to adjust for the header row.
212 bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y)
213 widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y)
215 util.show_song_popover(
216 song_ids,
217 event.x,
218 event.y + abs(bin_coords.by - widget_coords.wy),
219 tree,
220 self.offline_mode,
221 on_download_state_change=on_download_state_change,
222 )
224 # If the click was on a selected row, don't deselect anything.
225 if not allow_deselect:
226 return True
228 return False
230 def on_download_all_click(self, btn: Any):
231 AdapterManager.batch_download_songs(
232 [x[-1] for x in self.album_song_store],
233 before_download=lambda _: self.update(),
234 on_song_download_complete=lambda _: self.update(),
235 )
237 def play_btn_clicked(self, btn: Any):
238 song_ids = [x[-1] for x in self.album_song_store]
239 self.emit(
240 "song-clicked",
241 0,
242 song_ids,
243 {"force_shuffle_state": False},
244 )
246 def shuffle_btn_clicked(self, btn: Any):
247 song_ids = [x[-1] for x in self.album_song_store]
248 self.emit(
249 "song-clicked",
250 randint(0, len(self.album_song_store) - 1),
251 song_ids,
252 {"force_shuffle_state": True},
253 )
255 # Helper Methods
256 # =========================================================================
257 def deselect_all(self):
258 self.album_songs.get_selection().unselect_all()
260 def update(self, app_config: AppConfiguration = None, force: bool = False):
261 if app_config:
262 # Deselect everything and reset the error container if switching between
263 # online and offline.
264 if self.offline_mode != app_config.offline_mode:
265 self.album_songs.get_selection().unselect_all()
266 for c in self.error_container.get_children():
267 self.error_container.remove(c)
269 self.offline_mode = app_config.offline_mode
271 self.update_album_songs(self.album.id, app_config=app_config, force=force)
273 def set_loading(self, loading: bool):
274 if loading:
275 if len(self.loading_indicator_container.get_children()) == 0:
276 self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
277 spinner = Gtk.Spinner(name="album-list-song-list-spinner")
278 spinner.start()
279 self.loading_indicator_container.add(spinner)
280 self.loading_indicator_container.pack_start(Gtk.Box(), True, True, 0)
282 self.loading_indicator_container.show_all()
283 else:
284 self.loading_indicator_container.hide()
286 @util.async_callback(
287 AdapterManager.get_album,
288 before_download=lambda self: self.set_loading(True),
289 on_failure=lambda self, e: self.set_loading(False),
290 )
291 def update_album_songs(
292 self,
293 album: API.Album,
294 app_config: AppConfiguration,
295 force: bool = False,
296 order_token: int = None,
297 is_partial: bool = False,
298 ):
299 songs = album.songs or []
300 if is_partial:
301 if len(self.error_container.get_children()) == 0:
302 load_error = LoadError(
303 "Song list",
304 "retrieve songs",
305 has_data=len(songs) > 0,
306 offline_mode=self.offline_mode,
307 )
308 self.error_container.pack_start(load_error, True, True, 0)
309 self.error_container.show_all()
310 else:
311 self.error_container.hide()
313 song_ids = [s.id for s in songs]
314 new_store = []
315 any_song_playable = False
317 if len(songs) == 0:
318 self.album_songs.hide()
319 else:
320 self.album_songs.show()
321 for status, song in zip(util.get_cached_status_icons(song_ids), songs):
322 playable = not self.offline_mode or status in (
323 "folder-download-symbolic",
324 "view-pin-symbolic",
325 )
326 new_store.append(
327 [
328 playable,
329 status,
330 song.title or "",
331 util.format_song_duration(song.duration),
332 song.id,
333 ]
334 )
335 any_song_playable |= playable
337 song_ids = [cast(str, song[-1]) for song in new_store]
338 util.diff_song_store(self.album_song_store, new_store)
340 self.play_btn.set_sensitive(any_song_playable)
341 self.shuffle_btn.set_sensitive(any_song_playable)
342 self.download_all_btn.set_sensitive(
343 not self.offline_mode and AdapterManager.can_batch_download_songs()
344 )
346 if any_song_playable:
347 self.play_next_btn.set_action_target_value(GLib.Variant("as", song_ids))
348 self.add_to_queue_btn.set_action_target_value(GLib.Variant("as", song_ids))
349 self.play_next_btn.set_action_name("app.play-next")
350 self.add_to_queue_btn.set_action_name("app.add-to-queue")
351 else:
352 self.play_next_btn.set_action_name("")
353 self.add_to_queue_btn.set_action_name("")
355 # Have to idle_add here so that his happens after the component is rendered.
356 self.set_loading(False)