Initial Version

This commit is contained in:
Bashy 2026-04-25 13:09:44 +03:00
commit bdf4330738
5 changed files with 383 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*.log
__pycache__/
*.pyc
*.pyo

79
README.md Normal file
View file

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

171
hudfx.py Normal file
View file

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

67
obs_hudfx.py Normal file
View file

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

62
overlay.py Normal file
View file

@ -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 <url>")
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_())