This commit is contained in:
Johnny
2026-07-13 09:39:24 +02:00
parent 2e43c0ff49
commit 55684ec37c
13 changed files with 1132 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
build/
__pycache__/
*.pyc

92
CHANGELOG.md Normal file
View File

@@ -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`.

79
Makefile Normal file
View File

@@ -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 {} +

View File

@@ -0,0 +1 @@
__version__ = "1.1.1"

View File

@@ -0,0 +1,6 @@
import sys
from .app import main
if __name__ == "__main__":
sys.exit(main(sys.argv))

534
assembly_player/app.py Normal file
View File

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

157
assembly_player/bridge.js Normal file
View File

@@ -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();
})();

87
assembly_player/config.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#173042"/>
<stop offset="1" stop-color="#0d1b26"/>
</linearGradient>
<linearGradient id="play" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#26e0d6"/>
<stop offset="1" stop-color="#00bcd4"/>
</linearGradient>
</defs>
<rect x="8" y="8" width="240" height="240" rx="52" fill="url(#bg)"/>
<rect x="8" y="8" width="240" height="240" rx="52" fill="none" stroke="#00bcd4" stroke-opacity="0.25" stroke-width="4"/>
<!-- session list ticks -->
<g fill="#4c6b7a">
<rect x="60" y="72" width="60" height="10" rx="5"/>
<rect x="60" y="92" width="40" height="10" rx="5"/>
</g>
<g fill="#26e0d6">
<rect x="60" y="174" width="60" height="10" rx="5"/>
<rect x="60" y="194" width="40" height="10" rx="5"/>
</g>
<!-- play button -->
<circle cx="150" cy="132" r="58" fill="url(#play)"/>
<path d="M133 104 L178 132 L133 160 Z" fill="#0d1b26"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

81
assembly_player/state.py Normal file
View File

@@ -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)

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
#!/bin/sh
# Installed launcher for the Assembly Session Player.
exec env PYTHONPATH="__PYTHONPATH__:${PYTHONPATH}" python3 -m assembly_player "$@"

45
tools/gen_icons.py Normal file
View File

@@ -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()