streamer-widgets/app/win_tray.py

332 lines
11 KiB
Python

from __future__ import annotations
import os
import shutil
from dataclasses import dataclass
from typing import Any
import pyperclip
from app.main import ServerController
from app.paths import get_data_dir
@dataclass(frozen=True)
class TrayConfig:
host: str = "127.0.0.1"
port: int = 8765
def widget_url(self, widget: str) -> str:
return f"http://{self.host}:{self.port}/widgets/{widget}/"
def run_windows_tray(host: str = "127.0.0.1", port: int = 8765) -> None:
"""
Native Windows tray icon using pywin32 (more reliable than pystray on some Win11 setups).
"""
# Import inside function so non-Windows platforms can still import the module tree.
import win32api
import win32con
import win32gui
cfg = TrayConfig(host=host, port=port)
server = ServerController(host=host, port=port)
server.start()
status = {"running": True}
WM_TRAYICON = win32con.WM_USER + 20
TASKBAR_CREATED = win32gui.RegisterWindowMessage("TaskbarCreated")
ID_COPY = 1000
ID_START = 1001
ID_STOP = 1002
ID_QUIT = 1099
class_name = "StreamerWidgetsTray"
nid_id = 0
def _ensure_menu_bitmaps() -> dict[int, str]:
"""
Create small BMPs for menu item icons and return a mapping of command id -> bmp path.
We use BMPs because Win32 menu item bitmaps are HBITMAP-based.
"""
from PIL import Image, ImageDraw, ImageFont
base = get_data_dir() / "menu_icons"
base.mkdir(parents=True, exist_ok=True)
version_file = base / "_version.txt"
icon_set_version = "shape-v2"
try:
existing = version_file.read_text(encoding="utf-8").strip()
except Exception:
existing = ""
# If the icon set changed, clear old cached BMPs so new ones render.
if existing != icon_set_version:
try:
shutil.rmtree(base)
except Exception:
pass
base.mkdir(parents=True, exist_ok=True)
try:
version_file.write_text(icon_set_version, encoding="utf-8")
except Exception:
pass
def save_icon(name: str, draw_fn) -> str:
path = base / f"{name}.bmp"
if path.exists():
return str(path)
# 32x32 for decent high-DPI scaling; Win32 downscales reasonably well.
img = Image.new("RGBA", (32, 32), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
draw_fn(d)
# Paste onto white background to ensure visibility on standard menus (transparency is iffy with raw BMPs)
# or keep it simple with 255,255,255 background if transparent doesn't work.
# Using a solid white background is safest for standard menus.
final = Image.new("RGB", (32, 32), (255, 255, 255))
final.paste(img, (0, 0), img)
final.save(str(path), format="BMP")
return str(path)
blue = (56, 189, 248)
green = (34, 197, 94)
red = (239, 68, 68)
slate = (100, 116, 139)
# Drawing vector-style icons at 32x32
copy_bmp = save_icon(
"copy",
lambda d: (
# Two overlapping rectangles
d.rounded_rectangle((10, 10, 24, 26), radius=3, outline=blue, width=2),
d.rounded_rectangle((6, 6, 20, 22), radius=3, fill=(255, 255, 255), outline=blue, width=2),
),
)
start_bmp = save_icon(
"start",
lambda d: d.polygon([(10, 6), (24, 16), (10, 26)], fill=green),
)
stop_bmp = save_icon(
"stop",
lambda d: d.rounded_rectangle((8, 8, 24, 24), radius=2, fill=red),
)
quit_bmp = save_icon(
"quit",
lambda d: (
d.line((8, 8, 24, 24), fill=slate, width=3),
d.line((24, 8, 8, 24), fill=slate, width=3),
),
)
return {
ID_COPY: copy_bmp,
ID_START: start_bmp,
ID_STOP: stop_bmp,
ID_QUIT: quit_bmp,
}
return {
ID_COPY: copy_bmp,
ID_START: start_bmp,
ID_STOP: stop_bmp,
ID_QUIT: quit_bmp,
}
def _ensure_tray_ico_path(running: bool) -> str:
"""
Create a small custom .ico on disk so pywin32 can load it reliably.
"""
from PIL import Image, ImageDraw
data_dir = get_data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
ico_path = data_dir / ("tray_running.ico" if running else "tray_stopped.ico")
if ico_path.exists():
return str(ico_path)
def bubble(size: int) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# dark rounded background for visibility in the tray
r = max(2, size // 6)
d.rounded_rectangle((1, 1, size - 2, size - 2), radius=r, fill=(15, 23, 42, 255))
pad = max(2, size // 8)
d.ellipse((pad, pad, size - pad - 1, size - pad - 1), fill=(30, 64, 175, 255))
# highlights
d.ellipse((size * 0.30, size * 0.26, size * 0.62, size * 0.58), fill=(191, 219, 254, 210))
d.ellipse((size * 0.64, size * 0.60, size * 0.86, size * 0.82), fill=(56, 189, 248, 235))
# status dot (bottom-right)
dot_r = max(3, size // 10)
cx = int(size * 0.78)
cy = int(size * 0.78)
dot_color = (34, 197, 94, 255) if running else (148, 163, 184, 255)
ring = (15, 23, 42, 255)
d.ellipse((cx - dot_r - 2, cy - dot_r - 2, cx + dot_r + 2, cy + dot_r + 2), fill=ring)
d.ellipse((cx - dot_r, cy - dot_r, cx + dot_r, cy + dot_r), fill=dot_color)
return img
img = bubble(64)
# Multi-size ICO for crisp rendering at different DPI/scale.
img.save(str(ico_path), format="ICO", sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64)])
return str(ico_path)
def make_hicon() -> int:
try:
ico_path = _ensure_tray_ico_path(running=True)
return win32gui.LoadImage(
0,
ico_path,
win32con.IMAGE_ICON,
0,
0,
win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE,
)
except Exception:
return win32gui.LoadIcon(0, win32con.IDI_APPLICATION)
def _load_hicon_for_status(running: bool) -> int:
try:
ico_path = _ensure_tray_ico_path(running)
return win32gui.LoadImage(
0,
ico_path,
win32con.IMAGE_ICON,
0,
0,
win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE,
)
except Exception:
return win32gui.LoadIcon(0, win32con.IDI_APPLICATION)
def _tip_for_status(running: bool) -> str:
return f"Streamer Widgets ({'Running' if running else 'Stopped'}) - {host}:{port}"
def _modify_icon(hwnd: int, running: bool) -> None:
hicon = _load_hicon_for_status(running)
flags = win32gui.NIF_ICON | win32gui.NIF_TIP | win32gui.NIF_MESSAGE
nid = (hwnd, nid_id, flags, WM_TRAYICON, hicon, _tip_for_status(running))
win32gui.Shell_NotifyIcon(win32gui.NIM_MODIFY, nid)
def add_icon(hwnd: int) -> None:
"""
Add tray icon (tuple-style NOTIFYICONDATA; works across pywin32 versions).
"""
hicon = make_hicon()
flags = win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP
tip = _tip_for_status(status["running"])
nid = (hwnd, nid_id, flags, WM_TRAYICON, hicon, tip)
win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, nid)
def remove_icon(hwnd: int) -> None:
try:
win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, (hwnd, nid_id))
except Exception:
pass
def show_menu(hwnd: int) -> None:
menu = win32gui.CreatePopupMenu()
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_COPY, "Copy Now Playing URL")
win32gui.AppendMenu(menu, win32con.MF_SEPARATOR, 0, "")
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_START, "Start server")
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_STOP, "Stop server")
win32gui.AppendMenu(menu, win32con.MF_SEPARATOR, 0, "")
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_QUIT, "Quit")
# Attach icons to menu items (best-effort)
try:
bmp_map = _ensure_menu_bitmaps()
for cmd_id, bmp_path in bmp_map.items():
hbmp = win32gui.LoadImage(
0,
bmp_path,
win32con.IMAGE_BITMAP,
0,
0,
win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE,
)
if hbmp:
win32gui.SetMenuItemBitmaps(menu, cmd_id, win32con.MF_BYCOMMAND, hbmp, hbmp)
except Exception:
pass
x, y = win32gui.GetCursorPos()
win32gui.SetForegroundWindow(hwnd)
win32gui.TrackPopupMenu(menu, win32con.TPM_LEFTALIGN | win32con.TPM_RIGHTBUTTON, x, y, 0, hwnd, None)
win32gui.PostMessage(hwnd, win32con.WM_NULL, 0, 0)
def wndproc(hwnd: int, msg: int, wparam: int, lparam: int) -> int:
# Always return an int LRESULT.
if msg == TASKBAR_CREATED:
add_icon(hwnd)
_modify_icon(hwnd, status["running"])
return 0
if msg == win32con.WM_DESTROY:
remove_icon(hwnd)
win32gui.PostQuitMessage(0)
return 0
if msg == win32con.WM_COMMAND:
cmd = win32api.LOWORD(wparam)
if cmd == ID_COPY:
pyperclip.copy(cfg.widget_url("nowplaying"))
elif cmd == ID_START:
server.start()
status["running"] = True
_modify_icon(hwnd, True)
elif cmd == ID_STOP:
server.stop()
status["running"] = False
_modify_icon(hwnd, False)
elif cmd == ID_QUIT:
win32gui.DestroyWindow(hwnd)
return 0
if msg == WM_TRAYICON:
if lparam == win32con.WM_RBUTTONUP:
show_menu(hwnd)
elif lparam == win32con.WM_LBUTTONUP:
pyperclip.copy(cfg.widget_url("nowplaying"))
return 0
return win32gui.DefWindowProc(hwnd, msg, wparam, lparam)
wc = win32gui.WNDCLASS()
wc.hInstance = win32api.GetModuleHandle(None)
wc.lpszClassName = class_name
wc.lpfnWndProc = wndproc
wc.hIcon = make_hicon()
try:
win32gui.RegisterClass(wc)
except win32gui.error:
pass
hwnd = win32gui.CreateWindow(
class_name,
"Streamer Widgets",
0,
0,
0,
0,
0,
0,
0,
wc.hInstance,
None,
)
add_icon(hwnd)
try:
win32gui.PumpMessages()
finally:
# Stop server after the UI loop finishes, avoiding deadlock in wndproc
try:
server.stop()
except Exception:
pass