"""System tray icon for Media Server.""" import ctypes import io import logging import os 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 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 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") self._icon.stop() self._on_exit() os.environ["MEDIA_SERVER_RESTART"] = "1" os.execv(sys.executable, [sys.executable] + sys.argv) 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()