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