diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..255c255 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b2d4117 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +Toutes les modifications notables de ce projet sont documentées ici. +Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et ce +projet suit le [Semantic Versioning](https://semver.org/lang/fr/). + +## [1.1.1] - 2026-07-13 + +### Ajouté + +- Mention discrète en bas de la page « À propos » : « Application développée + avec l'assistance de l'IA, sous la direction et la supervision de Johnny. » + +## [1.1.0] - 2026-07-13 + +### Ajouté + +- Bouton discret « À propos » (icône ⓘ dans la barre du haut, et entrée dans + le menu de l'icône de la barre des tâches) affichant nom, version, icône et + rappel du caractère non officiel de l'application. + +## [1.0.4] - 2026-07-13 + +### Corrigé + +- La version 1.0.3 remplaçait l'image noire par une image **figée** lors du + changement de session (même bug de compositing, symptôme différent selon + la politique d'accélération). L'accélération matérielle de la page est + maintenant **entièrement désactivée** (`hardware-acceleration-policy=NEVER` + + `WEBKIT_DISABLE_COMPOSITING_MODE=1`) : chaque image est redessinée en + logiciel, ce qui évite structurellement qu'une texture GPU reste bloquée. + Léger coût CPU en contrepartie, pas de perte de fonctionnalité. + +## [1.0.3] - 2026-07-13 + +### Corrigé + +- Image noire (son présent, image figée/noire) parfois observée en passant + d'une session à l'autre : bug connu de WebKitGTK lié à sa politique + d'accélération matérielle « à la demande », qui promeut/rétrograde le + compositeur à chaque changement de source vidéo. La politique est + désormais forcée à « toujours activée », plus un correctif de secours côté + page (forçage d'un repaint de la vidéo au démarrage de chaque session). + +## [1.0.2] - 2026-07-13 + +### Corrigé + +- En plein écran, la barre du haut (bouton ☰ Paramètres) est masquée par GTK — + et le raccourci `F11` ne fonctionnait pas de façon fiable car la vidéo web + capte le clavier avant la fenêtre, ce qui rendait les Paramètres + inaccessibles une fois le plein écran activé. +- `F11` (plein écran) et `F10` (Paramètres) sont maintenant interceptés au + niveau de l'application avant la vidéo intégrée, donc ils fonctionnent de + façon fiable même en plein écran. +- Une notification système rappelle ces deux raccourcis à l'entrée en plein + écran. + +## [1.0.1] - 2026-07-13 + +### Corrigé + +- Fermer la fenêtre (croix) coupe désormais bien la lecture et **quitte + complètement** l'application, au lieu de la masquer silencieusement dans la + barre des tâches avec le son qui continuait. + +### Ajouté + +- Entrée « Masquer la fenêtre (continuer en arrière-plan) » dans le menu de + l'icône, pour celles et ceux qui veulent volontairement garder l'application + active sans fenêtre visible (comportement désormais explicite, plus jamais + implicite). + +## [1.0.0] - 2026-07-13 + +### Ajouté + +- Première version : lecteur automatisé pour les sessions d'assemblée sur un + lien `stream.jw.org` configurable. +- Détection automatique de la liste des sessions et lecture dans l'ordre, + avec enchaînement automatique en fin de session. +- Reprise de lecture (session + position) persistée entre les lancements et + après redémarrage du PC. +- Démarrage automatique au démarrage du PC (bascule dans les Paramètres, le + menu de l'icône, ou `make autostart-enable` / `make autostart-disable`). +- Mode plein écran (bouton, `F11`, menu de l'icône). +- Icône de la barre des tâches (AppIndicator, GNOME/Wayland) avec Lecture/ + Pause, navigation entre sessions, plein écran et changement de lien. +- Icône d'application dédiée (SVG source + PNG générés pour le thème + d'icônes hicolor). +- `Makefile` (`deps`, `icons`, `install`, `uninstall`, `run`, `test`, + `autostart-enable`/`disable`, `clean`), `README.md`. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1603701 --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +PYTHON ?= python3 +PREFIX ?= $(HOME)/.local +BINDIR := $(PREFIX)/bin +SHAREDIR := $(PREFIX)/share/assembly-player +APPDIR := $(PREFIX)/share/applications +ICONDIR := $(PREFIX)/share/icons/hicolor +BUILDDIR := build + +APT_PACKAGES := python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-webkit2-4.1 \ + gir1.2-ayatanaappindicator3-0.1 gir1.2-notify-0.7 gir1.2-rsvg-2.0 + +.PHONY: help deps check-deps icons install uninstall run test clean autostart-enable autostart-disable + +.DEFAULT_GOAL := help + +help: ## Afficher cette aide + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +check-deps: ## Vérifier que les bibliothèques système requises sont installées + @$(PYTHON) -c "import gi; gi.require_version('Gtk','3.0'); gi.require_version('WebKit2','4.1'); from gi.repository import Gtk, WebKit2" \ + || { echo "❌ GTK3 / WebKit2GTK (4.1) introuvables. Lancez 'make deps'."; exit 1; } + @echo "✅ Dépendances Python/GTK présentes." + +deps: ## Installer les paquets système requis (Debian/Ubuntu, sudo) + @command -v apt-get >/dev/null || { echo "❌ apt-get introuvable ; installez manuellement : $(APT_PACKAGES)"; exit 1; } + sudo apt-get update + sudo apt-get install -y $(APT_PACKAGES) + +icons: ## Générer les icônes PNG (hicolor) à partir du SVG source + @$(PYTHON) -c "import gi; gi.require_version('Rsvg','2.0')" \ + || { echo "❌ gir1.2-rsvg-2.0 introuvable. Lancez 'make deps'."; exit 1; } + $(PYTHON) tools/gen_icons.py $(BUILDDIR)/icons + +install: check-deps icons ## Installer l'application pour l'utilisateur courant (PREFIX=$(PREFIX)) + install -d $(BINDIR) $(SHAREDIR) $(APPDIR) + cp -r assembly_player $(SHAREDIR)/ + sed -e 's#__PYTHONPATH__#$(SHAREDIR)#' packaging/assembly-player.in > $(BUILDDIR)/assembly-player + install -m 755 $(BUILDDIR)/assembly-player $(BINDIR)/assembly-player + install -m 644 packaging/assembly-player.desktop $(APPDIR)/assembly-player.desktop + @for size in 16 22 24 32 48 64 128 256 512; do \ + install -d $(ICONDIR)/$${size}x$${size}/apps; \ + install -m 644 $(BUILDDIR)/icons/$${size}x$${size}/apps/assembly-player.png $(ICONDIR)/$${size}x$${size}/apps/assembly-player.png; \ + done + install -d $(ICONDIR)/scalable/apps + install -m 644 $(BUILDDIR)/icons/scalable/apps/assembly-player.svg $(ICONDIR)/scalable/apps/assembly-player.svg + @command -v update-desktop-database >/dev/null && update-desktop-database $(APPDIR) 2>/dev/null || true + @command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -f -t $(ICONDIR) 2>/dev/null || true + @echo "✅ Installé. Lancez 'assembly-player' (vérifiez que $(BINDIR) est dans votre PATH)." + +uninstall: ## Désinstaller l'application (PREFIX=$(PREFIX)) + rm -f $(BINDIR)/assembly-player + rm -rf $(SHAREDIR) + rm -f $(APPDIR)/assembly-player.desktop + @for size in 16 22 24 32 48 64 128 256 512; do \ + rm -f $(ICONDIR)/$${size}x$${size}/apps/assembly-player.png; \ + done + rm -f $(ICONDIR)/scalable/apps/assembly-player.svg + rm -f $(HOME)/.config/autostart/assembly-player.desktop + @echo "✅ Désinstallé." + +run: check-deps ## Lancer l'application depuis les sources (développement) + $(PYTHON) -m assembly_player + +test: ## Vérifier que les modules Python compilent + $(PYTHON) -m py_compile assembly_player/*.py tools/gen_icons.py + @echo "✅ Compilation OK." + +autostart-enable: ## Activer le lancement automatique au démarrage du PC + @$(PYTHON) -c "from assembly_player import config; config.set_autostart(True)" + @echo "✅ Démarrage automatique activé." + +autostart-disable: ## Désactiver le lancement automatique au démarrage du PC + @$(PYTHON) -c "from assembly_player import config; config.set_autostart(False)" + @echo "✅ Démarrage automatique désactivé." + +clean: ## Supprimer les fichiers générés + rm -rf $(BUILDDIR) + find . -name '__pycache__' -type d -exec rm -rf {} + diff --git a/assembly_player/__init__.py b/assembly_player/__init__.py new file mode 100644 index 0000000..a82b376 --- /dev/null +++ b/assembly_player/__init__.py @@ -0,0 +1 @@ +__version__ = "1.1.1" diff --git a/assembly_player/__main__.py b/assembly_player/__main__.py new file mode 100644 index 0000000..6ccbe2f --- /dev/null +++ b/assembly_player/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .app import main + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/assembly_player/app.py b/assembly_player/app.py new file mode 100644 index 0000000..f9dbcb0 --- /dev/null +++ b/assembly_player/app.py @@ -0,0 +1,534 @@ +"""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"]) diff --git a/assembly_player/bridge.js b/assembly_player/bridge.js new file mode 100644 index 0000000..15242a1 --- /dev/null +++ b/assembly_player/bridge.js @@ -0,0 +1,157 @@ +// Injected into stream.jw.org. Discovers the ordered list of on-demand +// sessions, drives session selection, and reports playback progress back to +// the native app through window.webkit.messageHandlers.bridge. +(function () { + if (window.__bridge) return; + + const CARD_TITLE_SELECTOR = '[data-testid="vod-program-card-title"]'; + const CARD_SELECTOR = '[data-testid="vod-program-card"]'; + const COOKIE_ACCEPT_SELECTOR = '.lnc-acceptCookiesButton'; + const PROGRESS_INTERVAL_MS = 4000; + + let video = null; + let pendingSeek = null; + let lastReportedTitle = null; + let knownTitles = []; + + function post(payload) { + try { + window.webkit.messageHandlers.bridge.postMessage(JSON.stringify(payload)); + } catch (e) { + // messageHandlers unavailable (e.g. plain browser testing); ignore. + } + } + + function currentTitle() { + return document.title.replace(/\s*::\s*JW Stream\s*$/, '').trim(); + } + + function dismissCookieBanner() { + const btn = document.querySelector(COOKIE_ACCEPT_SELECTOR); + if (btn) btn.click(); + } + + function scanPrograms() { + const nodes = document.querySelectorAll(CARD_TITLE_SELECTOR); + if (!nodes.length) return; + const titles = Array.from(nodes).map((n) => n.textContent.trim()); + const changed = + titles.length !== knownTitles.length || titles.some((t, i) => t !== knownTitles[i]); + if (changed) { + knownTitles = titles; + post({ type: 'sessions', titles }); + } + } + + function nudgeRepaint(v) { + // Workaround for a WebKitGTK compositing glitch: switching the video + // source can leave the picture black (audio keeps playing) until + // something forces the compositor to repaint the video layer. Toggling + // a transform forces exactly that, harmlessly. + v.style.transform = 'translateZ(0)'; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + v.style.transform = ''; + }); + }); + } + + function attachVideo(v) { + if (video === v) return; + video = v; + + video.addEventListener('loadedmetadata', () => { + if (pendingSeek != null && isFinite(video.duration)) { + video.currentTime = Math.min(pendingSeek, Math.max(video.duration - 1, 0)); + pendingSeek = null; + } + post({ type: 'metadata', title: currentTitle(), duration: video.duration }); + }); + + video.addEventListener('ended', () => { + post({ type: 'ended', title: currentTitle() }); + }); + + video.addEventListener('play', () => post({ type: 'playstate', paused: false })); + video.addEventListener('pause', () => post({ type: 'playstate', paused: true })); + video.addEventListener('playing', () => nudgeRepaint(v)); + } + + function reportProgress() { + if (!video) return; + const title = currentTitle(); + if (!title) return; + post({ + type: 'progress', + title, + currentTime: video.currentTime, + duration: video.duration, + paused: video.paused, + }); + lastReportedTitle = title; + } + + function scanForVideo() { + const v = document.querySelector('video.vjs-tech, [data-testid="main-player"] video'); + if (v) attachVideo(v); + } + + const observer = new MutationObserver(() => { + dismissCookieBanner(); + scanPrograms(); + scanForVideo(); + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + + setInterval(() => { + dismissCookieBanner(); + scanPrograms(); + scanForVideo(); + reportProgress(); + }, PROGRESS_INTERVAL_MS); + + window.__bridge = { + getPrograms() { + return JSON.stringify(knownTitles); + }, + openByTitle(title) { + const nodes = document.querySelectorAll(CARD_TITLE_SELECTOR); + for (const n of nodes) { + if (n.textContent.trim() === title) { + const btn = n.closest('button'); + if (btn) { + btn.click(); + return true; + } + } + } + return false; + }, + seek(seconds) { + if (video && isFinite(video.duration)) { + video.currentTime = Math.min(seconds, Math.max(video.duration - 1, 0)); + } else { + pendingSeek = seconds; + } + }, + play() { + if (video) video.play().catch(() => {}); + }, + pause() { + if (video) video.pause(); + }, + getState() { + return JSON.stringify({ + hasVideo: !!video, + paused: video ? video.paused : null, + currentTime: video ? video.currentTime : null, + duration: video ? video.duration : null, + title: currentTitle(), + }); + }, + }; + + dismissCookieBanner(); + scanPrograms(); + scanForVideo(); +})(); diff --git a/assembly_player/config.py b/assembly_player/config.py new file mode 100644 index 0000000..183b9a2 --- /dev/null +++ b/assembly_player/config.py @@ -0,0 +1,87 @@ +"""Persistent user configuration: the configured stream.jw.org link and the +autostart-at-login toggle.""" +import json +import os +import re +import shutil + +XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) +CONFIG_DIR = os.path.join(XDG_CONFIG_HOME, "assembly-player") +CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json") + +AUTOSTART_DIR = os.path.join(XDG_CONFIG_HOME, "autostart") +AUTOSTART_PATH = os.path.join(AUTOSTART_DIR, "assembly-player.desktop") + +DEFAULT_URL = "https://stream.jw.org/9021-8224-3277-8078" + +_URL_RE = re.compile(r"^https://stream\.jw\.org/[A-Za-z0-9-]+/?$") + + +def is_valid_stream_url(url): + return bool(_URL_RE.match(url.strip())) + + +def assembly_code(url): + """Extract the trailing code (e.g. 9021-8224-3277-8078) used as a state key.""" + return url.strip().rstrip("/").rsplit("/", 1)[-1] + + +def load(): + if not os.path.exists(CONFIG_PATH): + return {"url": DEFAULT_URL, "autostart": False, "start_minimized": False, "start_fullscreen": False} + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + data.setdefault("url", DEFAULT_URL) + data.setdefault("autostart", False) + data.setdefault("start_minimized", False) + data.setdefault("start_fullscreen", False) + return data + + +def save(config): + os.makedirs(CONFIG_DIR, exist_ok=True) + tmp_path = CONFIG_PATH + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + os.replace(tmp_path, CONFIG_PATH) + + +def _find_installed_desktop_file(): + """Locate the installed launcher .desktop file so autostart uses the exact + same Exec= / Icon= the user installed (user prefix or system prefix).""" + candidates = [ + os.path.expanduser("~/.local/share/applications/assembly-player.desktop"), + "/usr/local/share/applications/assembly-player.desktop", + "/usr/share/applications/assembly-player.desktop", + ] + for path in candidates: + if os.path.exists(path): + return path + return None + + +def set_autostart(enabled): + if not enabled: + if os.path.exists(AUTOSTART_PATH): + os.remove(AUTOSTART_PATH) + return + + os.makedirs(AUTOSTART_DIR, exist_ok=True) + source = _find_installed_desktop_file() + if source is None: + raise RuntimeError( + "Aucun fichier .desktop installé n'a été trouvé. " + "Lancez d'abord 'make install' avant d'activer le démarrage automatique." + ) + with open(source, "r", encoding="utf-8") as f: + content = f.read() + # Start hidden in the tray and silently resume playback, instead of + # popping a window in the user's face right after login. + content = content.replace("Exec=assembly-player", "Exec=assembly-player --minimized") + content += "X-GNOME-Autostart-enabled=true\n" + with open(AUTOSTART_PATH, "w", encoding="utf-8") as f: + f.write(content) + + +def is_autostart_enabled(): + return os.path.exists(AUTOSTART_PATH) diff --git a/assembly_player/icons/assembly-player.svg b/assembly_player/icons/assembly-player.svg new file mode 100644 index 0000000..9abc467 --- /dev/null +++ b/assembly_player/icons/assembly-player.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assembly_player/state.py b/assembly_player/state.py new file mode 100644 index 0000000..047c548 --- /dev/null +++ b/assembly_player/state.py @@ -0,0 +1,81 @@ +"""Per-assembly playback progress, persisted so playback can resume exactly +where the user left off, across app restarts and reboots.""" +import datetime +import json +import os + +XDG_STATE_HOME = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) +STATE_DIR = os.path.join(XDG_STATE_HOME, "assembly-player") +STATE_PATH = os.path.join(STATE_DIR, "state.json") + + +def _now(): + return datetime.datetime.now().astimezone().isoformat(timespec="seconds") + + +def load_all(): + if not os.path.exists(STATE_PATH): + return {} + try: + with open(STATE_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + +def save_all(data): + os.makedirs(STATE_DIR, exist_ok=True) + tmp_path = STATE_PATH + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + os.replace(tmp_path, STATE_PATH) + + +def _blank_entry(): + return {"sessions": [], "current": None, "position": 0.0, "completed": [], "updated": _now()} + + +class AssemblyState: + """Convenience wrapper around the state entry for a single assembly code.""" + + def __init__(self, code): + self.code = code + self._all = load_all() + self._entry = self._all.setdefault(code, _blank_entry()) + + def set_sessions(self, ordered_titles): + self._entry["sessions"] = ordered_titles + self._persist() + + def update_progress(self, title, position): + self._entry["current"] = title + self._entry["position"] = position + self._entry["updated"] = _now() + self._persist() + + def mark_completed(self, title): + if title not in self._entry["completed"]: + self._entry["completed"].append(title) + if self._entry.get("current") == title: + self._entry["current"] = None + self._entry["position"] = 0.0 + self._persist() + + def resume_target(self): + """Return (title, position) to resume at, or (None, 0) if nothing to resume.""" + current = self._entry.get("current") + sessions = self._entry.get("sessions") or [] + completed = set(self._entry.get("completed") or []) + + if current and current not in completed: + return current, self._entry.get("position", 0.0) + + for title in sessions: + if title not in completed: + return title, 0.0 + + return None, 0.0 + + def _persist(self): + self._all[self.code] = self._entry + save_all(self._all) diff --git a/packaging/assembly-player.desktop b/packaging/assembly-player.desktop new file mode 100644 index 0000000..18a01fb --- /dev/null +++ b/packaging/assembly-player.desktop @@ -0,0 +1,14 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=Lecteur de sessions +Name[fr]=Lecteur de sessions +Comment=Lit automatiquement les sessions d'une assemblée sur stream.jw.org, dans l'ordre, avec reprise de lecture +Comment[fr]=Lit automatiquement les sessions d'une assemblée sur stream.jw.org, dans l'ordre, avec reprise de lecture +Exec=assembly-player %U +Icon=assembly-player +Terminal=false +Categories=AudioVideo;Player; +StartupWMClass=assembly-player +StartupNotify=true +Keywords=JW;Stream;Assemblee;Convention;Video; diff --git a/packaging/assembly-player.in b/packaging/assembly-player.in new file mode 100644 index 0000000..501af2a --- /dev/null +++ b/packaging/assembly-player.in @@ -0,0 +1,3 @@ +#!/bin/sh +# Installed launcher for the Assembly Session Player. +exec env PYTHONPATH="__PYTHONPATH__:${PYTHONPATH}" python3 -m assembly_player "$@" diff --git a/tools/gen_icons.py b/tools/gen_icons.py new file mode 100644 index 0000000..6304872 --- /dev/null +++ b/tools/gen_icons.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Rasterise assembly-player.svg into the PNG sizes needed by the hicolor icon theme.""" +import os +import sys + +import gi + +gi.require_version("Rsvg", "2.0") +from gi.repository import Rsvg +import cairo + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SVG_PATH = os.path.join(ROOT, "assembly_player", "icons", "assembly-player.svg") +SIZES = (16, 22, 24, 32, 48, 64, 128, 256, 512) + + +def render(size, out_path): + handle = Rsvg.Handle.new_from_file(SVG_PATH) + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + ctx = cairo.Context(surface) + viewport = Rsvg.Rectangle() + viewport.x, viewport.y, viewport.width, viewport.height = 0, 0, size, size + handle.render_document(ctx, viewport) + surface.write_to_png(out_path) + + +def main(): + out_dir = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "build", "icons") + for size in SIZES: + size_dir = os.path.join(out_dir, f"{size}x{size}", "apps") + os.makedirs(size_dir, exist_ok=True) + out_path = os.path.join(size_dir, "assembly-player.png") + render(size, out_path) + print(f"wrote {out_path}") + + scalable_dir = os.path.join(out_dir, "scalable", "apps") + os.makedirs(scalable_dir, exist_ok=True) + scalable_svg = os.path.join(scalable_dir, "assembly-player.svg") + with open(SVG_PATH, "rb") as src, open(scalable_svg, "wb") as dst: + dst.write(src.read()) + print(f"wrote {scalable_svg}") + + +if __name__ == "__main__": + main()