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 datetime import timedelta 

2from functools import partial 

3from random import randint 

4from typing import cast, List, Sequence 

5 

6import bleach 

7 

8from gi.repository import Gio, GLib, GObject, Gtk, Pango 

9 

10from ..adapters import ( 

11 AdapterManager, 

12 api_objects as API, 

13 CacheMissError, 

14 SongCacheStatus, 

15) 

16from ..config import AppConfiguration 

17from ..ui import util 

18from ..ui.common import AlbumWithSongs, IconButton, LoadError, SpinnerImage 

19 

20 

21class ArtistsPanel(Gtk.Paned): 

22 """Defines the arist panel.""" 

23 

24 __gsignals__ = { 

25 "song-clicked": ( 

26 GObject.SignalFlags.RUN_FIRST, 

27 GObject.TYPE_NONE, 

28 (int, object, object), 

29 ), 

30 "refresh-window": ( 

31 GObject.SignalFlags.RUN_FIRST, 

32 GObject.TYPE_NONE, 

33 (object, bool), 

34 ), 

35 } 

36 

37 def __init__(self, *args, **kwargs): 

38 Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL) 

39 

40 self.artist_list = ArtistList() 

41 self.pack1(self.artist_list, False, False) 

42 

43 self.artist_detail_panel = ArtistDetailPanel() 

44 self.artist_detail_panel.connect( 

45 "song-clicked", 

46 lambda _, *args: self.emit("song-clicked", *args), 

47 ) 

48 self.artist_detail_panel.connect( 

49 "refresh-window", 

50 lambda _, *args: self.emit("refresh-window", *args), 

51 ) 

52 self.pack2(self.artist_detail_panel, True, False) 

53 

54 def update(self, app_config: AppConfiguration, force: bool = False): 

55 self.artist_list.update(app_config=app_config) 

56 self.artist_detail_panel.update(app_config=app_config) 

57 

58 

59class _ArtistModel(GObject.GObject): 

60 artist_id = GObject.Property(type=str) 

61 name = GObject.Property(type=str) 

62 album_count = GObject.Property(type=int) 

63 

64 def __init__(self, artist: API.Artist): 

65 GObject.GObject.__init__(self) 

66 self.artist_id = artist.id 

67 self.name = artist.name 

68 self.album_count = artist.album_count or 0 

69 

70 

71class ArtistList(Gtk.Box): 

72 def __init__(self): 

73 Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) 

74 

75 list_actions = Gtk.ActionBar() 

76 

77 self.refresh_button = IconButton( 

78 "view-refresh-symbolic", "Refresh list of artists" 

79 ) 

80 self.refresh_button.connect("clicked", lambda *a: self.update(force=True)) 

81 list_actions.pack_end(self.refresh_button) 

82 

83 self.add(list_actions) 

84 

85 self.error_container = Gtk.Box() 

86 self.add(self.error_container) 

87 

88 self.loading_indicator = Gtk.ListBox() 

89 spinner_row = Gtk.ListBoxRow(activatable=False, selectable=False) 

90 spinner = Gtk.Spinner(name="artist-list-spinner", active=True) 

91 spinner_row.add(spinner) 

92 self.loading_indicator.add(spinner_row) 

93 self.pack_start(self.loading_indicator, False, False, 0) 

94 

95 list_scroll_window = Gtk.ScrolledWindow(min_content_width=250) 

96 

97 def create_artist_row(model: _ArtistModel) -> Gtk.ListBoxRow: 

98 label_text = [f"<b>{model.name}</b>"] 

99 

100 if album_count := model.album_count: 

101 label_text.append( 

102 "{} {}".format(album_count, util.pluralize("album", album_count)) 

103 ) 

104 

105 row = Gtk.ListBoxRow( 

106 action_name="app.go-to-artist", 

107 action_target=GLib.Variant("s", model.artist_id), 

108 ) 

109 row.add( 

110 Gtk.Label( 

111 label=bleach.clean("\n".join(label_text)), 

112 use_markup=True, 

113 margin=12, 

114 halign=Gtk.Align.START, 

115 ellipsize=Pango.EllipsizeMode.END, 

116 ) 

117 ) 

