1# Copyright (C) 2010 Adam Olsen 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2, or (at your option) 6# any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program; if not, write to the Free Software 15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16# 17# 18# The developers of the Exaile media player hereby grant permission 19# for non-GPL compatible GStreamer and Exaile plugins to be used and 20# distributed together with GStreamer and Exaile. This permission is 21# above and beyond the permissions granted by the GPL license by which 22# Exaile is covered. If you modify this code, you may extend this 23# exception to your version of the code, but you are not obligated to 24# do so. If you do not wish to do so, delete this exception statement 25# from your version. 26 27from gi.repository import Gdk 28from gi.repository import Gtk 29 30import re 31from datetime import datetime 32from typing import List 33 34from xl.nls import gettext as _ 35from xl import event, providers, settings 36from xl.playlist import Playlist, PlaylistManager 37from xlgui.widgets import menu 38from xlgui.accelerators import Accelerator 39from xlgui.widgets.notebook import ( 40 SmartNotebook, 41 NotebookTab, 42 NotebookAction, 43 NotebookActionService, 44) 45from xlgui.widgets.playlist import PlaylistPage 46from xlgui.widgets.queue import QueuePage 47 48import logging 49 50logger = logging.getLogger(__name__) 51 52 53class NewPlaylistNotebookAction(NotebookAction, Gtk.Button): 54 """ 55 Playlist notebook action which allows for creating new playlists 56 regularly as well as by dropping tracks, files and directories on it 57 """ 58 59 __gsignals__ = {'clicked': 'override'} 60 name = 'new-playlist' 61 position = Gtk.PackType.START 62 63 def __init__(self, notebook): 64 NotebookAction.__init__(self, notebook) 65 Gtk.Button.__init__(self) 66 67 self.set_image(Gtk.Image.new_from_icon_name('tab-new', Gtk.IconSize.BUTTON)) 68 self.set_relief(Gtk.ReliefStyle.NONE) 69 70 self.__default_tooltip_text = _('New Playlist') 71 self.__drag_tooltip_text = _('Drop here to create a new playlist') 72 self.set_tooltip_text(self.__default_tooltip_text) 73 74 self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY) 75 self.drag_dest_add_uri_targets() 76 77 self.connect('drag-motion', self.on_drag_motion) 78 self.connect('drag-leave', self.on_drag_leave) 79 self.connect('drag-data-received', self.on_drag_data_received) 80 81 def do_clicked(self): 82 """ 83 Triggers creation of a new playlist 84 """ 85 self.notebook.create_new_playlist() 86 87 def on_drag_motion(self, widget, context, x, y, time): 88 """ 89 Updates the tooltip during drag operations 90 """ 91 self.set_tooltip_text(self.__drag_tooltip_text) 92 93 def on_drag_leave(self, widget, context, time): 94 """ 95 Restores the original tooltip 96 """ 97 self.set_tooltip_text(self.__default_tooltip_text) 98 99 def on_drag_data_received(self, widget, context, x, y, selection, info, time): 100 """ 101 Handles dropped data 102 """ 103 tab = self.notebook.create_new_playlist() 104 # Forward signal to the PlaylistView in the newly added tab 105 tab.page.view.emit('drag-data-received', context, x, y, selection, info, time) 106 107 108providers.register('playlist-notebook-actions', NewPlaylistNotebookAction) 109 110 111class PlaylistNotebook(SmartNotebook): 112 def __init__(self, manager_name, player, hotkey): 113 SmartNotebook.__init__(self) 114 115 self.tab_manager = PlaylistManager(manager_name) 116 self.manager_name = manager_name 117 self.player = player 118 119 # For saving closed tab history 120 self._moving_tab = False 121 self.tab_history = [] 122 self.history_counter = 90000 # to get unique (reverse-ordered) item names 123 124 # Build static menu entries 125 item = menu.simple_separator('clear-sep', []) 126 item.register('playlist-closed-tab-menu', self) 127 128 item = menu.simple_menu_item( 129 'clear-history', 130 ['clear-sep'], 131 _("_Clear Tab History"), 132 'edit-clear-all', 133 self.clear_closed_tabs, 134 ) 135 item.register('playlist-closed-tab-menu', self) 136 137 # Simple factory for 'Recently Closed Tabs' MenuItem 138 submenu = menu.ProviderMenu('playlist-closed-tab-menu', self) 139 140 def factory(menu_, parent, context): 141 if self.page_num(parent) == -1: 142 return None 143 item = Gtk.MenuItem.new_with_mnemonic(_("Recently Closed _Tabs")) 144 if len(self.tab_history) > 0: 145 item.set_submenu(submenu) 146 else: 147 item.set_sensitive(False) 148 return item 149 150 # Add menu to tab context menu 151 item = menu.MenuItem('%s-tab-history' % manager_name, factory, ['tab-close']) 152 item.register('playlist-tab-context-menu') 153 154 # Add menu to View menu 155 # item = menu.MenuItem('tab-history', factory, ['clear-playlist']) 156 # providers.register('menubar-view-menu', item) 157 158 # setup notebook actions 159 self.actions = NotebookActionService(self, 'playlist-notebook-actions') 160 161 # Add hotkey 162 self.accelerator = Accelerator( 163 hotkey, _('Restore closed tab'), lambda *x: self.restore_closed_tab(0) 164 ) 165 providers.register('mainwindow-accelerators', self.accelerator) 166 167 # Load saved tabs 168 self.load_saved_tabs() 169 170 self.tab_placement_map = { 171 'left': Gtk.PositionType.LEFT, 172 'right': Gtk.PositionType.RIGHT, 173 'top': Gtk.PositionType.TOP, 174 'bottom': Gtk.PositionType.BOTTOM, 175 } 176 177 self.connect('page-added', self.on_page_added) 178 self.connect('page-removed', self.on_page_removed) 179 180 self.on_option_set('gui_option_set', settings, 'gui/show_tabbar') 181 self.on_option_set('gui_option_set', settings, 'gui/tab_placement') 182 event.add_ui_callback(self.on_option_set, 'gui_option_set') 183 184 def create_tab_from_playlist(self, playlist): 185 """ 186 Create a tab that will contain the passed-in playlist 187 188 :param playlist: The playlist to create tab from 189 :type playlist: :class:`xl.playlist.Playlist` 190 """ 191 page = PlaylistPage(playlist, self.player) 192 tab = NotebookTab(self, page) 193 self.add_tab(tab, page) 194 return tab 195 196 def create_new_playlist(self): 197 """ 198 Create a new tab containing a blank playlist. 199 The tab will be automatically given a unique name. 200 """ 201 seen = [] 202 default_playlist_name = _('Playlist %d') 203 # Split into 'Playlist ' and '' 204 default_name_parts = default_playlist_name.split('%d') 205 206 for n in range(self.get_n_pages()): 207 page = self.get_nth_page(n) 208 name = page.get_page_name() 209 name_parts = [ 210 # 'Playlist 99' => 'Playlist ' 211 name[0 : len(default_name_parts[0])], 212 # 'Playlist 99' => '' 213 name[len(name) - len(default_name_parts[1]) :], 214 ] 215 216 # Playlist name matches our format 217 if name_parts == default_name_parts: 218 # Extract possible number between name parts 219 number = name[len(name_parts[0]) : len(name) - len(name_parts[1])] 220 221 try: 222 number = int(number) 223 except ValueError: 224 pass 225 else: 226 seen += [number] 227 228 seen.sort() 229 n = 1 230 231 while True: 232 if n not in seen: 233 break 234 n += 1 235 236 playlist = Playlist(default_playlist_name % n) 237 238 return self.create_tab_from_playlist(playlist) 239 240 def add_default_tab(self): 241 return self.create_new_playlist() 242 243 def load_saved_tabs(self): 244 names = self.tab_manager.list_playlists() 245 if not names: 246 return 247 248 count = -1 249 count2 = 0 250 names.sort() 251 # holds the order#'s of the already added tabs 252 added_tabs = {} 253 name_re = re.compile(r'^order(?P<tab>\d+)\.(?P<tag>[^.]*)\.(?P<name>.*)$') 254 for i, name in enumerate(names): 255 match = name_re.match(name) 256 if not match or not match.group('tab') or not match.group('name'): 257 logger.error("`%r` did not match valid playlist file", name) 258 continue 259 260 logger.debug("Adding playlist %d: %s", i, name) 261 logger.debug( 262 "Tab:%s; Tag:%s; Name:%s", 263 match.group('tab'), 264 match.group('tag'), 265 match.group('name'), 266 ) 267 pl = self.tab_manager.get_playlist(name) 268 pl.name = match.group('name') 269 270 if match.group('tab') not in added_tabs: 271 self.create_tab_from_playlist(pl) 272 added_tabs[match.group('tab')] = pl 273 pl = added_tabs[match.group('tab')] 274 275 if match.group('tag') == 'current': 276 count = i 277 if self.player.queue.current_playlist is None: 278 self.player.queue.set_current_playlist(pl) 279 elif match.group('tag') == 'playing': 280 count2 = i 281 self.player.queue.set_current_playlist(pl) 282 283 # If there's no selected playlist saved, use the currently 284 # playing 285 if count == -1: 286 count = count2 287 288 self.set_current_page(count) 289 290 def save_current_tabs(self): 291 """ 292 Saves the open tabs 293 """ 294 # first, delete the current tabs 295 names = self.tab_manager.list_playlists() 296 for name in names: 297 logger.debug("Removing tab %s", name) 298 self.tab_manager.remove_playlist(name) 299 300 # TODO: make this generic enough to save other kinds of tabs 301 for n, page in enumerate(self): 302 if not isinstance(page, PlaylistPage): 303 continue 304 305 tag = '' 306 307 if page.playlist is self.player.queue.current_playlist: 308 tag = 'playing' 309 elif n == self.get_current_page(): 310 tag = 'current' 311 312 page.playlist.name = 'order%d.%s.%s' % (n, tag, page.playlist.name) 313 logger.debug('Saving tab %r', page.playlist.name) 314 315 try: 316 self.tab_manager.save_playlist(page.playlist, True) 317 except Exception: 318 # an exception here could cause exaile to be unable to quit. 319 # Catch all exceptions. 320 logger.exception("Error saving tab %r", page.playlist.name) 321 322 def show_current_track(self): 323 """ 324 Tries to find the currently playing track 325 and selects it and its containing tab page 326 """ 327 for n, page in enumerate(self): 328 if not isinstance(page, PlaylistPage): 329 continue 330 331 if page.playlist is not self.player.queue.current_playlist: 332 continue 333 334 self.set_current_page(n) 335 page.view.scroll_to_cell(page.playlist.current_position) 336 page.view.set_cursor(page.playlist.current_position) 337 return True 338 339 def on_page_added(self, notebook, child, page_number): 340 """ 341 Updates appearance on page add 342 """ 343 if self.get_n_pages() > 1: 344 # Enforce tabbar visibility 345 self.set_show_tabs(True) 346 347 def on_page_removed(self, notebook, child, page_number): 348 """ 349 Updates appearance on page removal 350 """ 351 if self.get_n_pages() == 1: 352 self.set_show_tabs(settings.get_option('gui/show_tabbar', True)) 353 354 # closed tab history 355 if not self._moving_tab: 356 357 if settings.get_option('gui/save_closed_tabs', True) and isinstance( 358 child, PlaylistPage 359 ): 360 self.save_closed_tab(child.playlist) 361 362 # Destroy it unless it's the queue page 363 if not isinstance(child, QueuePage): 364 child.destroy() 365 366 def restore_closed_tab(self, pos=None, playlist=None, item_name=None): 367 ret = self.remove_closed_tab(pos, playlist, item_name) 368 if ret is not None: 369 self.create_tab_from_playlist(ret[0]) 370 371 def save_closed_tab(self, playlist): 372 # don't let the list grow indefinitely 373 if len(self.tab_history) > settings.get_option('gui/max_closed_tabs', 10): 374 self.remove_closed_tab(-1) # remove last item 375 376 item_name = 'playlist%05d' % self.history_counter 377 close_time = datetime.now() 378 # define a MenuItem factory that supports dynamic labels 379 380 def factory(menu_, parent, context): 381 item = None 382 383 dt = datetime.now() - close_time 384 if dt.seconds > 60: 385 display_name = _( 386 '{playlist_name} ({track_count} tracks, closed {minutes} min ago)' 387 ).format( 388 playlist_name=playlist.name, 389 track_count=len(playlist), 390 minutes=dt.seconds // 60, 391 ) 392 else: 393 display_name = _( 394 '{playlist_name} ({track_count} tracks, closed {seconds} sec ago)' 395 ).format( 396 playlist_name=playlist.name, 397 track_count=len(playlist), 398 seconds=dt.seconds, 399 ) 400 item = Gtk.ImageMenuItem.new_with_mnemonic(display_name) 401 item.set_image( 402 Gtk.Image.new_from_icon_name('music-library', Gtk.IconSize.MENU) 403 ) 404 405 # Add accelerator to top item 406 if self.tab_history[0][1].name == item_name: 407 key, mods = Gtk.accelerator_parse(self.accelerator.keys) 408 item.add_accelerator( 409 'activate', menu.FAKEACCELGROUP, key, mods, Gtk.AccelFlags.VISIBLE 410 ) 411 412 item.connect( 413 'activate', lambda w: self.restore_closed_tab(item_name=item_name) 414 ) 415 416 return item 417 418 # create menuitem 419 item = menu.MenuItem(item_name, factory, []) 420 providers.register('playlist-closed-tab-menu', item, self) 421 self.history_counter -= 1 422 423 # add 424 self.tab_history.insert(0, (playlist, item)) 425 426 def get_closed_tab(self, pos=None, playlist=None, item_name=None): 427 if pos is not None: 428 try: 429 return self.tab_history[pos] 430 except IndexError: 431 return None 432 elif playlist is not None: 433 for (pl, item) in self.tab_history: 434 if pl == playlist: 435 return (pl, item) 436 elif item_name is not None: 437 for (pl, item) in self.tab_history: 438 if item.name == item_name: 439 return (pl, item) 440 441 return None 442 # remove from menus 443 444 def remove_closed_tab(self, pos=None, playlist=None, item_name=None): 445 ret = self.get_closed_tab(pos, playlist, item_name) 446 if ret is not None: 447 self.tab_history.remove(ret) 448 providers.unregister('playlist-closed-tab-menu', ret[1], self) 449 return ret 450 451 def clear_closed_tabs(self, widget, name, parent, context): 452 for i in range(len(self.tab_history)): 453 self.remove_closed_tab(0) 454 455 def focus_tab(self, tab_nr): 456 """ 457 Selects the playlist notebook tab tab_nr, and gives it the keyboard 458 focus. 459 """ 460 if tab_nr < self.get_n_pages(): 461 self.set_current_page(tab_nr) 462 self.get_current_tab().focus() 463 464 def select_next_tab(self): 465 """ 466 Selects the previous playlist notebook tab, warping around if the 467 first page is currently displayed. 468 """ 469 tab_nr = self.get_current_page() 470 tab_nr += 1 471 tab_nr %= self.get_n_pages() 472 self.set_current_page(tab_nr) 473 474 def select_prev_tab(self): 475 """ 476 Selects the next playlist notebook tab, warping around if the last 477 page is currently displayed. 478 """ 479 tab_nr = self.get_current_page() 480 tab_nr -= 1 481 tab_nr %= self.get_n_pages() 482 self.set_current_page(tab_nr) 483 484 def on_option_set(self, event, settings, option): 485 """ 486 Updates appearance on setting change 487 """ 488 if option == 'gui/show_tabbar': 489 show_tabbar = settings.get_option(option, True) 490 491 if not show_tabbar and self.get_n_pages() > 1: 492 show_tabbar = True 493 494 self.set_show_tabs(show_tabbar) 495 496 if option == 'gui/tab_placement': 497 tab_placement = settings.get_option(option, 'top') 498 self.set_tab_pos(self.tab_placement_map[tab_placement]) 499 500 501class PlaylistContainer(Gtk.Box): 502 """ 503 Contains two playlist notebooks that can contain playlists. 504 Playlists can be moved between the two notebooks. 505 506 TODO: Does it make sense to support more than two notebooks? 507 I think with this implementation it does not -- we would need to 508 move to a different UI design that allowed arbitrary placement 509 of UI elements if that was the case. 510 """ 511 512 def __init__(self, manager_name, player): 513 Gtk.Box.__init__(self) 514 515 self.notebooks: List[PlaylistNotebook] = [] 516 self.notebooks.append( 517 PlaylistNotebook(manager_name, player, '<Primary><Shift>t') 518 ) 519 self.notebooks.append( 520 PlaylistNotebook(manager_name + '2', player, '<Primary><Alt>t') 521 ) 522 523 self.notebooks[1].set_add_tab_on_empty(False) 524 525 # add notebooks to self 526 self.pack_start(self.notebooks[0], True, True, 0) 527 528 # setup the paned window for separate views 529 self.paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) 530 self.paned.pack2(self.notebooks[1], True, True) 531 532 # setup queue page 533 self.queuepage = QueuePage(self, player) 534 self.queuetab = NotebookTab(None, self.queuepage) 535 if len(player.queue) > 0: 536 self.show_queue() 537 538 # ensure default notebook always has a tab in it 539 if self.notebooks[0].get_n_pages() == 0: 540 self.notebooks[0].add_default_tab() 541 542 # menu item 543 item = menu.simple_menu_item( 544 'move-tab', 545 [], 546 _('_Move to Other View'), 547 None, 548 lambda w, n, p, c: self._move_tab(p.tab), 549 condition_fn=lambda n, p, c: True 550 if p.tab.notebook in self.notebooks 551 else False, 552 ) 553 providers.register('playlist-tab-context-menu', item) 554 providers.register('queue-tab-context', item) 555 556 # connect events 557 for notebook in self.notebooks: 558 notebook.connect('page-reordered', self.on_page_reordered) 559 notebook.connect_after( 560 'page-removed', lambda *a: self._update_notebook_display() 561 ) 562 563 self._update_notebook_display() 564 565 def _move_tab(self, tab): 566 if tab.notebook is self.notebooks[0]: 567 src, dst = (0, 1) 568 else: 569 src, dst = (1, 0) 570 571 # don't put this notebook in the 'recently closed tabs' list 572 self.notebooks[src]._moving_tab = True 573 self.notebooks[src].remove_tab(tab) 574 self.notebooks[src]._moving_tab = False 575 576 self.notebooks[dst].add_tab(tab, tab.page) 577 578 # remember where the user moved the queue 579 if tab.page is self.queuepage: 580 settings.set_option('gui/queue_notebook_num', dst) 581 582 self._update_notebook_display() 583 584 def _update_notebook_display(self): 585 pane_installed = self.paned.get_parent() is not None 586 587 if self.notebooks[1].get_n_pages() != 0: 588 if not pane_installed: 589 parent = self.notebooks[0].get_parent() 590 parent.remove(self.notebooks[0]) 591 592 self.paned.pack1(self.notebooks[0], True, True) 593 self.pack_start(self.paned, True, True, 0) 594 else: 595 if pane_installed: 596 parent = self.notebooks[0].get_parent() 597 parent.remove(self.notebooks[0]) 598 599 self.remove(self.paned) 600 self.pack_start(self.notebooks[0], True, True, 0) 601 602 self.show_all() 603 604 def create_new_playlist(self): 605 """ 606 Create a new tab in the primary notebook containing a blank 607 playlist. The tab will be automatically given a unique name. 608 """ 609 return self.notebooks[0].create_new_playlist() 610 611 def create_tab_from_playlist(self, pl): 612 """ 613 Create a tab that will contain the passed-in playlist 614 615 :param playlist: The playlist to create tab from 616 :type playlist: :class:`xl.playlist.Playlist` 617 """ 618 return self.notebooks[0].create_tab_from_playlist(pl) 619 620 def get_current_notebook(self): 621 """ 622 Returns the last focused notebook, or the 623 primary notebook 624 """ 625 if self.paned.get_parent() is not None: 626 focus = self.paned.get_focus_child() 627 if focus is not None: 628 return focus 629 return self.notebooks[0] 630 631 def get_current_tab(self): 632 """ 633 Returns the currently showing tab on the current notebook 634 """ 635 notebook = self.get_current_notebook() 636 return notebook.get_current_tab() 637 638 def focus(self): 639 """ 640 Gives keyboard focus to the currently selected tab 641 """ 642 self.get_current_tab().focus() 643 644 def on_page_reordered(self, notebook, child, page_number): 645 if ( 646 self.queuepage.tab.notebook is notebook 647 and notebook.page_num(self.queuepage) != 0 648 ): 649 notebook.reorder_child(self.queuepage, 0) 650 651 def save_current_tabs(self): 652 """ 653 Saves the open tabs 654 """ 655 for notebook in self.notebooks: 656 notebook.save_current_tabs() 657 658 def show_queue(self, switch=True): 659 """ 660 Shows the queue page in the last notebook that 661 the queue was located. 662 663 :param switch: If True, switch focus to the queue page 664 """ 665 if self.queuepage.tab.notebook is None: 666 # ensure the queue is restored in the last place the user had it 667 n = settings.get_option('gui/queue_notebook_num', 0) 668 self.notebooks[n].add_tab(self.queuetab, self.queuepage, position=0) 669 if switch: 670 # should always be 0, but doesn't hurt to be safe... 671 qnotebook = self.queuepage.tab.notebook 672 qnotebook.set_current_page(qnotebook.page_num(self.queuepage)) 673 674 self._update_notebook_display() 675 676 def show_current_track(self): 677 """ 678 Tries to find the currently playing track 679 and selects it and its containing tab page 680 """ 681 for notebook in self.notebooks: 682 if notebook.show_current_track(): 683 break 684