From ba373ddf4e9fc663c76b06f058fc33eaa6734809 Mon Sep 17 00:00:00 2001 From: coderkun Date: Sun, 2 Aug 2020 17:18:28 +0200 Subject: [PATCH] Use GTK Composite Templates (close #62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use GTK Composite Templates for GUI elements to clean up and simplify the code for widgets and all UI elements. This includes splitting the large “gtk.glade” file into smaller .ui files and the large “widgets.py” file into smaller .py files. --- data/de.coderkun.mcg.gresource.xml | 19 +- data/gtk.glade | 1975 --------------- data/{ => icons}/mcg.svg | 250 +- data/mcg.png | Bin 0 -> 24983 bytes data/ui/album-headerbar.ui | 61 + data/ui/connection-panel.ui | 163 ++ data/ui/cover-panel.ui | 180 ++ data/ui/cover-toolbar.ui | 33 + data/{ => ui}/gtk.menu.ui | 2 +- data/ui/info-dialog.ui | 49 + data/ui/library-panel.ui | 303 +++ data/ui/library-toolbar.ui | 232 ++ data/ui/playlist-panel.ui | 194 ++ data/ui/playlist-toolbar.ui | 55 + data/ui/server-panel.ui | 512 ++++ data/ui/server-toolbar.ui | 14 + .../shortcuts-dialog.ui} | 4 +- data/ui/window.ui | 233 ++ mcg/albumheaderbar.py | 35 + mcg/application.py | 51 +- mcg/connectionpanel.py | 108 + mcg/coverpanel.py | 279 +++ mcg/infodialog.py | 21 + mcg/librarypanel.py | 635 +++++ mcg/mcg.py | 35 +- mcg/playlistpanel.py | 349 +++ mcg/serverpanel.py | 129 + mcg/shortcutsdialog.py | 18 + mcg/utils.py | 2 + mcg/widgets.py | 2150 ----------------- mcg/window.py | 650 +++++ 31 files changed, 4436 insertions(+), 4305 deletions(-) delete mode 100644 data/gtk.glade rename data/{ => icons}/mcg.svg (68%) create mode 100644 data/mcg.png create mode 100644 data/ui/album-headerbar.ui create mode 100644 data/ui/connection-panel.ui create mode 100644 data/ui/cover-panel.ui create mode 100644 data/ui/cover-toolbar.ui rename data/{ => ui}/gtk.menu.ui (97%) create mode 100644 data/ui/info-dialog.ui create mode 100644 data/ui/library-panel.ui create mode 100644 data/ui/library-toolbar.ui create mode 100644 data/ui/playlist-panel.ui create mode 100644 data/ui/playlist-toolbar.ui create mode 100644 data/ui/server-panel.ui create mode 100644 data/ui/server-toolbar.ui rename data/{gtk.shortcuts.ui => ui/shortcuts-dialog.ui} (98%) create mode 100644 data/ui/window.ui create mode 100644 mcg/albumheaderbar.py create mode 100644 mcg/connectionpanel.py create mode 100644 mcg/coverpanel.py create mode 100644 mcg/infodialog.py create mode 100644 mcg/librarypanel.py create mode 100644 mcg/playlistpanel.py create mode 100644 mcg/serverpanel.py create mode 100644 mcg/shortcutsdialog.py delete mode 100644 mcg/widgets.py create mode 100644 mcg/window.py diff --git a/data/de.coderkun.mcg.gresource.xml b/data/de.coderkun.mcg.gresource.xml index 02969fc..bd04296 100644 --- a/data/de.coderkun.mcg.gresource.xml +++ b/data/de.coderkun.mcg.gresource.xml @@ -1,11 +1,22 @@ - gtk.glade gtk.css - gtk.shortcuts.ui - gtk.menu.ui - mcg.svg noise-texture.png + icons/mcg.svg + ui/window.ui + ui/gtk.menu.ui + ui/info-dialog.ui + ui/shortcuts-dialog.ui + ui/connection-panel.ui + ui/album-headerbar.ui + ui/server-toolbar.ui + ui/server-panel.ui + ui/cover-toolbar.ui + ui/cover-panel.ui + ui/playlist-toolbar.ui + ui/playlist-panel.ui + ui/library-toolbar.ui + ui/library-panel.ui diff --git a/data/gtk.glade b/data/gtk.glade deleted file mode 100644 index fd77059..0000000 --- a/data/gtk.glade +++ /dev/null @@ -1,1975 +0,0 @@ - - - - - - True - False - - - True - True - True - - - - True - False - go-previous-symbolic - - - - - - - True - False - vertical - - - True - False - Title - True - - - - - - - False - True - 0 - - - - - True - False - Artist - True - - - False - True - 1 - - - - - - - True - False - - - True - True - True - - - - True - False - go-previous-symbolic - - - - - - - True - False - vertical - - - True - False - Title - True - - - - - - - False - True - 0 - - - - - True - False - Artist - True - - - False - True - 1 - - - - - - - 100 - 1000 - 150 - 1 - 10 - - - False - - - True - False - vertical - - - 350 - True - True - library-scale-adjustment - False - -1 - 0 - 0 - False - - - - - True - True - 0 - - - - - gtk-refresh - True - True - True - none - True - - - - False - True - 1 - - - - - True - False - vertical - - - True - False - vertical - - - False - True - 0 - - - - - True - False - Sort - - - False - True - 1 - - - - - sort by artist - True - True - False - True - library-toolbar-sort-year - - - - False - True - 2 - - - - - sort by title - True - True - False - True - library-toolbar-sort-year - - - - False - True - 3 - - - - - sort by year - True - True - False - True - True - - - - False - True - 4 - - - - - gtk-sort-descending - True - True - False - True - True - True - - - - False - True - 5 - - - - - False - True - 2 - - - - - - - 1024 - 9999 - 6600 - 1 - 100 - - - False - mcg.svg - - - - - headerbar - True - False - True - - - True - True - Connect or disconnect - - - - - 1 - - - - - True - False - - - 2 - - - - - True - True - True - Switch between play and pause - - - - True - False - media-playback-start - - - - - 3 - - - - - True - True - False - True - Adjust the volume - none - vertical - audio-volume-muted - audio-volume-high - audio-volume-low - audio-volume-medium - - - - - - True - True - center - center - none - - - - - True - True - center - center - none - - - - - 4 - - - - - headerbar-connection - True - False - 100 - crossfade - - - True - False - Connect to MPD - - - - - - page1 - page1 - - - - - True - False - panelstack - - - page0 - page0 - 1 - - - - - 4 - - - - - True - False - - - True - False - 6 - end - - - True - True - True - Show the cover in fullscreen mode - win.toggle-fullscreen - - - True - False - view-fullscreen-symbolic - - - - - False - False - 0 - True - - - - - cover - - - - - True - False - 6 - end - - - - - - server - 1 - - - - - True - False - 6 - end - - - True - True - True - Select multiple albums - - - - True - False - object-select-symbolic - - - - - False - False - 0 - True - - - - - True - True - True - Clear the playlist - - - - True - False - edit-clear - - - - - False - False - 1 - True - - - - - playlist - 2 - - - - - True - False - 6 - end - - - True - True - True - Search the library - - - - True - False - system-search-symbolic - - - - - - False - False - 0 - True - - - - - True - True - True - Select multiple albums - - - - True - False - object-select-symbolic - - - - - False - False - 1 - True - - - - - True - True - True - Settings and actions - library-toolbar-popover - - - True - False - open-menu-symbolic - - - - - False - False - 2 - True - - - - - library - 3 - - - - - end - 4 - - - - - - - True - False - - - True - False - 100 - crossfade - - - True - False - vertical - - - True - False - - - 500 - True - False - 5 - True - True - - - True - False - - - True - True - - - - - - - - - True - True - 0 - - - - - True - False - 5 - vertical - - - False - True - end - 1 - - - - - 0 - 0 - 6 - - - - - True - True - localhost - Enter hostname or IP address - - - - 1 - 1 - - - - - True - True - False - Enter password or leave blank - password - - - - 1 - 5 - - - - - True - True - 6600 - server-port-adjustment - 6600 - - - - 1 - 3 - - - - - True - False - start - Host: - - - 1 - 0 - - - - - True - False - start - Port: - - - 1 - 2 - - - - - True - False - start - Password: - - - 1 - 4 - - - - - True - False - 0 - - - - - True - False - 0 - - - - - page1 - page1 - - - - - True - False - - - True - False - - - True - False - server-stack - - - False - True - 0 - - - - - True - False - - - True - False - - - - - - True - False - vertical - - - - - - True - False - 5 - 5 - 5 - 5 - vertical - 10 - - - True - False - dialog-information-symbolic - 6 - - - False - True - 0 - - - - - True - False - 2 - 5 - - - True - False - start - start - File: - - - 0 - 0 - - - - - True - False - start - start - Audio: - - - 0 - 1 - - - - - True - False - start - start - Bitrate: - - - 0 - 2 - - - - - True - False - start - start - Error: - - - 0 - 3 - - - - - True - False - start - <i>none</i> - True - True - True - 0 - - - 1 - 2 - - - - - True - False - start - <i>none</i> - True - True - True - 0 - - - 1 - 1 - - - - - True - False - start - <i>none</i> - True - True - True - 0 - - - 1 - 0 - - - - - True - False - start - <i>none</i> - True - True - True - 0 - - - 1 - 3 - - - - - False - True - 1 - - - - - False - True - 1 - - - - - False - True - 1 - - - - - page0 - Status - - - - - True - False - - - - - - True - False - vertical - - - - - - True - False - vertical - 10 - - - True - False - starred-symbolic - 6 - - - False - True - 0 - - - - - True - False - 2 - 5 - - - True - False - end - right - True - - - 0 - 0 - - - - - True - False - end - right - - - 0 - 1 - - - - - True - False - end - right - - - 0 - 2 - - - - - True - False - start - Albums - - - 1 - 1 - - - - - True - False - start - Songs - - - 1 - 2 - - - - - True - False - start - Artists - - - 1 - 0 - - - - - True - False - end - right - - - 0 - 3 - - - - - True - False - start - Seconds - - - 1 - 3 - - - - - True - False - end - right - - - 0 - 6 - - - - - True - False - end - right - - - 0 - 5 - - - - - True - False - - - 0 - 4 - 2 - - - - - True - False - start - Seconds played - - - 1 - 5 - - - - - True - False - Seconds running - - - 1 - 6 - - - - - False - True - 1 - - - - - False - True - 1 - - - - - False - True - 1 - - - - - page1 - Statistics - 1 - - - - - True - False - - - - - - True - False - vertical - - - - - - True - False - vertical - 10 - - - True - False - audio-speakers-symbolic - 6 - - - False - True - 0 - - - - - True - False - none - - - - False - True - 1 - - - - - False - True - 1 - - - - - False - True - 1 - - - - - page2 - Audio Devices - 2 - - - - - True - True - 1 - - - - - server - Server - - - - - True - False - - - True - False - - - True - False - - - cover-spinner - - - - - True - True - False - False - - - - True - False - - - True - False - - - - True - False - 0 - - - - - - - - - cover-scroll - 1 - - - - - -1 - - - - - True - False - end - slide-right - - - True - True - start - never - 200 - True - - - True - False - none - - - True - False - start - 5 - 5 - 5 - 5 - vertical - - - True - False - 5 - 5 - 5 - True - - - True - False - start - Album - True - 0 - - - 0 - 0 - - - - - True - False - start - Date - True - 0 - - - 0 - 1 - - - - - True - False - start - Artist - True - 0 - - - 0 - 2 - - - - - - False - True - 10 - 0 - - - - - True - True - vertical - False - 0 - False - - - - - True - True - end - 1 - - - - - - - - - - - - - - cover - Cover - 1 - - - - - True - False - slide-left-right - - - True - False - vertical - - - True - True - - - True - True - 0 - horizontal - 0 - 0 - 1 - 0 - True - - - - - - - - - True - True - 0 - - - - - True - False - slide-up - - - True - False - - - - - False - True - 1 - - - - - page2 - page2 - - - - - True - False - vertical - - - True - False - - - True - False - - - standalone-spinne - - - - - True - True - False - False - - - True - False - - - True - False - gtk-missing-image - 6 - - - - - - - standalone-scroll - 1 - - - - - True - True - 0 - - - - - True - False - - - False - True - 1 - - - - - page1 - page1 - 1 - - - - - playlist - Playlist - 2 - - - - - True - False - slide-left-right - - - True - False - vertical - - - True - True - False - - - - True - True - edit-find-symbolic - False - False - search library - - - - - - False - True - 0 - - - - - - - - True - False - crossfade - - - True - False - vertical - - - - - - True - False - vertical - - - - - - True - False - center - vertical - 10 - - - True - False - 6 - - - False - True - 0 - - - - - 200 - True - False - 0.01 - True - - - False - True - 1 - - - - - False - True - 1 - - - - - False - True - 1 - - - - - page1 - page1 - - - - - True - True - - - True - True - 0 - horizontal - 0 - 0 - 1 - 0 - True - - - - - - - - - - page0 - page0 - 1 - - - - - True - True - 2 - - - - - True - False - none - - - True - False - - - - - False - True - 3 - - - - - page0 - page0 - - - - - True - False - vertical - - - True - False - crossfade - - - True - False - - - standalone-spinne - - - - - True - True - False - False - - - - True - False - - - True - False - gtk-missing-image - 6 - - - - - - - standalone-scroll - 1 - - - - - True - True - 0 - - - - - True - False - - - False - True - 1 - - - - - page1 - page1 - 1 - - - - - library - Library - 3 - - - - - page0 - page0 - 1 - - - - - -1 - - - - - True - False - start - - - False - start - 10 - 10 - vertical - top - True - - - - - False - - - False - False - 0 - - - - - False - - - True - False - - - False - True - 0 - - - - - False - False - 0 - - - - - - - - - - - - False - False - True - center - dialog - center - appwindow - CoverGrid - 2.1.2 - CoverGrid is a client for the Music Player Daemon, focusing on albums instead of single tracks. - http://www.suruatoel.xyz/codes/mcg - mcg.svg - gpl-3-0 - - - - - - False - vertical - 2 - - - False - end - - - - - - - - - False - False - 0 - - - - - - - - - diff --git a/data/mcg.svg b/data/icons/mcg.svg similarity index 68% rename from data/mcg.svg rename to data/icons/mcg.svg index ae0a7c5..1cebef7 100644 --- a/data/mcg.svg +++ b/data/icons/mcg.svg @@ -1,6 +1,4 @@ - - + version="1.1" + viewBox="0 0 67.733333 67.733335" + height="256" + width="256"> CoverGrid (mcg) + style="color-interpolation-filters:sRGB"> + flood-color="rgb(255,255,255)" + flood-opacity="0.498039" /> + operator="in" + in2="SourceGraphic" + in="flood" /> + stdDeviation="7" + in="composite1" /> + dy="6" + dx="6" /> + operator="over" + in2="offset" + in="SourceGraphic" /> + inkscape:showpageshadow="false" + showborder="true" + inkscape:pagecheckerboard="true" + units="px" + inkscape:window-maximized="1" + inkscape:window-y="0" + inkscape:window-x="0" + inkscape:window-height="1030" + inkscape:window-width="1916" + inkscape:guide-bbox="true" + showguides="true" + showgrid="false" + inkscape:current-layer="layer1" + inkscape:document-units="mm" + inkscape:cy="490.36801" + inkscape:cx="311.30244" + inkscape:zoom="0.7" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + borderopacity="1.0" + bordercolor="#666666" + pagecolor="#ffffff" + id="base"> + orientation="0,1" + position="319.76785,-21.166667" /> @@ -120,94 +119,95 @@ + inkscape:groupmode="layer" + inkscape:label="Ebene 1"> + transform="matrix(0.253073,0,0,0.25346533,-0.4687123,18.990219)" + id="g4504"> - + - - - - - + + + + + - + - + + width="87.841667" + height="87.841667" + x="1.8520833" + y="117.61248" /> + inkscape:rounded="0" + inkscape:flatsided="true" + sodipodi:arg2="1.051523" + sodipodi:arg1="0.0043254268" + sodipodi:r2="97.404572" + sodipodi:r1="102.49749" + sodipodi:cy="136.27939" + sodipodi:cx="102.3238" + sodipodi:sides="3" + id="path4661" + style="opacity:0.58;fill:#000000;fill-opacity:1;stroke-width:0.980952;filter:url(#filter4516)" + sodipodi:type="star" /> diff --git a/data/mcg.png b/data/mcg.png new file mode 100644 index 0000000000000000000000000000000000000000..e99296066b9090b79ea8f092f557ff3685d45b61 GIT binary patch literal 24983 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 literal 0 HcmV?d00001 diff --git a/data/ui/album-headerbar.ui b/data/ui/album-headerbar.ui new file mode 100644 index 0000000..a384338 --- /dev/null +++ b/data/ui/album-headerbar.ui @@ -0,0 +1,61 @@ + + + + + + diff --git a/data/ui/connection-panel.ui b/data/ui/connection-panel.ui new file mode 100644 index 0000000..7f8000e --- /dev/null +++ b/data/ui/connection-panel.ui @@ -0,0 +1,163 @@ + + + + + + 1024 + 9999 + 6600 + 1 + 100 + + + diff --git a/data/ui/cover-panel.ui b/data/ui/cover-panel.ui new file mode 100644 index 0000000..aca3b61 --- /dev/null +++ b/data/ui/cover-panel.ui @@ -0,0 +1,180 @@ + + + + + + diff --git a/data/ui/cover-toolbar.ui b/data/ui/cover-toolbar.ui new file mode 100644 index 0000000..338cee3 --- /dev/null +++ b/data/ui/cover-toolbar.ui @@ -0,0 +1,33 @@ + + + + + + diff --git a/data/gtk.menu.ui b/data/ui/gtk.menu.ui similarity index 97% rename from data/gtk.menu.ui rename to data/ui/gtk.menu.ui index e12fb59..a71ccb6 100644 --- a/data/gtk.menu.ui +++ b/data/ui/gtk.menu.ui @@ -51,7 +51,7 @@
- app.shortcuts + win.show-help-overlay Keyboard Shortcuts <Primary>k diff --git a/data/ui/info-dialog.ui b/data/ui/info-dialog.ui new file mode 100644 index 0000000..be9a804 --- /dev/null +++ b/data/ui/info-dialog.ui @@ -0,0 +1,49 @@ + + + + + + diff --git a/data/ui/library-panel.ui b/data/ui/library-panel.ui new file mode 100644 index 0000000..de06df0 --- /dev/null +++ b/data/ui/library-panel.ui @@ -0,0 +1,303 @@ + + + + + + diff --git a/data/ui/library-toolbar.ui b/data/ui/library-toolbar.ui new file mode 100644 index 0000000..dc63149 --- /dev/null +++ b/data/ui/library-toolbar.ui @@ -0,0 +1,232 @@ + + + + + + 100 + 1000 + 150 + 1 + 10 + + + False + + + True + False + vertical + + + 350 + True + True + grid_adjustment + False + -1 + 0 + 0 + False + + + + + True + True + 0 + + + + + gtk-refresh + True + True + True + none + True + + + + False + True + 1 + + + + + True + False + vertical + + + True + False + vertical + + + False + True + 0 + + + + + True + False + Sort + + + False + True + 1 + + + + + sort by artist + True + True + False + True + sort_year + + + + False + True + 2 + + + + + sort by title + True + True + False + True + sort_year + + + + False + True + 3 + + + + + sort by year + True + True + False + True + True + + + + False + True + 4 + + + + + gtk-sort-descending + True + True + False + True + True + True + + + + False + True + 5 + + + + + False + True + 2 + + + + + + + diff --git a/data/ui/playlist-panel.ui b/data/ui/playlist-panel.ui new file mode 100644 index 0000000..a2a31ce --- /dev/null +++ b/data/ui/playlist-panel.ui @@ -0,0 +1,194 @@ + + + + + + diff --git a/data/ui/playlist-toolbar.ui b/data/ui/playlist-toolbar.ui new file mode 100644 index 0000000..c061395 --- /dev/null +++ b/data/ui/playlist-toolbar.ui @@ -0,0 +1,55 @@ + + + + + + diff --git a/data/ui/server-panel.ui b/data/ui/server-panel.ui new file mode 100644 index 0000000..568648d --- /dev/null +++ b/data/ui/server-panel.ui @@ -0,0 +1,512 @@ + + + + + + diff --git a/data/ui/server-toolbar.ui b/data/ui/server-toolbar.ui new file mode 100644 index 0000000..c7561c1 --- /dev/null +++ b/data/ui/server-toolbar.ui @@ -0,0 +1,14 @@ + + + + + + diff --git a/data/gtk.shortcuts.ui b/data/ui/shortcuts-dialog.ui similarity index 98% rename from data/gtk.shortcuts.ui rename to data/ui/shortcuts-dialog.ui index 048cc3b..97dbc66 100644 --- a/data/gtk.shortcuts.ui +++ b/data/ui/shortcuts-dialog.ui @@ -1,7 +1,7 @@ - + diff --git a/data/ui/window.ui b/data/ui/window.ui new file mode 100644 index 0000000..0acc759 --- /dev/null +++ b/data/ui/window.ui @@ -0,0 +1,233 @@ + + + + + + diff --git a/mcg/albumheaderbar.py b/mcg/albumheaderbar.py new file mode 100644 index 0000000..5927a3f --- /dev/null +++ b/mcg/albumheaderbar.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, GObject + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/album-headerbar.ui') +class AlbumHeaderbar(Gtk.HeaderBar): + __gtype_name__ = 'McgAlbumHeaderbar' + __gsignals__ = { + 'close': (GObject.SIGNAL_RUN_FIRST, None, ()) + } + + # Widgets + standalone_title = Gtk.Template.Child() + standalone_artist = Gtk.Template.Child() + + + def __init__(self): + super().__init__() + + + @Gtk.Template.Callback() + def on_close_clicked(self, widget): + self.emit('close') + + + def set_album(self, album): + self.standalone_title.set_text(album.get_title()) + self.standalone_artist.set_text(", ".join(album.get_albumartists())) diff --git a/mcg/application.py b/mcg/application.py index 0c47831..f458fa1 100644 --- a/mcg/application.py +++ b/mcg/application.py @@ -1,16 +1,15 @@ #!/usr/bin/env python3 -import gi -gi.require_version('Gtk', '3.0') -import gettext import logging import urllib +import gi +gi.require_version('Gtk', '3.0') from gi.repository import Gio, Gtk, Gdk, GLib -from mcg import Environment -from mcg import widgets +from mcg.window import Window +from mcg.infodialog import InfoDialog @@ -32,7 +31,6 @@ class Application(Gtk.Application): def __init__(self): Gtk.Application.__init__(self, application_id=Application.ID, flags=Gio.ApplicationFlags.FLAGS_NONE) self._window = None - self._shortcuts_window = None self._info_dialog = None self._verbosity = logging.WARNING self.add_main_option_entries([ @@ -53,12 +51,9 @@ class Application(Gtk.Application): def do_startup(self): Gtk.Application.do_startup(self) self._setup_logging() - self._load_resource() self._load_settings() self._set_default_settings() self._load_css() - self._setup_locale() - self._load_ui() self._setup_actions() self._load_appmenu() @@ -66,22 +61,15 @@ class Application(Gtk.Application): def do_activate(self): Gtk.Application.do_activate(self) if not self._window: - self._window = widgets.Window(self, self._builder, Application.TITLE, self._settings) + self._window = Window(self, Application.TITLE, self._settings) self._window.present() - def on_menu_shortcuts(self, action, value): - builder = Gtk.Builder() - builder.set_translation_domain(Application.DOMAIN) - builder.add_from_resource(self._get_resource_path('gtk.shortcuts.ui')) - shortcuts_dialog = widgets.ShortcutsDialog(builder, self._window) - shortcuts_dialog.present() - - def on_menu_info(self, action, value): if not self._info_dialog: - self._info_dialog = widgets.InfoDialog(self._builder) + self._info_dialog = InfoDialog() self._info_dialog.run() + self._info_dialog.hide() def on_menu_quit(self, action, value): @@ -95,13 +83,6 @@ class Application(Gtk.Application): ) - def _load_resource(self): - self._resource = Gio.resource_load( - Environment.get_data(Application.ID + '.gresource') - ) - Gio.Resource._register(self._resource) - - def _load_settings(self): self._settings = Gio.Settings.new(Application.ID) @@ -121,23 +102,7 @@ class Application(Gtk.Application): ) - def _setup_locale(self): - relpath = Environment.get_locale() - gettext.bindtextdomain(Application.DOMAIN, relpath) - gettext.textdomain(Application.DOMAIN) - - - def _load_ui(self): - # Create builder to load UI - self._builder = Gtk.Builder() - self._builder.set_translation_domain(Application.DOMAIN) - self._builder.add_from_resource(self._get_resource_path('gtk.glade')) - - def _setup_actions(self): - action = Gio.SimpleAction.new("shortcuts", None) - action.connect('activate', self.on_menu_shortcuts) - self.add_action(action) action = Gio.SimpleAction.new("info", None) action.connect('activate', self.on_menu_info) self.add_action(action) @@ -149,7 +114,7 @@ class Application(Gtk.Application): def _load_appmenu(self): builder = Gtk.Builder() builder.set_translation_domain(Application.DOMAIN) - builder.add_from_resource(self._get_resource_path('gtk.menu.ui')) + builder.add_from_resource(self._get_resource_path('ui/gtk.menu.ui')) self.set_app_menu(builder.get_object('app-menu')) diff --git a/mcg/connectionpanel.py b/mcg/connectionpanel.py new file mode 100644 index 0000000..2139d01 --- /dev/null +++ b/mcg/connectionpanel.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, GObject + +from mcg.zeroconf import ZeroconfProvider + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/connection-panel.ui') +class ConnectionPanel(Gtk.Box): + __gtype_name__ = 'McgConnectionPanel' + __gsignals__ = { + 'connection-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, int, str)) + } + + # Widgets + zeroconf_list = Gtk.Template.Child() + host_entry = Gtk.Template.Child() + port_spinner = Gtk.Template.Child() + password_entry = Gtk.Template.Child() + + + def __init__(self): + super().__init__() + self._services = Gtk.ListStore(str, str, int) + self._profile = None + + # Zeroconf + self.zeroconf_list.set_model(self._services) + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn("Zeroconf", renderer, text=0) + self.zeroconf_list.append_column(column) + + # Zeroconf provider + self._zeroconf_provider = ZeroconfProvider() + self._zeroconf_provider.connect_signal(ZeroconfProvider.SIGNAL_SERVICE_NEW, self.on_new_service) + + + def on_new_service(self, service): + name, host, port = service + self._services.append([name, host, port]) + + + @Gtk.Template.Callback() + def on_service_selected(self, selection): + model, treeiter = selection.get_selected() + if treeiter != None: + service = model[treeiter] + self.set_host(service[1]) + self.set_port(service[2]) + + + @Gtk.Template.Callback() + def on_zeroconf_list_outfocused(self, widget, event): + self.zeroconf_list.get_selection().unselect_all() + + + @Gtk.Template.Callback() + def on_host_entry_outfocused(self, widget, event): + self._call_back() + + + @Gtk.Template.Callback() + def on_port_spinner_value_changed(self, widget): + self._call_back() + + + @Gtk.Template.Callback() + def on_password_entry_outfocused(self, widget, event): + self._call_back() + + + def set_host(self, host): + self.host_entry.set_text(host) + + + def get_host(self): + return self.host_entry.get_text() + + + def set_port(self, port): + self.port_spinner.set_value(port) + + + def get_port(self): + return self.port_spinner.get_value_as_int() + + + def set_password(self, password): + if password is None: + password = "" + self.password_entry.set_text(password) + + + def get_password(self): + if self.password_entry.get_text() == "": + return None + else: + return self.password_entry.get_text() + + + def _call_back(self): + self.emit('connection-changed', self.get_host(), self.get_port(), self.get_password(),) diff --git a/mcg/coverpanel.py b/mcg/coverpanel.py new file mode 100644 index 0000000..14da478 --- /dev/null +++ b/mcg/coverpanel.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') +import logging +import math + +from gi.repository import Gtk, Gdk, GObject, GdkPixbuf + +from mcg.utils import Utils + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/cover-toolbar.ui') +class CoverToolbar(Gtk.ButtonBox): + __gtype_name__ = 'McgCoverToolbar' + __gsignals__ = { + 'fullscreen': (GObject.SIGNAL_RUN_FIRST, None, ()) + } + + # Widgets + fullscreen_button = Gtk.Template.Child() + + + def __init__(self): + super().__init__() + + + def set_fullscreen_sensitive(self, sensitive): + self.fullscreen_button.set_sensitive(sensitive) + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/cover-panel.ui') +class CoverPanel(Gtk.Overlay): + __gtype_name__ = 'McgCoverPanel' + __gsignals__ = { + 'toggle-fullscreen': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'set-song': (GObject.SIGNAL_RUN_FIRST, None, (int, int,)), + 'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,)) + } + + # Widgets + # Cover + cover_stack = Gtk.Template.Child() + cover_spinner = Gtk.Template.Child() + cover_scroll = Gtk.Template.Child() + cover_box = Gtk.Template.Child() + cover_image = Gtk.Template.Child() + # Album Infos + cover_info_scroll = Gtk.Template.Child() + info_revealer = Gtk.Template.Child() + album_title_label = Gtk.Template.Child() + album_date_label = Gtk.Template.Child() + album_artist_label = Gtk.Template.Child() + # Songs + songs_scale = Gtk.Template.Child() + + + + def __init__(self): + super().__init__() + + self._current_album = None + self._current_cover_album = None + self._cover_pixbuf = None + self._timer = None + self._properties = {} + self._icon_theme = Gtk.IconTheme.get_default() + self._fullscreened = False + self._is_selected = False + self._current_size = None + self._cover_pixbuf = self._get_default_image() + + # Widgets + self._toolbar = CoverToolbar() + self.cover_stack.set_visible_child(self.cover_scroll) + + # Initial actions + GObject.idle_add(self._enable_tracklist) + + + def get_toolbar(self): + return self._toolbar + + + def set_selected(self, selected): + self._is_selected = selected + + + @Gtk.Template.Callback() + def on_cover_box_pressed(self, widget, event): + if self._current_album and event.type == Gdk.EventType._2BUTTON_PRESS: + self.emit('toggle-fullscreen') + + + @Gtk.Template.Callback() + def on_cover_size_allocate(self, widget, allocation): + GObject.idle_add(self._resize_image) + self.cover_info_scroll.set_max_content_width(allocation.width // 2) + + + @Gtk.Template.Callback() + def on_songs_start_change(self, widget, event): + if self._timer: + GObject.source_remove(self._timer) + self._timer = None + + + @Gtk.Template.Callback() + def on_songs_change(self, widget, event): + value = int(self.songs_scale.get_value()) + time = self._current_album.get_length() + tracks = self._current_album.get_tracks() + pos = 0 + for index in range(len(tracks)-1, -1, -1): + time = time - tracks[index].get_length() + pos = tracks[index].get_pos() + if time < value: + break + time = max(value - time - 1, 0) + self.emit('set-song', pos, time) + + + def set_album(self, album): + if album: + # Set labels + self.album_title_label.set_label(album.get_title()) + self.album_date_label.set_label(', '.join(album.get_dates())) + self.album_artist_label.set_label(', '.join(album.get_albumartists())) + + # Set tracks + self._set_tracks(album) + + # Load cover + if album != self._current_cover_album: + self.cover_stack.set_visible_child(self.cover_spinner) + self.cover_spinner.start() + self.emit('albumart', album.get_id() if album else None) + + # Set current album + self._current_album = album + self._enable_tracklist() + self._toolbar.set_fullscreen_sensitive(self._current_album is not None) + + + def set_play(self, pos, time): + if self._timer is not None: + GObject.source_remove(self._timer) + self._timer = None + tracks = self._current_album.get_tracks() + for index in range(0, pos): + time = time + tracks[index].get_length() + + self.songs_scale.set_value(time+1) + self._timer = GObject.timeout_add(1000, self._playing) + + + def set_pause(self): + if self._timer is not None: + GObject.source_remove(self._timer) + self._timer = None + + + def set_fullscreen(self, active): + if active: + self.info_revealer.set_reveal_child(False) + self.cover_box.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 1)) + GObject.idle_add(self._resize_image) + self._fullscreened = True + else: + self._fullscreened = False + self.info_revealer.set_reveal_child(True) + self.cover_box.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 0)) + GObject.idle_add(self._resize_image) + + + def set_albumart(self, album, data): + if album == self._current_album: + if data: + # Load image and draw it + try: + self._cover_pixbuf = Utils.load_pixbuf(data) + except Exception as e: + self._logger.exception("Failed to set albumart") + self._cover_pixbuf = self._get_default_image() + else: + # Reset image + self._cover_pixbuf = self._get_default_image() + self._current_size = None + # Show image + self._resize_image() + self.cover_stack.set_visible_child(self.cover_scroll) + self.cover_spinner.stop() + self._current_cover_album = album + + + def _set_tracks(self, album): + self.songs_scale.clear_marks() + self.songs_scale.set_range(0, album.get_length()) + length = 0 + for track in album.get_tracks(): + cur_length = length + if length > 0 and length < album.get_length(): + cur_length = cur_length + 1 + self.songs_scale.add_mark( + cur_length, + Gtk.PositionType.RIGHT, + GObject.markup_escape_text( + Utils.create_track_title(track) + ) + ) + length = length + track.get_length() + self.songs_scale.add_mark( + length, + Gtk.PositionType.RIGHT, + "{0[0]:02d}:{0[1]:02d} minutes".format(divmod(length, 60)) + ) + + + def _enable_tracklist(self): + if self._current_album: + # enable + self.info_revealer.set_reveal_child(True) + else: + # disable + self.info_revealer.set_reveal_child(False) + + + def _playing(self): + value = self.songs_scale.get_value() + 1 + self.songs_scale.set_value(value) + + return True + + + def _resize_image(self): + """Diese Methode skaliert das geladene Bild aus dem Pixelpuffer + auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse + """ + # Get size + size = self.cover_scroll.get_allocation() + # Abort if size is the same + if self._current_size and size.width == self._current_size.width and size.height == self._current_size.height: + return + self._current_size = size + + # Get pixelbuffer + pixbuf = self._cover_pixbuf + # Check pixelbuffer + if pixbuf is None: + return + + # Skalierungswert für Breite und Höhe ermitteln + ratioW = float(size.width) / float(pixbuf.get_width()) + ratioH = float(size.height) / float(pixbuf.get_height()) + # Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren + ratio = min(ratioW, ratioH) + ratio = min(ratio, 1) + # Neue Breite und Höhe berechnen + width = int(math.floor(pixbuf.get_width()*ratio)) + height = int(math.floor(pixbuf.get_height()*ratio)) + if width <= 0 or height <= 0: + return + # Pixelpuffer auf Oberfläche zeichnen + self.cover_image.set_allocation(self.cover_scroll.get_allocation()) + self.cover_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER)) + self.cover_image.show() + + + def _get_default_image(self): + return self._icon_theme.load_icon( + Utils.STOCK_ICON_DEFAULT, + 512, + Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE + ) diff --git a/mcg/infodialog.py b/mcg/infodialog.py new file mode 100644 index 0000000..5fa3e81 --- /dev/null +++ b/mcg/infodialog.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') +import logging + +from gi.repository import Gtk, GObject, GdkPixbuf + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/info-dialog.ui') +class InfoDialog(Gtk.AboutDialog): + __gtype_name__ = 'McgInfoDialog' + + + def __init__(self): + super().__init__() + + self._logger = logging.getLogger(__name__) diff --git a/mcg/librarypanel.py b/mcg/librarypanel.py new file mode 100644 index 0000000..eccb846 --- /dev/null +++ b/mcg/librarypanel.py @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') +import gettext +import logging +import math +import threading + +from gi.repository import Gtk, GObject, GdkPixbuf + +from mcg import client +from mcg.albumheaderbar import AlbumHeaderbar +from mcg.utils import SortOrder +from mcg.utils import Utils + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/library-toolbar.ui') +class LibraryToolbar(Gtk.ButtonBox): + __gtype_name__ = 'McgLibraryToolbar' + __gsignals__ = { + 'select': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), + 'toggle-search': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), + 'update': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'start-scale': (GObject.SIGNAL_RUN_FIRST, None, (int,)), + 'end-scale': (GObject.SIGNAL_RUN_FIRST, None, (int,)), + 'sort': (GObject.SIGNAL_RUN_FIRST, None, (int,)), + 'sort-type': (GObject.SIGNAL_RUN_FIRST, None, (Gtk.SortType,)) + } + + # Widgets + select_button = Gtk.Template.Child() + toolbar_search_bar = Gtk.Template.Child() + toolbar_popover = Gtk.Template.Child() + toolbar_sort_order_button = Gtk.Template.Child() + sort_artist = Gtk.Template.Child() + sort_title = Gtk.Template.Child() + sort_year = Gtk.Template.Child() + grid_scale = Gtk.Template.Child() + + + def __init__(self, item_size): + super().__init__() + + # Toolbar menu + self.grid_scale.set_value(item_size) + self._toolbar_sort_buttons = { + SortOrder.ARTIST: self.sort_artist, + SortOrder.TITLE: self.sort_title, + SortOrder.YEAR: self.sort_year + } + + + @Gtk.Template.Callback() + def on_select_toggled(self, widget): + self.emit('select', widget.get_active()) + + + @Gtk.Template.Callback() + def on_search_toggled(self, widget): + self.emit('toggle-search', widget.get_active()) + + + @Gtk.Template.Callback() + def on_update_clicked(self, widget): + self.emit('update') + + + @Gtk.Template.Callback() + def on_grid_scale_change(self, widget, scroll, value): + self.emit('start-scale', value) + + + @Gtk.Template.Callback() + def on_grid_scale_changed(self, widget, event): + self.emit('end-scale', self.grid_scale.get_value()) + self.toolbar_popover.popdown() + + + @Gtk.Template.Callback() + def on_sort_toggled(self, widget): + if widget.get_active(): + sort = [key for key, value in self._toolbar_sort_buttons.items() if value is widget][0] + self.emit('sort', sort) + + + @Gtk.Template.Callback() + def on_sort_order_toggled(self, button): + if button.get_active(): + sort_type = Gtk.SortType.DESCENDING + else: + sort_type = Gtk.SortType.ASCENDING + self.emit('sort-type', sort_type) + + + def get_grid_scale(self): + return self.grid_scale + + + def is_search_active(self): + return self.toolbar_search_bar.get_active() + + + def set_search_active(self, active): + self.toolbar_search_bar.set_active(active) + + + def exit_selection(self): + self.select_button.set_active(False) + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/library-panel.ui') +class LibraryPanel(Gtk.Stack): + __gtype_name__ = 'McgLibraryPanel' + __gsignals__ = { + 'open-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'close-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'update': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'play': (GObject.SIGNAL_RUN_FIRST, None, (str,)), + 'queue': (GObject.SIGNAL_RUN_FIRST, None, (str,)), + 'queue-multiple': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + 'item-size-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)), + 'sort-order-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)), + 'sort-type-changed': (GObject.SIGNAL_RUN_FIRST, None, (Gtk.SortType,)), + 'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,)) + } + + + # Widgets + panel_standalone = Gtk.Template.Child() + actionbar_revealer = Gtk.Template.Child() + # Filter/search bar + filter_bar = Gtk.Template.Child() + filter_entry = Gtk.Template.Child() + # Progress Bar + stack = Gtk.Template.Child() + progress_box = Gtk.Template.Child() + progress_image = Gtk.Template.Child() + progress_bar = Gtk.Template.Child() + scroll = Gtk.Template.Child() + # Library Grid + library_grid = Gtk.Template.Child() + # Standalone Image + standalone_stack = Gtk.Template.Child() + standalone_spinner = Gtk.Template.Child() + standalone_scroll = Gtk.Template.Child() + standalone_image = Gtk.Template.Child() + + + def __init__(self, client): + super().__init__() + self._logger = logging.getLogger(__name__) + self._client = client + self._buttons = {} + self._albums = None + self._host = "localhost" + self._filter_string = "" + self._item_size = 150 + self._sort_order = SortOrder.YEAR + self._sort_type = Gtk.SortType.DESCENDING + self._grid_pixbufs = {} + self._old_ranges = {} + self._library_lock = threading.Lock() + self._library_stop = threading.Event() + self._icon_theme = Gtk.IconTheme.get_default() + self._standalone_pixbuf = None + self._selected_albums = [] + self._allocation = (0, 0) + self._is_selected = False + + # Widgets + self._toolbar = LibraryToolbar(self._item_size) + self._toolbar.connect('select', self.on_toolbar_select) + self._toolbar.connect('toggle-search', self.on_toolbar_toggle_search) + self._toolbar.connect('update', self.on_toolbar_update) + self._toolbar.connect('start-scale', self.on_toolbar_scale) + self._toolbar.connect('end-scale', self.on_toolbar_scaled) + self._toolbar.connect('sort', self.on_toolbar_sort) + self._toolbar.connect('sort-type', self.on_toolbar_sort_type) + # Header bar + self._headerbar_standalone = AlbumHeaderbar() + self._headerbar_standalone.connect('close', self.on_standalone_close_clicked) + # Progress Bar + self.progress_image.set_from_pixbuf(self._get_default_image()) + # Library Grid: Model + self._library_grid_model = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str) + self._library_grid_model.set_sort_func(2, self.compare_albums, self._sort_order) + self._library_grid_model.set_sort_column_id(2, self._sort_type) + self._library_grid_filter = self._library_grid_model.filter_new() + self._library_grid_filter.set_visible_func(self.on_filter_visible) + # Library Grid + self.library_grid.set_model(self._library_grid_filter) + self.library_grid.set_pixbuf_column(0) + self.library_grid.set_text_column(-1) + self.library_grid.set_tooltip_column(1) + + + def get_headerbar_standalone(self): + return self._headerbar_standalone + + + def get_toolbar(self): + return self._toolbar + + + def set_selected(self, selected): + self._is_selected = selected + + + @Gtk.Template.Callback() + def on_resize(self, widget, event): + new_allocation = (widget.get_allocation().width, widget.get_allocation().height) + if new_allocation == self._allocation: + return + self._allocation = new_allocation + self._toolbar.get_grid_scale().clear_marks() + width = widget.get_allocation().width + + lower = int(self._toolbar.get_grid_scale().get_adjustment().get_lower()) + upper = int(self._toolbar.get_grid_scale().get_adjustment().get_upper()) + countMin = max(int(width / upper), 1) + countMax = max(int(width / lower), 1) + for index in range(countMin, countMax): + pixel = int(width / index) + pixel = pixel - (2 * int(pixel / 100)) + self._toolbar.get_grid_scale().add_mark( + pixel, + Gtk.PositionType.BOTTOM, + None + ) + + + def on_toolbar_toggle_search(self, widget, active): + self.filter_bar.set_search_mode(active) + + + def on_toolbar_select(self, widget, active): + if active: + self.actionbar_revealer.set_reveal_child(True) + self.library_grid.set_selection_mode(Gtk.SelectionMode.MULTIPLE) + self.library_grid.get_style_context().add_class(Utils.CSS_SELECTION) + else: + self.actionbar_revealer.set_reveal_child(False) + self.library_grid.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.library_grid.get_style_context().remove_class(Utils.CSS_SELECTION) + + + def on_toolbar_update(self, widget): + self.emit('update') + + + def on_toolbar_scale(self, widget, value): + size = math.floor(value) + range = self._toolbar.get_grid_scale().get_adjustment() + if size < range.get_lower() or size > range.get_upper(): + return + self._item_size = size + GObject.idle_add(self.library_grid.set_item_padding, size / 100) + GObject.idle_add(self._set_widget_grid_size, self.library_grid, size, True) + + + def on_toolbar_scaled(self, widget, value): + size = round(value) + range = self._toolbar.get_grid_scale().get_adjustment() + if size < range.get_lower() or size > range.get_upper(): + return False + self.emit('item-size-changed', size) + self._redraw() + return False + + + def on_toolbar_sort(self, widget, sort): + self._change_sort(sort) + + + def on_toolbar_sort_type(self, widget, sort_type): + self._sort_type = sort_type + self._library_grid_model.set_sort_column_id(2, sort_type) + self.emit('sort-type-changed', sort_type) + + + @Gtk.Template.Callback() + def on_filter_bar_notify(self, widget, value): + if self._toolbar.is_search_active() is not self.filter_bar.get_search_mode(): + self._toolbar.set_search_active(self.filter_bar.get_search_mode()) + + + @Gtk.Template.Callback() + def on_filter_entry_changed(self, widget): + self._filter_string = self.filter_entry.get_text() + GObject.idle_add(self._library_grid_filter.refilter) + + + @Gtk.Template.Callback() + def on_library_grid_clicked(self, widget, path): + # Get selected album + path = self._library_grid_filter.convert_path_to_child_path(path) + iter = self._library_grid_model.get_iter(path) + id = self._library_grid_model.get_value(iter, 2) + album = self._albums[id] + self._selected_albums = [album] + self.emit('albumart', id) + + # Show standalone album + if widget.get_selection_mode() == Gtk.SelectionMode.SINGLE: + # Set labels + self._headerbar_standalone.set_album(album) + + # Show panel + self._open_standalone() + + # Set cover loading indicator + self.standalone_stack.set_visible_child(self.standalone_spinner) + self.standalone_spinner.start() + + + @Gtk.Template.Callback() + def on_library_grid_selection_changed(self, widget): + self._selected_albums = [] + for path in widget.get_selected_items(): + path = self._library_grid_filter.convert_path_to_child_path(path) + iter = self._library_grid_model.get_iter(path) + id = self._library_grid_model.get_value(iter, 2) + self._selected_albums.insert(0, self._albums[id]) + + + def on_filter_visible(self, model, iter, data): + id = model.get_value(iter, 2) + if not id in self._albums.keys(): + return + album = self._albums[id] + return album.filter(self._filter_string) + + + @Gtk.Template.Callback() + def on_selection_cancel_clicked(self, widget): + self._toolbar.exit_selection() + + + @Gtk.Template.Callback() + def on_selection_add_clicked(self, widget): + ids = [album.get_id() for album in self._selected_albums] + self.emit('queue-multiple', ids) + self._toolbar.exit_selection() + + + @Gtk.Template.Callback() + def on_standalone_scroll_size_allocate(self, widget, allocation): + self._resize_standalone_image() + + + @Gtk.Template.Callback() + def on_standalone_play_clicked(self, widget): + self.emit('play', self._selected_albums[0].get_id()) + self._close_standalone() + + + @Gtk.Template.Callback() + def on_standalone_queue_clicked(self, widget): + self.emit('queue', self._selected_albums[0].get_id()) + self._close_standalone() + + + def on_standalone_close_clicked(self, widget): + self._close_standalone() + + + def set_item_size(self, item_size): + if self._item_size != item_size: + self._item_size = item_size + self._toolbar.get_grid_scale().set_value(item_size) + self._redraw() + + + def get_item_size(self): + return self._item_size + + + def set_sort_order(self, sort): + if self._sort_order != sort: + button = self._toolbar_sort_buttons[sort] + if button and not button.get_active(): + button.set_active(True) + self._sort_order = sort + self._library_grid_model.set_sort_func(2, self.compare_albums, self._sort_order) + + + def get_sort_order(self): + return self._sort_order + + + def set_sort_type(self, sort_type): + if self._sort_type != sort_type: + if sort_type: + sort_type_gtk = Gtk.SortType.DESCENDING + self.toolbar_sort_order_button.set_active(True) + else: + sort_type_gtk = Gtk.SortType.ASCENDING + self.toolbar_sort_order_button.set_active(False) + if self._sort_type != sort_type_gtk: + self._sort_type = sort_type_gtk + self._library_grid_model.set_sort_column_id(2, sort_type) + + + def get_sort_type(self): + return (self._sort_type != Gtk.SortType.ASCENDING) + + + def init_albums(self): + self.progress_bar.set_text(gettext.gettext("Loading albums")) + + + def load_albums(self): + self.progress_bar.pulse() + + + def set_albums(self, host, albums): + self._host = host + self._library_stop.set() + threading.Thread(target=self._set_albums, args=(host, albums, self._item_size,)).start() + + + def set_albumart(self, album, data): + if album in self._selected_albums: + if data: + # Load image and draw it + try: + self._standalone_pixbuf = Utils.load_pixbuf(data) + except Exception as e: + self._logger.exception("Failed to set albumart") + self._standalone_pixbuf = self._get_default_image() + else: + self._standalone_pixbuf = self._get_default_image() + # Show image + self._resize_standalone_image() + self.standalone_stack.set_visible_child(self.standalone_scroll) + self.standalone_spinner.stop() + + + def compare_albums(self, model, row1, row2, criterion): + id1 = model.get_value(row1, 2) + id2 = model.get_value(row2, 2) + + if not id1 or not id2: + return + return client.MCGAlbum.compare(self._albums[id1], self._albums[id2], criterion) + + + def stop_threads(self): + self._library_stop.set() + + + def _change_sort(self, sort): + self._sort_order = sort + self._library_grid_model.set_sort_func(2, self.compare_albums, sort) + self.emit('sort-order-changed', sort) + + + def _set_albums(self, host, albums, size): + if not self._is_selected and albums != self._albums: + GObject.idle_add( + self.get_parent().child_set_property, + self, + 'needs-attention', + True + ) + self._library_lock.acquire() + self._library_stop.clear() + self._albums = albums + GObject.idle_add(self.stack.set_visible_child, self.progress_box) + GObject.idle_add(self.progress_bar.set_fraction, 0.0) + GObject.idle_add(self.library_grid.set_item_padding, size / 100) + self.library_grid.set_model(None) + self.library_grid.freeze_child_notify() + self._library_grid_model.clear() + + i = 0 + n = len(albums) + cache = client.MCGCache(host, size) + self._grid_pixbufs.clear() + for album_id in albums.keys(): + album = albums[album_id] + pixbuf = None + try: + pixbuf = Utils.load_thumbnail(cache, self._client, album, size) + except client.CommandException: + # Exception is handled by client + pass + except Exception as e: + self._logger.exception("Failed to load albumart") + if pixbuf is None: + pixbuf = self._icon_theme.load_icon( + Utils.STOCK_ICON_DEFAULT, + self._item_size, + Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE + ) + if pixbuf is not None: + self._grid_pixbufs[album.get_id()] = pixbuf + self._library_grid_model.append([ + pixbuf, + GObject.markup_escape_text("\n".join([ + album.get_title(), + ', '.join(album.get_dates()), + Utils.create_artists_label(album), + Utils.create_length_label(album) + ])), + album_id + ]) + + i += 1 + GObject.idle_add(self.progress_bar.set_fraction, i/n) + GObject.idle_add(self.progress_bar.set_text, gettext.gettext("Loading images")) + if self._library_stop.is_set(): + self._library_lock.release() + return + + self.library_grid.set_model(self._library_grid_filter) + self.library_grid.thaw_child_notify() + self.library_grid.set_item_width(-1) + self._library_lock.release() + self.stack.set_visible_child(self.scroll) + + + def _set_widget_grid_size(self, grid_widget, size, vertical): + self._library_stop.set() + threading.Thread(target=self._set_widget_grid_size_thread, args=(grid_widget, size, vertical,)).start() + + + def _set_widget_grid_size_thread(self, grid_widget, size, vertical): + self._library_lock.acquire() + self._library_stop.clear() + grid_filter = grid_widget.get_model() + grid_model = grid_filter.get_model() + + # get old_range + grid_widget_id = id(grid_widget) + if grid_widget_id not in self._old_ranges or self._old_ranges[grid_widget_id] is None: + self._old_ranges[grid_widget_id] = range(0, len(grid_filter)) + old_range = self._old_ranges[grid_widget_id] + old_start = len(old_range) > 0 and old_range[0] or 0 + old_end = len(old_range) > 0 and old_range[len(old_range)-1] + 1 or 0 + + # calculate visible range + w = (grid_widget.get_allocation().width // size) + (vertical and 0 or 1) + h = (grid_widget.get_allocation().height // size) + (vertical and 1 or 0) + c = w * h + vis_range = grid_widget.get_visible_range() + if vis_range is None: + self._library_lock.release() + return + (vis_start,), (vis_end,) = vis_range + vis_end = min(vis_start + c, len(grid_filter)) + vis_range = range(vis_start, vis_end) + + # set pixbuf + cur_start = min(old_start, vis_start) + cur_end = max(old_end, vis_end) + cur_range = range(cur_start, cur_end) + for index in cur_range: + iter = grid_filter.convert_iter_to_child_iter(grid_filter[index].iter) + if index in vis_range: + album_id = grid_model.get_value(iter, 2) + pixbuf = self._grid_pixbufs[album_id] + pixbuf = pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.NEAREST) + else: + pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, 1, 1) + grid_model.set_value(iter, 0, pixbuf) + + if self._library_stop.is_set(): + self._library_lock.release() + return + + self._old_ranges[grid_widget_id] = vis_range + grid_widget.set_item_width(size) + + self._library_lock.release() + + + def _redraw(self): + if self._albums is not None: + self.set_albums(self._host, self._albums) + + + def _open_standalone(self): + self.set_visible_child(self.panel_standalone) + self.emit('open-standalone') + + + def _close_standalone(self): + self.set_visible_child(self.get_children()[0]) + self.emit('close-standalone') + + + def _resize_standalone_image(self): + """Diese Methode skaliert das geladene Bild aus dem Pixelpuffer + auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse + """ + pixbuf = self._standalone_pixbuf + size = self.standalone_scroll.get_allocation() + # Check pixelbuffer + if pixbuf is None: + return + + # Skalierungswert für Breite und Höhe ermitteln + ratioW = float(size.width) / float(pixbuf.get_width()) + ratioH = float(size.height) / float(pixbuf.get_height()) + # Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren + ratio = min(ratioW, ratioH) + ratio = min(ratio, 1) + # Neue Breite und Höhe berechnen + width = int(math.floor(pixbuf.get_width()*ratio)) + height = int(math.floor(pixbuf.get_height()*ratio)) + if width <= 0 or height <= 0: + return + # Pixelpuffer auf Oberfläche zeichnen + self.standalone_image.set_allocation(self.standalone_scroll.get_allocation()) + self.standalone_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER)) + self.standalone_image.show() + + + def _get_default_image(self): + return self._icon_theme.load_icon( + Utils.STOCK_ICON_DEFAULT, + 512, + Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE + ) + + + + diff --git a/mcg/mcg.py b/mcg/mcg.py index 7e5913c..e449755 100755 --- a/mcg/mcg.py +++ b/mcg/mcg.py @@ -3,17 +3,42 @@ import sys -from mcg.application import Application +from mcg import Environment +def setup_resources(): + from gi.repository import Gio + + resource = Gio.resource_load( + Environment.get_data('de.coderkun.mcg.gresource') + ) + Gio.Resource._register(resource) + + +def setup_locale(): + import gettext + + gettext.bindtextdomain('mcg', Environment.get_locale()) + gettext.textdomain('mcg') + + +def run_application(): + from mcg.application import Application + + app = Application() + return app.run(sys.argv) + + def main(): - # Start application - app = Application() - exit_status = app.run(sys.argv) - sys.exit(exit_status) + setup_resources() + setup_locale() + return run_application() + + if __name__ == "__main__": main() + sys.exit(main()) diff --git a/mcg/playlistpanel.py b/mcg/playlistpanel.py new file mode 100644 index 0000000..4210efc --- /dev/null +++ b/mcg/playlistpanel.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') +import logging +import math +import threading + +from gi.repository import Gtk, GObject, GdkPixbuf + +from mcg import client +from mcg.albumheaderbar import AlbumHeaderbar +from mcg.utils import Utils + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/playlist-toolbar.ui') +class PlaylistToolbar(Gtk.ButtonBox): + __gtype_name__ = 'McgPlaylistToolbar' + __gsignals__ = { + 'select': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), + 'clear-playlist': (GObject.SIGNAL_RUN_FIRST, None, ()) + } + + # Widgets + playlist_clear_button = Gtk.Template.Child() + select_button = Gtk.Template.Child() + + + def __init__(self): + super().__init__() + + + @Gtk.Template.Callback() + def on_select_toggled(self, widget): + self.emit('select', widget.get_active()) + + + @Gtk.Template.Callback() + def on_clear_clicked(self, widget): + if widget is self.playlist_clear_button: + self.emit('clear-playlist') + + + def exit_selection(self): + self.select_button.set_active(False) + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/playlist-panel.ui') +class PlaylistPanel(Gtk.Stack): + __gtype_name__ = 'McgPlaylistPanel' + __gsignals__ = { + 'open-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'close-standalone': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'clear-playlist': (GObject.SIGNAL_RUN_FIRST, None, ()), + 'remove-album': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + 'remove-multiple-albums': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + 'play': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), + 'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,)) + } + + + # Widgets + panel_standalone = Gtk.Template.Child() + actionbar_revealer = Gtk.Template.Child() + # Playlist Grid + playlist_grid = Gtk.Template.Child() + # Action bar (normal) + actionbar = Gtk.Template.Child() + actionbar_standalone = Gtk.Template.Child() + # Standalone Image + standalone_stack = Gtk.Template.Child() + standalone_spinner = Gtk.Template.Child() + standalone_scroll = Gtk.Template.Child() + standalone_image = Gtk.Template.Child() + + + def __init__(self, client): + GObject.GObject.__init__(self) + self._client = client + self._host = None + self._item_size = 150 + self._playlist = None + self._playlist_albums = None + self._playlist_lock = threading.Lock() + self._playlist_stop = threading.Event() + self._icon_theme = Gtk.IconTheme.get_default() + self._standalone_pixbuf = None + self._selected_albums = [] + self._is_selected = False + + # Widgets + self._toolbar = PlaylistToolbar() + self._toolbar.connect('select', self.on_toolbar_select) + self._toolbar.connect('clear-playlist', self.on_toolbar_clear) + # Header bar + self._headerbar_standalone = AlbumHeaderbar() + self._headerbar_standalone.connect('close', self.on_headerbar_close_clicked) + # Playlist Grid: Model + self._playlist_grid_model = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str) + # Playlist Grid + self.playlist_grid.set_model(self._playlist_grid_model) + self.playlist_grid.set_pixbuf_column(0) + self.playlist_grid.set_text_column(-1) + self.playlist_grid.set_tooltip_column(1) + + + def get_headerbar_standalone(self): + return self._headerbar_standalone + + + def get_toolbar(self): + return self._toolbar + + + def set_selected(self, selected): + self._is_selected = selected + + + def on_toolbar_select(self, widget, active): + if active: + self.actionbar_revealer.set_reveal_child(True) + self.playlist_grid.set_selection_mode(Gtk.SelectionMode.MULTIPLE) + self.playlist_grid.get_style_context().add_class(Utils.CSS_SELECTION) + else: + self.actionbar_revealer.set_reveal_child(False) + self.playlist_grid.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.playlist_grid.get_style_context().remove_class(Utils.CSS_SELECTION) + + + def on_toolbar_clear(self, widget): + self.emit('clear-playlist') + + + @Gtk.Template.Callback() + def on_playlist_grid_clicked(self, widget, path): + # Get selected album + iter = self._playlist_grid_model.get_iter(path) + hash = self._playlist_grid_model.get_value(iter, 2) + album = self._playlist_albums[hash] + self._selected_albums = [album] + self.emit('albumart', hash) + + # Show standalone album + if widget.get_selection_mode() == Gtk.SelectionMode.SINGLE: + # Set labels + self._headerbar_standalone.set_album(album) + + # Show panel + self._open_standalone() + + # Set cover loading indicator + self.standalone_stack.set_visible_child(self.standalone_spinner) + self.standalone_spinner.start() + + + @Gtk.Template.Callback() + def on_playlist_grid_selection_changed(self, widget): + self._selected_albums = [] + for path in widget.get_selected_items(): + iter = self._playlist_grid_model.get_iter(path) + hash = self._playlist_grid_model.get_value(iter, 2) + self._selected_albums.append(self._playlist_albums[hash]) + + + @Gtk.Template.Callback() + def on_selection_cancel_clicked(self, widget): + self._toolbar.exit_selection() + + + @Gtk.Template.Callback() + def on_selection_remove_clicked(self, widget): + self.emit('remove-multiple-albums', self._selected_albums) + self._toolbar.exit_selection() + + + @Gtk.Template.Callback() + def on_standalone_scroll_size_allocate(self, widget, allocation): + self._resize_standalone_image() + + + def on_headerbar_close_clicked(self, widget): + self._close_standalone() + + + @Gtk.Template.Callback() + def on_standalone_remove_clicked(self, widget): + self.emit('remove-album', self._selected_albums[0]) + self._close_standalone() + + + @Gtk.Template.Callback() + def on_standalone_play_clicked(self, widget): + self.emit('play', self._selected_albums[0]) + self._close_standalone() + + + def set_item_size(self, item_size): + if self._item_size != item_size: + self._item_size = item_size + self._redraw() + + + def get_item_size(self): + return self._item_size + + + def set_playlist(self, host, playlist): + self._host = host + self._playlist_stop.set() + threading.Thread(target=self._set_playlist, args=(host, playlist, self._item_size,)).start() + + + def set_albumart(self, album, data): + if album in self._selected_albums: + if data: + # Load image and draw it + try: + self._standalone_pixbuf = Utils.load_pixbuf(data) + except Exception as e: + self._logger.exception("Failed to set albumart") + self._cover_pixbuf = self._get_default_image() + else: + self._cover_pixbuf = self._get_default_image() + # Show image + self._resize_standalone_image() + self.standalone_stack.set_visible_child(self.standalone_scroll) + self.standalone_spinner.stop() + + + def stop_threads(self): + self._playlist_stop.set() + + + def _set_playlist(self, host, playlist, size): + if not self._is_selected and self._playlist != playlist: + GObject.idle_add( + self.get_parent().child_set_property, + self, + 'needs-attention', + True + ) + self._playlist_lock.acquire() + self._playlist_stop.clear() + self._playlist = playlist + self._playlist_albums = {} + for album in playlist: + self._playlist_albums[album.get_id()] = album + self.playlist_grid.set_model(None) + self.playlist_grid.freeze_child_notify() + self._playlist_grid_model.clear() + GObject.idle_add(self.playlist_grid.set_item_padding, size / 100) + + cache = client.MCGCache(host, size) + for album in playlist: + pixbuf = None + # Load albumart thumbnail + try: + pixbuf = Utils.load_thumbnail(cache, self._client, album, size) + except client.CommandException: + # Exception is handled by client + pass + except Exception: + self._logger.exception("Failed to load albumart") + if pixbuf is None: + pixbuf = self._icon_theme.load_icon( + Utils.STOCK_ICON_DEFAULT, + self._item_size, + Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE + ) + if pixbuf is not None: + self._playlist_grid_model.append([ + pixbuf, + GObject.markup_escape_text("\n".join([ + album.get_title(), + ', '.join(album.get_dates()), + Utils.create_artists_label(album), + Utils.create_length_label(album) + ])), + album.get_id() + ]) + + if self._playlist_stop.is_set(): + self._playlist_lock.release() + return + + self.playlist_grid.set_model(self._playlist_grid_model) + self.playlist_grid.thaw_child_notify() + # TODO why set_columns()? + #self.playlist_grid.set_columns(len(playlist)) + self._playlist_lock.release() + + + def _redraw(self): + if self._playlist is not None: + self.set_playlist(self._host, self._playlist) + + + def _open_standalone(self): + self.set_visible_child(self.panel_standalone) + self.emit('open-standalone') + + + def _close_standalone(self): + self.set_visible_child(self.get_children()[0]) + self.emit('close-standalone') + + + def _resize_standalone_image(self): + """Diese Methode skaliert das geladene Bild aus dem Pixelpuffer + auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse + """ + pixbuf = self._standalone_pixbuf + size = self.standalone_scroll.get_allocation() + # Check pixelbuffer + if pixbuf is None: + return + + # Skalierungswert für Breite und Höhe ermitteln + ratioW = float(size.width) / float(pixbuf.get_width()) + ratioH = float(size.height) / float(pixbuf.get_height()) + # Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren + ratio = min(ratioW, ratioH) + ratio = min(ratio, 1) + # Neue Breite und Höhe berechnen + width = int(math.floor(pixbuf.get_width()*ratio)) + height = int(math.floor(pixbuf.get_height()*ratio)) + if width <= 0 or height <= 0: + return + # Pixelpuffer auf Oberfläche zeichnen + self.standalone_image.set_allocation(self.standalone_scroll.get_allocation()) + self.standalone_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER)) + self.standalone_image.show() + + + def _get_default_image(self): + return self._icon_theme.load_icon( + Utils.STOCK_ICON_DEFAULT, + 512, + Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE + ) + + + + diff --git a/mcg/serverpanel.py b/mcg/serverpanel.py new file mode 100644 index 0000000..0876dc3 --- /dev/null +++ b/mcg/serverpanel.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk, GObject + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/server-toolbar.ui') +class ServerToolbar(Gtk.ButtonBox): + __gtype_name__ = 'McgServerToolbar' + + + def __init__(self): + super().__init__() + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/server-panel.ui') +class ServerPanel(Gtk.Box): + __gtype_name__ = 'McgServerPanel' + __gsignals__ = { + 'change-output-device': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,bool,)), + } + + # Widgets + # Status widgets + status_file = Gtk.Template.Child() + status_audio = Gtk.Template.Child() + status_bitrate = Gtk.Template.Child() + status_error = Gtk.Template.Child() + # Stats widgets + stats_artists = Gtk.Template.Child() + stats_albums = Gtk.Template.Child() + stats_songs = Gtk.Template.Child() + stats_dbplaytime = Gtk.Template.Child() + stats_playtime = Gtk.Template.Child() + stats_uptime = Gtk.Template.Child() + # Audio ouptut devices widgets + output_devices = Gtk.Template.Child() + + + def __init__(self): + super().__init__() + self._none_label = "" + self._output_buttons = {} + self._is_selected = False + + # Widgets + self._toolbar = ServerToolbar() + self._none_label = self.status_file.get_label() + + + def set_selected(self, selected): + self._is_selected = selected + + + def get_toolbar(self): + return self._toolbar + + + def on_output_device_toggled(self, widget, device): + self.emit('change-output-device', device, widget.get_active()) + + + def set_status(self, file, audio, bitrate, error): + if file: + file = GObject.markup_escape_text(file) + else: + file = self._none_label + self.status_file.set_markup(file) + # Audio information + if audio: + parts = audio.split(":") + if len(parts) == 3: + audio = "{} Hz, {} bit, {} channels".format(parts[0], parts[1], parts[2]) + else: + audio = self._none_label + self.status_audio.set_markup(audio) + # Bitrate + if bitrate: + bitrate = bitrate + " kb/s" + else: + bitrate = self._none_label + self.status_bitrate.set_markup(bitrate) + # Error + if error: + error = GObject.markup_escape_text(error) + else: + error = self._none_label + self.status_error.set_markup(error) + + + def set_stats(self, artists, albums, songs, dbplaytime, playtime, uptime): + self.stats_artists.set_text(str(artists)) + self.stats_albums.set_text(str(albums)) + self.stats_songs.set_text(str(songs)) + self.stats_dbplaytime.set_text(str(dbplaytime)) + self.stats_playtime.set_text(str(playtime)) + self.stats_uptime.set_text(str(uptime)) + + + def set_output_devices(self, devices): + device_ids = [] + + # Add devices + for device in devices: + device_ids.append(device.get_id()) + if device.get_id() in self._output_buttons.keys(): + self._output_buttons[device.get_id()].freeze_notify() + self._output_buttons[device.get_id()].set_active(device.is_enabled()) + self._output_buttons[device.get_id()].thaw_notify() + else: + button = Gtk.CheckButton(device.get_name()) + if device.is_enabled(): + button.set_active(True) + handler = button.connect('toggled', self.on_output_device_toggled, device) + self.output_devices.insert(button, -1) + self._output_buttons[device.get_id()] = button + self.output_devices.show_all() + + # Remove devices + for id in self._output_buttons.keys(): + if id not in device_ids: + self.output_devices.remove(self._output_buttons[id].get_parent()) diff --git a/mcg/shortcutsdialog.py b/mcg/shortcutsdialog.py new file mode 100644 index 0000000..197400c --- /dev/null +++ b/mcg/shortcutsdialog.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/shortcuts-dialog.ui') +class ShortcutsDialog(Gtk.ShortcutsWindow): + __gtype_name__ = 'McgShortcutsDialog' + + + def __init__(self): + super().__init__() diff --git a/mcg/utils.py b/mcg/utils.py index 0cc53ec..12a3ce4 100644 --- a/mcg/utils.py +++ b/mcg/utils.py @@ -14,6 +14,8 @@ from gi.repository import GdkPixbuf class Utils: + CSS_SELECTION = 'selection' + STOCK_ICON_DEFAULT = 'image-x-generic-symbolic' def load_pixbuf(data): diff --git a/mcg/widgets.py b/mcg/widgets.py deleted file mode 100644 index a0197b5..0000000 --- a/mcg/widgets.py +++ /dev/null @@ -1,2150 +0,0 @@ -#!/usr/bin/env python3 - - -import gi -gi.require_version('Gtk', '3.0') -try: - import keyring - use_keyring = True -except: - use_keyring = False -import gettext -import logging -import math -import sys -import threading - -from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib, Gio - -from mcg import client -from mcg.utils import SortOrder -from mcg.utils import Utils -from mcg.zeroconf import ZeroconfProvider - - - - -class ShortcutsDialog(): - - - def __init__(self, builder, window): - # Widgets - self._window = builder.get_object('shortcuts-dialog') - self._window.set_transient_for(window.get()) - - - def get(self): - return self._window - - - def present(self): - self._window.present() - - - - -class InfoDialog(): - - - def __init__(self, builder): - self._logger = logging.getLogger(__name__) - - # Widgets - self._info_dialog = builder.get_object('info-dialog') - self._resize_logo() - - - def get(self): - return self._info_dialog - - - def run(self): - self._info_dialog.run() - self._info_dialog.hide() - - - def _resize_logo(self): - try: - logo_pixbuf = self._info_dialog.get_logo() - self._info_dialog.set_logo( - logo_pixbuf.scale_simple(256, 256, GdkPixbuf.InterpType.HYPER) - ) - except: - self._logger.warn("Failed to resize logo") - - - - -class WindowState(GObject.Object): - WIDTH = 'width' - HEIGHT = 'height' - IS_MAXIMIZED = 'is_maximized' - IS_FULLSCREENED = 'is_fullscreened' - width = GObject.Property(type=int, default=800) - height = GObject.Property(type=int, default=600) - is_maximized = GObject.Property(type=bool, default=False) - is_fullscreened = GObject.Property(type=bool, default=False) - - - def __init__(self): - GObject.Object.__init__(self) - - - - -class Window(): - SETTING_HOST = 'host' - SETTING_PORT = 'port' - SETTING_CONNECTED = 'connected' - SETTING_WINDOW_WIDTH = 'width' - SETTING_WINDOW_HEIGHT = 'height' - SETTING_WINDOW_MAXIMIZED = 'is-maximized' - SETTING_PANEL = 'panel' - SETTING_ITEM_SIZE = 'item-size' - SETTING_SORT_ORDER = 'sort-order' - SETTING_SORT_TYPE = 'sort-type' - STOCK_ICON_DEFAULT = 'image-x-generic-symbolic' - _PANEL_INDEX_SERVER = 0 - _PANEL_INDEX_COVER = 1 - _PANEL_INDEX_PLAYLIST = 2 - _PANEL_INDEX_LIBRARY = 3 - _CSS_SELECTION = 'selection' - _CUSTOM_STARTUP_COMPLETE = 'startup-complete' - - - def __init__(self, app, builder, title, settings): - self._appwindow = builder.get_object('appwindow') - self._appwindow.set_application(app) - self._appwindow.set_title(title) - self._settings = settings - self._panels = [] - self._mcg = client.Client() - self._logger = logging.getLogger(__name__) - self._state = WindowState() - - # Login screen - self._connection_panel = ConnectionPanel(builder) - # Panels - self._panels.append(ServerPanel(builder)) - self._panels.append(CoverPanel(builder)) - self._panels.append(PlaylistPanel(builder, self._mcg)) - self._panels.append(LibraryPanel(builder, self._mcg)) - - # Widgets - # InfoBar - self._infobar = InfoBar(builder) - # Stack - self._content_stack = builder.get_object('contentstack') - self._stack = builder.get_object('panelstack') - # Header - self._header_bar = HeaderBar(builder) - # Toolbar stack - self._toolbar_stack = builder.get_object('toolbarstack') - - # Properties - self._header_bar.set_sensitive(False, False) - self._connection_panel.set_host(self._settings.get_string(Window.SETTING_HOST)) - self._connection_panel.set_port(self._settings.get_int(Window.SETTING_PORT)) - if use_keyring: - self._connection_panel.set_password(keyring.get_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME)) - self._panels[Window._PANEL_INDEX_PLAYLIST].set_item_size(self._settings.get_int(Window.SETTING_ITEM_SIZE)) - self._panels[Window._PANEL_INDEX_LIBRARY].set_item_size(self._settings.get_int(Window.SETTING_ITEM_SIZE)) - self._panels[Window._PANEL_INDEX_LIBRARY].set_sort_order(self._settings.get_enum(Window.SETTING_SORT_ORDER)) - self._panels[Window._PANEL_INDEX_LIBRARY].set_sort_type(self._settings.get_boolean(Window.SETTING_SORT_TYPE)) - - # Signals - self._header_bar.connect('stack-switched', self.on_header_bar_stack_switched) - self._header_bar.connect('toolbar-connect', self.on_header_bar_connect) - self._header_bar.connect('toolbar-playpause', self.on_header_bar_playpause) - self._header_bar.connect('toolbar-set-volume', self.on_header_bar_set_volume) - self._connection_panel.connect('connection-changed', self.on_connection_panel_connection_changed) - self._panels[Window._PANEL_INDEX_SERVER].connect('change-output-device', self.on_server_panel_output_device_changed) - self._panels[Window._PANEL_INDEX_COVER].connect('toggle-fullscreen', self.on_cover_panel_toggle_fullscreen) - self._panels[Window._PANEL_INDEX_COVER].connect('set-song', self.on_cover_panel_set_song) - self._panels[Window._PANEL_INDEX_COVER].connect('albumart', self.on_cover_panel_albumart) - self._panels[Window._PANEL_INDEX_PLAYLIST].connect('clear-playlist', self.on_playlist_panel_clear_playlist) - self._panels[Window._PANEL_INDEX_PLAYLIST].connect('remove', self.on_playlist_panel_remove) - self._panels[Window._PANEL_INDEX_PLAYLIST].connect('remove-multiple', self.on_playlist_panel_remove_multiple) - self._panels[Window._PANEL_INDEX_PLAYLIST].connect('play', self.on_playlist_panel_play) - self._panels[Window._PANEL_INDEX_PLAYLIST].connect('albumart', self.on_playlist_panel_albumart) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('update', self.on_library_panel_update) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('play', self.on_library_panel_play) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('queue', self.on_library_panel_queue) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('queue-multiple', self.on_library_panel_queue_multiple) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('item-size-changed', self.on_library_panel_item_size_changed) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('sort-order-changed', self.on_library_panel_sort_order_changed) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('sort-type-changed', self.on_library_panel_sort_type_changed) - self._panels[Window._PANEL_INDEX_LIBRARY].connect('albumart', self.on_library_panel_albumart) - self._mcg.connect_signal(client.Client.SIGNAL_CONNECTION, self.on_mcg_connect) - self._mcg.connect_signal(client.Client.SIGNAL_STATUS, self.on_mcg_status) - self._mcg.connect_signal(client.Client.SIGNAL_STATS, self.on_mcg_stats) - self._mcg.connect_signal(client.Client.SIGNAL_LOAD_OUTPUT_DEVICES, self.on_mcg_load_output_devices) - self._mcg.connect_signal(client.Client.SIGNAL_LOAD_PLAYLIST, self.on_mcg_load_playlist) - self._mcg.connect_signal(client.Client.SIGNAL_PULSE_ALBUMS, self.on_mcg_pulse_albums) - self._mcg.connect_signal(client.Client.SIGNAL_INIT_ALBUMS, self.on_mcg_init_albums) - self._mcg.connect_signal(client.Client.SIGNAL_LOAD_ALBUMS, self.on_mcg_load_albums) - self._mcg.connect_signal(client.Client.SIGNAL_LOAD_ALBUMART, self.on_mcg_load_albumart) - self._mcg.connect_signal(client.Client.SIGNAL_CUSTOM, self.on_mcg_custom) - self._mcg.connect_signal(client.Client.SIGNAL_ERROR, self.on_mcg_error) - self._settings.connect('changed::'+Window.SETTING_PANEL, self.on_settings_panel_changed) - self._settings.connect('changed::'+Window.SETTING_ITEM_SIZE, self.on_settings_item_size_changed) - self._settings.connect('changed::'+Window.SETTING_SORT_ORDER, self.on_settings_sort_order_changed) - self._settings.connect('changed::'+Window.SETTING_SORT_TYPE, self.on_settings_sort_type_changed) - self._settings.bind(Window.SETTING_WINDOW_WIDTH, self._state, WindowState.WIDTH, Gio.SettingsBindFlags.DEFAULT) - self._settings.bind(Window.SETTING_WINDOW_HEIGHT, self._state, WindowState.HEIGHT, Gio.SettingsBindFlags.DEFAULT) - self._settings.bind(Window.SETTING_WINDOW_MAXIMIZED, self._state, WindowState.IS_MAXIMIZED, Gio.SettingsBindFlags.DEFAULT) - - handlers = { - 'on_appwindow_size_allocate': self.on_resize, - 'on_appwindow_window_state_event': self.on_state - } - handlers.update(self._header_bar.get_signal_handlers()) - handlers.update(self._infobar.get_signal_handlers()) - handlers.update(self._connection_panel.get_signal_handlers()) - handlers.update(self._panels[Window._PANEL_INDEX_COVER].get_signal_handlers()) - handlers.update(self._panels[Window._PANEL_INDEX_PLAYLIST].get_signal_handlers()) - handlers.update(self._panels[Window._PANEL_INDEX_LIBRARY].get_signal_handlers()) - builder.connect_signals(handlers) - - # Actions - self._appwindow.set_default_size(self._state.width, self._state.height) - if self._state.get_property(WindowState.IS_MAXIMIZED): - self._appwindow.maximize() - self._appwindow.show_all() - self._content_stack.set_visible_child(self._connection_panel.get()) - if self._settings.get_boolean(Window.SETTING_CONNECTED): - self._connect() - - # Menu actions - self._connect_action = Gio.SimpleAction.new_stateful("connect", None, GLib.Variant.new_boolean(False)) - self._connect_action.connect('change-state', self.on_menu_connect) - self._appwindow.add_action(self._connect_action) - self._play_action = Gio.SimpleAction.new_stateful("play", None, GLib.Variant.new_boolean(False)) - self._play_action.set_enabled(False) - self._play_action.connect('change-state', self.on_menu_play) - self._appwindow.add_action(self._play_action) - self._clear_playlist_action = Gio.SimpleAction.new("clear-playlist", None) - self._clear_playlist_action.set_enabled(False) - self._clear_playlist_action.connect('activate', self.on_menu_clear_playlist) - self._appwindow.add_action(self._clear_playlist_action) - panel_variant = GLib.Variant.new_string("0") - self._panel_action = Gio.SimpleAction.new_stateful("panel", panel_variant.get_type(), panel_variant) - self._panel_action.set_enabled(False) - self._panel_action.connect('change-state', self.on_menu_panel) - self._appwindow.add_action(self._panel_action) - self._toggle_fullscreen_action = Gio.SimpleAction.new("toggle-fullscreen", None) - self._toggle_fullscreen_action.set_enabled(True) - self._toggle_fullscreen_action.connect('activate', self.on_menu_toggle_fullscreen) - self._appwindow.add_action(self._toggle_fullscreen_action) - - - def get(self): - return self._appwindow - - - def present(self): - self._appwindow.present() - - - def on_resize(self, widget, event): - if not self._state.get_property(WindowState.IS_MAXIMIZED): - size = self._appwindow.get_size() - self._state.set_property(WindowState.WIDTH, size.width) - self._state.set_property(WindowState.HEIGHT, size.height) - - - def on_state(self, widget, state): - self._state.set_property(WindowState.IS_MAXIMIZED, (state.new_window_state & Gdk.WindowState.MAXIMIZED > 0)) - self._fullscreen((state.new_window_state & Gdk.WindowState.FULLSCREEN > 0)) - - - def on_menu_connect(self, action, value): - self._connect() - - - def on_menu_play(self, action, value): - self._mcg.playpause() - - - def on_menu_clear_playlist(self, action, value): - self._mcg.clear_playlist() - - - def on_menu_panel(self, action, value): - action.set_state(value) - self._stack.set_visible_child(self._panels[int(value.get_string())].get()) - - - def on_menu_toggle_fullscreen(self, action, value): - self._stack.set_visible_child(self._panels[Window._PANEL_INDEX_COVER].get()) - if not self._state.get_property(WindowState.IS_FULLSCREENED): - self._appwindow.fullscreen() - else: - self._appwindow.unfullscreen() - - - # HeaderBar callbacks - - def on_header_bar_stack_switched(self, widget): - self._set_visible_toolbar() - self._save_visible_panel() - self._set_menu_visible_panel() - for panel in self._panels: - panel.set_selected(panel.get() == self._stack.get_visible_child()) - GObject.idle_add( - self._stack.child_set_property, - self._stack.get_visible_child(), - 'needs-attention', - False - ) - - - def on_header_bar_connect(self, widget): - self._connect() - - - def on_header_bar_playpause(self, widget): - self._mcg.playpause() - self._mcg.get_status() - - - def on_header_bar_set_volume(self, widget, volume): - self._mcg.set_volume(volume) - - - # Panel callbacks - - def on_connection_panel_connection_changed(self, widget, host, port, password): - self._settings.set_string(Window.SETTING_HOST, host) - self._settings.set_int(Window.SETTING_PORT, port) - if use_keyring: - if password: - keyring.set_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME, password) - else: - if keyring.get_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME): - keyring.delete_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME) - - - def on_playlist_panel_clear_playlist(self, widget): - self._mcg.clear_playlist() - - - def on_playlist_panel_remove(self, widget, album): - self._mcg.remove_album_from_playlist(album) - - - def on_playlist_panel_remove_multiple(self, widget, albums): - self._mcg.remove_albums_from_playlist(albums) - - - def on_playlist_panel_play(self, widget, album): - self._mcg.play_album_from_playlist(album) - - - def on_playlist_panel_albumart(self, widget, album): - self._mcg.get_albumart(album) - - - def on_server_panel_output_device_changed(self, widget, device, enabled): - self._mcg.enable_output_device(device, enabled) - - - def on_cover_panel_toggle_fullscreen(self, widget): - if not self._state.get_property(WindowState.IS_FULLSCREENED): - self._appwindow.fullscreen() - else: - self._appwindow.unfullscreen() - - - def on_cover_panel_set_song(self, widget, pos, time): - self._mcg.seek(pos, time) - - - def on_cover_panel_albumart(self, widget, album): - self._mcg.get_albumart(album) - - - def on_library_panel_update(self, widget): - self._mcg.update() - - - def on_library_panel_play(self, widget, album): - self._mcg.play_album(album) - - - def on_library_panel_queue(self, widget, album): - self._mcg.queue_album(album) - - - def on_library_panel_queue_multiple(self, widget, albums): - self._mcg.queue_albums(albums) - - - def on_library_panel_item_size_changed(self, widget, size): - self._panels[Window._PANEL_INDEX_PLAYLIST].set_item_size(size) - self._settings.set_int(Window.SETTING_ITEM_SIZE, self._panels[Window._PANEL_INDEX_LIBRARY].get_item_size()) - - - def on_library_panel_sort_order_changed(self, widget, sort_order): - self._settings.set_enum(Window.SETTING_SORT_ORDER, self._panels[Window._PANEL_INDEX_LIBRARY].get_sort_order()) - - - def on_library_panel_sort_type_changed(self, widget, sort_type): - self._settings.set_boolean(Window.SETTING_SORT_TYPE, self._panels[Window._PANEL_INDEX_LIBRARY].get_sort_type()) - - - def on_library_panel_albumart(self, widget, album): - self._mcg.get_albumart(album) - - - # MCG callbacks - - def on_mcg_connect(self, connected): - if connected: - GObject.idle_add(self._connect_connected) - self._mcg.load_playlist() - self._mcg.load_albums() - self._mcg.get_custom(Window._CUSTOM_STARTUP_COMPLETE) - self._mcg.get_status() - self._mcg.get_stats() - self._mcg.get_output_devices() - self._connect_action.set_state(GLib.Variant.new_boolean(True)) - self._play_action.set_enabled(True) - self._clear_playlist_action.set_enabled(True) - self._panel_action.set_enabled(True) - else: - GObject.idle_add(self._connect_disconnected) - self._connect_action.set_state(GLib.Variant.new_boolean(False)) - self._play_action.set_enabled(False) - self._clear_playlist_action.set_enabled(False) - self._panel_action.set_enabled(False) - - - def on_mcg_status(self, state, album, pos, time, volume, file, audio, bitrate, error): - # Album - GObject.idle_add(self._panels[Window._PANEL_INDEX_COVER].set_album, album) - if not album and self._state.get_property(WindowState.IS_FULLSCREENED): - self._fullscreen(False) - # State - if state == 'play': - GObject.idle_add(self._header_bar.set_play) - GObject.idle_add(self._panels[Window._PANEL_INDEX_COVER].set_play, pos, time) - self._play_action.set_state(GLib.Variant.new_boolean(True)) - elif state == 'pause' or state == 'stop': - GObject.idle_add(self._header_bar.set_pause) - GObject.idle_add(self._panels[Window._PANEL_INDEX_COVER].set_pause) - self._play_action.set_state(GLib.Variant.new_boolean(False)) - # Volume - GObject.idle_add(self._header_bar.set_volume, volume) - # Status - self._panels[Window._PANEL_INDEX_SERVER].set_status(file, audio, bitrate, error) - # Error - if error is None: - self._infobar.hide() - else: - self._show_error(error) - - - def on_mcg_stats(self, artists, albums, songs, dbplaytime, playtime, uptime): - self._panels[Window._PANEL_INDEX_SERVER].set_stats(artists, albums, songs, dbplaytime, playtime, uptime) - - - def on_mcg_load_output_devices(self, devices): - self._panels[Window._PANEL_INDEX_SERVER].set_output_devices(devices) - - - def on_mcg_load_playlist(self, playlist): - self._panels[self._PANEL_INDEX_PLAYLIST].set_playlist(self._connection_panel.get_host(), playlist) - - - def on_mcg_init_albums(self): - GObject.idle_add(self._panels[self._PANEL_INDEX_LIBRARY].init_albums) - - - def on_mcg_pulse_albums(self): - GObject.idle_add(self._panels[self._PANEL_INDEX_LIBRARY].load_albums) - - - def on_mcg_load_albums(self, albums): - self._panels[self._PANEL_INDEX_LIBRARY].set_albums(self._connection_panel.get_host(), albums) - - - def on_mcg_load_albumart(self, album, data): - GObject.idle_add(self._panels[self._PANEL_INDEX_COVER].set_albumart, album, data) - GObject.idle_add(self._panels[self._PANEL_INDEX_PLAYLIST].set_albumart, album, data) - GObject.idle_add(self._panels[self._PANEL_INDEX_LIBRARY].set_albumart, album, data) - - - def on_mcg_custom(self, name): - if name == Window._CUSTOM_STARTUP_COMPLETE: - for panel in self._panels: - GObject.idle_add( - self._stack.child_set_property, - panel.get(), - 'needs-attention', - False - ) - - - def on_mcg_error(self, error): - GObject.idle_add(self._show_error, str(error)) - - - # Settings callbacks - - def on_settings_panel_changed(self, settings, key): - panel_index = settings.get_int(key) - self._stack.set_visible_child(self._panels[panel_index].get()) - - - def on_settings_item_size_changed(self, settings, key): - size = settings.get_int(key) - self._panels[Window._PANEL_INDEX_PLAYLIST].set_item_size(size) - self._panels[Window._PANEL_INDEX_LIBRARY].set_item_size(size) - - - def on_settings_sort_order_changed(self, settings, key): - sort_order = settings.get_enum(key) - self._panels[Window._PANEL_INDEX_LIBRARY].set_sort_order(sort_order) - - - def on_settings_sort_type_changed(self, settings, key): - sort_type = settings.get_boolean(key) - self._panels[Window._PANEL_INDEX_LIBRARY].set_sort_type(sort_type) - - - # Private methods - - def _connect(self): - self._connection_panel.get().set_sensitive(False) - self._header_bar.set_sensitive(False, True) - if self._mcg.is_connected(): - self._mcg.disconnect() - self._settings.set_boolean(Window.SETTING_CONNECTED, False) - else: - host = self._connection_panel.get_host() - port = self._connection_panel.get_port() - password = self._connection_panel.get_password() - self._mcg.connect(host, port, password) - self._settings.set_boolean(Window.SETTING_CONNECTED, True) - - - def _connect_connected(self): - self._header_bar.connected() - self._header_bar.set_sensitive(True, False) - self._content_stack.set_visible_child(self._stack) - self._stack.set_visible_child(self._panels[self._settings.get_int(Window.SETTING_PANEL)].get()) - - - def _connect_disconnected(self): - self._panels[Window._PANEL_INDEX_PLAYLIST].stop_threads(); - self._panels[Window._PANEL_INDEX_LIBRARY].stop_threads(); - self._header_bar.disconnected() - self._header_bar.set_sensitive(False, False) - self._save_visible_panel() - self._content_stack.set_visible_child(self._connection_panel.get()) - self._connection_panel.get().set_sensitive(True) - - - def _fullscreen(self, fullscreened_new): - if fullscreened_new != self._state.get_property(WindowState.IS_FULLSCREENED): - self._state.set_property(WindowState.IS_FULLSCREENED, fullscreened_new) - if self._state.get_property(WindowState.IS_FULLSCREENED): - self._header_bar.get().hide() - self._panels[Window._PANEL_INDEX_COVER].set_fullscreen(True) - else: - self._header_bar.get().show() - self._panels[Window._PANEL_INDEX_COVER].set_fullscreen(False) - - - def _save_visible_panel(self): - panels = [panel.get() for panel in self._panels] - panel_index_selected = panels.index(self._stack.get_visible_child()) - self._settings.set_int(Window.SETTING_PANEL, panel_index_selected) - - - def _set_menu_visible_panel(self): - panels = [panel.get() for panel in self._panels] - panel_index_selected = panels.index(self._stack.get_visible_child()) - self._panel_action.set_state(GLib.Variant.new_string(str(panel_index_selected))) - - - def _set_visible_toolbar(self): - panels = [panel.get() for panel in self._panels] - panel_index_selected = panels.index(self._stack.get_visible_child()) - toolbar = self._panels[panel_index_selected].get_toolbar() - self._toolbar_stack.set_visible_child(toolbar) - - - def _show_error(self, message): - self._infobar.show_error(message) - - - - -class HeaderBar(GObject.GObject): - __gsignals__ = { - 'stack-switched': (GObject.SIGNAL_RUN_FIRST, None, ()), - 'toolbar-connect': (GObject.SIGNAL_RUN_FIRST, None, ()), - 'toolbar-playpause': (GObject.SIGNAL_RUN_FIRST, None, ()), - 'toolbar-set-volume': (GObject.SIGNAL_RUN_FIRST, None, (int,)) - } - - - def __init__(self, builder): - GObject.GObject.__init__(self) - self._changing_volume = False - self._setting_volume = False - - # Widgets - self._header_bar = builder.get_object('headerbar') - self._title_stack = builder.get_object('headerbar-title-stack') - self._connection_label = builder.get_object('headerbar-connectionn-label') - self._stack_switcher = StackSwitcher(builder) - self._button_connect = builder.get_object('headerbar-connection') - self._button_playpause = builder.get_object('headerbar-playpause') - self._button_volume = builder.get_object('headerbar-volume') - - # Signals - self._stack_switcher.connect('stack-switched', self.on_stack_switched) - self._button_handlers = { - 'on_headerbar-connection_active_notify': self.on_connection_active_notify, - 'on_headerbar-connection_state_set': self.on_connection_state_set, - 'on_headerbar-playpause_toggled': self.on_playpause_toggled, - 'on_headerbar-volume_value_changed': self.on_volume_changed, - 'on_headerbar-volume_button_press_event': self.on_volume_press, - 'on_headerbar-volume_button_release_event': self.on_volume_release - } - - - def get(self): - return self._header_bar - - - def get_signal_handlers(self): - return self._button_handlers - - - def set_sensitive(self, sensitive, connecting): - self._button_playpause.set_sensitive(sensitive) - self._button_volume.set_sensitive(sensitive) - self._stack_switcher.get().set_sensitive(sensitive) - self._button_connect.set_sensitive(not connecting) - - - def on_connection_active_notify(self, widget, status): - self.emit('toolbar-connect') - - - def on_connection_state_set(self, widget, state): - return True - - - def on_playpause_toggled(self, widget): - self.emit('toolbar-playpause') - - - def on_stack_switched(self, widget): - self.emit('stack-switched') - - - def on_volume_changed(self, widget, value): - if not self._setting_volume: - self.emit('toolbar-set-volume', int(value*100)) - - - def on_volume_press(self, *args): - self.volume_set_active(None, None, True) - - - def on_volume_release(self, *args): - self.volume_set_active(None, None, False) - - - def volume_set_active(self, widget, event, active): - self._changing_volume = active - - - def connected(self): - self._button_connect.handler_block_by_func( - self.on_connection_active_notify - ) - self._button_connect.set_active(True) - self._button_connect.set_state(True) - self._button_connect.handler_unblock_by_func( - self.on_connection_active_notify - ) - self._title_stack.set_visible_child(self._stack_switcher.get()) - - - def disconnected(self): - self._button_connect.handler_block_by_func( - self.on_connection_active_notify - ) - self._button_connect.set_active(False) - self._button_connect.set_state(False) - self._button_connect.handler_unblock_by_func( - self.on_connection_active_notify - ) - self._title_stack.set_visible_child(self._connection_label) - - - def set_play(self): - self._button_playpause.handler_block_by_func( - self.on_playpause_toggled - ) - self._button_playpause.set_active(True) - self._button_playpause.handler_unblock_by_func( - self.on_playpause_toggled - ) - - - def set_pause(self): - self._button_playpause.handler_block_by_func( - self.on_playpause_toggled - ) - self._button_playpause.set_active(False) - self._button_playpause.handler_unblock_by_func( - self.on_playpause_toggled - ) - - - def set_volume(self, volume): - if volume >= 0: - self._button_volume.set_visible(True) - if not self._changing_volume: - self._setting_volume = True - self._button_volume.set_value(volume / 100) - self._setting_volume = False - else: - self._button_volume.set_visible(False) - - - - -class InfoBar(): - def __init__(self, builder): - # Widgets - self._revealer = builder.get_object('server-info-revealer') - self._bar = builder.get_object('server-info-bar') - self._message_label = builder.get_object('server-info-label') - - - def get_signal_handlers(self): - return { - 'on_server-info-bar_close': self.on_close, - 'on_server-info-bar_response': self.on_response - } - - - def on_close(self, *args): - self.hide() - - - def on_response(self, widget, response): - self.hide() - - - def hide(self): - self._revealer.set_reveal_child(False) - - - def show_error(self, message): - self._bar.set_message_type(Gtk.MessageType.ERROR) - self._message_label.set_text(message) - self._revealer.set_reveal_child(True) - - - - -class ConnectionPanel(GObject.GObject): - __gsignals__ = { - 'connection-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, int, str)) - } - - - def __init__(self, builder): - GObject.GObject.__init__(self) - self._services = Gtk.ListStore(str, str, int) - self._profile = None - - # Widgets - self._panel = builder.get_object('connection-panel') - # Zeroconf - self._zeroconf_list = builder.get_object('server-zeroconf-list') - self._zeroconf_list.set_model(self._services) - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn("Zeroconf", renderer, text=0) - self._zeroconf_list.append_column(column) - # Host - self._host_entry = builder.get_object('server-host') - # Port - self._port_spinner = builder.get_object('server-port') - # Passwort - self._password_entry = builder.get_object('server-password') - - # Zeroconf provider - self._zeroconf_provider = ZeroconfProvider() - self._zeroconf_provider.connect_signal(ZeroconfProvider.SIGNAL_SERVICE_NEW, self.on_new_service) - - - def get(self): - return self._panel - - - def get_signal_handlers(self): - return { - 'on_server-zeroconf-list-selection_changed': self.on_service_selected, - 'on_server-zeroconf-list_focus_out_event': self.on_zeroconf_list_outfocused, - 'on_server-host_focus_out_event': self.on_host_entry_outfocused, - 'on_server-port_value_changed': self.on_port_spinner_value_changed, - 'on_server-password_focus_out_event': self.on_password_entry_outfocused - } - - - def on_new_service(self, service): - name, host, port = service - self._services.append([name, host, port]) - - - def on_service_selected(self, selection): - model, treeiter = selection.get_selected() - if treeiter != None: - service = model[treeiter] - self.set_host(service[1]) - self.set_port(service[2]) - - - def on_zeroconf_list_outfocused(self, widget, event): - self._zeroconf_list.get_selection().unselect_all() - - - def on_host_entry_outfocused(self, widget, event): - self._call_back() - - - def on_port_spinner_value_changed(self, widget): - self._call_back() - - - def on_password_entry_outfocused(self, widget, event): - self._call_back() - - - def set_host(self, host): - self._host_entry.set_text(host) - - - def get_host(self): - return self._host_entry.get_text() - - - def set_port(self, port): - self._port_spinner.set_value(port) - - - def get_port(self): - return self._port_spinner.get_value_as_int() - - - def set_password(self, password): - if password is None: - password = "" - self._password_entry.set_text(password) - - - def get_password(self): - if self._password_entry.get_text() == "": - return None - else: - return self._password_entry.get_text() - - - def _call_back(self): - self.emit('connection-changed', self.get_host(), self.get_port(), self.get_password(),) - - - - -class ServerPanel(GObject.GObject): - __gsignals__ = { - 'change-output-device': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,bool,)), - } - - - def __init__(self, builder): - GObject.GObject.__init__(self) - self._none_label = "" - self._output_buttons = {} - self._is_selected = False - - # Widgets - self._panel = builder.get_object('server-panel') - self._toolbar = builder.get_object('server-toolbar') - self._stack = builder.get_object('server-stack') - - # Status widgets - self._status_file = builder.get_object('server-status-file') - self._status_audio = builder.get_object('server-status-audio') - self._status_bitrate = builder.get_object('server-status-bitrate') - self._status_error = builder.get_object('server-status-error') - self._none_label = self._status_file.get_label() - - # Stats widgets - self._stats_artists = builder.get_object('server-stats-artists') - self._stats_albums = builder.get_object('server-stats-albums') - self._stats_songs = builder.get_object('server-stats-songs') - self._stats_dbplaytime = builder.get_object('server-stats-dbplaytime') - self._stats_playtime = builder.get_object('server-stats-playtime') - self._stats_uptime = builder.get_object('server-stats-uptime') - - # Audio ouptut devices widgets - self._output_devices = builder.get_object('server-output-devices') - - - def get(self): - return self._panel - - - def set_selected(self, selected): - self._is_selected = selected - - - def get_toolbar(self): - return self._toolbar - - - def on_output_device_toggled(self, widget, device): - self.emit('change-output-device', device, widget.get_active()) - - - def set_status(self, file, audio, bitrate, error): - if file: - file = GObject.markup_escape_text(file) - else: - file = self._none_label - self._status_file.set_markup(file) - # Audio information - if audio: - parts = audio.split(":") - if len(parts) == 3: - audio = "{} Hz, {} bit, {} channels".format(parts[0], parts[1], parts[2]) - else: - audio = self._none_label - self._status_audio.set_markup(audio) - # Bitrate - if bitrate: - bitrate = bitrate + " kb/s" - else: - bitrate = self._none_label - self._status_bitrate.set_markup(bitrate) - # Error - if error: - error = GObject.markup_escape_text(error) - else: - error = self._none_label - self._status_error.set_markup(error) - - - def set_stats(self, artists, albums, songs, dbplaytime, playtime, uptime): - self._stats_artists.set_text(str(artists)) - self._stats_albums.set_text(str(albums)) - self._stats_songs.set_text(str(songs)) - self._stats_dbplaytime.set_text(str(dbplaytime)) - self._stats_playtime.set_text(str(playtime)) - self._stats_uptime.set_text(str(uptime)) - - - def set_output_devices(self, devices): - device_ids = [] - - # Add devices - for device in devices: - device_ids.append(device.get_id()) - if device.get_id() in self._output_buttons.keys(): - self._output_buttons[device.get_id()].freeze_notify() - self._output_buttons[device.get_id()].set_active(device.is_enabled()) - self._output_buttons[device.get_id()].thaw_notify() - else: - button = Gtk.CheckButton(device.get_name()) - if device.is_enabled(): - button.set_active(True) - handler = button.connect('toggled', self.on_output_device_toggled, device) - self._output_devices.insert(button, -1) - self._output_buttons[device.get_id()] = button - self._output_devices.show_all() - - # Remove devices - for id in self._output_buttons.keys(): - if id not in device_ids: - self._output_devices.remove(self._output_buttons[id].get_parent()) - - - - - -class CoverPanel(GObject.GObject): - __gsignals__ = { - 'toggle-fullscreen': (GObject.SIGNAL_RUN_FIRST, None, ()), - 'set-song': (GObject.SIGNAL_RUN_FIRST, None, (int, int,)), - 'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,)) - } - - - def __init__(self, builder): - GObject.GObject.__init__(self) - - self._current_album = None - self._current_cover_album = None - self._cover_pixbuf = None - self._timer = None - self._properties = {} - self._icon_theme = Gtk.IconTheme.get_default() - self._fullscreened = False - self._is_selected = False - self._current_size = None - - # Widgets - self._appwindow = builder.get_object('appwindow') - self._panel = builder.get_object('cover-panel') - self._toolbar = builder.get_object('cover-toolbar') - # Toolbar menu - self._toolbar_fullscreen_button = builder.get_object('cover-toolbar-fullscreen') - # Cover - self._cover_stack = builder.get_object('cover-stack') - self._cover_spinner = builder.get_object('cover-spinner') - self._cover_scroll = builder.get_object('cover-scroll') - self._cover_box = builder.get_object('cover-box') - self._cover_image = builder.get_object('cover-image') - self._cover_stack.set_visible_child(self._cover_scroll) - self._cover_pixbuf = self._get_default_image() - # Album Infos - self._cover_info_scroll = builder.get_object('cover-info-scroll') - self._info_revealer = builder.get_object('cover-info-revealer') - self._info_box = builder.get_object('cover-info-box') - self._album_title_label = builder.get_object('cover-album') - self._album_date_label = builder.get_object('cover-date') - self._album_artist_label = builder.get_object('cover-artist') - # Songs - self._songs_scale = builder.get_object('cover-songs') - - # Initial actions - GObject.idle_add(self._enable_tracklist) - - - def get(self): - return self._panel - - - def set_selected(self, selected): - self._is_selected = selected - - - def get_toolbar(self): - return self._toolbar - - - def get_signal_handlers(self): - return { - 'on_cover-box_button_press_event': self.on_cover_box_pressed, - 'on_cover-scroll_size_allocate': self.on_cover_size_allocate, - 'on_cover-songs_button_press_event': self.on_songs_start_change, - 'on_cover-songs_button_release_event': self.on_songs_change - } - - - def on_cover_box_pressed(self, widget, event): - if self._current_album and event.type == Gdk.EventType._2BUTTON_PRESS: - self.emit('toggle-fullscreen') - - - def on_cover_size_allocate(self, widget, allocation): - GObject.idle_add(self._resize_image) - self._cover_info_scroll.set_max_content_width(allocation.width // 2) - - - def on_songs_start_change(self, widget, event): - if self._timer: - GObject.source_remove(self._timer) - self._timer = None - - - def on_songs_change(self, widget, event): - value = int(self._songs_scale.get_value()) - time = self._current_album.get_length() - tracks = self._current_album.get_tracks() - pos = 0 - for index in range(len(tracks)-1, -1, -1): - time = time - tracks[index].get_length() - pos = tracks[index].get_pos() - if time < value: - break - time = max(value - time - 1, 0) - self.emit('set-song', pos, time) - - - def set_album(self, album): - if album: - # Set labels - self._album_title_label.set_label(album.get_title()) - self._album_date_label.set_label(', '.join(album.get_dates())) - self._album_artist_label.set_label(', '.join(album.get_albumartists())) - - # Set tracks - self._set_tracks(album) - - # Load cover - if album != self._current_cover_album: - self._cover_stack.set_visible_child(self._cover_spinner) - self._cover_spinner.start() - self.emit('albumart', album.get_id() if album else None) - - # Set current album - self._current_album = album - self._enable_tracklist() - self._toolbar_fullscreen_button.set_sensitive(self._current_album is not None) - - - def set_play(self, pos, time): - if self._timer is not None: - GObject.source_remove(self._timer) - self._timer = None - tracks = self._current_album.get_tracks() - for index in range(0, pos): - time = time + tracks[index].get_length() - - self._songs_scale.set_value(time+1) - self._timer = GObject.timeout_add(1000, self._playing) - - - def set_pause(self): - if self._timer is not None: - GObject.source_remove(self._timer) - self._timer = None - - - def set_fullscreen(self, active): - if active: - self._info_revealer.set_reveal_child(False) - self._cover_box.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 1)) - GObject.idle_add(self._resize_image) - # Hide curser - self._appwindow.get_window().set_cursor( - Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none") - ) - self._fullscreened = True - else: - self._fullscreened = False - self._info_revealer.set_reveal_child(True) - self._cover_box.override_background_color(Gtk.StateFlags.NORMAL, Gdk.RGBA(0, 0, 0, 0)) - GObject.idle_add(self._resize_image) - # Reset cursor - self._appwindow.get_window().set_cursor( - Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "default") - ) - - - def set_albumart(self, album, data): - if album == self._current_album: - if data: - # Load image and draw it - try: - self._cover_pixbuf = Utils.load_pixbuf(data) - except Exception as e: - self._logger.exception("Failed to set albumart") - self._cover_pixbuf = self._get_default_image() - else: - # Reset image - self._cover_pixbuf = self._get_default_image() - self._current_size = None - # Show image - self._resize_image() - self._cover_stack.set_visible_child(self._cover_scroll) - self._cover_spinner.stop() - self._current_cover_album = album - - - def _set_tracks(self, album): - self._songs_scale.clear_marks() - self._songs_scale.set_range(0, album.get_length()) - length = 0 - for track in album.get_tracks(): - cur_length = length - if length > 0 and length < album.get_length(): - cur_length = cur_length + 1 - self._songs_scale.add_mark( - cur_length, - Gtk.PositionType.RIGHT, - GObject.markup_escape_text( - Utils.create_track_title(track) - ) - ) - length = length + track.get_length() - self._songs_scale.add_mark( - length, - Gtk.PositionType.RIGHT, - "{0[0]:02d}:{0[1]:02d} minutes".format(divmod(length, 60)) - ) - - - def _enable_tracklist(self): - if self._current_album: - # enable - self._info_revealer.set_reveal_child(True) - else: - # disable - self._info_revealer.set_reveal_child(False) - - - def _playing(self): - value = self._songs_scale.get_value() + 1 - self._songs_scale.set_value(value) - - return True - - - def _resize_image(self): - """Diese Methode skaliert das geladene Bild aus dem Pixelpuffer - auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse - """ - # Get size - size = self._cover_scroll.get_allocation() - # Abort if size is the same - if self._current_size and size.width == self._current_size.width and size.height == self._current_size.height: - return - self._current_size = size - - # Get pixelbuffer - pixbuf = self._cover_pixbuf - # Check pixelbuffer - if pixbuf is None: - return - - # Skalierungswert für Breite und Höhe ermitteln - ratioW = float(size.width) / float(pixbuf.get_width()) - ratioH = float(size.height) / float(pixbuf.get_height()) - # Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren - ratio = min(ratioW, ratioH) - ratio = min(ratio, 1) - # Neue Breite und Höhe berechnen - width = int(math.floor(pixbuf.get_width()*ratio)) - height = int(math.floor(pixbuf.get_height()*ratio)) - if width <= 0 or height <= 0: - return - # Pixelpuffer auf Oberfläche zeichnen - self._cover_image.set_allocation(self._cover_scroll.get_allocation()) - self._cover_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER)) - self._cover_image.show() - - - def _get_default_image(self): - return self._icon_theme.load_icon( - Window.STOCK_ICON_DEFAULT, - 512, - Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE - ) - - - - -class PlaylistPanel(GObject.GObject): - __gsignals__ = { - 'clear-playlist': (GObject.SIGNAL_RUN_FIRST, None, ()), - 'remove': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), - 'remove-multiple': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), - 'play': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), - 'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,)) - } - - - def __init__(self, builder, client): - GObject.GObject.__init__(self) - self._logger = logging.getLogger(__name__) - self._client = client - self._host = None - self._item_size = 150 - self._playlist = None - self._playlist_albums = None - self._playlist_lock = threading.Lock() - self._playlist_stop = threading.Event() - self._icon_theme = Gtk.IconTheme.get_default() - self._standalone_pixbuf = None - self._selected_albums = [] - self._is_selected = False - - # Widgets - self._appwindow = builder.get_object('appwindow') - self._panel = builder.get_object('playlist-panel') - self._toolbar = builder.get_object('playlist-toolbar') - self._headerbar = builder.get_object('headerbar') - self._headerbar_standalone = builder.get_object('headerbar-playlist-standalone') - self._panel_normal = builder.get_object('playlist-panel-normal') - self._panel_standalone = builder.get_object('playlist-panel-standalone') - self._actionbar_revealer = builder.get_object('playlist-actionbar-revealer') - - # Select button - self._select_button = builder.get_object('playlist-toolbar-select') - # Clear button - self._playlist_clear_button = builder.get_object('playlist-toolbar-clear') - # Playlist Grid: Model - self._playlist_grid_model = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str) - # Playlist Grid - self._playlist_grid = builder.get_object('playlist-iconview') - self._playlist_grid.set_model(self._playlist_grid_model) - self._playlist_grid.set_pixbuf_column(0) - self._playlist_grid.set_text_column(-1) - self._playlist_grid.set_tooltip_column(1) - # Action bar (normal) - actionbar = builder.get_object('playlist-actionbar') - cancel_button = Gtk.Button(gettext.gettext("cancel")) - cancel_button.connect('clicked', self.on_selection_cancel_clicked) - actionbar.pack_start(cancel_button) - remove_button = Gtk.Button('remove') - remove_button.connect('clicked', self.on_selection_remove_clicked) - actionbar.pack_end(remove_button) - - # Standalone labels - self._standalone_title = builder.get_object('headerbar-playlist-standalone-title') - self._standalone_artist = builder.get_object('headerbar-playlist-standalone-artist') - # Standalone Image - self._standalone_stack = builder.get_object('playlist-standalone-stack') - self._standalone_spinner = builder.get_object('playlist-standalone-spinner') - self._standalone_scroll = builder.get_object('playlist-standalone-scroll') - self._standalone_image = builder.get_object('playlist-standalone-image') - # Action bar (standalone) - actionbar_standalone = builder.get_object('playlist-standalone-actionbar') - play_button = Gtk.Button(gettext.gettext("play")) - play_button.connect('clicked', self.on_standalone_play_clicked) - actionbar_standalone.pack_end(play_button) - remove_button = Gtk.Button(gettext.gettext("remove")) - remove_button.connect('clicked', self.on_standalone_remove_clicked) - actionbar_standalone.pack_end(remove_button) - - - def get(self): - return self._panel - - - def set_selected(self, selected): - self._is_selected = selected - - - def get_toolbar(self): - return self._toolbar - - - def get_signal_handlers(self): - return { - 'on_playlist-toolbar-select_toggled': self.on_select_toggled, - 'on_playlist-toolbar-clear_clicked': self.clear_clicked, - 'on_playlist-iconview_item_activated': self.on_playlist_grid_clicked, - 'on_playlist-iconview_selection_changed': self.on_playlist_grid_selection_changed, - 'on_playlist-standalone-scroll_size_allocate': self.on_standalone_scroll_size_allocate, - 'on_headerbar-playlist-standalone-close_clicked': self.on_standalone_close_clicked - } - - - def on_select_toggled(self, widget): - if widget.get_active(): - self._actionbar_revealer.set_reveal_child(True) - self._playlist_grid.set_selection_mode(Gtk.SelectionMode.MULTIPLE) - self._playlist_grid.get_style_context().add_class(Window._CSS_SELECTION) - else: - self._actionbar_revealer.set_reveal_child(False) - self._playlist_grid.set_selection_mode(Gtk.SelectionMode.SINGLE) - self._playlist_grid.get_style_context().remove_class(Window._CSS_SELECTION) - - - def clear_clicked(self, widget): - if widget is self._playlist_clear_button: - self.emit('clear-playlist') - - - def on_playlist_grid_clicked(self, widget, path): - # Get selected album - iter = self._playlist_grid_model.get_iter(path) - hash = self._playlist_grid_model.get_value(iter, 2) - album = self._playlist_albums[hash] - self._selected_albums = [album] - self.emit('albumart', hash) - - # Show standalone album - if widget.get_selection_mode() == Gtk.SelectionMode.SINGLE: - # Set labels - self._standalone_title.set_text(album.get_title()) - self._standalone_artist.set_text(", ".join(album.get_albumartists())) - - # Show panel - self._open_standalone() - - # Set cover loading indicator - self._standalone_stack.set_visible_child(self._standalone_spinner) - self._standalone_spinner.start() - - - def on_playlist_grid_selection_changed(self, widget): - self._selected_albums = [] - for path in widget.get_selected_items(): - iter = self._playlist_grid_model.get_iter(path) - hash = self._playlist_grid_model.get_value(iter, 2) - self._selected_albums.append(self._playlist_albums[hash]) - - - def on_selection_cancel_clicked(self, widget): - self._select_button.set_active(False) - - - def on_selection_remove_clicked(self, widget): - self.emit('remove-multiple', self._selected_albums) - self._select_button.set_active(False) - - - def on_standalone_scroll_size_allocate(self, widget, allocation): - self._resize_standalone_image() - - - def on_standalone_close_clicked(self, widget): - self._close_standalone() - - - def on_standalone_remove_clicked(self, widget): - self.emit('remove', self._selected_albums[0]) - self._close_standalone() - - - def on_standalone_play_clicked(self, widget): - self.emit('play', self._selected_albums[0]) - self._close_standalone() - - - def set_item_size(self, item_size): - if self._item_size != item_size: - self._item_size = item_size - self._redraw() - - - def get_item_size(self): - return self._item_size - - - def set_playlist(self, host, playlist): - self._host = host - self._playlist_stop.set() - threading.Thread(target=self._set_playlist, args=(host, playlist, self._item_size,)).start() - - - def set_albumart(self, album, data): - if album in self._selected_albums: - if data: - # Load image and draw it - try: - self._standalone_pixbuf = Utils.load_pixbuf(data) - except Exception as e: - self._logger.exception("Failed to set albumart") - self._cover_pixbuf = self._get_default_image() - else: - self._cover_pixbuf = self._get_default_image() - # Show image - self._resize_standalone_image() - self._standalone_stack.set_visible_child(self._standalone_scroll) - self._standalone_spinner.stop() - - - def stop_threads(self): - self._playlist_stop.set() - - - def _set_playlist(self, host, playlist, size): - if not self._is_selected and self._playlist != playlist: - GObject.idle_add( - self.get().get_parent().child_set_property, - self.get(), - 'needs-attention', - True - ) - self._playlist_lock.acquire() - self._playlist_stop.clear() - self._playlist = playlist - self._playlist_albums = {} - for album in playlist: - self._playlist_albums[album.get_id()] = album - self._playlist_grid.set_model(None) - self._playlist_grid.freeze_child_notify() - self._playlist_grid_model.clear() - GObject.idle_add(self._playlist_grid.set_item_padding, size / 100) - - cache = client.MCGCache(host, size) - for album in playlist: - pixbuf = None - # Load albumart thumbnail - try: - pixbuf = Utils.load_thumbnail(cache, self._client, album, size) - except client.CommandException: - # Exception is handled by client - pass - except Exception: - self._logger.exception("Failed to load albumart") - if pixbuf is None: - pixbuf = self._icon_theme.load_icon( - Window.STOCK_ICON_DEFAULT, - self._item_size, - Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf is not None: - self._playlist_grid_model.append([ - pixbuf, - GObject.markup_escape_text("\n".join([ - album.get_title(), - ', '.join(album.get_dates()), - Utils.create_artists_label(album), - Utils.create_length_label(album) - ])), - album.get_id() - ]) - - if self._playlist_stop.is_set(): - self._playlist_lock.release() - return - - self._playlist_grid.set_model(self._playlist_grid_model) - self._playlist_grid.thaw_child_notify() - # TODO why set_columns()? - #self._playlist_grid.set_columns(len(playlist)) - self._playlist_lock.release() - - - def _redraw(self): - if self._playlist is not None: - self.set_playlist(self._host, self._playlist) - - - def _open_standalone(self): - self._panel.set_visible_child(self._panel_standalone) - self._appwindow.set_titlebar(self._headerbar_standalone) - - - def _close_standalone(self): - self._panel.set_visible_child(self._panel.get_children()[0]) - self._appwindow.set_titlebar(self._headerbar) - - - def _resize_standalone_image(self): - """Diese Methode skaliert das geladene Bild aus dem Pixelpuffer - auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse - """ - pixbuf = self._standalone_pixbuf - size = self._standalone_scroll.get_allocation() - # Check pixelbuffer - if pixbuf is None: - return - - # Skalierungswert für Breite und Höhe ermitteln - ratioW = float(size.width) / float(pixbuf.get_width()) - ratioH = float(size.height) / float(pixbuf.get_height()) - # Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren - ratio = min(ratioW, ratioH) - ratio = min(ratio, 1) - # Neue Breite und Höhe berechnen - width = int(math.floor(pixbuf.get_width()*ratio)) - height = int(math.floor(pixbuf.get_height()*ratio)) - if width <= 0 or height <= 0: - return - # Pixelpuffer auf Oberfläche zeichnen - self._standalone_image.set_allocation(self._standalone_scroll.get_allocation()) - self._standalone_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER)) - self._standalone_image.show() - - - def _get_default_image(self): - return self._icon_theme.load_icon( - Window.STOCK_ICON_DEFAULT, - 512, - Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE - ) - - - - -class LibraryPanel(GObject.GObject): - __gsignals__ = { - 'update': (GObject.SIGNAL_RUN_FIRST, None, ()), - 'play': (GObject.SIGNAL_RUN_FIRST, None, (str,)), - 'queue': (GObject.SIGNAL_RUN_FIRST, None, (str,)), - 'queue-multiple': (GObject.SIGNAL_RUN_FIRST, None, (GObject.TYPE_PYOBJECT,)), - 'item-size-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)), - 'sort-order-changed': (GObject.SIGNAL_RUN_FIRST, None, (int,)), - 'sort-type-changed': (GObject.SIGNAL_RUN_FIRST, None, (Gtk.SortType,)), - 'albumart': (GObject.SIGNAL_RUN_FIRST, None, (str,)) - } - - - def __init__(self, builder, client): - GObject.GObject.__init__(self) - self._logger = logging.getLogger(__name__) - self._client = client - self._buttons = {} - self._albums = None - self._host = "localhost" - self._filter_string = "" - self._item_size = 150 - self._sort_order = SortOrder.YEAR - self._sort_type = Gtk.SortType.DESCENDING - self._grid_pixbufs = {} - self._old_ranges = {} - self._library_lock = threading.Lock() - self._library_stop = threading.Event() - self._icon_theme = Gtk.IconTheme.get_default() - self._standalone_pixbuf = None - self._selected_albums = [] - self._allocation = (0, 0) - self._is_selected = False - - # Widgets - self._appwindow = builder.get_object('appwindow') - self._panel = builder.get_object('library-panel') - self._toolbar = builder.get_object('library-toolbar') - self._headerbar = builder.get_object('headerbar') - self._headerbar_standalone = builder.get_object('headerbar-library-standalone') - self._panel_normal = builder.get_object('library-panel-normal') - self._panel_standalone = builder.get_object('library-panel-standalone') - self._actionbar_revealer = builder.get_object('library-actionbar-revealer') - - # Select button - self._select_button = builder.get_object('library-toolbar-select') - # Filter/search bar - self._filter_bar = builder.get_object('library-filter-bar') - self._filter_entry = builder.get_object('library-filter') - # Progress Bar - self._stack = builder.get_object('library-stack') - self._progress_box = builder.get_object('library-progress-box') - self._pgross_image = builder.get_object('library-progress-image') - self._pgross_image.set_from_pixbuf(self._get_default_image()) - self._progress_bar = builder.get_object('library-progress') - self._scroll = builder.get_object('library-scroll') - # Toolbar menu - self._toolbar_search_bar = builder.get_object('library-toolbar-search') - self._toolbar_popover = builder.get_object('library-toolbar-popover') - self._toolbar_sort_buttons = { - SortOrder.ARTIST: builder.get_object('library-toolbar-sort-artist'), - SortOrder.TITLE: builder.get_object('library-toolbar-sort-title'), - SortOrder.YEAR: builder.get_object('library-toolbar-sort-year') - } - self._toolbar_sort_order_button = builder.get_object('library-toolbar-sort-order') - self._grid_scale = builder.get_object('library-toolbar-scale') - self._grid_scale.set_value(self._item_size) - self._grid_adjustment = builder.get_object('library-scale-adjustment') - # Library Grid: Model - self._library_grid_model = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str) - self._library_grid_model.set_sort_func(2, self.compare_albums, self._sort_order) - self._library_grid_model.set_sort_column_id(2, self._sort_type) - self._library_grid_filter = self._library_grid_model.filter_new() - self._library_grid_filter.set_visible_func(self.on_filter_visible) - # Library Grid - self._library_grid = builder.get_object('library-iconview') - self._library_grid.set_model(self._library_grid_filter) - self._library_grid.set_pixbuf_column(0) - self._library_grid.set_text_column(-1) - self._library_grid.set_tooltip_column(1) - # Action bar (normal) - actionbar = builder.get_object('library-actionbar') - cancel_button = Gtk.Button(gettext.gettext("cancel")) - cancel_button.connect('clicked', self.on_selection_cancel_clicked) - actionbar.pack_start(cancel_button) - add_button = Gtk.Button(gettext.gettext("queue")) - add_button.connect('clicked', self.on_selection_add_clicked) - actionbar.pack_end(add_button) - - # Standalone labels - self._standalone_title = builder.get_object('headerbar-library-standalone-title') - self._standalone_artist = builder.get_object('headerbar-library-standalone-artist') - # Standalone Image - self._standalone_stack = builder.get_object('library-standalone-stack') - self._standalone_spinner = builder.get_object('library-standalone-spinner') - self._standalone_scroll = builder.get_object('library-standalone-scroll') - self._standalone_image = builder.get_object('library-standalone-image') - # Action bar (standalone) - actionbar_standalone = builder.get_object('library-standalone-actionbar') - play_button = Gtk.Button(gettext.gettext("play")) - play_button.connect('clicked', self.on_standalone_play_clicked) - actionbar_standalone.pack_end(play_button) - queue_button = Gtk.Button(gettext.gettext("queue")) - queue_button.connect('clicked', self.on_standalone_queue_clicked) - actionbar_standalone.pack_end(queue_button) - - - def get(self): - return self._panel - - - def set_selected(self, selected): - self._is_selected = selected - - - def get_toolbar(self): - return self._toolbar - - - def get_signal_handlers(self): - return { - 'on_library-toolbar-search_toggled': self.on_search_toggled, - 'on_library-toolbar-select_toggled': self.on_select_toggled, - 'on_library-toolbar-scale_change_value': self.on_grid_scale_change, - 'on_library-toolbar-scale_button_release_event': self.on_grid_scale_changed, - 'on_library-toolbar-update_clicked': self.on_update_clicked, - 'on_library-toolbar-sort-toggled': self.on_sort_toggled, - 'on_library-toolbar-sort-order_toggled': self.on_sort_order_toggled, - 'on_library-filter-bar_notify': self.on_filter_bar_notify, - 'on_library-filter_search_changed': self.on_filter_entry_changed, - 'on_library-iconview_item_activated': self.on_library_grid_clicked, - 'on_library-iconview_selection_changed': self.on_library_grid_selection_changed, - 'on_library-standalone-scroll_size_allocate': self.on_standalone_scroll_size_allocate, - 'on_headerbar-library-standalone-close_clicked': self.on_standalone_close_clicked, - 'on_library-iconview_size_allocate': self.on_resize - } - - - def on_resize(self, widget, event): - new_allocation = (widget.get_allocation().width, widget.get_allocation().height) - if new_allocation == self._allocation: - return - self._allocation = new_allocation - self._grid_scale.clear_marks() - width = widget.get_allocation().width - - lower = int(self._grid_adjustment.get_lower()) - upper = int(self._grid_adjustment.get_upper()) - countMin = max(int(width / upper), 1) - countMax = max(int(width / lower), 1) - for index in range(countMin, countMax): - pixel = int(width / index) - pixel = pixel - (2 * int(pixel / 100)) - self._grid_scale.add_mark( - pixel, - Gtk.PositionType.BOTTOM, - None - ) - - - def on_search_toggled(self, widget): - self._filter_bar.set_search_mode(widget.get_active()) - - - def on_select_toggled(self, widget): - if widget.get_active(): - self._actionbar_revealer.set_reveal_child(True) - self._library_grid.set_selection_mode(Gtk.SelectionMode.MULTIPLE) - self._library_grid.get_style_context().add_class(Window._CSS_SELECTION) - else: - self._actionbar_revealer.set_reveal_child(False) - self._library_grid.set_selection_mode(Gtk.SelectionMode.SINGLE) - self._library_grid.get_style_context().remove_class(Window._CSS_SELECTION) - - - def on_grid_scale_change(self, widget, scroll, value): - size = math.floor(value) - range = self._grid_scale.get_adjustment() - if size < range.get_lower() or size > range.get_upper(): - return - self._item_size = size - GObject.idle_add(self._library_grid.set_item_padding, size / 100) - GObject.idle_add(self._set_widget_grid_size, self._library_grid, size, True) - - - def on_grid_scale_changed(self, widget, event): - size = round(self._grid_scale.get_value()) - range = self._grid_scale.get_adjustment() - if size < range.get_lower() or size > range.get_upper(): - return False - self.emit('item-size-changed', size) - self._toolbar_popover.popdown() - self._redraw() - return False - - - def on_update_clicked(self, widget): - self.emit('update') - - - def on_sort_toggled(self, widget): - if widget.get_active(): - sort = [key for key, value in self._toolbar_sort_buttons.items() if value is widget][0] - self._change_sort(sort) - - - def on_sort_order_toggled(self, button): - if button.get_active(): - sort_type = Gtk.SortType.DESCENDING - else: - sort_type = Gtk.SortType.ASCENDING - self._sort_type = sort_type - self._library_grid_model.set_sort_column_id(2, sort_type) - self.emit('sort-type-changed', sort_type) - - - def on_filter_bar_notify(self, widget, value): - if self._toolbar_search_bar.get_active() is not self._filter_bar.get_search_mode(): - self._toolbar_search_bar.set_active(self._filter_bar.get_search_mode()) - - - def on_filter_entry_changed(self, widget): - self._filter_string = self._filter_entry.get_text() - GObject.idle_add(self._library_grid_filter.refilter) - - - def on_library_grid_clicked(self, widget, path): - # Get selected album - path = self._library_grid_filter.convert_path_to_child_path(path) - iter = self._library_grid_model.get_iter(path) - id = self._library_grid_model.get_value(iter, 2) - album = self._albums[id] - self._selected_albums = [album] - self.emit('albumart', id) - - # Show standalone album - if widget.get_selection_mode() == Gtk.SelectionMode.SINGLE: - # Set labels - self._standalone_title.set_text(album.get_title()) - self._standalone_artist.set_text(", ".join(album.get_albumartists())) - - # Show panel - self._open_standalone() - - # Set cover loading indicator - self._standalone_stack.set_visible_child(self._standalone_spinner) - self._standalone_spinner.start() - - - def on_library_grid_selection_changed(self, widget): - self._selected_albums = [] - for path in widget.get_selected_items(): - path = self._library_grid_filter.convert_path_to_child_path(path) - iter = self._library_grid_model.get_iter(path) - id = self._library_grid_model.get_value(iter, 2) - self._selected_albums.insert(0, self._albums[id]) - - - def on_filter_visible(self, model, iter, data): - id = model.get_value(iter, 2) - if not id in self._albums.keys(): - return - album = self._albums[id] - return album.filter(self._filter_string) - - - def on_selection_cancel_clicked(self, widget): - self._select_button.set_active(False) - - - def on_selection_add_clicked(self, widget): - ids = [album.get_id() for album in self._selected_albums] - self.emit('queue-multiple', ids) - self._select_button.set_active(False) - - - def on_standalone_scroll_size_allocate(self, widget, allocation): - self._resize_standalone_image() - - - def on_standalone_play_clicked(self, widget): - self.emit('play', self._selected_albums[0].get_id()) - self._close_standalone() - - - def on_standalone_queue_clicked(self, widget): - self.emit('queue', self._selected_albums[0].get_id()) - self._close_standalone() - - - def on_standalone_close_clicked(self, widget): - self._close_standalone() - - - def set_item_size(self, item_size): - if self._item_size != item_size: - self._item_size = item_size - self._grid_scale.set_value(item_size) - self._redraw() - - - def get_item_size(self): - return self._item_size - - - def set_sort_order(self, sort): - if self._sort_order != sort: - button = self._toolbar_sort_buttons[sort] - if button and not button.get_active(): - button.set_active(True) - self._sort_order = sort - self._library_grid_model.set_sort_func(2, self.compare_albums, self._sort_order) - - - def get_sort_order(self): - return self._sort_order - - - def set_sort_type(self, sort_type): - if self._sort_type != sort_type: - if sort_type: - sort_type_gtk = Gtk.SortType.DESCENDING - self._toolbar_sort_order_button.set_active(True) - else: - sort_type_gtk = Gtk.SortType.ASCENDING - self._toolbar_sort_order_button.set_active(False) - if self._sort_type != sort_type_gtk: - self._sort_type = sort_type_gtk - self._library_grid_model.set_sort_column_id(2, sort_type) - - - def get_sort_type(self): - return (self._sort_type != Gtk.SortType.ASCENDING) - - - def init_albums(self): - self._progress_bar.set_text(gettext.gettext("Loading albums")) - - - def load_albums(self): - self._progress_bar.pulse() - - - def set_albums(self, host, albums): - self._host = host - self._library_stop.set() - threading.Thread(target=self._set_albums, args=(host, albums, self._item_size,)).start() - - - def set_albumart(self, album, data): - if album in self._selected_albums: - if data: - # Load image and draw it - try: - self._standalone_pixbuf = Utils.load_pixbuf(data) - except Exception as e: - self._logger.exception("Failed to set albumart") - self._standalone_pixbuf = self._get_default_image() - else: - self._standalone_pixbuf = self._get_default_image() - # Show image - self._resize_standalone_image() - self._standalone_stack.set_visible_child(self._standalone_scroll) - self._standalone_spinner.stop() - - - def compare_albums(self, model, row1, row2, criterion): - id1 = model.get_value(row1, 2) - id2 = model.get_value(row2, 2) - - if not id1 or not id2: - return - return client.MCGAlbum.compare(self._albums[id1], self._albums[id2], criterion) - - - def stop_threads(self): - self._library_stop.set() - - - def _change_sort(self, sort): - self._sort_order = sort - self._library_grid_model.set_sort_func(2, self.compare_albums, sort) - self.emit('sort-order-changed', sort) - - - def _set_albums(self, host, albums, size): - if not self._is_selected and albums != self._albums: - GObject.idle_add( - self.get().get_parent().child_set_property, - self.get(), - 'needs-attention', - True - ) - self._library_lock.acquire() - self._library_stop.clear() - self._albums = albums - GObject.idle_add(self._stack.set_visible_child, self._progress_box) - GObject.idle_add(self._progress_bar.set_fraction, 0.0) - GObject.idle_add(self._library_grid.set_item_padding, size / 100) - self._library_grid.set_model(None) - self._library_grid.freeze_child_notify() - self._library_grid_model.clear() - - i = 0 - n = len(albums) - cache = client.MCGCache(host, size) - self._grid_pixbufs.clear() - for album_id in albums.keys(): - album = albums[album_id] - pixbuf = None - try: - pixbuf = Utils.load_thumbnail(cache, self._client, album, size) - except client.CommandException: - # Exception is handled by client - pass - except Exception as e: - self._logger.exception("Failed to load albumart") - if pixbuf is None: - pixbuf = self._icon_theme.load_icon( - Window.STOCK_ICON_DEFAULT, - self._item_size, - Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf is not None: - self._grid_pixbufs[album.get_id()] = pixbuf - self._library_grid_model.append([ - pixbuf, - GObject.markup_escape_text("\n".join([ - album.get_title(), - ', '.join(album.get_dates()), - Utils.create_artists_label(album), - Utils.create_length_label(album) - ])), - album_id - ]) - - i += 1 - GObject.idle_add(self._progress_bar.set_fraction, i/n) - GObject.idle_add(self._progress_bar.set_text, gettext.gettext("Loading images")) - if self._library_stop.is_set(): - self._library_lock.release() - return - - self._library_grid.set_model(self._library_grid_filter) - self._library_grid.thaw_child_notify() - self._library_grid.set_item_width(-1) - self._library_lock.release() - self._stack.set_visible_child(self._scroll) - - - def _set_widget_grid_size(self, grid_widget, size, vertical): - self._library_stop.set() - threading.Thread(target=self._set_widget_grid_size_thread, args=(grid_widget, size, vertical,)).start() - - - def _set_widget_grid_size_thread(self, grid_widget, size, vertical): - self._library_lock.acquire() - self._library_stop.clear() - grid_filter = grid_widget.get_model() - grid_model = grid_filter.get_model() - - # get old_range - grid_widget_id = id(grid_widget) - if grid_widget_id not in self._old_ranges or self._old_ranges[grid_widget_id] is None: - self._old_ranges[grid_widget_id] = range(0, len(grid_filter)) - old_range = self._old_ranges[grid_widget_id] - old_start = len(old_range) > 0 and old_range[0] or 0 - old_end = len(old_range) > 0 and old_range[len(old_range)-1] + 1 or 0 - - # calculate visible range - w = (grid_widget.get_allocation().width // size) + (vertical and 0 or 1) - h = (grid_widget.get_allocation().height // size) + (vertical and 1 or 0) - c = w * h - vis_range = grid_widget.get_visible_range() - if vis_range is None: - self._library_lock.release() - return - (vis_start,), (vis_end,) = vis_range - vis_end = min(vis_start + c, len(grid_filter)) - vis_range = range(vis_start, vis_end) - - # set pixbuf - cur_start = min(old_start, vis_start) - cur_end = max(old_end, vis_end) - cur_range = range(cur_start, cur_end) - for index in cur_range: - iter = grid_filter.convert_iter_to_child_iter(grid_filter[index].iter) - if index in vis_range: - album_id = grid_model.get_value(iter, 2) - pixbuf = self._grid_pixbufs[album_id] - pixbuf = pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.NEAREST) - else: - pixbuf = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False, 8, 1, 1) - grid_model.set_value(iter, 0, pixbuf) - - if self._library_stop.is_set(): - self._library_lock.release() - return - - self._old_ranges[grid_widget_id] = vis_range - grid_widget.set_item_width(size) - - self._library_lock.release() - - - def _redraw(self): - if self._albums is not None: - self.set_albums(self._host, self._albums) - - - def _open_standalone(self): - self._panel.set_visible_child(self._panel_standalone) - self._appwindow.set_titlebar(self._headerbar_standalone) - - - def _close_standalone(self): - self._panel.set_visible_child(self._panel.get_children()[0]) - self._appwindow.set_titlebar(self._headerbar) - - - def _resize_standalone_image(self): - """Diese Methode skaliert das geladene Bild aus dem Pixelpuffer - auf die Größe des Fensters unter Beibehalt der Seitenverhältnisse - """ - pixbuf = self._standalone_pixbuf - size = self._standalone_scroll.get_allocation() - # Check pixelbuffer - if pixbuf is None: - return - - # Skalierungswert für Breite und Höhe ermitteln - ratioW = float(size.width) / float(pixbuf.get_width()) - ratioH = float(size.height) / float(pixbuf.get_height()) - # Kleineren beider Skalierungswerte nehmen, nicht Hochskalieren - ratio = min(ratioW, ratioH) - ratio = min(ratio, 1) - # Neue Breite und Höhe berechnen - width = int(math.floor(pixbuf.get_width()*ratio)) - height = int(math.floor(pixbuf.get_height()*ratio)) - if width <= 0 or height <= 0: - return - # Pixelpuffer auf Oberfläche zeichnen - self._standalone_image.set_allocation(self._standalone_scroll.get_allocation()) - self._standalone_image.set_from_pixbuf(pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.HYPER)) - self._standalone_image.show() - - - def _get_default_image(self): - return self._icon_theme.load_icon( - Window.STOCK_ICON_DEFAULT, - 512, - Gtk.IconLookupFlags.FORCE_SVG & Gtk.IconLookupFlags.FORCE_SIZE - ) - - - - -class StackSwitcher(GObject.GObject): - __gsignals__ = { - 'stack-switched': (GObject.SIGNAL_RUN_FIRST, None, ()) - } - - - def __init__(self, builder): - GObject.GObject.__init__(self) - - self._temp_button = None - self._stack_switcher = builder.get_object('header-panelswitcher') - for child in self._stack_switcher.get_children(): - if type(child) is Gtk.RadioButton: - child.connect('clicked', self.on_clicked) - - - def on_clicked(self, widget): - if not self._temp_button: - self._temp_button = widget - else: - self._temp_button = None - self.emit('stack-switched') - - - def get(self): - return self._stack_switcher diff --git a/mcg/window.py b/mcg/window.py new file mode 100644 index 0000000..e7a487d --- /dev/null +++ b/mcg/window.py @@ -0,0 +1,650 @@ +#!/usr/bin/env python3 + + +import gi +gi.require_version('Gtk', '3.0') +try: + import keyring + use_keyring = True +except: + use_keyring = False +import logging + +from gi.repository import Gtk, Gdk, GObject, GLib, Gio + +from mcg import client +from mcg.shortcutsdialog import ShortcutsDialog +from mcg.connectionpanel import ConnectionPanel +from mcg.serverpanel import ServerPanel +from mcg.coverpanel import CoverPanel +from mcg.playlistpanel import PlaylistPanel +from mcg.librarypanel import LibraryPanel +from mcg.zeroconf import ZeroconfProvider + + + + +class WindowState(GObject.Object): + WIDTH = 'width' + HEIGHT = 'height' + IS_MAXIMIZED = 'is_maximized' + IS_FULLSCREENED = 'is_fullscreened' + width = GObject.Property(type=int, default=800) + height = GObject.Property(type=int, default=600) + is_maximized = GObject.Property(type=bool, default=False) + is_fullscreened = GObject.Property(type=bool, default=False) + + + def __init__(self): + super().__init__() + + + + +@Gtk.Template(resource_path='/de/coderkun/mcg/ui/window.ui') +class Window(Gtk.ApplicationWindow): + __gtype_name__ = 'McgAppWindow' + SETTING_HOST = 'host' + SETTING_PORT = 'port' + SETTING_CONNECTED = 'connected' + SETTING_WINDOW_WIDTH = 'width' + SETTING_WINDOW_HEIGHT = 'height' + SETTING_WINDOW_MAXIMIZED = 'is-maximized' + SETTING_PANEL = 'panel' + SETTING_ITEM_SIZE = 'item-size' + SETTING_SORT_ORDER = 'sort-order' + SETTING_SORT_TYPE = 'sort-type' + _CUSTOM_STARTUP_COMPLETE = 'startup-complete' + + # Widgets + content_stack = Gtk.Template.Child() + panel_stack = Gtk.Template.Child() + toolbar_stack = Gtk.Template.Child() + # Headerbar + headerbar = Gtk.Template.Child() + headerbar_title_stack = Gtk.Template.Child() + headerbar_panel_switcher = Gtk.Template.Child() + headerbar_connection_label = Gtk.Template.Child() + headerbar_button_connect = Gtk.Template.Child() + headerbar_button_playpause = Gtk.Template.Child() + headerbar_button_volume = Gtk.Template.Child() + # Infobar + info_revealer = Gtk.Template.Child() + info_bar = Gtk.Template.Child() + info_label = Gtk.Template.Child() + + + def __init__(self, app, title, settings): + super().__init__() + self.set_application(app) + self.set_title(title) + self._settings = settings + self._panels = [] + self._mcg = client.Client() + self._state = WindowState() + self._changing_volume = False + self._setting_volume = False + + # Help/Shortcuts dialog + self.set_help_overlay(ShortcutsDialog()) + + # Login screen + self._connection_panel = ConnectionPanel() + # Server panel + self._server_panel = ServerPanel() + self._panels.append(self._server_panel) + # Cover panel + self._cover_panel = CoverPanel() + self._panels.append(self._cover_panel) + # Playlist panel + self._playlist_panel = PlaylistPanel(self._mcg) + self._playlist_panel.connect('open-standalone', self.on_panel_open_standalone) + self._playlist_panel.connect('close-standalone', self.on_panel_close_standalone) + self._panels.append(self._playlist_panel) + # Library panel + self._library_panel = LibraryPanel(self._mcg) + self._library_panel.connect('open-standalone', self.on_panel_open_standalone) + self._library_panel.connect('close-standalone', self.on_panel_close_standalone) + self._panels.append(self._library_panel) + # Stack + self.content_stack.add(self._connection_panel) + self.panel_stack.add_titled(self._server_panel, 'server-panel', "Server") + self.panel_stack.add_titled(self._cover_panel, 'cover-panel', "Cover") + self.panel_stack.add_titled(self._playlist_panel, 'playlist-panel', "Playlist") + self.panel_stack.add_titled(self._library_panel, 'library-panel', "Library") + # Header + self._playlist_panel.get_headerbar_standalone().connect('close', self.on_panel_close_standalone) + self._library_panel.get_headerbar_standalone().connect('close', self.on_panel_close_standalone) + # Toolbar stack + self.toolbar_stack.add(self._server_panel.get_toolbar()) + self.toolbar_stack.add(self._cover_panel.get_toolbar()) + self.toolbar_stack.add(self._playlist_panel.get_toolbar()) + self.toolbar_stack.add(self._library_panel.get_toolbar()) + + # Properties + self._set_headerbar_sensitive(False, False) + self._connection_panel.set_host(self._settings.get_string(Window.SETTING_HOST)) + self._connection_panel.set_port(self._settings.get_int(Window.SETTING_PORT)) + if use_keyring: + self._connection_panel.set_password(keyring.get_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME)) + self._playlist_panel.set_item_size(self._settings.get_int(Window.SETTING_ITEM_SIZE)) + self._library_panel.set_item_size(self._settings.get_int(Window.SETTING_ITEM_SIZE)) + self._library_panel.set_sort_order(self._settings.get_enum(Window.SETTING_SORT_ORDER)) + self._library_panel.set_sort_type(self._settings.get_boolean(Window.SETTING_SORT_TYPE)) + + # Signals + self._connection_panel.connect('connection-changed', self.on_connection_panel_connection_changed) + self.panel_stack.connect('notify::visible-child', self.on_stack_switched) + self._server_panel.connect('change-output-device', self.on_server_panel_output_device_changed) + self._cover_panel.connect('toggle-fullscreen', self.on_cover_panel_toggle_fullscreen) + self._cover_panel.connect('set-song', self.on_cover_panel_set_song) + self._cover_panel.connect('albumart', self.on_cover_panel_albumart) + self._playlist_panel.connect('clear-playlist', self.on_playlist_panel_clear_playlist) + self._playlist_panel.connect('remove-album', self.on_playlist_panel_remove) + self._playlist_panel.connect('remove-multiple-albums', self.on_playlist_panel_remove_multiple) + self._playlist_panel.connect('play', self.on_playlist_panel_play) + self._playlist_panel.connect('albumart', self.on_playlist_panel_albumart) + self._library_panel.connect('update', self.on_library_panel_update) + self._library_panel.connect('play', self.on_library_panel_play) + self._library_panel.connect('queue', self.on_library_panel_queue) + self._library_panel.connect('queue-multiple', self.on_library_panel_queue_multiple) + self._library_panel.connect('item-size-changed', self.on_library_panel_item_size_changed) + self._library_panel.connect('sort-order-changed', self.on_library_panel_sort_order_changed) + self._library_panel.connect('sort-type-changed', self.on_library_panel_sort_type_changed) + self._library_panel.connect('albumart', self.on_library_panel_albumart) + self._mcg.connect_signal(client.Client.SIGNAL_CONNECTION, self.on_mcg_connect) + self._mcg.connect_signal(client.Client.SIGNAL_STATUS, self.on_mcg_status) + self._mcg.connect_signal(client.Client.SIGNAL_STATS, self.on_mcg_stats) + self._mcg.connect_signal(client.Client.SIGNAL_LOAD_OUTPUT_DEVICES, self.on_mcg_load_output_devices) + self._mcg.connect_signal(client.Client.SIGNAL_LOAD_PLAYLIST, self.on_mcg_load_playlist) + self._mcg.connect_signal(client.Client.SIGNAL_PULSE_ALBUMS, self.on_mcg_pulse_albums) + self._mcg.connect_signal(client.Client.SIGNAL_INIT_ALBUMS, self.on_mcg_init_albums) + self._mcg.connect_signal(client.Client.SIGNAL_LOAD_ALBUMS, self.on_mcg_load_albums) + self._mcg.connect_signal(client.Client.SIGNAL_LOAD_ALBUMART, self.on_mcg_load_albumart) + self._mcg.connect_signal(client.Client.SIGNAL_CUSTOM, self.on_mcg_custom) + self._mcg.connect_signal(client.Client.SIGNAL_ERROR, self.on_mcg_error) + self._settings.connect('changed::'+Window.SETTING_PANEL, self.on_settings_panel_changed) + self._settings.connect('changed::'+Window.SETTING_ITEM_SIZE, self.on_settings_item_size_changed) + self._settings.connect('changed::'+Window.SETTING_SORT_ORDER, self.on_settings_sort_order_changed) + self._settings.connect('changed::'+Window.SETTING_SORT_TYPE, self.on_settings_sort_type_changed) + self._settings.bind(Window.SETTING_WINDOW_WIDTH, self._state, WindowState.WIDTH, Gio.SettingsBindFlags.DEFAULT) + self._settings.bind(Window.SETTING_WINDOW_HEIGHT, self._state, WindowState.HEIGHT, Gio.SettingsBindFlags.DEFAULT) + self._settings.bind(Window.SETTING_WINDOW_MAXIMIZED, self._state, WindowState.IS_MAXIMIZED, Gio.SettingsBindFlags.DEFAULT) + + # Actions + self.set_default_size(self._state.width, self._state.height) + if self._state.get_property(WindowState.IS_MAXIMIZED): + self.maximize() + self.show_all() + self.content_stack.set_visible_child(self._connection_panel) + if self._settings.get_boolean(Window.SETTING_CONNECTED): + self._connect() + + # Menu actions + self._connect_action = Gio.SimpleAction.new_stateful("connect", None, GLib.Variant.new_boolean(False)) + self._connect_action.connect('change-state', self.on_menu_connect) + self.add_action(self._connect_action) + self._play_action = Gio.SimpleAction.new_stateful("play", None, GLib.Variant.new_boolean(False)) + self._play_action.set_enabled(False) + self._play_action.connect('change-state', self.on_menu_play) + self.add_action(self._play_action) + self._clear_playlist_action = Gio.SimpleAction.new("clear-playlist", None) + self._clear_playlist_action.set_enabled(False) + self._clear_playlist_action.connect('activate', self.on_menu_clear_playlist) + self.add_action(self._clear_playlist_action) + panel_variant = GLib.Variant.new_string("0") + self._panel_action = Gio.SimpleAction.new_stateful("panel", panel_variant.get_type(), panel_variant) + self._panel_action.set_enabled(False) + self._panel_action.connect('change-state', self.on_menu_panel) + self.add_action(self._panel_action) + self._toggle_fullscreen_action = Gio.SimpleAction.new("toggle-fullscreen", None) + self._toggle_fullscreen_action.set_enabled(True) + self._toggle_fullscreen_action.connect('activate', self.on_menu_toggle_fullscreen) + self.add_action(self._toggle_fullscreen_action) + + + # Menu callbacks + + def on_menu_connect(self, action, value): + self._connect() + + + def on_menu_play(self, action, value): + self._mcg.playpause() + + + def on_menu_clear_playlist(self, action, value): + self._mcg.clear_playlist() + + + def on_menu_panel(self, action, value): + action.set_state(value) + self.panel_stack.set_visible_child(self._panels[int(value.get_string())]) + + + def on_menu_toggle_fullscreen(self, action, value): + self.panel_stack.set_visible_child(self._cover_panel) + if not self._state.get_property(WindowState.IS_FULLSCREENED): + self.fullscreen() + else: + self.unfullscreen() + + + # Window callbacks + + @Gtk.Template.Callback() + def on_resize(self, widget, event): + if not self._state.get_property(WindowState.IS_MAXIMIZED): + size = self.get_size() + self._state.set_property(WindowState.WIDTH, size.width) + self._state.set_property(WindowState.HEIGHT, size.height) + + + @Gtk.Template.Callback() + def on_state(self, widget, state): + self._state.set_property(WindowState.IS_MAXIMIZED, (state.new_window_state & Gdk.WindowState.MAXIMIZED > 0)) + self._fullscreen((state.new_window_state & Gdk.WindowState.FULLSCREEN > 0)) + + + # HeaderBar callbacks + + @Gtk.Template.Callback() + def on_headerbar_connection_active_notify(self, widget, status): + self._connect() + + + @Gtk.Template.Callback() + def on_headerbar_connection_state_set(self, widget, state): + return True + + + @Gtk.Template.Callback() + def on_headerbar_volume_press(self, widget, active): + self._changing_volume = active + + + @Gtk.Template.Callback() + def on_headerbar_volume_release(self, widget, active): + self._changing_volume = active + + + @Gtk.Template.Callback() + def on_headerbar_playpause_toggled(self, widget): + self._mcg.playpause() + self._mcg.get_status() + + + @Gtk.Template.Callback() + def on_headerbar_volume_changed(self, widget, value): + if not self._setting_volume: + self._mcg.set_volume(int(value*100)) + + + # Infobar callback + + @Gtk.Template.Callback() + def on_info_bar_close(self, *args): + self.info_revealer.set_reveal_child(False) + + + @Gtk.Template.Callback() + def on_info_bar_response(self, widget, response): + self.info_revealer.set_reveal_child(False) + + + + # Panel callbacks + + def on_stack_switched(self, widget, prop): + self._set_visible_toolbar() + self._save_visible_panel() + self._set_menu_visible_panel() + for panel in self._panels: + panel.set_selected(panel == self.panel_stack.get_visible_child()) + GObject.idle_add( + self.panel_stack.child_set_property, + self.panel_stack.get_visible_child(), + 'needs-attention', + False + ) + + def on_panel_open_standalone(self, panel): + self.set_titlebar(panel.get_headerbar_standalone()) + + + def on_panel_close_standalone(self, headerbar): + self.set_titlebar(self.headerbar) + + def on_connection_panel_connection_changed(self, widget, host, port, password): + self._settings.set_string(Window.SETTING_HOST, host) + self._settings.set_int(Window.SETTING_PORT, port) + if use_keyring: + if password: + keyring.set_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME, password) + else: + if keyring.get_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME): + keyring.delete_password(ZeroconfProvider.KEYRING_SYSTEM, ZeroconfProvider.KEYRING_USERNAME) + + + def on_playlist_panel_clear_playlist(self, widget): + self._mcg.clear_playlist() + + + def on_playlist_panel_remove(self, widget, album): + self._mcg.remove_album_from_playlist(album) + + + def on_playlist_panel_remove_multiple(self, widget, albums): + self._mcg.remove_albums_from_playlist(albums) + + + def on_playlist_panel_play(self, widget, album): + self._mcg.play_album_from_playlist(album) + + + def on_playlist_panel_albumart(self, widget, album): + self._mcg.get_albumart(album) + + + def on_server_panel_output_device_changed(self, widget, device, enabled): + self._mcg.enable_output_device(device, enabled) + + + def on_cover_panel_toggle_fullscreen(self, widget): + if not self._state.get_property(WindowState.IS_FULLSCREENED): + self.fullscreen() + else: + self.unfullscreen() + + + def on_cover_panel_set_song(self, widget, pos, time): + self._mcg.seek(pos, time) + + + def on_cover_panel_albumart(self, widget, album): + self._mcg.get_albumart(album) + + + def on_library_panel_update(self, widget): + self._mcg.update() + + + def on_library_panel_play(self, widget, album): + self._mcg.play_album(album) + + + def on_library_panel_queue(self, widget, album): + self._mcg.queue_album(album) + + + def on_library_panel_queue_multiple(self, widget, albums): + self._mcg.queue_albums(albums) + + + def on_library_panel_item_size_changed(self, widget, size): + self._playlist_panel.set_item_size(size) + self._settings.set_int(Window.SETTING_ITEM_SIZE, self._library_panel.get_item_size()) + + + def on_library_panel_sort_order_changed(self, widget, sort_order): + self._settings.set_enum(Window.SETTING_SORT_ORDER, self._library_panel.get_sort_order()) + + + def on_library_panel_sort_type_changed(self, widget, sort_type): + self._settings.set_boolean(Window.SETTING_SORT_TYPE, self._library_panel.get_sort_type()) + + + def on_library_panel_albumart(self, widget, album): + self._mcg.get_albumart(album) + + + # MCG callbacks + + def on_mcg_connect(self, connected): + if connected: + GObject.idle_add(self._connect_connected) + self._mcg.load_playlist() + self._mcg.load_albums() + self._mcg.get_custom(Window._CUSTOM_STARTUP_COMPLETE) + self._mcg.get_status() + self._mcg.get_stats() + self._mcg.get_output_devices() + self._connect_action.set_state(GLib.Variant.new_boolean(True)) + self._play_action.set_enabled(True) + self._clear_playlist_action.set_enabled(True) + self._panel_action.set_enabled(True) + else: + GObject.idle_add(self._connect_disconnected) + self._connect_action.set_state(GLib.Variant.new_boolean(False)) + self._play_action.set_enabled(False) + self._clear_playlist_action.set_enabled(False) + self._panel_action.set_enabled(False) + + + def on_mcg_status(self, state, album, pos, time, volume, file, audio, bitrate, error): + # Album + GObject.idle_add(self._cover_panel.set_album, album) + if not album and self._state.get_property(WindowState.IS_FULLSCREENED): + self._fullscreen(False) + # State + if state == 'play': + GObject.idle_add(self._set_play) + GObject.idle_add(self._cover_panel.set_play, pos, time) + self._play_action.set_state(GLib.Variant.new_boolean(True)) + elif state == 'pause' or state == 'stop': + GObject.idle_add(self._set_pause) + GObject.idle_add(self._cover_panel.set_pause) + self._play_action.set_state(GLib.Variant.new_boolean(False)) + # Volume + GObject.idle_add(self._set_volume, volume) + # Status + self._server_panel.set_status(file, audio, bitrate, error) + # Error + if error is None: + self.info_revealer.set_reveal_child(False) + else: + self._show_error(error) + + + def on_mcg_stats(self, artists, albums, songs, dbplaytime, playtime, uptime): + self._server_panel.set_stats(artists, albums, songs, dbplaytime, playtime, uptime) + + + def on_mcg_load_output_devices(self, devices): + self._server_panel.set_output_devices(devices) + + + def on_mcg_load_playlist(self, playlist): + self._playlist_panel.set_playlist(self._connection_panel.get_host(), playlist) + + + def on_mcg_init_albums(self): + GObject.idle_add(self._library_panel.init_albums) + + + def on_mcg_pulse_albums(self): + GObject.idle_add(self._library_panel.load_albums) + + + def on_mcg_load_albums(self, albums): + self._library_panel.set_albums(self._connection_panel.get_host(), albums) + + + def on_mcg_load_albumart(self, album, data): + GObject.idle_add(self._cover_panel.set_albumart, album, data) + GObject.idle_add(self._playlist_panel.set_albumart, album, data) + GObject.idle_add(self._library_panel.set_albumart, album, data) + + + def on_mcg_custom(self, name): + if name == Window._CUSTOM_STARTUP_COMPLETE: + for panel in self._panels: + GObject.idle_add( + self.panel_stack.child_set_property, + panel, + 'needs-attention', + False + ) + + + def on_mcg_error(self, error): + GObject.idle_add(self._show_error, str(error)) + + + # Settings callbacks + + def on_settings_panel_changed(self, settings, key): + panel_index = settings.get_int(key) + self.panel_stack.set_visible_child(self._panels[panel_index]) + + + def on_settings_item_size_changed(self, settings, key): + size = settings.get_int(key) + self._playlist_panel.set_item_size(size) + self._library_panel.set_item_size(size) + + + def on_settings_sort_order_changed(self, settings, key): + sort_order = settings.get_enum(key) + self._library_panel.set_sort_order(sort_order) + + + def on_settings_sort_type_changed(self, settings, key): + sort_type = settings.get_boolean(key) + self._library_panel.set_sort_type(sort_type) + + + # Private methods + + def _connect(self): + self._connection_panel.set_sensitive(False) + self._set_headerbar_sensitive(False, True) + if self._mcg.is_connected(): + self._mcg.disconnect() + self._settings.set_boolean(Window.SETTING_CONNECTED, False) + else: + host = self._connection_panel.get_host() + port = self._connection_panel.get_port() + password = self._connection_panel.get_password() + self._mcg.connect(host, port, password) + self._settings.set_boolean(Window.SETTING_CONNECTED, True) + + + def _connect_connected(self): + self._headerbar_connected() + self._set_headerbar_sensitive(True, False) + self.content_stack.set_visible_child(self.panel_stack) + self.panel_stack.set_visible_child(self._panels[self._settings.get_int(Window.SETTING_PANEL)]) + + + def _connect_disconnected(self): + self._playlist_panel.stop_threads(); + self._library_panel.stop_threads(); + self._headerbar_disconnected() + self._set_headerbar_sensitive(False, False) + self._save_visible_panel() + self.content_stack.set_visible_child(self._connection_panel) + self._connection_panel.set_sensitive(True) + + + def _fullscreen(self, fullscreened_new): + if fullscreened_new != self._state.get_property(WindowState.IS_FULLSCREENED): + self._state.set_property(WindowState.IS_FULLSCREENED, fullscreened_new) + if self._state.get_property(WindowState.IS_FULLSCREENED): + self.headerbar.hide() + self._cover_panel.set_fullscreen(True) + # Hide cursor + self.get_window().set_cursor( + Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "none") + ) + else: + self.headerbar.show() + self._cover_panel.set_fullscreen(False) + # Reset cursor + self.get_window().set_cursor( + Gdk.Cursor.new_from_name(Gdk.Display.get_default(), "default") + ) + + + def _save_visible_panel(self): + panel_index_selected = self._panels.index(self.panel_stack.get_visible_child()) + self._settings.set_int(Window.SETTING_PANEL, panel_index_selected) + + + def _set_menu_visible_panel(self): + panel_index_selected = self._panels.index(self.panel_stack.get_visible_child()) + self._panel_action.set_state(GLib.Variant.new_string(str(panel_index_selected))) + + + def _set_visible_toolbar(self): + panel_index_selected = self._panels.index(self.panel_stack.get_visible_child()) + toolbar = self._panels[panel_index_selected].get_toolbar() + self.toolbar_stack.set_visible_child(toolbar) + + + def _set_play(self): + self.headerbar_button_playpause.handler_block_by_func( + self.on_headerbar_playpause_toggled + ) + self.headerbar_button_playpause.set_active(True) + self.headerbar_button_playpause.handler_unblock_by_func( + self.on_headerbar_playpause_toggled + ) + + + def _set_pause(self): + self.headerbar_button_playpause.handler_block_by_func( + self.on_headerbar_playpause_toggled + ) + self.headerbar_button_playpause.set_active(False) + self.headerbar_button_playpause.handler_unblock_by_func( + self.on_headerbar_playpause_toggled + ) + + + def _set_volume(self, volume): + if volume >= 0: + self.headerbar_button_volume.set_visible(True) + if not self._changing_volume: + self._setting_volume = True + self.headerbar_button_volume.set_value(volume / 100) + self._setting_volume = False + else: + self.headerbar_button_volume.set_visible(False) + + + def _headerbar_connected(self): + self.headerbar_button_connect.handler_block_by_func( + self.on_headerbar_connection_active_notify + ) + self.headerbar_button_connect.set_active(True) + self.headerbar_button_connect.set_state(True) + self.headerbar_button_connect.handler_unblock_by_func( + self.on_headerbar_connection_active_notify + ) + self.headerbar_title_stack.set_visible_child(self.headerbar_panel_switcher) + + + def _headerbar_disconnected(self): + self.headerbar_button_connect.handler_block_by_func( + self.on_headerbar_connection_active_notify + ) + self.headerbar_button_connect.set_active(False) + self.headerbar_button_connect.set_state(False) + self.headerbar_button_connect.handler_unblock_by_func( + self.on_headerbar_connection_active_notify + ) + self.headerbar_title_stack.set_visible_child(self.headerbar_connection_label) + + + def _set_headerbar_sensitive(self, sensitive, connecting): + self.headerbar_button_playpause.set_sensitive(sensitive) + self.headerbar_button_volume.set_sensitive(sensitive) + self.headerbar_panel_switcher.set_sensitive(sensitive) + self.headerbar_button_connect.set_sensitive(not connecting) + + + def _show_error(self, message): + self.info_bar.set_message_type(Gtk.MessageType.ERROR) + self.info_label.set_text(message) + self.info_revealer.set_reveal_child(True)