118 row.show_all() 

119 return row 

120 

121 self.artists_store = Gio.ListStore() 

122 self.list = Gtk.ListBox(name="artist-list") 

123 self.list.bind_model(self.artists_store, create_artist_row) 

124 list_scroll_window.add(self.list) 

125 

126 self.pack_start(list_scroll_window, True, True, 0) 

127 

128 _app_config = None 

129 

130 @util.async_callback( 

131 AdapterManager.get_artists, 

132 before_download=lambda self: self.loading_indicator.show_all(), 

133 on_failure=lambda self, e: self.loading_indicator.hide(), 

134 ) 

135 def update( 

136 self, 

137 artists: Sequence[API.Artist], 

138 app_config: AppConfiguration = None, 

139 is_partial: bool = False, 

140 **kwargs, 

141 ): 

142 if app_config: 

143 self._app_config = app_config 

144 self.refresh_button.set_sensitive(not app_config.offline_mode) 

145 

146 for c in self.error_container.get_children(): 

147 self.error_container.remove(c) 

148 if is_partial: 

149 load_error = LoadError( 

150 "Artist list", 

151 "load artists", 

152 has_data=len(artists) > 0, 

153 offline_mode=( 

154 self._app_config.offline_mode if self._app_config else False 

155 ), 

156 ) 

157 self.error_container.pack_start(load_error, True, True, 0) 

158 self.error_container.show_all() 

159 else: 

160 self.error_container.hide() 

161 

162 new_store = [] 

163 selected_idx = None 

164 for i, artist in enumerate(artists): 

165 if ( 

166 self._app_config 

167 and self._app_config.state 

168 and self._app_config.state.selected_artist_id == artist.id 

169 ): 

170 selected_idx = i 

171 new_store.append(_ArtistModel(artist)) 

172 

173 util.diff_model_store(self.artists_store, new_store) 

174 

175 # Preserve selection 

176 if selected_idx is not None: 

177 row = self.list.get_row_at_index(selected_idx) 

178 self.list.select_row(row) 

179 

180 self.loading_indicator.hide() 

181 

182 

183class ArtistDetailPanel(Gtk.Box): 

184 """Defines the artists list.""" 

185 

186 __gsignals__ = { 

187 "song-clicked": ( 

188 GObject.SignalFlags.RUN_FIRST, 

189 GObject.TYPE_NONE, 

190 (int, object, object), 

191 ), 

192 "refresh-window": ( 

193 GObject.SignalFlags.RUN_FIRST, 

194 GObject.TYPE_NONE, 

195 (object, bool), 

196 ), 

197 } 

198 

199 update_order_token = 0 

200 artist_details_expanded = False 

201 

202 def __init__(self, *args, **kwargs): 

203 super().__init__( 

204 *args, 

205 name="artist-detail-panel", 

206 orientation=Gtk.Orientation.VERTICAL, 

207 **kwargs, 

208 ) 

209 self.albums: Sequence[API.Album] = [] 

210 self.artist_id = None 

211 

212 # Artist info panel 

213 self.big_info_panel = Gtk.Box( 

214 orientation=Gtk.Orientation.HORIZONTAL, name="artist-info-panel" 

215 ) 

216 

217 self.artist_artwork = SpinnerImage( 

218 loading=False, 

219 image_name="artist-album-artwork", 

220 spinner_name="artist-artwork-spinner", 

221 image_size=300, 

222 ) 

223 self.big_info_panel.pack_start(self.artist_artwork, False, False, 0) 

224 

225 # Action buttons, name, comment, number of songs, etc. 

226 artist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

227 

228 artist_details_box.pack_start(Gtk.Box(), True, False, 0) 

229 

230 self.artist_indicator = self.make_label(name="artist-indicator") 

231 artist_details_box.add(self.artist_indicator) 

232 

233 self.artist_name = self.make_label( 

234 name="artist-name", ellipsize=Pango.EllipsizeMode.END 

235 ) 

