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

1import math 

2from functools import lru_cache, partial 

3from random import randint 

4from typing import Any, cast, Dict, List, Tuple 

5 

6from fuzzywuzzy import fuzz 

7from gi.repository import Gdk, Gio, GLib, GObject, Gtk, Pango 

8 

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) 

18 

19 

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) 

23 

24 # HEADER 

25 self.header = Gtk.HeaderBar() 

26 self._set_title(playlist.name) 

27 

28 cancel_button = Gtk.Button(label="Cancel") 

29 cancel_button.connect("clicked", lambda _: self.close()) 

30 self.header.pack_start(cancel_button) 

31 

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) 

38 

39 self.set_titlebar(self.header) 

40 

41 content_area = self.get_content_area() 

42 content_grid = Gtk.Grid(column_spacing=10, row_spacing=10, margin=10) 

43 

44 make_label = lambda label_text: Gtk.Label(label_text, halign=Gtk.Align.END) 

45 

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) 

50 

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) 

54 

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) 

58 

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) 

62 

63 content_area.add(content_grid) 

64 self.show_all() 

65 

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) 

71 

72 def _set_title(self, playlist_name: str): 

73 self.header.props.title = f"Edit {playlist_name}" 

74 

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 } 

81 

82 

83class PlaylistsPanel(Gtk.Paned): 

84 """Defines the playlists panel.""" 

85 

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 } 

98 

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

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

101 

102 self.playlist_list = PlaylistList() 

103 self.pack1(self.playlist_list, False, False) 

104 

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) 

115 

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) 

119 

120 

121class PlaylistList(Gtk.Box): 

122 __gsignals__ = { 

123 "refresh-window": ( 

124 GObject.SignalFlags.RUN_FIRST, 

125 GObject.TYPE_NONE, 

126 (object, bool), 

127 ), 

128 } 

129 

130 offline_mode = False 

131 

132 class PlaylistModel(GObject.GObject): 

133 playlist_id = GObject.Property(type=str) 

134 name = GObject.Property(type=str) 

135 

136 def __init__(self, playlist_id: str, name: str): 

137 GObject.GObject.__init__(self) 

138 self.playlist_id = playlist_id 

139 self.name = name 

140 

141 def __init__(self): 

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

143 

144 playlist_list_actions = Gtk.ActionBar() 

145 

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) 

149 

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) 

155 

156 self.add(playlist_list_actions) 

157 

158 self.error_container = Gtk.Box() 

159 self.add(self.error_container) 

160 

161 loading_new_playlist = Gtk.ListBox() 

162 

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) 

167 

168 self.new_playlist_row = Gtk.ListBoxRow(activatable=False, selectable=False) 

169 new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=False) 

170 

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) 

174 

175 new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 

176 

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) 

185 

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) 

194 

195 new_playlist_box.add(new_playlist_actions) 

196 self.new_playlist_row.add(new_playlist_box) 

197 

198 loading_new_playlist.add(self.new_playlist_row) 

199 self.add(loading_new_playlist) 

200 

201 list_scroll_window = Gtk.ScrolledWindow(min_content_width=220) 

202 

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 

219 

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) 

225 

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) 

233 

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

260 

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 

270 

271 new_store.append(PlaylistList.PlaylistModel(playlist.id, playlist.name)) 

272 

273 util.diff_model_store(self.playlists_store, new_store) 

274 

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) 

279 

280 self.loading_indicator.hide() 

281 

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

288 

289 def on_list_refresh_click(self, _): 

290 self.update(force=True) 

291 

292 def new_entry_activate(self, entry: Gtk.Entry): 

293 self.create_playlist(entry.get_text()) 

294 

295 def cancel_button_clicked(self, _): 

296 self.new_playlist_row.hide() 

297 

298 def confirm_button_clicked(self, _): 

299 self.create_playlist(self.new_playlist_entry.get_text()) 

300 

301 def create_playlist(self, playlist_name: str): 

302 def on_playlist_created(_): 

303 self.update(force=True) 

304 

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 ) 

310 

311 

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 } 

325 

326 playlist_id = None 

327 playlist_details_expanded = False 

328 offline_mode = False 

329 

330 editing_playlist_song_list: bool = False 

331 reordering_playlist_song_list: bool = False 

332 

333 def __init__(self): 

334 Gtk.Overlay.__init__(self, name="playlist-view-overlay") 

335 self.playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

336 

337 playlist_info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 

338 

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) 

345 

346 # Name, comment, number of songs, etc. 

347 playlist_details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

348 

349 playlist_details_box.pack_start(Gtk.Box(), True, False, 0) 

350 

