Hide keyboard shortcuts

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 

3 

4from gi.repository import Gdk, GLib, GObject, Gtk, Pango 

5 

6from sublime_music.adapters import AdapterManager, api_objects as API, Result 

7from sublime_music.config import AppConfiguration 

8from sublime_music.ui import util 

9 

10from .icon_button import IconButton 

11from .load_error import LoadError 

12from .song_list_column import SongListColumn 

13from .spinner_image import SpinnerImage 

14 

15 

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 } 

25 

26 offline_mode = True 

27 

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 

36 

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) 

49 

50 def cover_art_future_done(f: Result): 

51 artist_artwork.set_from_file(f.result()) 

52 artist_artwork.set_loading(False) 

53 

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 ) 

62 

63 album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

64 album_title_and_buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 

65 

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 ) 

75 

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) 

83 

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) 

91 

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) 

98 

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) 

105 

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) 

113 

114 album_details.add(album_title_and_buttons) 

115 

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 ] 

122 

123 album_details.add( 

124 Gtk.Label( 

125 label=util.dot_join(*stats), 

126 halign=Gtk.Align.START, 

127 margin_left=10, 

128 ) 

129 ) 

130 

131 self.loading_indicator_container = Gtk.Box() 

132 album_details.add(self.loading_indicator_container) 

133 

134 self.error_container = Gtk.Box() 

135 album_details.add(self.error_container) 

136 

137 # clickable, cache status, title, duration, song ID 

138 self.album_song_store = Gtk.ListStore(bool, str, str, str, str) 

139 

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]) 

152 

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) 

159 

160 self.album_songs.append_column(SongListColumn("TITLE", 2, bold=True)) 

161 self.album_songs.append_column(SongListColumn("DURATION", 3, align=1, width=40)) 

162 

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) 

169 

170 self.pack_end(album_details, True, True, 0) 

171 

172 self.update_album_songs(album.id) 

173 

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") 

179 

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 ) 

190 

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 

196 

197 store, paths = tree.get_selection().get_selected_rows() 

198 allow_deselect = False 

199 

200 def on_download_state_change(song_id: str): 

201 self.update_album_songs(self.album.id) 

202 

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 

208 

209 song_ids = [self.album_song_store[p][-1] for p in paths] 

210 

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) 

214 

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 ) 

223 

224 # If the click was on a selected row, don't deselect anything. 

225 if not allow_deselect: 

226 return True 

227 

228 return False 

229 

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 ) 

236 

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 ) 

245 

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 ) 

254 

255 # Helper Methods 

256 # ========================================================================= 

257 def deselect_all(self): 

258 self.album_songs.get_selection().unselect_all() 

259 

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) 

268 

269 self.offline_mode = app_config.offline_mode 

270 

271 self.update_album_songs(self.album.id, app_config=app_config, force=force) 

272 

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) 

281 

282 self.loading_indicator_container.show_all() 

283 else: 

284 self.loading_indicator_container.hide() 

285 

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() 

312 

313 song_ids = [s.id for s in songs] 

314 new_store = [] 

315 any_song_playable = False 

316 

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 

336 

337 song_ids = [cast(str, song[-1]) for song in new_store] 

338 util.diff_song_store(self.album_song_store, new_store) 

339 

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 ) 

345 

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("") 

354 

355 # Have to idle_add here so that his happens after the component is rendered. 

356 self.set_loading(False)