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 functools import partial 

2from typing import Any, cast, List, Optional, Tuple 

3 

4import bleach 

5 

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

7 

8from ..adapters import AdapterManager, api_objects as API, CacheMissError, Result 

9from ..config import AppConfiguration 

10from ..ui import util 

11from ..ui.common import IconButton, LoadError, SongListColumn 

12 

13 

14class BrowsePanel(Gtk.Overlay): 

15 """Defines the arist panel.""" 

16 

17 __gsignals__ = { 

18 "song-clicked": ( 

19 GObject.SignalFlags.RUN_FIRST, 

20 GObject.TYPE_NONE, 

21 (int, object, object), 

22 ), 

23 "refresh-window": ( 

24 GObject.SignalFlags.RUN_FIRST, 

25 GObject.TYPE_NONE, 

26 (object, bool), 

27 ), 

28 } 

29 

30 update_order_token = 0 

31 

32 def __init__(self): 

33 super().__init__() 

34 scrolled_window = Gtk.ScrolledWindow() 

35 window_box = Gtk.Box() 

36 

37 self.error_container = Gtk.Box() 

38 window_box.pack_start(self.error_container, True, True, 0) 

39 

40 self.root_directory_listing = ListAndDrilldown() 

41 self.root_directory_listing.connect( 

42 "song-clicked", 

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

44 ) 

45 self.root_directory_listing.connect( 

46 "refresh-window", 

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

48 ) 

49 window_box.add(self.root_directory_listing) 

50 

51 scrolled_window.add(window_box) 

52 self.add(scrolled_window) 

53 

54 self.spinner = Gtk.Spinner( 

55 name="browse-spinner", 

56 active=True, 

57 halign=Gtk.Align.CENTER, 

58 valign=Gtk.Align.CENTER, 

59 ) 

60 self.add_overlay(self.spinner) 

61 

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

63 self.update_order_token += 1 

64 

65 def do_update(update_order_token: int, id_stack: Tuple[str, ...]): 

66 if self.update_order_token != update_order_token: 

67 return 

68 

69 if len(id_stack) == 0: 

70 self.root_directory_listing.hide() 

71 if len(self.error_container.get_children()) == 0: 

72 load_error = LoadError( 

73 "Directory list", 

74 "browse to song", 

75 has_data=False, 

76 offline_mode=app_config.offline_mode, 

77 ) 

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

79 self.error_container.show_all() 

80 else: 

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

82 self.error_container.remove(c) 

83 self.error_container.hide() 

84 self.root_directory_listing.update(id_stack, app_config, force) 

85 self.spinner.hide() 

86 

87 def calculate_path() -> Tuple[str, ...]: 

88 if (current_dir_id := app_config.state.selected_browse_element_id) is None: 

89 return ("root",) 

90 

91 id_stack = [] 

92 while current_dir_id: 

93 try: 

94 directory = AdapterManager.get_directory( 

95 current_dir_id, 

96 before_download=self.spinner.show, 

97 ).result() 

98 except CacheMissError as e: 

99 directory = cast(API.Directory, e.partial_data) 

100 

101 if not directory: 

102 break 

103 else: 

104 id_stack.append(directory.id) 

105 current_dir_id = directory.parent_id 

106 

107 return tuple(id_stack) 

108 

109 path_result: Result[Tuple[str, ...]] = Result(calculate_path) 

110 path_result.add_done_callback( 

111 lambda f: GLib.idle_add( 

112 partial(do_update, self.update_order_token), f.result() 

113 ) 

114 ) 

115 

116 

117class ListAndDrilldown(Gtk.Paned): 

118 __gsignals__ = { 

119 "song-clicked": ( 

120 GObject.SignalFlags.RUN_FIRST, 

121 GObject.TYPE_NONE, 

122 (int, object, object), 

123 ), 

124 "refresh-window": ( 

125 GObject.SignalFlags.RUN_FIRST, 

126 GObject.TYPE_NONE, 

127 (object, bool), 

128 ), 

129 } 

130 

131 def __init__(self): 

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

133 

134 self.list = MusicDirectoryList() 

135 self.list.connect( 

136 "song-clicked", 

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

138 ) 

