diff --git a/media_server/main.py b/media_server/main.py index 38458d5..15880c9 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -216,6 +216,11 @@ def main(): action="store_true", help="Show the current API token and exit", ) + parser.add_argument( + "--no-tray", + action="store_true", + help="Disable system tray icon (for headless/service mode)", + ) args = parser.parse_args() @@ -235,12 +240,23 @@ def main(): print("\nAuthentication is DISABLED (no tokens configured)") return - uvicorn.run( - "media_server.main:app", - host=args.host, - port=args.port, - reload=False, - ) + # Start system tray icon (unless disabled) + tray_icon = None + if not args.no_tray: + from .tray import start_tray + + tray_icon = start_tray(args.host, args.port) + + try: + uvicorn.run( + "media_server.main:app", + host=args.host, + port=args.port, + reload=False, + ) + finally: + if tray_icon is not None: + tray_icon.stop() if __name__ == "__main__": diff --git a/media_server/tray.py b/media_server/tray.py new file mode 100644 index 0000000..cb6ae7e --- /dev/null +++ b/media_server/tray.py @@ -0,0 +1,101 @@ +"""System tray icon for Media Server.""" + +import io +import logging +import os +import signal +import threading +import webbrowser + +from PIL import Image, ImageDraw + +logger = logging.getLogger(__name__) + +# pystray is optional — tray silently disabled when missing +try: + import pystray +except ImportError: + pystray = None + + +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 SVG app icon, falling back to a generated image.""" + try: + import cairosvg + + svg_path = os.path.join( + os.path.dirname(__file__), "static", "icons", "icon.svg" + ) + if os.path.exists(svg_path): + png_data = cairosvg.svg2png(url=svg_path, output_width=64, output_height=64) + return Image.open(io.BytesIO(png_data)) + except Exception: + pass + + return _create_icon_image() + + +def start_tray(host: str, port: int) -> "pystray.Icon | None": + """Start system tray icon in a background thread. + + Returns the Icon instance (call icon.stop() to remove), or None if + pystray is not installed. + """ + if pystray is None: + logger.info("pystray not installed — tray icon disabled") + return None + + url = f"http://{'localhost' if host == '0.0.0.0' else host}:{port}" + + def on_show_ui(_icon, _item): + webbrowser.open(url) + + def on_exit(_icon, _item): + logger.info("Exit requested from tray") + _icon.stop() + # Signal the main process to shut down gracefully + os.kill(os.getpid(), signal.SIGINT) + + menu = pystray.Menu( + pystray.MenuItem("Show UI", on_show_ui, default=True), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Exit", on_exit), + ) + + icon = pystray.Icon( + name="media-server", + icon=_load_icon_image(), + title="Media Server", + menu=menu, + ) + + thread = threading.Thread(target=icon.run, daemon=True) + thread.start() + logger.info("System tray icon started") + + return icon diff --git a/pyproject.toml b/pyproject.toml index 89ad0b9..b847b98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ windows = [ "pycaw>=20230407", "screen-brightness-control>=0.20.0", "monitorcontrol>=3.0.0", + "pystray>=0.19.0", ] visualizer = [ "soundcard>=0.4.0",