236 artist_details_box.add(self.artist_name) 

237 

238 self.artist_bio = self.make_label( 

239 name="artist-bio", justify=Gtk.Justification.LEFT 

240 ) 

241 self.artist_bio.set_line_wrap(True) 

242 artist_details_box.add(self.artist_bio) 

243 

244 self.similar_artists_scrolledwindow = Gtk.ScrolledWindow() 

245 similar_artists_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 

246 

247 self.similar_artists_label = self.make_label(name="similar-artists") 

248 similar_artists_box.add(self.similar_artists_label) 

249 

250 self.similar_artists_button_box = Gtk.Box( 

251 orientation=Gtk.Orientation.HORIZONTAL 

252 ) 

253 similar_artists_box.add(self.similar_artists_button_box) 

254 self.similar_artists_scrolledwindow.add(similar_artists_box) 

255 

256 artist_details_box.add(self.similar_artists_scrolledwindow) 

257 

258 self.artist_stats = self.make_label(name="artist-stats") 

259 artist_details_box.add(self.artist_stats) 

260 

261 self.play_shuffle_buttons = Gtk.Box( 

262 orientation=Gtk.Orientation.HORIZONTAL, 

263 name="playlist-play-shuffle-buttons", 

264 ) 

265 

266 self.play_button = IconButton( 

267 "media-playback-start-symbolic", label="Play All", relief=True 

268 ) 

269 self.play_button.connect("clicked", self.on_play_all_clicked) 

270 self.play_shuffle_buttons.pack_start(self.play_button, False, False, 0) 

271 

272 self.shuffle_button = IconButton( 

273 "media-playlist-shuffle-symbolic", label="Shuffle All", relief=True 

274 ) 

275 self.shuffle_button.connect("clicked", self.on_shuffle_all_button) 

276 self.play_shuffle_buttons.pack_start(self.shuffle_button, False, False, 5) 

277 artist_details_box.add(self.play_shuffle_buttons) 

278 

279 self.big_info_panel.pack_start(artist_details_box, True, True, 0) 

280 

281 # Action buttons 

282 action_buttons_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

283 self.artist_action_buttons = Gtk.Box( 

284 orientation=Gtk.Orientation.HORIZONTAL, spacing=10 

285 ) 

286 

287 self.download_all_button = IconButton( 

288 "folder-download-symbolic", "Download all songs by this artist" 

289 ) 

290 self.download_all_button.connect("clicked", self.on_download_all_click) 

291 self.artist_action_buttons.add(self.download_all_button) 

292 

293 self.refresh_button = IconButton("view-refresh-symbolic", "Refresh artist info") 

294 self.refresh_button.connect("clicked", self.on_view_refresh_click) 

295 self.artist_action_buttons.add(self.refresh_button) 

296 

297 action_buttons_container.pack_start( 

298 self.artist_action_buttons, False, False, 10 

299 ) 

300 

301 action_buttons_container.pack_start(Gtk.Box(), True, True, 0) 

302 

303 expand_button_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 

304 self.expand_collapse_button = IconButton( 

305 "pan-up-symbolic", "Expand playlist details" 

306 ) 

307 self.expand_collapse_button.connect("clicked", self.on_expand_collapse_click) 

308 expand_button_container.pack_end(self.expand_collapse_button, False, False, 0) 

309 action_buttons_container.add(expand_button_container) 

310 

311 self.big_info_panel.pack_start(action_buttons_container, False, False, 5) 

312 

313 self.pack_start(self.big_info_panel, False, True, 0) 

314 

315 self.error_container = Gtk.Box() 

316 self.add(self.error_container) 

317 

318 self.album_list_scrolledwindow = Gtk.ScrolledWindow() 

319 self.albums_list = AlbumsListWithSongs() 

320 self.albums_list.connect( 

321 "song-clicked", 

322 lambda _, *args: self.emit("song-clicked", *args), 

323 ) 

324 self.album_list_scrolledwindow.add(self.albums_list) 

325 self.pack_start(self.album_list_scrolledwindow, True, True, 0) 

326 

