feat: add system tray icon with Show UI and Exit actions
Lint & Test / test (push) Successful in 9s

Adds pystray-based tray icon (green play button) that runs alongside
uvicorn. Double-click opens the web UI in the browser, Exit triggers
graceful shutdown. Disabled with --no-tray flag for headless/service mode.
This commit is contained in:
2026-03-23 14:05:13 +03:00
parent 4d1bb78c83
commit 6500d6f615
3 changed files with 124 additions and 6 deletions
+22 -6
View File
@@ -216,6 +216,11 @@ def main():
action="store_true", action="store_true",
help="Show the current API token and exit", 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() args = parser.parse_args()
@@ -235,12 +240,23 @@ def main():
print("\nAuthentication is DISABLED (no tokens configured)") print("\nAuthentication is DISABLED (no tokens configured)")
return return
uvicorn.run( # Start system tray icon (unless disabled)
"media_server.main:app", tray_icon = None
host=args.host, if not args.no_tray:
port=args.port, from .tray import start_tray
reload=False,
) 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__": if __name__ == "__main__":
+101
View File
@@ -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
+1
View File
@@ -42,6 +42,7 @@ windows = [
"pycaw>=20230407", "pycaw>=20230407",
"screen-brightness-control>=0.20.0", "screen-brightness-control>=0.20.0",
"monitorcontrol>=3.0.0", "monitorcontrol>=3.0.0",
"pystray>=0.19.0",
] ]
visualizer = [ visualizer = [
"soundcard>=0.4.0", "soundcard>=0.4.0",