1# Copyright (C) 2012 Mathias Brodala 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2, or (at your option) 6# any later version. 7# 8# This program is distributed in the hope that it will be useful, 9# but WITHOUT ANY WARRANTY; without even the implied warranty of 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11# GNU General Public License for more details. 12# 13# You should have received a copy of the GNU General Public License 14# along with this program; if not, write to the Free Software 15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 17 18from collections import namedtuple 19import logging 20import sys 21 22from gi.repository import Gdk 23from gi.repository import GLib 24from gi.repository import Gtk 25 26from xl import event, player, settings as xl_settings 27from xl.nls import gettext as _ 28from xlgui.widgets import info 29from xlgui import guiutil 30 31from . import osd_preferences 32 33 34LOGGER = logging.getLogger(__name__) 35 36 37Point = namedtuple('Point', 'x y') 38 39 40def do_assert(is_bool): 41 """ 42 Simulates the `assert` statement 43 """ 44 if not is_bool: 45 raise AssertionError() 46 47 48def _sanitize_window_geometry( 49 window, current_allocation, padding, width_fill, height_fill 50): 51 """ 52 Sanitizes (x-offset, y-offset, width, height) of the given window, 53 to make the window show on the screen. 54 55 :param width_fill, height_fill: specifies the maximum width or height 56 of a monitor to fill. 1.0 means "fill the whole monitor" 57 :param padding: specifies the padding (from workarea border) to leave empty 58 """ 59 work_area = guiutil.get_workarea_dimensions(window) 60 cural = current_allocation 61 newal = Gdk.Rectangle() 62 63 newal.x = max(padding, cural.x) 64 newal.y = max(padding, cural.y) 65 66 newal.width = min(work_area.width // width_fill, cural.width) 67 newal.height = min(work_area.height // height_fill, cural.height) 68 69 newal.x = min(newal.x, work_area.x + work_area.width - newal.width - padding) 70 newal.y = min(newal.y, work_area.y + work_area.height - newal.height - padding) 71 72 if newal == cural: 73 return None 74 75 if cural.x != newal.x or cural.y != newal.y: 76 if cural.width != newal.width or cural.height != newal.height: 77 window.get_window().move_resize(newal.x, newal.y, newal.width, newal.height) 78 else: 79 window.move(newal.x, newal.y) 80 else: 81 if cural.width != newal.width or cural.height != newal.height: 82 window.resize(newal.width, newal.height) 83 return newal 84 85 86class OSDPlugin: 87 """ 88 The plugin for showing an On-Screen Display. 89 This object holds all the stuff which may live longer than the window. 90 Please note that the window has to be destroyed during plugin runtime, 91 see the OSDWindow docstring below for details. 92 """ 93 94 __window = None 95 __css_provider = None 96 __options = None 97 98 def enable(self, _exaile): 99 """ 100 Enables the on screen display plugin 101 """ 102 do_assert(self.__window is None) 103 104 # Note: Moving windows will not work on Wayland by design, because Wayland does not know 105 # absolute window positioning. Gtk.Window.move() does not work there. 106 # See https://blog.gtk.org/2016/07/15/future-of-relative-window-positioning/ 107 # and https://lists.freedesktop.org/archives/wayland-devel/2015-September/024464.html 108 if guiutil.platform_is_wayland(): 109 raise EnvironmentError("This plugin does not work on Wayland backend.") 110 111 # Cached option values 112 self.__options = { 113 'background': None, 114 'display_duration': None, 115 'border_radius': None, 116 'use_alpha': False, 117 } 118 self.__css_provider = Gtk.CssProvider() 119 if sys.platform.startswith("win32"): 120 # Setting opacity on Windows crashes with segfault, 121 # see https://bugzilla.gnome.org/show_bug.cgi?id=674449 122 self.__options['use_alpha'] = False 123 LOGGER.warning( 124 "OSD: Disabling alpha channel because it is not supported on Windows." 125 ) 126 else: 127 self.__options['use_alpha'] = True 128 129 def teardown(self, _exaile): 130 """ 131 Shuts down the on screen display plugin 132 """ 133 do_assert(self.__window is not None) 134 self.__window.destroy_osd() 135 event.remove_callback(self.__on_option_set, 'plugin_osd_option_set') 136 event.remove_callback(self.__on_playback_track_start, 'playback_track_start') 137 event.remove_callback(self.__on_playback_toggle_pause, 'playback_toggle_pause') 138 event.remove_callback(self.__on_playback_player_end, 'playback_player_end') 139 event.remove_callback(self.__on_playback_error, 'playback_error') 140 self.__window = None 141 142 def disable(self, _exaile): 143 """ 144 Disables the on screen display plugin 145 """ 146 self.teardown(_exaile) 147 148 def on_gui_loaded(self): 149 """ 150 Called when Exaile mostly finished loading 151 """ 152 do_assert(self.__window is None) 153 event.add_callback(self.__on_option_set, 'plugin_osd_option_set') 154 self.__prepare_osd(False) 155 # TODO: OSD looks ugly with CSS not applied on first show. Why is that? 156 157 event.add_callback(self.__on_playback_track_start, 'playback_track_start') 158 event.add_callback(self.__on_playback_toggle_pause, 'playback_toggle_pause') 159 event.add_callback(self.__on_playback_player_end, 'playback_player_end') 160 event.add_callback(self.__on_playback_error, 'playback_error') 161 162 def get_preferences_pane(self): 163 """ 164 Called when the user wants to see the preferences pane for this plugin 165 """ 166 osd_preferences.OSDPLUGIN = self 167 return osd_preferences 168 169 def make_osd_editable(self, be_editable): 170 """ 171 Rebuilds the OSD to make it movable and resizable 172 """ 173 do_assert(self.__window is not None) 174 self.__window.destroy_osd() 175 self.__window = None 176 self.__prepare_osd(be_editable) 177 self.__window.show_for_a_while() 178 179 def __prepare_osd(self, be_editable): 180 do_assert(self.__window is None) 181 self.__window = OSDWindow(self.__css_provider, self.__options, be_editable) 182 # Trigger initial setup through options. 183 for option in ( 184 'format', 185 'background', 186 'display_duration', 187 'show_progress', 188 'position', 189 'width', 190 'height', 191 'border_radius', 192 ): 193 self.__on_option_set( 194 'plugin_osd_option_set', 195 xl_settings, 196 'plugin/osd/{option}'.format(option=option), 197 ) 198 self.__window.restore_geometry_and_show() 199 200 def __on_option_set(self, _event, settings, option): 201 """ 202 Updates appearance on setting change 203 """ 204 if option == 'plugin/osd/format': 205 self.__window.info_area.set_info_format( 206 settings.get_option(option, osd_preferences.FormatPreference.default) 207 ) 208 elif option == 'plugin/osd/background': 209 if not self.__options['background']: 210 self.__options['background'] = Gdk.RGBA() 211 rgba = self.__options['background'] 212 rgba.parse( 213 settings.get_option( 214 option, osd_preferences.BackgroundPreference.default 215 ) 216 ) 217 if self.__options['use_alpha'] is True: 218 if rgba.alpha > 0.995: 219 # Bug: We need to set opacity to some value < 1 here 220 # otherwise both corners and fade out transition will look ugly 221 rgba.alpha = 0.99 222 settings.set_option(option, rgba.to_string()) 223 else: 224 if rgba.alpha < 1: 225 rgba.to_color() 226 settings.set_option(option, rgba.to_string()) 227 GLib.idle_add(self.__update_css_provider) 228 elif option == 'plugin/osd/border_radius': 229 value = settings.get_option( 230 option, osd_preferences.BorderRadiusPreference.default 231 ) 232 self.__window.set_border_width(max(6, value // 2)) 233 self.__options['border_radius'] = value 234 GLib.idle_add(self.__update_css_provider) 235 self.__window.emit('size-allocate', self.__window.get_allocation()) 236 elif option == 'plugin/osd/display_duration': 237 self.__options['display_duration'] = int( 238 settings.get_option( 239 option, osd_preferences.DisplayDurationPreference.default 240 ) 241 ) 242 elif option == 'plugin/osd/show_progress': 243 self.__window.info_area.set_display_progress( 244 settings.get_option( 245 option, osd_preferences.ShowProgressPreference.default 246 ) 247 ) 248 elif option == 'plugin/osd/position': 249 position = Point._make(settings.get_option(option, [20, 20])) 250 self.__window.geometry['x'] = position.x 251 self.__window.geometry['y'] = position.y 252 elif option == 'plugin/osd/width': 253 width = settings.get_option(option, 300) 254 self.__window.geometry['width'] = width 255 elif option == 'plugin/osd/height': 256 height = settings.get_option(option, 120) 257 self.__window.geometry['height'] = height 258 259 def __update_css_provider(self): 260 bgcolor = self.__options['background'] 261 radius = self.__options['border_radius'] 262 if bgcolor is None or radius is None: 263 return # seems like we are in early initialization state 264 if self.__options['use_alpha'] is True: 265 color_str = guiutil.css_from_rgba(bgcolor) 266 else: 267 color_str = guiutil.css_from_rgba_without_alpha(bgcolor) 268 data_str = "window { background-color: %s; border-radius: %spx; }" % ( 269 color_str, 270 str(radius), 271 ) 272 self.__css_provider.load_from_data(data_str.encode('utf-8')) 273 return False 274 275 def __on_playback_track_start(self, _event, _player, _track): 276 self.__window.show_for_a_while() 277 278 def __on_playback_toggle_pause(self, _event, _player, _track): 279 self.__window.show_for_a_while() 280 281 def __on_playback_player_end(self, _event, _player, track): 282 if track is None: 283 self.__window.hide_immediately() 284 else: 285 self.__window.show_for_a_while() 286 287 def __on_playback_error(self, _event, _player, _message): 288 # TODO: show error instead? 289 self.__window.hide_immediately() 290 291 292plugin_class = OSDPlugin 293 294 295class OSDWindow(Gtk.Window): 296 """ 297 A popup window showing information of the currently playing track 298 299 Due to the way, the Gtk+ API and some of the many different window managers work, 300 the OSD cannot be resizable and movable and have no keyboard focus nor decorations 301 at the same time. 302 Additionally, in some cases the Gtk+ API specifies that functions may not 303 be called after the window has been realized (). In some other cases, Gtk+ API does 304 not guarantee that a function works after Gtk.Window.show() is called 305 (e.g. Gtk.Window.set_decorated(), set_deletable(), set_titlebar()). 306 For these reasons, we need to destroy and rebuild the OSD when we want it to be 307 resizable and movable by simple drag operations. 308 309 Another related bug report: 310 https://bugzilla.gnome.org/show_bug.cgi?id=782117: 311 If a window was initially shown undecorated and set_decorated(True) is called, 312 titlebar is drawn inside the window 313 """ 314 315 __hide_id = None 316 __fadeout_id = None 317 __autohide = True 318 __options = None 319 geometry = dict(x=20, y=20, width=300, height=120) # the default 320 321 def __init__(self, css_provider, options, allow_resize_move): 322 """ 323 Initializes the OSD Window. 324 Important: Do not call this constructor before Exaile finished loading, 325 otherwise the internal TrackInfoPane will re-render label and icon on each 326 `track_tags_changed` event, which causes unnecessary CPU load and delays startup. 327 328 Apply the options after this object was initialized. 329 """ 330 Gtk.Window.__init__(self, type=Gtk.WindowType.TOPLEVEL) 331 self.__options = options 332 333 self.set_title('Exaile OSD') 334 self.set_keep_above(True) 335 self.stick() 336 337 # the next two options don't work on GNOME/Wayland due to a bug 338 # between Gtk+ and gnome-shell: 339 # https://bugzilla.gnome.org/show_bug.cgi?id=771329 340 # there is no API guaranty that they work on other platforms. 341 self.set_skip_pager_hint(True) 342 self.set_skip_taskbar_hint(True) 343 # There is no API guaranty that set_deletable() will work 344 self.set_deletable(False) 345 self.connect('delete-event', lambda _widget, _event: self.hide_immediately) 346 347 self.connect('screen-changed', self.__on_screen_changed) 348 349 style_context = self.get_style_context() 350 style_context.add_provider( 351 css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 352 ) 353 354 # Init child widgets 355 self.info_area = info.TrackInfoPane(player.PLAYER) 356 self.info_area.set_default_text(_('No track played yet')) 357 # enable updating OSD contents 358 # this is very expensive if done during Exaile startup! 359 self.info_area.set_auto_update(True) 360 self.info_area.cover.set_property('visible', True) 361 # If we don't do this, the label text will be selected if the user 362 # pressed the mouse button while the OSD is shown for the first time. 363 self.info_area.info_label.set_selectable(False) 364 self.info_area.show_all() 365 self.add(self.info_area) 366 367 self.__setup_resize_move_related_stuff(allow_resize_move) 368 369 # callbacks needed to show the OSD long enough: 370 self.add_events(Gdk.EventMask.LEAVE_NOTIFY_MASK) 371 self.connect('leave-notify-event', self.__on_leave_notify_event) 372 373 # Also, maximize, minimize, etc. might happen and we want to undo that 374 self.add_events(Gdk.EventMask.STRUCTURE_MASK) 375 self.connect('window-state-event', self.__on_window_state_event) 376 377 # Needed to acquire size 378 self.info_area.set_display_progress(True) 379 380 # set up the window visual 381 self.__on_screen_changed(self, None) 382 383 def __setup_resize_move_related_stuff(self, allow_resize_move): 384 # Without decorations, the window cannot be resized on some desktops 385 # this especially effects GNOME/Wayland and is probably caused by 386 # missing client-side decorations (CSD). This code might break when 387 # using client side decorations. In this case, we probably shoud hide 388 # the titlebar instead of removing the decorations. 389 # Removing decorations is ignored on some platforms to enable 390 # the resize grid. 391 self.set_decorated(False) 392 self.set_resizable(allow_resize_move) 393 394 if allow_resize_move: 395 self.set_type_hint(Gdk.WindowTypeHint.NORMAL) 396 self.set_title(_("Move or resize OSD")) 397 # This is often ignored, but we could try: 398 self.connect( 399 'realize', 400 lambda _widget: self.get_window().set_decorations( 401 Gdk.WMDecoration.RESIZEH | Gdk.WMDecoration.TITLE 402 ), 403 ) 404 else: 405 # On X11 (at least XWayland), this will make the window be not movable, 406 # but makes sure the user can still type. 407 self.set_type_hint(Gdk.WindowTypeHint.NOTIFICATION) 408 409 # We do not want to disturb user keyboard input 410 self.set_accept_focus(allow_resize_move) 411 412 self.__autohide = not allow_resize_move 413 414 def __on_window_state_event(self, _widget, win_state): 415 illegal_states = ( 416 Gdk.WindowState.FULLSCREEN 417 | Gdk.WindowState.ICONIFIED 418 | Gdk.WindowState.TILED 419 | Gdk.WindowState.MAXIMIZED 420 | Gdk.WindowState.BELOW 421 ) 422 if ( 423 win_state.changed_mask & illegal_states 424 and win_state.new_window_state & illegal_states 425 ): 426 # Just returning Gdk.EVENT_STOP doesn't stop the window manager 427 # from changing window state. 428 # TODO: This often does not work at all. 429 GLib.idle_add(self.restore_geometry_and_show) 430 return Gdk.EVENT_STOP 431 else: 432 return Gdk.EVENT_PROPAGATE 433 434 def destroy_osd(self): 435 """ 436 Cleanups 437 """ 438 # Getting the position can only work on a window being shown on screen 439 # This is no problem since the window will be shown permanently during configuration. 440 if self.is_visible(): 441 # X11: Position is off by OSD, because it is fetched with OSD but set without OSD 442 # There is no simple way to fix this because we don't know the OSD geometry. 443 # Wayland: Position does not work at all. 444 xl_settings.set_option('plugin/osd/position', list(self.get_position())) 445 446 # Don't use Gdk.Window.get_width() here, it may include client-side window decorations! 447 # This is not guaranteed to work according to Gtk.Window docs, but it works for me. 448 width, height = self.get_size() 449 xl_settings.set_option('plugin/osd/width', width) 450 xl_settings.set_option('plugin/osd/height', height) 451 self.hide_immediately() 452 if self.__fadeout_id: 453 GLib.source_remove(self.__fadeout_id) 454 self.__fadeout_id = None 455 if self.__hide_id: 456 GLib.source_remove(self.__hide_id) 457 self.__hide_id = None 458 Gtk.Window.destroy(self) 459 460 def __start_fadeout(self): 461 """ 462 Starts fadeout of the window. 463 Hides the window it immediately if fadeout is disabled 464 """ 465 self.__hide_id = None 466 467 gdk_display = self.get_window().get_display() 468 # Keep showing the OSD in case the pointer is still over the OSD 469 if ( 470 Gtk.get_major_version() > 3 471 or Gtk.get_major_version() == 3 472 and Gtk.get_minor_version() >= 20 473 ): 474 gdk_seat = gdk_display.get_default_seat() 475 gdk_device = gdk_seat.get_pointer() 476 else: 477 gdk_device_manager = gdk_display.get_device_manager() 478 gdk_device = gdk_device_manager.get_client_pointer() 479 window, _posx, _posy = gdk_device.get_window_at_position() 480 if window and window is self.get_window(): 481 self.show_for_a_while() 482 return 483 484 if self.__options['use_alpha'] is True: 485 if self.__fadeout_id is None: 486 self.__fadeout_id = GLib.timeout_add(30, self.__do_fadeout_step) 487 else: 488 Gtk.Window.hide(self) 489 return False 490 491 def show_for_a_while(self): 492 """ 493 This method makes sure that the OSD is shown. Any previous hiding 494 timers or fading transitions will be stopped. 495 If hiding is allowed through self.__autohide, a new hiding timer 496 will be started. 497 """ 498 # unset potential fadeout process 499 if self.__fadeout_id: 500 GLib.source_remove(self.__fadeout_id) 501 self.__fadeout_id = None 502 if Gtk.Widget.get_opacity(self) < 1: 503 Gtk.Widget.set_opacity(self, 1) 504 # unset potential hide process 505 if self.__hide_id: 506 do_assert(self.__fadeout_id is None) 507 GLib.source_remove(self.__hide_id) 508 self.__hide_id = None 509 # (re)start hide process 510 if self.__autohide: 511 self.__hide_id = GLib.timeout_add_seconds( 512 self.__options['display_duration'], self.__start_fadeout 513 ) 514 Gtk.Window.present(self) 515 516 def restore_geometry_and_show(self): 517 """ 518 Restores window geometry from options and shows the window afterwards. 519 """ 520 geo = self.geometry 521 # automatically resizes to minimum required size 522 self.set_default_size(geo['width'], geo['height']) 523 524 self.move(geo['x'], geo['y']) 525 self.show_for_a_while() 526 # screen size might have changed 527 allocation = Gdk.Rectangle() 528 allocation.x = geo['x'] 529 allocation.y = geo['y'] 530 allocation.width = geo['width'] 531 allocation.height = geo['height'] 532 _sanitize_window_geometry(super(Gtk.Window, self), allocation, 10, 0.2, 0.2) 533 534 def set_autohide(self, do_autohide): 535 """ 536 Permanently shows the OSD during configuration. 537 This method should only be used from osd_preferences. 538 """ 539 self.__autohide = do_autohide 540 if do_autohide: 541 do_assert(self.__hide_id is None) 542 do_assert(self.__fadeout_id is None) 543 GLib.idle_add(self.show_for_a_while) 544 545 def __do_fadeout_step(self): 546 """ 547 Constantly decreases the opacity to fade out the window 548 """ 549 do_assert(self.__hide_id is None) 550 if Gtk.Widget.get_opacity(self) > 0.001: 551 Gtk.Widget.set_opacity(self, Gtk.Widget.get_opacity(self) - 0.05) 552 return True 553 else: 554 self.__fadeout_id = None 555 Gtk.Window.hide(self) 556 return False 557 558 def __on_screen_changed(self, _widget, _oldscreen): 559 """ 560 Updates the used colormap 561 """ 562 screen = self.get_screen() 563 visual = screen.get_rgba_visual() 564 if visual is None: 565 # This might happen if there is no X compositor so the X Server 566 # does not support transparency 567 visual = screen.get_system_visual() 568 self.__options['use_alpha'] = False 569 LOGGER.warning( 570 "OSD: Disabling alpha channel because the Gtk+ " 571 "backend does not support it." 572 ) 573 self.set_visual(visual) 574 575 ''' 576 def __on_size_allocate(self, _widget, _allocation): 577 """ 578 Applies the non-rectangular shape 579 """ 580 # TODO: make this work again 581 # Bug in pycairo: cairo_region_* functions are not available before 582 # version 1.11.0, see https://bugs.freedesktop.org/show_bug.cgi?id=44336 583 # we might want to enable this code below once pycairo is distributed on 584 # most Linux distros. 585 # cairo_region = cairo.Region.create_rectangle(allocation) 586 # as a result, calling 587 # self.get_window().shape_combine_region(cairo_region, 0, 0) 588 # is impossible. Thus, it is impossible to shape the window. 589 # Instead, we have to work around this issue by leaving parts 590 # of the window undrawn. 591 592 # leave the old code here for reference: 593 width, height = allocation.width, allocation.height 594 mask = Gdk.Pixmap(None, width, height, 1) 595 context = mask.cairo_create() 596 597 context.set_source_rgb(0, 0, 0) 598 context.set_operator(cairo.OPERATOR_CLEAR) 599 context.paint() 600 601 radius = self.__options['border_radius'] 602 inner = (radius, radius, width - radius, height - radius) 603 604 context.set_source_rgb(1, 1, 1) 605 context.set_operator(cairo.OPERATOR_SOURCE) 606 # Top left corner 607 context.arc(inner.x, inner.y, radius, 1.0 * pi, 1.5 * pi) 608 # Top right corner 609 context.arc(inner.width, inner.y, radius, 1.5 * pi, 2.0 * pi) 610 # Bottom right corner 611 context.arc(inner.width, inner.height, radius, 0.0 * pi, 0.5 * pi) 612 # Bottom left corner 613 context.arc(inner.x, inner.height, radius, 0.5 * pi, 1.0 * pi) 614 context.fill() 615 616 self.shape_combine_mask(mask, 0, 0) 617 ''' 618 619 def __on_leave_notify_event(self, _widget, event_crossing): 620 # NotifyType.NONLINEAR means the pointer left the window, not just the widget. 621 if event_crossing.detail == Gdk.NotifyType.NONLINEAR: 622 self.show_for_a_while() 623 return Gdk.EVENT_PROPAGATE 624 625 def hide_immediately(self): 626 """ 627 Immediately hides the OSD and removes all remaining timers or transitions 628 """ 629 if self.__fadeout_id: 630 GLib.source_remove(self.__fadeout_id) 631 self.__fadeout_id = None 632 if self.__hide_id: 633 GLib.source_remove(self.__hide_id) 634 self.__hide_id = None 635 Gtk.Window.hide(self) 636