327 def update(self, app_config: AppConfiguration): 

328 self.artist_id = app_config.state.selected_artist_id 

329 self.offline_mode = app_config.offline_mode 

330 if app_config.state.selected_artist_id is None: 

331 self.big_info_panel.hide() 

332 self.album_list_scrolledwindow.hide() 

333 self.play_shuffle_buttons.hide() 

334 else: 

335 self.update_order_token += 1 

336 self.album_list_scrolledwindow.show() 

337 self.update_artist_view( 

338 app_config.state.selected_artist_id, 

339 app_config=app_config, 

340 order_token=self.update_order_token, 

341 ) 

342 self.refresh_button.set_sensitive(not self.offline_mode) 

343 self.download_all_button.set_sensitive(not self.offline_mode) 

344 

345 @util.async_callback( 

346 AdapterManager.get_artist, 

347 before_download=lambda self: self.set_all_loading(True), 

348 on_failure=lambda self, e: self.set_all_loading(False), 

349 ) 

350 def update_artist_view( 

351 self, 

352 artist: API.Artist, 

353 app_config: AppConfiguration, 

354 force: bool = False, 

355 order_token: int = None, 

356 is_partial: bool = False, 

357 ): 

358 if order_token != self.update_order_token: 

359 return 

360 

361 self.big_info_panel.show_all() 

362 

363 if app_config: 

364 self.artist_details_expanded = app_config.state.artist_details_expanded 

365 

366 up_down = "up" if self.artist_details_expanded else "down" 

367 self.expand_collapse_button.set_icon(f"pan-{up_down}-symbolic") 

368 self.expand_collapse_button.set_tooltip_text( 

369 "Collapse" if self.artist_details_expanded else "Expand" 

370 ) 

371 

372 self.artist_name.set_markup(bleach.clean(f"<b>{artist.name}</b>")) 

373 self.artist_name.set_tooltip_text(artist.name) 

374 

375 if self.artist_details_expanded: 

376 self.artist_artwork.get_style_context().remove_class("collapsed") 

377 self.artist_name.get_style_context().remove_class("collapsed") 

378 self.artist_indicator.set_text("ARTIST") 

379 self.artist_stats.set_markup(self.format_stats(artist)) 

380 

381 if artist.biography: 

382 self.artist_bio.set_markup(bleach.clean(artist.biography)) 

383 self.artist_bio.show() 

384 else: 

385 self.artist_bio.hide() 

386 

387 if len(artist.similar_artists or []) > 0: 

388 self.similar_artists_label.set_markup("<b>Similar Artists:</b> ") 

389 for c in self.similar_artists_button_box.get_children(): 

390 self.similar_artists_button_box.remove(c) 

391 

392 for similar_artist in (artist.similar_artists or [])[:5]: 

393 self.similar_artists_button_box.add( 

394 Gtk.LinkButton( 

395 label=similar_artist.name, 

396 name="similar-artist-button", 

397 action_name="app.go-to-artist", 

398 action_target=GLib.Variant("s", similar_artist.id), 

399 ) 

400 ) 

401 self.similar_artists_scrolledwindow.show_all() 

402 else: 

403 self.similar_artists_scrolledwindow.hide() 

404 else: 

405 self.artist_artwork.get_style_context().add_class("collapsed") 

406 self.artist_name.get_style_context().add_class("collapsed") 

407 self.artist_indicator.hide() 

408 self.artist_stats.hide() 

409 self.artist_bio.hide() 

410 self.similar_artists_scrolledwindow.hide() 

411 

412 self.play_shuffle_buttons.show_all() 

413 

414 self.update_artist_artwork( 

415 artist.artist_image_url, 

416 force=force, 

417 order_token=order_token, 

418 ) 

419 

420 for c in self.error_container.get_children(): 

421 self.error_container.remove(c) 

422 if is_partial: 

423 has_data = len(artist.albums or []) > 0 

424 load_error = LoadError( 

425 "Artist data", 

426 "load artist details", 

427 has_data=has_data, 

428 offline_mode=self.offline_mode, 

429 ) 