351 self.playlist_indicator = self.make_label(name="playlist-indicator") 

352 playlist_details_box.add(self.playlist_indicator) 

353 

354 self.playlist_name = self.make_label(name="playlist-name") 

355 playlist_details_box.add(self.playlist_name) 

356 

357 self.playlist_comment = self.make_label(name="playlist-comment") 

358 playlist_details_box.add(self.playlist_comment) 

359 

360 self.playlist_stats = self.make_label(name="playlist-stats") 

361 playlist_details_box.add(self.playlist_stats) 

362 

363 self.play_shuffle_buttons = Gtk.Box( 

364 orientation=Gtk.Orientation.HORIZONTAL, 

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

366 ) 

367 

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) 

375 

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) 

383 

384 playlist_details_box.add(self.play_shuffle_buttons) 

385 

386 playlist_info_box.pack_start(playlist_details_box, True, True, 0) 

387 

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 ) 

393 

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) 

401 

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) 

405 

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) 

411 

412 action_buttons_container.pack_start( 

413 self.playlist_action_buttons, False, False, 10 

414 ) 

415 

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

417 

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) 

425 

426 playlist_info_box.pack_end(action_buttons_container, False, False, 5) 

427 

428 self.playlist_box.add(playlist_info_box) 

429 

430 self.error_container = Gtk.Box() 

431 self.playlist_box.add(self.error_container) 

432 

433 # Playlist songs list 

434 self.playlist_song_scroll_window = Gtk.ScrolledWindow() 

435 

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 ) 

445 

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

449 

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 

459 

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

470 

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) 

477 

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 ) 

484 

485 self.playlist_songs.connect("row-activated", self.on_song_activated) 

486 self.playlist_songs.connect("button-press-event", self.on_song_button_press) 

487 

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) 

494 

495 self.playlist_song_scroll_window.add(self.playlist_songs) 

496 

497 self.playlist_box.pack_start(self.playlist_song_scroll_window, True, True, 0) 

498 self.add(self.playlist_box) 

499 

500 playlist_view_spinner = Gtk.Spinner(active=True) 

501 playlist_view_spinner.start() 

502 

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) 

508 

509 update_playlist_view_order_token = 0 

510 

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

515 

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) 

532 

533 _current_song_ids: List[str] = [] 

534 

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 

550 

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

555 

556 self.playlist_id = playlist.id 

557 

558 if app_config: 

559 self.playlist_details_expanded = app_config.state.playlist_details_expanded 

560 

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 ) 

566 

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) 

570 

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

576 

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

583 

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

592 

593 # Update the artwork. 

594 self.update_playlist_artwork(playlist.cover_art, order_token=order_token) 

595 

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

613 

614 # Update the song list model. This requires some fancy diffing to 

615 # update the list. 

616 self.editing_playlist_song_list = True 

617 

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 

629 

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) 

635 

636 new_songs_store = [] 

637 can_play_any_song = False 

638 cached_status_icons = ("folder-download-symbolic", "view-pin-symbolic") 

639 

640 if force: 

641 self._current_song_ids = song_ids 

642 

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

671 

672 util.diff_song_store(self.playlist_song_store, new_songs_store) 

673 

674 self.play_all_button.set_sensitive(can_play_any_song) 

675 self.shuffle_all_button.set_sensitive(can_play_any_song) 

676 

677 self.editing_playlist_song_list = False 

678 

679 self.playlist_view_loading_box.hide() 

680 self.playlist_action_buttons.show_all() 

681 

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 

697 

698 self.playlist_artwork.set_from_file(cover_art_filename) 

699 self.playlist_artwork.set_loading(False) 

700 

701 if self.playlist_details_expanded: 

702 self.playlist_artwork.set_image_size(200) 

703 else: 

704 self.playlist_artwork.set_image_size(70) 

705 

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 ) 

714 

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 

720 

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 

726 

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 

756 

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

764 

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 ) 

772 

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 ) 

779 

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 ) 

787 

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 ) 

795 

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 ) 

802 

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 ) 

813 

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 

819 

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

821 allow_deselect = False 

822 

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 ) 

830 

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 

836 

837 song_ids = [self.playlist_song_store[p][-1] for p in paths] 

838 

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) 

842 

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 ) 

859 

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 ) 

886 

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

888 if not allow_deselect: 

889 return True 

890 

891 return False 

892 

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 

897 

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 

906 

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

912 

913 def hide_loading_all(self): 

914 self.playlist_artwork.set_loading(False) 

915 self.playlist_view_loading_box.hide() 

916 

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 ) 

925 

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 ) 

937 

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 ) 

947 

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

953 

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)