139 self.list.connect( 

140 "refresh-window", 

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

142 ) 

143 self.pack1(self.list, False, False) 

144 

145 self.box = Gtk.Box() 

146 self.pack2(self.box, True, False) 

147 

148 def update( 

149 self, 

150 id_stack: Tuple[str, ...], 

151 app_config: AppConfiguration, 

152 force: bool = False, 

153 ): 

154 *child_id_stack, dir_id = id_stack 

155 selected_id = child_id_stack[-1] if len(child_id_stack) > 0 else None 

156 self.show() 

157 

158 self.list.update( 

159 directory_id=dir_id, 

160 selected_id=selected_id, 

161 app_config=app_config, 

162 force=force, 

163 ) 

164 

165 children = self.box.get_children() 

166 if len(child_id_stack) == 0: 

167 if len(children) > 0: 

168 self.box.remove(children[0]) 

169 else: 

170 if len(children) == 0: 

171 drilldown = ListAndDrilldown() 

172 drilldown.connect( 

173 "song-clicked", 

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

175 ) 

176 drilldown.connect( 

177 "refresh-window", 

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

179 ) 

180 self.box.add(drilldown) 

181 self.box.show_all() 

182 

183 self.box.get_children()[0].update( 

184 tuple(child_id_stack), app_config, force=force 

185 ) 

186 

187 

188class MusicDirectoryList(Gtk.Box): 

189 __gsignals__ = { 

190 "song-clicked": ( 

191 GObject.SignalFlags.RUN_FIRST, 

192 GObject.TYPE_NONE, 

193 (int, object, object), 

194 ), 

195 "refresh-window": ( 

196 GObject.SignalFlags.RUN_FIRST, 

197 GObject.TYPE_NONE, 

198 (object, bool), 

199 ), 

200 } 

201 

202 update_order_token = 0 

203 directory_id: Optional[str] = None 

204 selected_id: Optional[str] = None 

205 offline_mode = False 

206 

207 class DrilldownElement(GObject.GObject): 

208 id = GObject.Property(type=str) 

209 name = GObject.Property(type=str) 

210 

211 def __init__(self, element: API.Directory): 

212 GObject.GObject.__init__(self) 

213 self.id = element.id 

214 self.name = element.name 

215 

216 def __init__(self): 

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

218 

219 list_actions = Gtk.ActionBar() 

220 

221 self.refresh_button = IconButton("view-refresh-symbolic", "Refresh folder") 

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

223 list_actions.pack_end(self.refresh_button) 

224 

225 self.add(list_actions) 

226 

227 self.loading_indicator = Gtk.ListBox() 

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

229 spinner = Gtk.Spinner(name="drilldown-list-spinner", active=True) 

230 spinner_row.add(spinner) 

231 self.loading_indicator.add(spinner_row) 

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

233 

234 self.error_container = Gtk.Box() 

235 self.add(self.error_container) 

236 

237 self.scroll_window = Gtk.ScrolledWindow(min_content_width=250) 

238 scrollbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

239 

240 self.drilldown_directories_store = Gio.ListStore() 

241 self.list = Gtk.ListBox() 

242 self.list.bind_model(self.drilldown_directories_store, self.create_row) 

243 scrollbox.add(self.list) 

244 

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

246 self.directory_song_store = Gtk.ListStore(bool, str, str, str, str) 

247 

248 self.directory_song_list = Gtk.TreeView( 

249 model=self.directory_song_store, 

250 name="directory-songs-list", 

251 headers_visible=False, 

252 ) 

253 selection = self.directory_song_list.get_selection() 

254 selection.set_mode(Gtk.SelectionMode.MULTIPLE) 

255 selection.set_select_function(lambda _, model, path, current: model[path[0]][0]) 

256 

257 # Song status column. 

258 renderer = Gtk.CellRendererPixbuf() 

259 renderer.set_fixed_size(30, 35) 

260 column = Gtk.TreeViewColumn("", renderer, icon_name=1) 

261 column.set_resizable(True) 

262 self.directory_song_list.append_column(column) 

263 

264 self.directory_song_list.append_column(SongListColumn("TITLE", 2, bold=True)) 

