From bdf43307385bcffd84934403435d457b3f7a2edc Mon Sep 17 00:00:00 2001 From: Bashy Date: Sat, 25 Apr 2026 13:09:44 +0300 Subject: [PATCH] Initial Version --- .gitignore | 4 ++ README.md | 79 ++++++++++++++++++++++++ hudfx.py | 171 +++++++++++++++++++++++++++++++++++++++++++++++++++ obs_hudfx.py | 67 ++++++++++++++++++++ overlay.py | 62 +++++++++++++++++++ 5 files changed, 383 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 hudfx.py create mode 100644 obs_hudfx.py create mode 100644 overlay.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..416014b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.log +__pycache__/ +*.pyc +*.pyo diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3984e3 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# HudFX LNX + +A transparent, always-on-top screen overlay for HudFX alerts on Linux. Displays your HudFX browser overlay URL on a chosen monitor without interfering with other windows or inputs. Optionally launches automatically with OBS. + +## Requirements + +**Python 3** and the following packages: + +```bash +sudo apt install python3-pyqt5 python3-pyqt5.qtwebengine +``` + +## Setup + +### 1. Configure the overlay + +Run the tray app: + +```bash +python3 ~/projects/HudFX_LNX/hudfx.py +``` + +A tray icon will appear. Right-click → **Settings** and enter: +- Your HudFX overlay URL +- Which monitor to display on (picked from a list of detected screens) + +Settings are saved to `~/.config/hudfx-lnx/config.json`. + +### 2. Using the overlay + +The overlay starts automatically when the tray app launches. Right-click the tray icon to: +- **Stop overlay** / **Start overlay** — toggle it on or off +- **Settings** — change URL or monitor +- **Quit** — close everything + +### 3. Auto-start with OBS (optional) + +To have the overlay launch and close automatically with OBS: + +1. Open OBS → **Tools → Scripts** +2. Click **+** and select `~/projects/HudFX_LNX/obs_hudfx.py` + +The overlay will start when OBS finishes loading and stop when OBS exits. The tray icon still appears so you can toggle it off mid-session if needed. + +The OBS script auto-detects whether OBS is installed as a Flatpak or natively and adjusts accordingly — no manual configuration needed. + +## Logging + +When launched via OBS, the overlay logs to: + +``` +~/projects/HudFX_LNX/hudfx.log +``` + +OBS script activity (start/stop events, errors) is visible in OBS under **Tools → Scripts → Script Log**. + +To follow the overlay log in real time: + +```bash +tail -f ~/projects/HudFX_LNX/hudfx.log +``` + +`hudfx.log` is excluded from version control via `.gitignore`. + +## Files + +| File | Purpose | +|---|---| +| `hudfx.py` | Main tray app — run this directly for standalone use | +| `obs_hudfx.py` | OBS script — add via OBS Tools → Scripts | +| `overlay.py` | Standalone overlay (no tray UI, takes URL as argument) | + +## Notes + +- The overlay is click-through — you can interact with everything underneath it normally +- Audio and video autoplay are enabled — no interaction needed for alerts to play +- No cookies or data are written to disk (off-the-record browser profile) +- Tested on Linux Mint 21.3 (Virginia) with OBS 31.1.2 (Flatpak), 3-monitor setup (2560×1440 center + two 1080p) +- Compatible with both native and Flatpak OBS installations diff --git a/hudfx.py b/hudfx.py new file mode 100644 index 0000000..b5a8ff3 --- /dev/null +++ b/hudfx.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +import sys +import json +import signal +import os +from PyQt5.QtCore import Qt, QUrl, QTimer +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QSystemTrayIcon, QMenu, QAction, + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QComboBox, QPushButton, QMessageBox +) +from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage, QWebEngineSettings +from PyQt5.QtGui import QColor, QIcon + +CONFIG_PATH = os.path.expanduser("~/.config/hudfx-lnx/config.json") + + +def load_config(): + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH) as f: + return json.load(f) + return {"url": "", "screen_index": 0} + + +def save_config(config): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump(config, f, indent=2) + + +class Overlay(QMainWindow): + def __init__(self, url, screen): + super().__init__() + self.setWindowFlags( + Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint | + Qt.Tool | + Qt.WindowTransparentForInput + ) + self.setAttribute(Qt.WA_TranslucentBackground) + + view = QWebEngineView() + profile = QWebEngineProfile(view) + page = QWebEnginePage(profile, view) + view.setPage(page) + view.page().setBackgroundColor(QColor(0, 0, 0, 0)) + view.page().setAudioMuted(False) + + settings = view.page().settings() + settings.setAttribute(QWebEngineSettings.PlaybackRequiresUserGesture, False) + settings.setAttribute(QWebEngineSettings.JavascriptEnabled, True) + + view.load(QUrl(url)) + self.setCentralWidget(view) + self.setGeometry(screen.geometry()) + self.show() + + +class SettingsDialog(QDialog): + def __init__(self, config, screens, parent=None): + super().__init__(parent) + self.setWindowTitle("HudFX Settings") + self.setMinimumWidth(420) + self.config = config + + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Overlay URL:")) + self.url_input = QLineEdit(config.get("url", "")) + self.url_input.setPlaceholderText("https://...") + layout.addWidget(self.url_input) + + layout.addWidget(QLabel("Display monitor:")) + self.screen_combo = QComboBox() + for i, screen in enumerate(screens): + s = screen.size() + name = f"Monitor {i + 1} — {s.width()}x{s.height()} ({screen.name()})" + self.screen_combo.addItem(name) + self.screen_combo.setCurrentIndex(config.get("screen_index", 0)) + layout.addWidget(self.screen_combo) + + buttons = QHBoxLayout() + save_btn = QPushButton("Save") + cancel_btn = QPushButton("Cancel") + save_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + buttons.addWidget(save_btn) + buttons.addWidget(cancel_btn) + layout.addLayout(buttons) + + def get_values(self): + return { + "url": self.url_input.text().strip(), + "screen_index": self.screen_combo.currentIndex(), + } + + +class TrayApp: + def __init__(self, app): + self.app = app + self.config = load_config() + self.overlay = None + + self.tray = QSystemTrayIcon() + self.tray.setToolTip("HudFX Overlay") + + icon = QIcon.fromTheme("video-display") + if icon.isNull(): + icon = QIcon.fromTheme("preferences-desktop-display") + self.tray.setIcon(icon) + + self.menu = QMenu() + + self.toggle_action = QAction("Start overlay") + self.toggle_action.triggered.connect(self.toggle_overlay) + self.menu.addAction(self.toggle_action) + + self.settings_action = QAction("Settings...") + self.settings_action.triggered.connect(self.open_settings) + self.menu.addAction(self.settings_action) + + self.menu.addSeparator() + + self.quit_action = QAction("Quit") + self.quit_action.triggered.connect(self.quit) + self.menu.addAction(self.quit_action) + + self.tray.setContextMenu(self.menu) + self.tray.show() + + self.toggle_overlay() + + def toggle_overlay(self): + if self.overlay: + self.overlay.close() + self.overlay = None + self.toggle_action.setText("Start overlay") + else: + url = self.config.get("url", "").strip() + if not url: + QMessageBox.warning(None, "HudFX", "No URL set. Open Settings first.") + return + idx = self.config.get("screen_index", 0) + screens = self.app.screens() + screen = screens[idx] if idx < len(screens) else self.app.primaryScreen() + self.overlay = Overlay(url, screen) + self.toggle_action.setText("Stop overlay") + + def open_settings(self): + dialog = SettingsDialog(self.config, self.app.screens()) + if dialog.exec_() == QDialog.Accepted: + self.config = dialog.get_values() + save_config(self.config) + + def quit(self): + if self.overlay: + self.overlay.close() + self.app.quit() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + signal.signal(signal.SIGINT, signal.SIG_DFL) + timer = QTimer() + timer.start(200) + timer.timeout.connect(lambda: None) + + tray = TrayApp(app) + sys.exit(app.exec_()) diff --git a/obs_hudfx.py b/obs_hudfx.py new file mode 100644 index 0000000..2d9a555 --- /dev/null +++ b/obs_hudfx.py @@ -0,0 +1,67 @@ +import obspython as obs +import subprocess +import os +from datetime import datetime + +OVERLAY_SCRIPT = "/home/bashy/projects/HudFX_LNX/hudfx.py" +PYTHON_BIN = "/usr/bin/python3" +FLATPAK_SPAWN = "/usr/bin/flatpak-spawn" +LOG_FILE = "/home/bashy/projects/HudFX_LNX/hudfx.log" + +process = None + + +def ts(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def is_flatpak(): + return bool(os.environ.get("FLATPAK_ID")) or os.path.exists("/run/host/etc") + + +def build_cmd(): + if is_flatpak(): + return [FLATPAK_SPAWN, "--host", PYTHON_BIN, OVERLAY_SCRIPT] + return [PYTHON_BIN, OVERLAY_SCRIPT] + + +def script_description(): + return "Starts HudFX overlay when OBS loads and stops it when OBS closes." + + +def script_load(settings): + obs.obs_frontend_add_event_callback(on_event) + mode = "Flatpak" if is_flatpak() else "native" + print(f"[HudFX] {ts()} Script loaded (OBS mode: {mode})") + + +def on_event(event): + if event == obs.OBS_FRONTEND_EVENT_FINISHED_LOADING: + _start() + elif event == obs.OBS_FRONTEND_EVENT_EXIT: + _stop() + + +def script_unload(): + _stop() + + +def _start(): + global process + if process is None or process.poll() is not None: + try: + log = open(LOG_FILE, "a") + cmd = build_cmd() + process = subprocess.Popen(cmd, env=os.environ.copy(), stdout=log, stderr=log) + print(f"[HudFX] {ts()} Overlay started (pid {process.pid})") + print(f"[HudFX] {ts()} Log: {LOG_FILE}") + except Exception as e: + print(f"[HudFX] {ts()} Failed to start overlay: {e}") + + +def _stop(): + global process + if process and process.poll() is None: + process.terminate() + print(f"[HudFX] {ts()} Overlay stopped") + process = None diff --git a/overlay.py b/overlay.py new file mode 100644 index 0000000..94b686f --- /dev/null +++ b/overlay.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +import sys +import signal +from PyQt5.QtCore import Qt, QUrl, QTimer +from PyQt5.QtWidgets import QApplication, QMainWindow +from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEngineProfile, QWebEnginePage, QWebEngineSettings +from PyQt5.QtGui import QColor + + +def find_screen(app, width=2560, height=1440): + for screen in app.screens(): + s = screen.size() + if s.width() == width and s.height() == height: + return screen + print(f"Warning: no {width}x{height} screen found, falling back to primary") + return app.primaryScreen() + + +class Overlay(QMainWindow): + def __init__(self, url, screen): + super().__init__() + self.setWindowFlags( + Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint | + Qt.Tool | + Qt.WindowTransparentForInput + ) + self.setAttribute(Qt.WA_TranslucentBackground) + + view = QWebEngineView() + profile = QWebEngineProfile(view) # off-the-record, no disk storage + page = QWebEnginePage(profile, view) + view.setPage(page) + view.page().setBackgroundColor(QColor(0, 0, 0, 0)) + view.page().setAudioMuted(False) + + settings = view.page().settings() + settings.setAttribute(QWebEngineSettings.PlaybackRequiresUserGesture, False) + settings.setAttribute(QWebEngineSettings.JavascriptEnabled, True) + + view.load(QUrl(url)) + self.setCentralWidget(view) + + self.setGeometry(screen.geometry()) + self.show() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python3 overlay.py ") + sys.exit(1) + + app = QApplication(sys.argv) + + signal.signal(signal.SIGINT, signal.SIG_DFL) + timer = QTimer() + timer.start(200) + timer.timeout.connect(lambda: None) + + screen = find_screen(app) + window = Overlay(sys.argv[1], screen) + sys.exit(app.exec_())