430 self.error_container.pack_start(load_error, True, True, 0) 

431 self.error_container.show_all() 

432 if not has_data: 

433 self.album_list_scrolledwindow.hide() 

434 else: 

435 self.error_container.hide() 

436 self.album_list_scrolledwindow.show() 

437 

438 self.albums = artist.albums or [] 

439 

440 # (Dis|En)able the "Play All" and "Shuffle All" buttons. If in offline mode, it 

441 # depends on whether or not there are any cached songs. 

442 if self.offline_mode: 

443 has_cached_song = False 

444 playable_statuses = ( 

445 SongCacheStatus.CACHED, 

446 SongCacheStatus.PERMANENTLY_CACHED, 

447 ) 

448 

449 for album in self.albums: 

450 if album.id: 

451 try: 

452 songs = AdapterManager.get_album(album.id).result().songs or [] 

453 except CacheMissError as e: 

454 if e.partial_data: 

455 songs = cast(API.Album, e.partial_data).songs or [] 

456 else: 

457 songs = [] 

458 statuses = AdapterManager.get_cached_statuses([s.id for s in songs]) 

459 if any(s in playable_statuses for s in statuses): 

460 has_cached_song = True 

461 break 

462 

463 self.play_button.set_sensitive(has_cached_song) 

464 self.shuffle_button.set_sensitive(has_cached_song) 

465 else: 

466 self.play_button.set_sensitive(not self.offline_mode) 

467 self.shuffle_button.set_sensitive(not self.offline_mode) 

468 

469 self.albums_list.update(artist, app_config, force=force) 

470 

471 @util.async_callback( 

472 partial(AdapterManager.get_cover_art_uri, scheme="file"), 

473 before_download=lambda self: self.artist_artwork.set_loading(True), 

474 on_failure=lambda self, e: self.artist_artwork.set_loading(False), 

475 ) 

476 def update_artist_artwork( 

477 self, 

478 cover_art_filename: str, 

479 app_config: AppConfiguration, 

480 force: bool = False, 

481 order_token: int = None, 

482 is_partial: bool = False, 

483 ): 

484 if order_token != self.update_order_token: 

485 return 

486 self.artist_artwork.set_from_file(cover_art_filename) 

487 self.artist_artwork.set_loading(False) 

488 

489 if self.artist_details_expanded: 

490 self.artist_artwork.set_image_size(300) 

491 else: 

492 self.artist_artwork.set_image_size(70) 

493 

494 # Event Handlers 

495 # ========================================================================= 

496 def on_view_refresh_click(self, *args): 

497 self.update_artist_view( 

498 self.artist_id, 

499 force=True, 

500 order_token=self.update_order_token, 

501 ) 

502 

503 def on_download_all_click(self, _): 

504 AdapterManager.batch_download_songs( 

505 self.get_artist_song_ids(), 

506 before_download=lambda _: self.update_artist_view( 

507 self.artist_id, 

508 order_token=self.update_order_token, 

509 ), 

510 on_song_download_complete=lambda _: self.update_artist_view( 

511 self.artist_id, 

512 order_token=self.update_order_token, 

513 ), 

514 ) 

515 

516 def on_play_all_clicked(self, _): 

517 songs = self.get_artist_song_ids() 

518 self.emit( 

519 "song-clicked", 

520 0, 

521 songs, 

522 {"force_shuffle_state": False}, 

523 ) 

524 

525 def on_shuffle_all_button(self, _): 

526 songs = self.get_artist_song_ids() 

527 self.emit( 

528 "song-clicked", 

529 randint(0, len(songs) - 1), 

530 songs, 

531 {"force_shuffle_state": True}, 

532 ) 

533 

534 def on_expand_collapse_click(self, _): 

535 self.emit( 

536 "refresh-window", 

537 {"artist_details_expanded": not self.artist_details_expanded}, 

538 False, 

539 ) 

540 

541 # Helper Methods 

542 # ========================================================================= 

543 def set_all_loading(self, loading_state: bool): 

544 if loading_state: 

545 self.albums_list.spinner.start() 