265 self.directory_song_list.append_column( 

266 SongListColumn("DURATION", 3, align=1, width=40) 

267 ) 

268 

269 self.directory_song_list.connect("row-activated", self.on_song_activated) 

270 self.directory_song_list.connect( 

271 "button-press-event", self.on_song_button_press 

272 ) 

273 scrollbox.add(self.directory_song_list) 

274 

275 self.scroll_window.add(scrollbox) 

276 self.pack_start(self.scroll_window, True, True, 0) 

277 

278 def update( 

279 self, 

280 app_config: AppConfiguration = None, 

281 force: bool = False, 

282 directory_id: str = None, 

283 selected_id: str = None, 

284 ): 

285 self.directory_id = directory_id or self.directory_id 

286 self.selected_id = selected_id or self.selected_id 

287 self.update_store( 

288 self.directory_id, 

289 force=force, 

290 order_token=self.update_order_token, 

291 ) 

292 

293 if app_config: 

294 # Deselect everything if switching online to offline. 

295 if self.offline_mode != app_config.offline_mode: 

296 self.directory_song_list.get_selection().unselect_all() 

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

298 self.error_container.remove(c) 

299 

300 self.offline_mode = app_config.offline_mode 

301 

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

303 

304 _current_child_ids: List[str] = [] 

305 

306 @util.async_callback( 

307 AdapterManager.get_directory, 

308 before_download=lambda self: self.loading_indicator.show(), 

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

310 ) 

311 def update_store( 

312 self, 

313 directory: API.Directory, 

314 app_config: AppConfiguration = None, 

315 force: bool = False, 

316 order_token: int = None, 

317 is_partial: bool = False, 

318 ): 

319 if order_token != self.update_order_token: 

320 return 

321 

322 dir_children = directory.children or [] 

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

324 self.error_container.remove(c) 

325 if is_partial: 

326 load_error = LoadError( 

327 "Directory listing", 

328 "load directory", 

329 has_data=len(dir_children) > 0, 

330 offline_mode=self.offline_mode, 

331 ) 

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

333 self.error_container.show_all() 

334 else: 

335 self.error_container.hide() 

336 

337 # This doesn't look efficient, since it's doing a ton of passses over the data, 

338 # but there is some annoying memory overhead for generating the stores to diff, 

339 # so we are short-circuiting by checking to see if any of the the IDs have 

340 # changed. 

341 # 

342 # The entire algorithm ends up being O(2n), but the first loop is very tight, 

343 # and the expensive parts of the second loop are avoided if the IDs haven't 

344 # changed. 

345 children_ids, children, song_ids = [], [], [] 

346 selected_dir_idx = None 

347 if len(self._current_child_ids) != len(dir_children): 

348 force = True 

349 

350 for i, c in enumerate(dir_children): 

351 if i >= len(self._current_child_ids) or c.id != self._current_child_ids[i]: 

352 force = True 

353 

354 if c.id == self.selected_id: 

355 selected_dir_idx = i 

356 

357 children_ids.append(c.id) 

358 children.append(c) 

359 

360 if not hasattr(c, "children"): 

361 song_ids.append(c.id) 

362 

363 if force: 

364 new_directories_store = [] 

365 self._current_child_ids = children_ids 

366 

367 songs = [] 

368 for el in children: 

369 if hasattr(el, "children"): 

370 new_directories_store.append( 

371 MusicDirectoryList.DrilldownElement(cast(API.Directory, el)) 

372 ) 

373 else: 

374 songs.append(cast(API.Song, el)) 

375 

376 util.diff_model_store( 

377 self.drilldown_directories_store, new_directories_store 

378 ) 

379 

380 def song_sort_key(song: API.Song) -> Tuple[Optional[int], Optional[int]]: 

381 return ( 

382 song.disc_number if hasattr(song, "disc_number") else 0, 

383 song.track if hasattr(song, "track") else 0, 

384 ) 

385 

386 songs.sort(key=song_sort_key) 

387 

