d798fedf55
- Replace generic Spotify-green circle with a refined "Beacon" design: squircle + deep-teal diagonal gradient (#0B3D3B → #1A6B5E) + warm parchment play triangle (#F5F1E8) with drop shadow, top sheen, and ghosted echo-chevrons hinting at broadcast/stream - Grow icon.ico from a single 16×16 frame (208 B) to a 10-frame multi-resolution ICO (16/20/24/32/40/48/64/96/128/256, ~37 KB) so Windows no longer upscales 16×16 into mush for the installer chrome, Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles - Add scripts/generate-icon.py: SVG is the source of truth; resvg-py rasterizes every ICO size; Pillow packs the multi-resolution ICO - Update tray.py to pick a 64×64 frame from the new ICO and update its procedural fallback to the same Beacon palette so a missing ICO no longer regresses the tray to the old Spotify-green circle - Add resvg-py to [dev] deps (build-time only) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
229 lines
7.6 KiB
Python
229 lines
7.6 KiB
Python
"""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()
|