135 lines
3.6 KiB
Python
135 lines
3.6 KiB
Python
"""System tray icon for Media Server."""
|
|
|
|
import io
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
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
|
|
|
|
# tkinter is optional — used for confirmation dialogs
|
|
try:
|
|
import tkinter as tk
|
|
from tkinter import messagebox
|
|
except ImportError:
|
|
tk = None
|
|
messagebox = None
|
|
|
|
|
|
def _confirm(title: str, message: str) -> bool:
|
|
"""Show a Yes/No confirmation dialog. Returns True if user clicks Yes."""
|
|
if messagebox is None:
|
|
return True # No tkinter — skip confirmation
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
root.attributes("-topmost", True)
|
|
result = messagebox.askyesno(title, message, parent=root)
|
|
root.destroy()
|
|
return result
|
|
|
|
|
|
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_restart(_icon, _item):
|
|
if not _confirm("Restart Media Server", "Are you sure you want to restart the server?"):
|
|
return
|
|
logger.info("Restart requested from tray")
|
|
_icon.stop()
|
|
# Re-launch the same process, then exit current
|
|
subprocess.Popen([sys.executable] + sys.argv)
|
|
os.kill(os.getpid(), signal.SIGINT)
|
|
|
|
def on_exit(_icon, _item):
|
|
if not _confirm("Exit Media Server", "Are you sure you want to shut down the server?"):
|
|
return
|
|
logger.info("Exit requested from tray")
|
|
_icon.stop()
|
|
os.kill(os.getpid(), signal.SIGINT)
|
|
|
|
menu = pystray.Menu(
|
|
pystray.MenuItem("Show UI", on_show_ui, default=True),
|
|
pystray.Menu.SEPARATOR,
|
|
pystray.MenuItem("Restart", on_restart),
|
|
pystray.MenuItem("Shutdown", 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
|