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>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 208 B After Width: | Height: | Size: 37 KiB |
@@ -1,10 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1db954;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1ed760;stop-opacity:1" />
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0B3D3B"/>
|
||||
<stop offset="100%" stop-color="#1A6B5E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
|
||||
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feOffset dx="3" dy="5"/>
|
||||
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
||||
<path fill="white" d="M35 25 L35 75 L75 50 Z"/>
|
||||
<g clip-path="url(#clip)">
|
||||
<rect width="256" height="256" fill="url(#bg)"/>
|
||||
<rect width="256" height="256" fill="url(#sheen)"/>
|
||||
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
|
||||
<polyline points="66,72 105,128 66,184"/>
|
||||
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
|
||||
</g>
|
||||
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
|
||||
fill="#F5F1E8"
|
||||
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
|
||||
filter="url(#triShadow)"/>
|
||||
</g>
|
||||
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
|
||||
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 421 B After Width: | Height: | Size: 1.6 KiB |
+95
-34
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0B3D3B"/>
|
||||
<stop offset="100%" stop-color="#1A6B5E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
|
||||
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3"/>
|
||||
<feOffset dx="3" dy="5"/>
|
||||
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
|
||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip)">
|
||||
<rect width="256" height="256" fill="url(#bg)"/>
|
||||
<rect width="256" height="256" fill="url(#sheen)"/>
|
||||
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
|
||||
<polyline points="66,72 105,128 66,184"/>
|
||||
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
|
||||
</g>
|
||||
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
|
||||
fill="#F5F1E8"
|
||||
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
|
||||
filter="url(#triShadow)"/>
|
||||
</g>
|
||||
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
|
||||
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
|
||||
</svg>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user