Initial
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
build/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
92
CHANGELOG.md
Normal file
92
CHANGELOG.md
Normal 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
79
Makefile
Normal 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 {} +
|
||||||
1
assembly_player/__init__.py
Normal file
1
assembly_player/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "1.1.1"
|
||||||
6
assembly_player/__main__.py
Normal file
6
assembly_player/__main__.py
Normal 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
534
assembly_player/app.py
Normal 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
157
assembly_player/bridge.js
Normal 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
87
assembly_player/config.py
Normal 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)
|
||||||
30
assembly_player/icons/assembly-player.svg
Normal file
30
assembly_player/icons/assembly-player.svg
Normal 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
81
assembly_player/state.py
Normal 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)
|
||||||
14
packaging/assembly-player.desktop
Normal file
14
packaging/assembly-player.desktop
Normal 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;
|
||||||
3
packaging/assembly-player.in
Normal file
3
packaging/assembly-player.in
Normal 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
45
tools/gen_icons.py
Normal 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()
|
||||||
Reference in New Issue
Block a user