388 new_songs_store = [ 

389 [ 

390 ( 

391 not self.offline_mode 

392 or status_icon 

393 in ("folder-download-symbolic", "view-pin-symbolic") 

394 ), 

395 status_icon, 

396 bleach.clean(song.title), 

397 util.format_song_duration(song.duration), 

398 song.id, 

399 ] 

400 for status_icon, song in zip( 

401 util.get_cached_status_icons(song_ids), songs 

402 ) 

403 ] 

404 else: 

405 new_songs_store = [ 

406 [ 

407 ( 

408 not self.offline_mode 

409 or status_icon 

410 in ("folder-download-symbolic", "view-pin-symbolic") 

411 ), 

412 status_icon, 

413 *song_model[2:], 

414 ] 

415 for status_icon, song_model in zip( 

416 util.get_cached_status_icons(song_ids), self.directory_song_store 

417 ) 

418 ] 

419 

420 util.diff_song_store(self.directory_song_store, new_songs_store) 

421 self.directory_song_list.show() 

422 

423 if len(self.drilldown_directories_store) == 0: 

424 self.list.hide() 

425 else: 

426 self.list.show() 

427 

428 if len(self.directory_song_store) == 0: 

429 self.directory_song_list.hide() 

430 self.scroll_window.set_min_content_width(275) 

431 else: 

432 self.directory_song_list.show() 

433 self.scroll_window.set_min_content_width(350) 

434 

435 # Preserve selection 

436 if selected_dir_idx is not None: 

437 row = self.list.get_row_at_index(selected_dir_idx) 

438 self.list.select_row(row) 

439 

440 self.loading_indicator.hide() 

441 

442 def on_download_state_change(self, _): 

443 self.update() 

444 

445 # Create Element Helper Functions 

446 # ================================================================================== 

447 def create_row(self, model: DrilldownElement) -> Gtk.ListBoxRow: 

448 row = Gtk.ListBoxRow( 

449 action_name="app.browse-to", 

450 action_target=GLib.Variant("s", model.id), 

451 ) 

452 rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 

453 rowbox.add( 

454 Gtk.Label( 

455 label=bleach.clean(f"<b>{model.name}</b>"), 

456 use_markup=True, 

457 margin=8, 

458 halign=Gtk.Align.START, 

459 ellipsize=Pango.EllipsizeMode.END, 

460 ) 

461 ) 

462 

463 image = Gtk.Image.new_from_icon_name("go-next-symbolic", Gtk.IconSize.BUTTON) 

464 rowbox.pack_end(image, False, False, 5) 

465 

466 row.add(rowbox) 

467 row.show_all() 

468 return row 

469 

470 # Event Handlers 

471 # ================================================================================== 

472 def on_song_activated(self, treeview: Any, idx: Gtk.TreePath, column: Any): 

473 if not self.directory_song_store[idx[0]][0]: 

474 return 

475 # The song ID is in the last column of the model. 

476 self.emit( 

477 "song-clicked", 

478 idx.get_indices()[0], 

479 [m[-1] for m in self.directory_song_store], 

480 {}, 

481 ) 

482 

483 def on_song_button_press(self, tree: Gtk.TreeView, event: Gdk.EventButton) -> bool: 

484 if event.button == 3: # Right click 

485 clicked_path = tree.get_path_at_pos(event.x, event.y) 

486 if not clicked_path: 

487 return False 

488 

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

490 allow_deselect = False 

491 

492 # Use the new selection instead of the old one for calculating what 

493 # to do the right click on. 

494 if clicked_path[0] not in paths: 

495 paths = [clicked_path[0]] 

496 allow_deselect = True 

497 

498 song_ids = [self.directory_song_store[p][-1] for p in paths] 

499 

500 # Used to adjust for the header row. 

501 bin_coords = tree.convert_tree_to_bin_window_coords(event.x, event.y) 

502 widget_coords = tree.convert_tree_to_widget_coords(event.x, event.y) 

503 

504 util.show_song_popover( 

505 song_ids, 

506 event.x, 

507 event.y + abs(bin_coords.by - widget_coords.wy), 

508 tree, 

509 self.offline_mode, 

510 on_download_state_change=self.on_download_state_change, 

511 ) 

512 

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

514 if not allow_deselect: 

515 return True 

516 

517 return False