"""System tray icon for Media Server.""" import ctypes import io import logging 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 using native Windows MessageBox.""" result = ctypes.windll.user32.MessageBoxW( 0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND ) return result == _IDYES def _create_icon_image(size: int = 64) -> Image.Image: """Create a tray icon: green circle with white play triangle.""" img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # Green circle background padding = 2 draw.ellipse( [padding, padding, size - padding, size - padding], fill=(29, 185, 84, 255), ) # White play triangle cx, cy = size // 2, size // 2 r = size * 0.28 triangle = [ (cx - r * 0.6, cy - r), (cx - r * 0.6, cy + r), (cx + r * 0.9, cy), ] draw.polygon(triangle, fill=(255, 255, 255, 255)) return img def _load_icon_image() -> Image.Image: """Load the ICO/SVG app icon, falling back to a generated image.""" icons_dir = Path(__file__).parent / "static" / "icons" # Try .ico first (best for Windows tray) ico_path = icons_dir / "icon.ico" if ico_path.exists(): try: return Image.open(ico_path) except Exception: pass # Try SVG via cairosvg try: import cairosvg svg_path = icons_dir / "icon.svg" if svg_path.exists(): png_data = cairosvg.svg2png(url=str(svg_path), output_width=64, output_height=64) return Image.open(io.BytesIO(png_data)) except Exception: pass return _create_icon_image() 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()