Files
JW-Player/assembly_player/app.py
2026-07-13 09:39:24 +02:00

535 lines
22 KiB
Python

"""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", "<Primary>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"])