@@ 5,15 5,21 @@ gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw
class IconButton(Gtk.Button):
- def __init__(self, name, icon, tooltip="", *args, **kwargs):
+ def __init__(self, name, icon, tooltip=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.inner = Adw.ButtonContent()
self.inner.set_can_shrink(True)
self.set_can_shrink(True)
self.set_child(self.inner)
self.update(name, icon, tooltip)
- def update(self, name, icon, tooltip=""):
- self.inner.set_label(name)
+ def set_icon(self, icon):
self.inner.set_icon_name(icon)
- if tooltip != "":
- self.set_tooltip_text(tooltip)
+ def set_name(self, name):
+ self.inner.set_label(name)
+ def set_tooltip(self, tooltip):
+ self.set_tooltip_text(tooltip)
+ def update(self, name, icon, tooltip=None):
+ self.set_name(name)
+ self.set_icon(icon)
+ if not tooltip is None:
+ self.set_tooltip(tooltip)
@@ 8,6 8,8 @@ from unidecode import unidecode
from gettext import gettext as _
from datetime import datetime
+from enum import Enum, auto
+
from melon.servers import Stream
from melon.widgets.iconbutton import IconButton
from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference
@@ 79,7 81,7 @@ class VolumeDisplay(OverlayDispay):
def get_text_for(self, value:float) -> str:
return f"{int(value * 100)}%"
-class VideoPlayer(Gtk.Overlay):
+class VideoPlayerBase(Gtk.Overlay):
volume = 1.0
# inverse opacity of overlay
brightness = 1.0
@@ 94,6 96,8 @@ class VideoPlayer(Gtk.Overlay):
self.stopped = True
self.update_callback=None
self.ended_callback=None
+ self.toggle_fullscreen = None
+ self.toggle_popout = None
overlay = Gtk.Overlay()
self.set_child(overlay)
@@ 126,6 130,7 @@ class VideoPlayer(Gtk.Overlay):
# this makes the player full-width
self.picture.set_can_shrink(True)
self.picture.set_keep_aspect_ratio(True)
+ self.picture.set_hexpand(True)
# Set the paintable on the picture
self.picture.set_paintable(paintable)
@@ 144,6 149,30 @@ class VideoPlayer(Gtk.Overlay):
overlay.add_overlay(self.volume_display)
self.volume_display.hide()
+ self.win_controls = Gtk.Box()
+ overlay.add_overlay(self.win_controls)
+ # used for flat button styles
+ self.win_controls.add_css_class("toolbar")
+ # add padding to box
+ self.win_controls.add_css_class("osd")
+ # distance control box from edges
+ margin = 4
+ self.win_controls.set_margin_start(margin)
+ self.win_controls.set_margin_end(margin)
+ self.win_controls.set_margin_top(margin)
+ self.win_controls.set_margin_bottom(margin)
+ # add player window control buttons on top right
+ self.win_controls.set_halign(Gtk.Align.END)
+ self.win_controls.set_valign(Gtk.Align.START)
+
+ self.popout_ctr = IconButton("", "view-paged-symbolic", tooltip=_("Toggle floating window"))
+ self.popout_ctr.connect("clicked", lambda _: self._toggle_popout())
+ self.win_controls.append(self.popout_ctr)
+
+ self.fullscreen_ctr = IconButton("", "view-fullscreen-symbolic", tooltip=_("Toggle fullscreen"))
+ self.fullscreen_ctr.connect("clicked", lambda _: self._toggle_fullscreen())
+ self.win_controls.append(self.fullscreen_ctr)
+
self.controls = Gtk.Box()
overlay.add_overlay(self.controls)
# used for flat button styles
@@ 151,7 180,6 @@ class VideoPlayer(Gtk.Overlay):
# add padding to box
self.controls.add_css_class("osd")
# distance control box from edges
- margin = 4
self.controls.set_margin_start(margin)
self.controls.set_margin_end(margin)
self.controls.set_margin_top(margin)
@@ 251,14 279,40 @@ class VideoPlayer(Gtk.Overlay):
def connect_ended(self, callback=None):
self.ended_callback = callback
+ def _toggle_fullscreen(self):
+ if not self.toggle_fullscreen is None:
+ state = self.toggle_fullscreen()
+ def _toggle_popout(self):
+ if not self.toggle_popout is None:
+ state = self.toggle_popout()
+
+ def set_toggle_states(self, popout:bool, fullscreen:bool):
+ if popout:
+ # window now popped out
+ self.popout_ctr.set_icon("view-dual-symbolic")
+ else:
+ self.popout_ctr.set_icon("view-paged-symbolic")
+ if fullscreen:
+ # now fullscreen
+ self.fullscreen_ctr.set_icon("view-restore-symbolic")
+ else:
+ self.fullscreen_ctr.set_icon("view-fullscreen-symbolic")
+
+ def connect_toggle_fullscreen(self, callback=None):
+ self.toggle_fullscreen = callback
+ def connect_toggle_popout(self, callback=None):
+ self.toggle_popout = callback
+
def _show_controls(self):
self.controls.set_opacity(1)
+ self.win_controls.set_opacity(1)
def _hide_controls(self, force=False):
if self.hover_ctr.get_property("contains-pointer") and not force:
# mouse is still hovering on player
# do not hide - try again later
return True
self.controls.set_opacity(0)
+ self.win_controls.set_opacity(0)
def _schedule_hide_controls(self, timeout=2):
GLib.timeout_add(timeout*1000, self._hide_controls)
@@ 519,3 573,151 @@ def format_seconds(secs:float) -> str:
m = f"{minutes:02}"
s = f"{seconds:02}"
return f"{h}{m}:{s}"
+
+class VideoPlayer(Gtk.Stack):
+ def __init__(self, streams: list[Stream], *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.window = None
+ self.player = VideoPlayerBase(streams)
+ self.player.connect_toggle_fullscreen(self.toggle_fullscreen)
+ self.player.connect_toggle_popout(self.toggle_popout)
+ placeholder = Gtk.Label()
+ placeholder.set_label(_("The video is playing in separate window"))
+ self.add_named(placeholder, "placeholder")
+ self._show_player()
+ def _show_player(self):
+ has_player = self.get_child_by_name("player")
+ if not has_player is None:
+ # has player -> remove it
+ self.remove(has_player)
+ else:
+ self.player.unparent()
+ self.add_named(self.player, "player")
+ self.set_visible_child_name("player")
+ def _popout_player(self):
+ has_player = self.get_child_by_name("player")
+ if not has_player is None:
+ self.remove(has_player)
+ self.set_visible_child_name("placeholder")
+ # bridge methods
+ def connect_update(self, callback=None):
+ self.player.connect_update(callback)
+ def connect_ended(self, callback=None):
+ self.player.connect_ended(callback)
+ def change_volume(self, vol):
+ self.player.change_volume(vol)
+ def change_brightness(self, bright):
+ self.player.change_brightness(bright)
+ def seek(self, delta):
+ self.player.seek(delta)
+ def seek_forwards(self, delta=30):
+ self.player.seek_forwards(delta)
+ def seek_backwards(self, delta=30):
+ self.player.seek_backwards(delta)
+ def goto(self, position):
+ self.player.goto(position)
+ def play(self):
+ self.player.play()
+ def pause(self):
+ self.player.pause()
+ def stop(self):
+ self.player.stop()
+ def select_stream(self, stream):
+ self.player.select_stream(stream)
+
+ def _create_window(self):
+ # remove the player from old tree
+ self._popout_player()
+ # create a new window
+ self.window = VideoPlayerWindow(self.player)
+ self.window.connect_close(self._show_player)
+ self.window.conenct_full(lambda s: self.player.set_toggle_states(True, s))
+
+ _only_fullscreen = False
+ def toggle_fullscreen(self) -> bool:
+ if self.window is None:
+ # currently fixed
+ self._only_fullscreen = True
+ self._create_window()
+ self.window.fullscreen()
+ # Note that you shouldn’t assume the window is definitely fullscreen afterward
+ res = self.window.is_fullscreen()
+ self.player.set_toggle_states(True, res)
+ return res
+ else:
+ # already popped out
+ if self.window.is_fullscreen():
+ # currently fullscreened so we want to exit that
+ self.window.unfullscreen()
+ # video was triggert from fixed mode
+ if self._only_fullscreen:
+ # we also want to close the window
+ # request close first to properly detatch the player
+ self.window.close()
+ # this force closes the window afterwards
+ self.window.destroy()
+ self.window = None
+ self.player.set_toggle_states(False, False)
+ return False
+ # Note that you shouldn’t assume the window is definitely fullscreen afterward
+ res = self.window.is_fullscreen()
+ self.player.set_toggle_states(True, res)
+ return res
+ else:
+ # in floating mode
+ self.window.fullscreen()
+ self._only_fullscreen = False
+ # Note that you shouldn’t assume the window is definitely fullscreen afterward
+ res = self.window.is_fullscreen()
+ self.player.set_toggle_states(True, res)
+ return res
+ def toggle_popout(self) -> bool:
+ if self.window is None:
+ # currently fixed
+ self._create_window()
+ self.player.set_toggle_states(True, False)
+ # Note that you shouldn’t assume the window is definitely fullscreen afterward
+ return True
+ else:
+ # we also want to close the window
+ # request close first to properly detatch the player
+ self.window.close()
+ # this force closes the window afterwards
+ self.window.destroy()
+ self.window = None
+ self.player.set_toggle_states(False, False)
+ return False
+
+
+class VideoPlayerWindow(Adw.Window):
+ def __init__(self, player:VideoPlayerBase, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.player = player
+ self.onclose = None
+ self.onfull = None
+ self._lastfull = None
+ # connect to new parent
+ self.set_content(self.player)
+ # this should block the application
+ self.set_modal(True)
+ self.connect("close-request", lambda _: self.close())
+ self.connect("notify", lambda a,b: self._notify())
+ self.set_visible(True)
+ self.set_title(_("Player"))
+
+ def _notify(self):
+ curr = self.is_fullscreen()
+ if curr == self._lastfull:
+ return
+ self._lastfull = curr
+ if not self.onfull is None:
+ self.onfull(curr)
+
+ def close(self):
+ if not self.onclose is None:
+ self.onclose()
+
+ def connect_close(self, callback=None):
+ self.onclose = callback
+ def conenct_full(self, callback=None):
+ self.onfull = callback