546 self.albums_list.spinner.show() 

547 self.artist_artwork.set_loading(True) 

548 else: 

549 self.albums_list.spinner.hide() 

550 self.artist_artwork.set_loading(False) 

551 

552 def make_label(self, text: str = None, name: str = None, **params) -> Gtk.Label: 

553 return Gtk.Label( 

554 label=text, name=name, halign=Gtk.Align.START, xalign=0, **params 

555 ) 

556 

557 def format_stats(self, artist: API.Artist) -> str: 

558 album_count = artist.album_count or len(artist.albums or []) 

559 song_count, duration = 0, timedelta(0) 

560 for album in artist.albums or []: 

561 song_count += album.song_count or 0 

562 duration += album.duration or timedelta(0) 

563 

564 return util.dot_join( 

565 "{} {}".format(album_count, util.pluralize("album", album_count)), 

566 "{} {}".format(song_count, util.pluralize("song", song_count)), 

567 util.format_sequence_duration(duration), 

568 ) 

569 

570 def get_artist_song_ids(self) -> List[str]: 

571 try: 

572 artist = AdapterManager.get_artist(self.artist_id).result() 

573 except CacheMissError as c: 

574 artist = cast(API.Artist, c.partial_data) 

575 

576 if not artist: 

577 return [] 

578 

579 songs = [] 

580 for album in artist.albums or []: 

581 assert album.id 

582 try: 

583 album_with_songs = AdapterManager.get_album(album.id).result() 

584 except CacheMissError as c: 

585 album_with_songs = cast(API.Album, c.partial_data) 

586 if not album_with_songs: 

587 continue 

588 for song in album_with_songs.songs or []: 

589 songs.append(song.id) 

590 

591 return songs 

592 

593 

594class AlbumsListWithSongs(Gtk.Overlay): 

595 __gsignals__ = { 

596 "song-clicked": ( 

597 GObject.SignalFlags.RUN_FIRST, 

598 GObject.TYPE_NONE, 

599 (int, object, object), 

600 ), 

601 } 

602 

603 def __init__(self): 

604 Gtk.Overlay.__init__(self) 

605 self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

606 self.add(self.box) 

607 

608 self.spinner = Gtk.Spinner( 

609 name="albumslist-with-songs-spinner", 

610 active=False, 

611 halign=Gtk.Align.CENTER, 

612 valign=Gtk.Align.CENTER, 

613 ) 

614 self.add_overlay(self.spinner) 

615 

616 self.albums = [] 

617 

618 def update( 

619 self, artist: API.Artist, app_config: AppConfiguration, force: bool = False 

620 ): 

621 def remove_all(): 

622 for c in self.box.get_children(): 

623 self.box.remove(c) 

624 

625 if artist is None: 

626 remove_all() 

627 self.spinner.hide() 

628 return 

629 

630 new_albums = sorted( 

631 artist.albums or [], key=lambda a: (a.year or float("inf"), a.name) 

632 ) 

633 

634 if self.albums == new_albums: 

635 # Just go through all of the colidren and update them. 

636 for c in self.box.get_children(): 

637 c.update(app_config=app_config, force=force) 

638 

639 self.spinner.hide() 

640 return 

641 

642 self.albums = new_albums 

643 

644 remove_all() 

645 

646 for album in self.albums: 

647 album_with_songs = AlbumWithSongs(album, show_artist_name=False) 

648 album_with_songs.connect( 

649 "song-clicked", 

650 lambda _, *args: self.emit("song-clicked", *args), 

651 ) 

652 album_with_songs.connect("song-selected", self.on_song_selected) 

653 album_with_songs.show_all() 

654 self.box.add(album_with_songs) 

655 

656 # Update everything (no force to ensure that if we are online, then everything 

657 # is clickable) 

658 for c in self.box.get_children(): 

659 c.update(app_config=app_config) 

660 

661 self.spinner.hide() 

662 

663 def on_song_selected(self, album_component: AlbumWithSongs): 

664 for child in self.box.get_children(): 

665 if album_component != child: 

666 child.deselect_all()