Files
media-player-server/media_server/tray.py
T
alexei.dolgolyov d798fedf55
Lint & Test / test (push) Successful in 20s
Lint & Test / linux-smoke (push) Failing after 34s
feat(icon): redesign app icon as "Beacon" and ship multi-resolution ICO
- 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>
2026-05-28 17:18:58 +03:00

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()