"""GTK application: embeds the real stream.jw.org page in a WebKitGTK view, drives session selection/resume through the injected JS bridge, and exposes a GNOME/Wayland tray icon for pause/resume and quick controls.""" import json import os import sys # Must be set before WebKitGTK's WebProcess is spawned. Belt-and-suspenders # alongside the hardware-acceleration-policy setting below: switching video # sessions can otherwise leave the GPU-composited video layer stuck (black or # frozen frame, audio keeps playing) — a WebKitGTK/driver compositing bug. os.environ.setdefault("WEBKIT_DISABLE_COMPOSITING_MODE", "1") import gi gi.require_version("Gtk", "3.0") gi.require_version("WebKit2", "4.1") gi.require_version("JavaScriptCore", "4.1") try: gi.require_version("AyatanaAppIndicator3", "0.1") from gi.repository import AyatanaAppIndicator3 as AppIndicator3 HAVE_INDICATOR = True except (ValueError, ImportError): HAVE_INDICATOR = False try: gi.require_version("Notify", "0.7") from gi.repository import Notify HAVE_NOTIFY = True except (ValueError, ImportError): HAVE_NOTIFY = False from gi.repository import Gtk, WebKit2, GLib, Gio, Gdk, GdkPixbuf from . import config as cfg from . import state as st from . import __version__ APP_ID = "io.github.assemblyplayer.Player" ICON_NAME = "assembly-player" BRIDGE_JS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge.js") ICON_SVG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icons", "assembly-player.svg") def _load_bridge_source(): with open(BRIDGE_JS_PATH, "r", encoding="utf-8") as f: return f.read() class SettingsDialog(Gtk.Dialog): def __init__(self, parent, current_url, current_autostart, current_fullscreen): super().__init__(title="Paramètres", transient_for=parent, modal=True) self.set_default_size(460, -1) self.add_buttons("Annuler", Gtk.ResponseType.CANCEL, "Enregistrer", Gtk.ResponseType.OK) box = self.get_content_area() box.set_spacing(10) box.set_border_width(16) url_label = Gtk.Label(label="Lien de l'assemblée (stream.jw.org) :", xalign=0) box.add(url_label) self.url_entry = Gtk.Entry() self.url_entry.set_text(current_url) box.add(self.url_entry) self.autostart_check = Gtk.CheckButton(label="Lancer automatiquement au démarrage du PC") self.autostart_check.set_active(current_autostart) box.add(self.autostart_check) self.fullscreen_check = Gtk.CheckButton(label="Ouvrir en plein écran au démarrage") self.fullscreen_check.set_active(current_fullscreen) box.add(self.fullscreen_check) self.show_all() def get_values(self): return ( self.url_entry.get_text().strip(), self.autostart_check.get_active(), self.fullscreen_check.get_active(), ) class PlayerWindow(Gtk.ApplicationWindow): def __init__(self, application, start_minimized=False): super().__init__(application=application, title="Lecteur de sessions") self.set_default_size(1180, 760) self.set_icon_from_file(ICON_SVG_PATH) self.config = cfg.load() self.assembly_state = st.AssemblyState(cfg.assembly_code(self.config["url"])) self._resume_requested = False self._resume_title = None self._resume_position = 0.0 self._known_sessions = [] self._first_play_seen = False self._is_fullscreen = False self._build_ui() self._build_webview() self._build_indicator() self._build_actions() if HAVE_NOTIFY: Notify.init("Lecteur de sessions") self.connect("delete-event", self._on_delete_event) self.connect("window-state-event", self._on_window_state_event) if start_minimized: self.iconify() else: self.show_all() self._hint_bar.set_visible(True) if self.config.get("start_fullscreen") and not start_minimized: self.fullscreen() # -- UI --------------------------------------------------------------- def _build_ui(self): header = Gtk.HeaderBar() header.set_show_close_button(True) header.set_title("Lecteur de sessions") header.set_subtitle("stream.jw.org") self.set_titlebar(header) self.play_pause_btn = Gtk.Button.new_from_icon_name("media-playback-pause-symbolic", Gtk.IconSize.BUTTON) self.play_pause_btn.set_tooltip_text("Lecture / Pause") self.play_pause_btn.connect("clicked", self._on_play_pause_clicked) header.pack_start(self.play_pause_btn) prev_btn = Gtk.Button.new_from_icon_name("media-skip-backward-symbolic", Gtk.IconSize.BUTTON) prev_btn.set_tooltip_text("Session précédente") prev_btn.connect("clicked", lambda *_: self._jump_relative(-1)) header.pack_start(prev_btn) next_btn = Gtk.Button.new_from_icon_name("media-skip-forward-symbolic", Gtk.IconSize.BUTTON) next_btn.set_tooltip_text("Session suivante") next_btn.connect("clicked", lambda *_: self._jump_relative(1)) header.pack_start(next_btn) self.fullscreen_btn = Gtk.Button.new_from_icon_name("view-fullscreen-symbolic", Gtk.IconSize.BUTTON) self.fullscreen_btn.set_tooltip_text("Plein écran (F11)") self.fullscreen_btn.connect("clicked", lambda *_: self._toggle_fullscreen()) header.pack_start(self.fullscreen_btn) settings_btn = Gtk.Button.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.BUTTON) settings_btn.set_tooltip_text("Paramètres (F10)") settings_btn.connect("clicked", self._on_settings_clicked) header.pack_end(settings_btn) about_btn = Gtk.Button.new_from_icon_name("help-about-symbolic", Gtk.IconSize.BUTTON) about_btn.set_tooltip_text("À propos") about_btn.get_style_context().add_class("flat") about_btn.connect("clicked", self._on_about_clicked) header.pack_end(about_btn) self._root_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.add(self._root_box) self._hint_bar = Gtk.InfoBar() self._hint_bar.set_message_type(Gtk.MessageType.INFO) hint_label = Gtk.Label( label="Cliquez une fois sur ▶ dans la vidéo pour démarrer la lecture " "(requis une seule fois par lancement de l'application). " "La suite s'enchaîne ensuite automatiquement." ) hint_label.set_line_wrap(True) self._hint_bar.get_content_area().add(hint_label) self._root_box.pack_start(self._hint_bar, False, False, 0) def _build_webview(self): ucm = WebKit2.UserContentManager() script = WebKit2.UserScript.new( _load_bridge_source(), WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None, ) ucm.add_script(script) ucm.register_script_message_handler("bridge") ucm.connect("script-message-received::bridge", self._on_bridge_message) self.webview = WebKit2.WebView.new_with_user_content_manager(ucm) settings = self.webview.get_settings() settings.set_property("enable-mediasource", True) settings.set_property("media-playback-allows-inline", True) # Switching video sessions can leave the GPU-composited video layer # stuck (black frame, or a frozen stale frame with ALWAYS) — this is # a WebKitGTK/driver compositing bug tied to accelerated video # rendering itself. Disabling hardware acceleration removes the GPU # layer entirely: every frame is redrawn from scratch in software, so # a stale/stuck texture cannot happen. Costs some CPU, not GPU. settings.set_property("hardware-acceleration-policy", WebKit2.HardwareAccelerationPolicy.NEVER) self._root_box.pack_start(self.webview, True, True, 0) self.webview.load_uri(self.config["url"]) def _build_actions(self): # Registered as GActions (win.*) with application-wide accelerators: # these fire before the focused child widget gets the key event, so # they keep working even while the embedded video/web page has # keyboard focus (a plain widget-level key-press-event handler does # not: WebKitGTK swallows almost every keystroke itself). fullscreen_action = Gio.SimpleAction.new("toggle-fullscreen", None) fullscreen_action.connect("activate", lambda *_: self._toggle_fullscreen()) self.add_action(fullscreen_action) settings_action = Gio.SimpleAction.new("open-settings", None) settings_action.connect("activate", self._on_settings_clicked) self.add_action(settings_action) app = self.get_application() app.set_accels_for_action("win.toggle-fullscreen", ["F11"]) app.set_accels_for_action("win.open-settings", ["F10", "comma"]) def _build_indicator(self): if not HAVE_INDICATOR: self.indicator = None return self.indicator = AppIndicator3.Indicator.new( APP_ID, ICON_NAME, AppIndicator3.IndicatorCategory.APPLICATION_STATUS ) self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) self.indicator.set_icon_theme_path(os.path.dirname(ICON_SVG_PATH)) menu = Gtk.Menu() show_item = Gtk.MenuItem(label="Afficher la fenêtre") show_item.connect("activate", lambda *_: self._show_window()) menu.append(show_item) hide_item = Gtk.MenuItem(label="Masquer la fenêtre (continuer en arrière-plan)") hide_item.connect("activate", lambda *_: self.hide()) menu.append(hide_item) self.tray_play_pause_item = Gtk.MenuItem(label="Lecture / Pause") self.tray_play_pause_item.connect("activate", self._on_play_pause_clicked) menu.append(self.tray_play_pause_item) self.fullscreen_item = Gtk.CheckMenuItem(label="Plein écran") self.fullscreen_item.set_active(self._is_fullscreen) self._fullscreen_item_handler = self.fullscreen_item.connect( "toggled", lambda *_: self._toggle_fullscreen() ) menu.append(self.fullscreen_item) menu.append(Gtk.SeparatorMenuItem()) self.autostart_item = Gtk.CheckMenuItem(label="Lancer au démarrage du PC") self.autostart_item.set_active(cfg.is_autostart_enabled()) self.autostart_item.connect("toggled", self._on_autostart_toggled) menu.append(self.autostart_item) settings_item = Gtk.MenuItem(label="Changer le lien de l'assemblée…") settings_item.connect("activate", self._on_settings_clicked) menu.append(settings_item) about_item = Gtk.MenuItem(label="À propos") about_item.connect("activate", self._on_about_clicked) menu.append(about_item) menu.append(Gtk.SeparatorMenuItem()) quit_item = Gtk.MenuItem(label="Quitter") quit_item.connect("activate", lambda *_: self.get_application().quit()) menu.append(quit_item) menu.show_all() self.indicator.set_menu(menu) # -- bridge / JS helpers ----------------------------------------------- def _run_js(self, code): self.webview.evaluate_javascript(code, -1, None, None, None, None, None) def _on_bridge_message(self, _manager, js_result): try: raw = js_result.get_js_value().to_string() data = json.loads(raw) except (ValueError, AttributeError): return msg_type = data.get("type") if msg_type == "sessions": self._handle_sessions(data.get("titles") or []) elif msg_type == "metadata": self._handle_metadata(data.get("title")) elif msg_type == "progress": self._handle_progress(data.get("title"), data.get("currentTime")) elif msg_type == "ended": self._handle_ended(data.get("title")) elif msg_type == "playstate": self._handle_playstate(data.get("paused")) def _handle_sessions(self, titles): if not titles: return self._known_sessions = titles self.assembly_state.set_sessions(titles) if self._resume_requested: return self._resume_requested = True title, position = self.assembly_state.resume_target() if title is None: title = titles[0] position = 0.0 self._resume_title = title self._resume_position = position self._run_js(f"window.__bridge.openByTitle({json.dumps(title)})") def _handle_metadata(self, title): if title and title == self._resume_title and self._resume_position: self._run_js(f"window.__bridge.seek({self._resume_position})") self._resume_position = 0.0 def _handle_progress(self, title, current_time): if not title or current_time is None: return self.assembly_state.update_progress(title, current_time) def _handle_ended(self, title): if not title: return self.assembly_state.mark_completed(title) next_title = self._next_session_after(title) if next_title: self._run_js(f"window.__bridge.openByTitle({json.dumps(next_title)})") self._notify("Session suivante", next_title) else: self._notify("Assemblée terminée", "Toutes les sessions ont été lues.") def _handle_playstate(self, paused): icon = "media-playback-start-symbolic" if paused else "media-playback-pause-symbolic" self.play_pause_btn.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) if paused is False and not self._first_play_seen: self._first_play_seen = True self._hint_bar.set_visible(False) def _next_session_after(self, title): if title not in self._known_sessions: return None idx = self._known_sessions.index(title) if idx + 1 < len(self._known_sessions): return self._known_sessions[idx + 1] return None def _jump_relative(self, delta): if not self._known_sessions: return current = self._resume_title try: idx = self._known_sessions.index(current) if current else 0 except ValueError: idx = 0 new_idx = max(0, min(len(self._known_sessions) - 1, idx + delta)) title = self._known_sessions[new_idx] self._resume_title = title self._run_js(f"window.__bridge.openByTitle({json.dumps(title)})") # -- UI callbacks -------------------------------------------------- def _on_play_pause_clicked(self, *_a): self._run_js( "(function(){const s=JSON.parse(window.__bridge.getState());" "if(s.paused){window.__bridge.play();}else{window.__bridge.pause();}})()" ) def _on_about_clicked(self, *_a): dialog = Gtk.AboutDialog(transient_for=self, modal=True) dialog.set_program_name("Lecteur de sessions") dialog.set_version(__version__) dialog.set_comments( "Lit automatiquement, dans l'ordre, les sessions vidéo d'une assemblée " "sur un lien stream.jw.org configurable, avec reprise de lecture." ) dialog.set_license_type(Gtk.License.CUSTOM) dialog.set_license( "Projet non officiel, réalisé pour un usage personnel.\n" "Ni développé, ni approuvé, ni affilié à watchtower.org / jw.org / " "stream.jw.org. L'application affiche la page officielle telle quelle " "dans une fenêtre et l'automatise ; aucun contenu n'est téléchargé, " "copié ou redistribué." ) dialog.set_copyright( "Application développée avec l'assistance de l'IA, sous la direction " "et la supervision de Johnny." ) try: logo = GdkPixbuf.Pixbuf.new_from_file_at_scale(ICON_SVG_PATH, 128, 128, True) dialog.set_logo(logo) except GLib.Error: dialog.set_logo_icon_name(ICON_NAME) dialog.run() dialog.destroy() def _on_settings_clicked(self, *_a): dialog = SettingsDialog( self, self.config["url"], cfg.is_autostart_enabled(), self.config.get("start_fullscreen", False) ) while True: response = dialog.run() if response != Gtk.ResponseType.OK: break url, autostart, start_fullscreen = dialog.get_values() if not cfg.is_valid_stream_url(url): error = Gtk.MessageDialog( transient_for=self, modal=True, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text="Lien invalide", secondary_text="Le lien doit ressembler à https://stream.jw.org/xxxx-xxxx-xxxx-xxxx", ) error.run() error.destroy() continue url_changed = url != self.config["url"] self.config["url"] = url self.config["start_fullscreen"] = start_fullscreen cfg.save(self.config) self._apply_autostart(autostart) if url_changed: self._reload_for_new_url(url) break dialog.destroy() def _apply_autostart(self, enabled): try: cfg.set_autostart(enabled) if getattr(self, "autostart_item", None): self.autostart_item.set_active(enabled) except RuntimeError as e: error = Gtk.MessageDialog( transient_for=self, modal=True, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=str(e), ) error.run() error.destroy() def _on_autostart_toggled(self, item): self._apply_autostart(item.get_active()) def _reload_for_new_url(self, url): self.assembly_state = st.AssemblyState(cfg.assembly_code(url)) self._resume_requested = False self._resume_title = None self._resume_position = 0.0 self._known_sessions = [] self._first_play_seen = False self._hint_bar.set_visible(True) self.webview.load_uri(url) def _toggle_fullscreen(self): if self._is_fullscreen: self.unfullscreen() else: self.fullscreen() self._notify( "Plein écran activé", "F11 pour revenir en fenêtré, F10 pour les Paramètres (la barre du haut est masquée).", ) def _on_window_state_event(self, _widget, event): self._is_fullscreen = bool(event.new_window_state & Gdk.WindowState.FULLSCREEN) icon = "view-restore-symbolic" if self._is_fullscreen else "view-fullscreen-symbolic" self.fullscreen_btn.set_image(Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.BUTTON)) if getattr(self, "fullscreen_item", None): self.fullscreen_item.handler_block(self._fullscreen_item_handler) self.fullscreen_item.set_active(self._is_fullscreen) self.fullscreen_item.handler_unblock(self._fullscreen_item_handler) def _show_window(self): self.deiconify() self.present() def _on_delete_event(self, *_a): # Closing the window (the [x] button) fully quits the app and stops # playback immediately — it must not linger silently in the tray with # audio still running. Use "Masquer la fenêtre" in the tray menu (or # the window manager's own minimize button) to keep it running in the # background on purpose. self._run_js("window.__bridge && window.__bridge.pause()") self.get_application().quit() return True def _notify(self, title, body): if HAVE_NOTIFY: try: Notify.Notification.new(title, body, ICON_NAME).show() except GLib.Error: pass class AssemblyPlayerApplication(Gtk.Application): def __init__(self, start_minimized=False): super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE) self._start_minimized = start_minimized self.window = None def do_activate(self): if self.window is None: self.window = PlayerWindow(self, start_minimized=self._start_minimized) # A plain accelerator can be swallowed by the embedded WebView # before it ever reaches the window (WebKitGTK consumes almost # every keystroke itself). Gdk.event_handler_set intercepts every # event application-wide, before GTK's normal widget dispatch, so # F11/F10 keep working no matter what has keyboard focus. Gdk.event_handler_set(self._on_global_event, None) else: self.window.present() def _on_global_event(self, event, _data): if event.type == Gdk.EventType.KEY_PRESS: _, keyval = event.get_keyval() action_name = {Gdk.KEY_F11: "toggle-fullscreen", Gdk.KEY_F10: "open-settings"}.get(keyval) if action_name and self.window is not None: self.window.lookup_action(action_name).activate(None) return Gtk.main_do_event(event) def main(argv=None): argv = argv if argv is not None else sys.argv start_minimized = "--minimized" in argv GLib.set_prgname("assembly-player") Gtk.Window.set_default_icon_from_file(ICON_SVG_PATH) app = AssemblyPlayerApplication(start_minimized=start_minimized) return app.run([a for a in argv if a != "--minimized"])