"""System tray icon for Media Server.""" import ctypes import io import logging import sys import webbrowser from pathlib import Path from typing import Callable from PIL import Image, ImageDraw logger = logging.getLogger(__name__) # pystray is optional — tray silently disabled when missing try: import pystray PYSTRAY_AVAILABLE = True except ImportError: pystray = None PYSTRAY_AVAILABLE = False # Windows-native confirmation (no tkinter needed) _MB_YESNO = 0x04 _MB_ICONQUESTION = 0x20 _MB_TOPMOST = 0x40000 _MB_SETFOREGROUND = 0x10000 _IDYES = 6 def _confirm(title: str, message: str) -> bool: """Show a Yes/No dialog before a destructive tray action. Uses the native Windows MessageBox on win32; on Linux/macOS we don't have a no-dependency GUI dialog available (Tk pulls in tkinter, gtk pulls in PyGObject), so we log + auto-confirm — the tray menu items themselves require a deliberate click already. """ if sys.platform == "win32": result = ctypes.windll.user32.MessageBoxW( 0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND, ) return result == _IDYES logger.info("Tray confirm (auto-yes on non-Windows): %s — %s", title, message) return True # Frame size we ask the multi-resolution ICO for. Most Windows tray surfaces # show 16x16 in the notification area and 32x32 in jump lists / Alt+Tab; 64 # gives pystray enough headroom for both without forcing it to upscale. _TRAY_ICON_SIZE = 64 # Palette mirrors media_server/static/icons/icon.svg ("Beacon" design). _BG_DARK = (11, 61, 59, 255) # #0B3D3B _BG_LIGHT = (26, 107, 94, 255) # #1A6B5E _FG_PARCHMENT = (245, 241, 232, 255) # #F5F1E8 def _create_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image: """Procedural fallback when no icon file is available. Matches the "Beacon" palette (deep teal squircle + warm parchment play triangle) so a missing icon.ico does not regress us back to the old Spotify-green circle. """ img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Squircle background. Vertical gradient approximates the diagonal one # in the real SVG well enough for a 64px fallback. radius = int(size * 0.225) for y in range(size): t = y / max(1, size - 1) color = tuple( round(_BG_DARK[i] + (_BG_LIGHT[i] - _BG_DARK[i]) * t) for i in range(3) ) + (255,) draw.line([(0, y), (size - 1, y)], fill=color) mask = Image.new("L", (size, size), 0) ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255) bg = img.copy() img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img.paste(bg, (0, 0), mask=mask) # Play triangle, positioned to match icon.svg's geometry. draw = ImageDraw.Draw(img) draw.polygon( [ (size * 0.345, size * 0.215), (size * 0.345, size * 0.785), (size * 0.755, size * 0.500), ], fill=_FG_PARCHMENT, ) return img def _select_frame(image: Image.Image, target: int) -> Image.Image: """Pick the best frame from a multi-resolution ICO. Pillow's ICO loader exposes the embedded sizes via ``image.info['sizes']``. We pick the smallest frame at least as large as the target (so we never upscale) and resize down to ``target x target`` with LANCZOS. """ sizes = sorted(image.info.get("sizes", []) or [], key=lambda wh: wh[0]) chosen = next((wh for wh in sizes if wh[0] >= target), sizes[-1] if sizes else None) if chosen is not None: image.size = chosen frame = image.copy().convert("RGBA") if frame.size != (target, target): frame = frame.resize((target, target), Image.LANCZOS) return frame def _load_icon_image(size: int = _TRAY_ICON_SIZE) -> Image.Image: """Load the app icon for the tray. Order: 1. ``icon.ico`` — the multi-resolution Windows icon ships with every build; pick the frame closest to ``size`` and downscale if needed. 2. ``icon.svg`` via resvg-py (preferred) or cairosvg (legacy). 3. Procedural ``_create_icon_image`` fallback. """ icons_dir = Path(__file__).parent / "static" / "icons" ico_path = icons_dir / "icon.ico" if ico_path.exists(): try: with Image.open(ico_path) as ico: return _select_frame(ico, size) except Exception as exc: logger.warning("Failed to load tray icon from %s: %s", ico_path, exc) svg_path = icons_dir / "icon.svg" if svg_path.exists(): try: import resvg_py png_data = resvg_py.svg_to_bytes( svg_string=svg_path.read_text(encoding="utf-8"), width=size, height=size, ) return Image.open(io.BytesIO(bytes(png_data))).convert("RGBA") except ImportError: pass except Exception as exc: logger.warning("resvg rasterization of %s failed: %s", svg_path, exc) try: import cairosvg png_data = cairosvg.svg2png(url=str(svg_path), output_width=size, output_height=size) return Image.open(io.BytesIO(png_data)).convert("RGBA") except Exception: pass return _create_icon_image(size) class TrayManager: """Manages the system tray icon and its context menu. Call ``run()`` on the **main thread** — it blocks until ``stop()`` is called (from any thread) or the user picks *Shutdown* from the menu. """ def __init__(self, port: int, on_exit: Callable[[], None]) -> None: if not PYSTRAY_AVAILABLE: raise ImportError("pystray is required for system tray support") self._port = port self._on_exit = on_exit # Initialize so the property and any cross-thread reader cannot ever # observe an uninitialized attribute. Set before _on_exit() fires. self._restart_requested = False menu = pystray.Menu( pystray.MenuItem("Show UI", self._show_ui, default=True), pystray.Menu.SEPARATOR, pystray.MenuItem("Restart", self._restart), pystray.MenuItem("Shutdown", self._shutdown), ) self._icon = pystray.Icon( name="media-server", icon=_load_icon_image(), title="Media Server", menu=menu, ) def _show_ui(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: webbrowser.open(f"http://localhost:{self._port}") def _restart(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: if not _confirm("Media Server", "Restart the server?"): return logger.info("Restart requested from tray") # Set the flag BEFORE signalling exit so the main thread observes it # when it wakes from server_thread.join() — order matters across the # tray/uvicorn handoff. self._restart_requested = True self._on_exit() self._icon.stop() @property def restart_requested(self) -> bool: return self._restart_requested def _shutdown(self, icon: "pystray.Icon", item: "pystray.MenuItem") -> None: if not _confirm("Media Server", "Shut down the server?"): return logger.info("Shutdown requested from tray") self._on_exit() self._icon.stop() def run(self) -> None: """Block the calling thread running the tray message loop.""" self._icon.run() def stop(self) -> None: """Stop the tray icon from any thread.""" self._icon.stop()