diff --git a/media_server/static/icons/icon-256.png b/media_server/static/icons/icon-256.png new file mode 100644 index 0000000..4d1724a Binary files /dev/null and b/media_server/static/icons/icon-256.png differ diff --git a/media_server/static/icons/icon.ico b/media_server/static/icons/icon.ico index 41dda85..5cde230 100644 Binary files a/media_server/static/icons/icon.ico and b/media_server/static/icons/icon.ico differ diff --git a/media_server/static/icons/icon.svg b/media_server/static/icons/icon.svg index d26b69a..4b8ff26 100644 --- a/media_server/static/icons/icon.svg +++ b/media_server/static/icons/icon.svg @@ -1,10 +1,33 @@ - + - - - + + + + + + + + + + + + + + - - + + + + + + + + + + diff --git a/media_server/tray.py b/media_server/tray.py index cd01a8e..8c38ede 100644 --- a/media_server/tray.py +++ b/media_server/tray.py @@ -51,55 +51,116 @@ def _confirm(title: str, message: str) -> bool: return True -def _create_icon_image(size: int = 64) -> Image.Image: - """Create a tray icon: green circle with white play triangle.""" +# 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) - # Green circle background - padding = 2 - draw.ellipse( - [padding, padding, size - padding, size - padding], - fill=(29, 185, 84, 255), + # 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, ) - - # 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.""" +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" - # Try .ico first (best for Windows tray) ico_path = icons_dir / "icon.ico" if ico_path.exists(): try: - return Image.open(ico_path) + 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 - # 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() + return _create_icon_image(size) class TrayManager: diff --git a/pyproject.toml b/pyproject.toml index 43717f8..c75e70d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ dev = [ "pytest-asyncio>=0.21", "httpx>=0.24", "ruff>=0.4.0", + # SVG -> PNG rasterizer used by scripts/generate-icon.py to (re)build + # media_server/static/icons/icon.ico from icon.svg. Build-time only. + "resvg-py>=0.3.2", ] [project.urls] diff --git a/scripts/generate-icon.py b/scripts/generate-icon.py new file mode 100644 index 0000000..9fc111e --- /dev/null +++ b/scripts/generate-icon.py @@ -0,0 +1,113 @@ +"""Generate the Media Server application icon. + +The SVG in ``media_server/static/icons/icon.svg`` is the single source of +truth. This script rasterizes it at every Windows ICO size via ``resvg-py`` +(Rust-backed, identical math to Firefox's SVG renderer) and packs them all +into a multi-resolution ``icon.ico``. + +This replaces the original 16x16-only ICO that Windows was upscaling into +mush for the installer chrome, Start Menu, desktop shortcuts, and Alt+Tab. + +Usage: + python scripts/generate-icon.py + +Dependencies: + pip install resvg-py Pillow +""" + +from __future__ import annotations + +import io +from pathlib import Path + +import resvg_py +from PIL import Image + +# Sizes packed into the ICO. Windows picks the closest match per surface; +# more sizes = sharper rendering everywhere (taskbar, installer header, +# Alt+Tab, jump lists, Start tile, desktop, file explorer details/tiles). +ICO_SIZES: tuple[int, ...] = (16, 20, 24, 32, 40, 48, 64, 96, 128, 256) + +# The SVG source. Squircle + diagonal teal gradient + warm parchment play +# triangle with a drop shadow + ghosted echo-chevrons hinting at broadcast. +SVG_SOURCE = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def _render_png(size: int) -> Image.Image: + """Rasterize the SVG to a PNG at ``size x size`` via resvg.""" + data = resvg_py.svg_to_bytes( + svg_string=SVG_SOURCE, + width=size, + height=size, + shape_rendering="geometric_precision", + ) + return Image.open(io.BytesIO(bytes(data))).convert("RGBA") + + +def main() -> None: + root = Path(__file__).resolve().parent.parent + out_dir = root / "media_server" / "static" / "icons" + out_dir.mkdir(parents=True, exist_ok=True) + + # SVG — canonical source, also used as the Web UI favicon. + svg_path = out_dir / "icon.svg" + svg_path.write_text(SVG_SOURCE, encoding="utf-8") + print(f"wrote {svg_path}") + + # Rasterize every ICO size via resvg. + frames = [_render_png(size) for size in ICO_SIZES] + + # Pack into a multi-resolution ICO. The "primary" image must be the + # largest; the rest go via append_images. Pillow's ICO writer then + # serializes one frame per size. + primary = frames[-1] + ico_path = out_dir / "icon.ico" + primary.save( + ico_path, + format="ICO", + sizes=[(s, s) for s in ICO_SIZES], + append_images=frames[:-1], + ) + print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes, sizes={list(ICO_SIZES)})") + + # Largest PNG for documentation / non-Windows surfaces. + png_path = out_dir / "icon-256.png" + primary.save(png_path, format="PNG", optimize=True) + print(f"wrote {png_path}") + + +if __name__ == "__main__": + main()