Initial commit with the now playing

This commit is contained in:
Joey Yakimowich-Payne 2026-01-07 10:28:32 -07:00
commit de2f9cccb7
25 changed files with 2729 additions and 0 deletions

3
app/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""Streamer widgets host application."""

25
app/__main__.py Normal file
View file

@ -0,0 +1,25 @@
from __future__ import annotations
import argparse
from app.main import run_forever
from app.tray import run_tray_app
def main() -> None:
p = argparse.ArgumentParser(prog="streamer-widgets")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765)
p.add_argument("--tray", action="store_true", help="Run with Windows tray UI (recommended).")
args = p.parse_args()
if args.tray:
run_tray_app(host=args.host, port=args.port)
else:
run_forever(host=args.host, port=args.port)
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

125
app/assets/web/index.html Normal file
View file

@ -0,0 +1,125 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Streamer Widgets</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #f8fafc;
color: #334155;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 640px;
margin: 40px 20px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
header {
background: #0f172a;
color: white;
padding: 24px 32px;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.subtitle {
margin-top: 8px;
font-size: 14px;
color: #94a3b8;
}
.content {
padding: 32px;
}
h2 {
font-size: 18px;
margin: 0 0 16px;
color: #1e293b;
}
.widget-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.widget-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
background: #f1f5f9;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.widget-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.widget-name {
font-weight: 600;
color: #0f172a;
text-decoration: none;
}
.widget-name:hover {
text-decoration: underline;
}
.widget-url-row {
display: flex;
align-items: center;
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 8px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
color: #475569;
overflow-x: auto;
}
.footer {
border-top: 1px solid #e2e8f0;
padding: 16px 32px;
background: #f8fafc;
font-size: 13px;
color: #64748b;
text-align: center;
}
code {
font-family: inherit;
background: rgba(0,0,0,0.05);
padding: 2px 4px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Streamer Widgets</h1>
<div class="subtitle">Local OBS overlay server</div>
</header>
<div class="content">
<h2>Available Widgets</h2>
<ul class="widget-list">
{{WIDGET_LIST}}
</ul>
</div>
<div class="footer">
Server running at <code>{{HOSTPORT}}</code>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,194 @@
const card = document.getElementById("card");
const artWrap = document.getElementById("artWrap");
const artImg = document.getElementById("artImg");
const bubbleContainer = document.getElementById("bubbleContainer");
const bgBubbles = document.getElementById("bgBubbles");
const bgImg = document.getElementById("bgImg");
const titleEl = document.getElementById("title");
const albumEl = document.getElementById("album");
const artistEl = document.getElementById("artist");
const pill = document.getElementById("statusPill");
let lastKey = "";
let lastTitle = "";
let lastAlbum = "";
let lastArtist = "";
function createBubbles(container, count, scale = 1) {
if (!container || container.childElementCount > 0) return;
const random = (min, max) => Math.random() * (max - min) + min;
for (let i = 0; i < count; i++) {
const bubble = document.createElement("div");
bubble.classList.add("bubble");
const size = random(12, 48) * scale;
const left = random(0, 100);
const duration = random(9, 18);
const delay = random(-18, 0);
const opacity = random(0.4, 0.85);
const swayDistance = random(-24, 24) * scale;
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
bubble.style.left = `${left}%`;
bubble.style.animationDuration = `${duration}s`;
bubble.style.animationDelay = `${delay}s`;
bubble.style.setProperty("--bubble-opacity", opacity);
bubble.style.setProperty("--sway-distance", `${swayDistance}px`);
container.appendChild(bubble);
}
}
function initBubbles(){
createBubbles(bubbleContainer, 16, 1);
createBubbles(bgBubbles, 8, 2.5); // fewer bubbles, scaled up
}
function safeText(s){
return (s ?? "").toString().trim();
}
function setTextWithMarquee(el, rawValue){
const content = safeText(rawValue) || "—";
// Reset to plain text for accurate measurement
el.classList.remove("marquee");
el.innerHTML = content;
const needsMarquee = content !== "—" && el.scrollWidth > el.clientWidth + 2;
if(needsMarquee){
el.classList.add("marquee");
// Calculate duration based on content length for consistent speed
// e.g., 50px per second
const width = el.scrollWidth;
const duration = width / 50;
el.innerHTML = `<div class="marqueeInner" style="animation-duration:${duration}s"><span>${content}</span><span>${content}</span></div>`;
}
}
function applyNowPlaying(data){
try{
const title = safeText(data.title);
const album = safeText(data.album);
const artist = safeText(data.artist);
const playing = !!data.playing;
// If nothing is active, hide the card
const hasAny = title || album || artist;
if(!hasAny){
card.classList.add("hidden");
lastTitle = "";
lastAlbum = "";
lastArtist = "";
return;
}
const key = `${title}||${album}||${artist}||${playing}`;
// Update text with marquee when overflowing
if(title !== lastTitle){
setTextWithMarquee(titleEl, title);
lastTitle = title;
}
if(album !== lastAlbum){
setTextWithMarquee(albumEl, album);
lastAlbum = album;
}
if(artist !== lastArtist){
setTextWithMarquee(artistEl, artist);
lastArtist = artist;
}
// Update status
pill.textContent = playing ? "Playing" : "Paused";
pill.classList.toggle("playing", playing);
const hasArt = !!data.has_art;
artWrap.classList.toggle("placeholder", !hasArt);
card.classList.toggle("no-art", !hasArt);
if(!hasArt){
initBubbles();
bgBubbles.classList.remove("hidden");
bgImg.style.display = "none";
} else {
bgBubbles.classList.add("hidden");
bgImg.style.display = "block";
}
// If track changed, swap art with a cache-buster
if(key !== lastKey){
const artUrl = `${data.art_url}?v=${data.updated_unix}`;
artImg.src = artUrl;
bgImg.src = artUrl;
// small “pop-in” animation
card.classList.add("hidden");
requestAnimationFrame(() => {
card.classList.remove("hidden");
});
lastKey = key;
} else {
card.classList.remove("hidden");
}
} catch (e){
// ignore render issues
}
}
window.addEventListener("resize", () => {
// Clear cache to force re-evaluation of overflow
lastTitle = "";
lastAlbum = "";
lastArtist = "";
});
let ws = null;
let retryMs = 500;
function wsUrl(){
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/ws`;
}
function connect(){
try{
ws = new WebSocket(wsUrl());
} catch (e){
scheduleReconnect();
return;
}
ws.onopen = () => {
retryMs = 500;
};
ws.onmessage = (evt) => {
try{
const msg = JSON.parse(evt.data);
if(msg?.type === "nowplaying"){
applyNowPlaying(msg.data || {});
}
} catch (e){
// ignore
}
};
ws.onclose = () => scheduleReconnect();
ws.onerror = () => {
try{ ws.close(); } catch(e){}
};
}
function scheduleReconnect(){
const wait = retryMs;
retryMs = Math.min(5000, retryMs * 1.5);
setTimeout(connect, wait);
}
connect();

View file

@ -0,0 +1,40 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Now Playing</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="wrap">
<div id="card" class="card hidden">
<div class="art" id="artWrap">
<div class="bubble-container" id="bubbleContainer"></div>
<div class="bubble-overlay"></div>
<img id="artImg" alt="Album art" />
</div>
<div class="meta">
<div id="title" class="title"></div>
<div class="sub">
<span id="artist" class="artist"></span>
<span class="dot"></span>
<span id="album" class="album"></span>
</div>
<div class="statusRow">
<span id="statusPill" class="pill">Paused</span>
</div>
</div>
<div class="bg">
<div id="bgBubbles" class="bg-bubbles hidden"></div>
<img id="bgImg" alt="" />
</div>
<div class="bgShade"></div>
</div>
</div>
<script src="./app.js"></script>
</body>
</html>

View file

@ -0,0 +1,294 @@
:root{
/* Responsive sizing based on viewport width */
--outer-pad: 16px;
--min-w: 320px;
--w: max(var(--min-w), calc(100vw - (var(--outer-pad) * 2)));
--h: clamp(120px, calc(var(--w) * 0.22), 190px);
--art: clamp(88px, calc(var(--w) * 0.15), 128px);
--radius: clamp(16px, calc(var(--w) * 0.03), 26px);
/* Art corners slightly smaller than the card */
--art-radius: max(12px, calc(var(--radius) - 6px));
--pad: clamp(12px, calc(var(--w) * 0.02), 18px);
--gap: clamp(10px, calc(var(--w) * 0.02), 18px);
--blur: 18px;
--shadow: 0 18px 50px rgba(0,0,0,.35);
--stroke: rgba(255,255,255,.16);
--text: rgba(255,255,255,.92);
--muted: rgba(255,255,255,.70);
}
html, body {
margin: 0;
background: transparent;
overflow: visible;
width: 100vw;
height: 100vh;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;
box-sizing: border-box;
}
*, *::before, *::after { box-sizing: inherit; }
.wrap{
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
justify-content: stretch;
padding: var(--outer-pad);
box-sizing: border-box;
}
.card{
position: relative;
width: var(--w);
height: var(--h);
border-radius: var(--radius);
box-shadow: var(--shadow);
border: 1px solid var(--stroke);
background: rgba(20,20,24,.40);
backdrop-filter: blur(var(--blur));
-webkit-backdrop-filter: blur(var(--blur));
display: flex;
align-items: center;
gap: var(--gap);
padding: var(--pad);
box-sizing: border-box;
overflow: hidden;
transform: translateY(0);
opacity: 1;
transition: opacity .25s ease, transform .25s ease;
}
.card.hidden{
opacity: 0;
transform: translateY(10px);
}
.card.no-art{
background: linear-gradient(135deg, rgba(30, 41, 59, 0.4), rgba(15, 23, 42, 0.6));
border-color: rgba(56, 189, 248, 0.3);
box-shadow:
0 18px 50px rgba(0,0,0,.45),
inset 0 0 20px rgba(56, 189, 248, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
backdrop-filter: blur(24px) saturate(1.2);
-webkit-backdrop-filter: blur(24px) saturate(1.2);
}
.bg-bubbles{
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
filter: blur(20px);
transform: scale(1.2);
background: linear-gradient(to bottom right, #0f172a, #1e40af, #0f172a);
opacity: 0.8;
}
.bg-bubbles.hidden{
display: none;
}
.bg{
position:absolute;
inset:-20px;
z-index:0;
opacity: .55;
filter: blur(26px) saturate(1.1);
transform: scale(1.12);
}
.bg img{
width:100%;
height:100%;
object-fit: cover;
}
.bgShade{
position:absolute;
inset:0;
z-index:0;
background: radial-gradient(120% 160% at 20% 20%, rgba(255,255,255,.10), rgba(0,0,0,.55));
}
.art{
position: relative;
z-index: 2;
width: var(--art);
height: var(--art);
border-radius: var(--art-radius);
overflow: hidden;
flex: 0 0 auto;
border: 1px solid rgba(255,255,255,.14);
background: rgba(0,0,0,.25);
overflow: hidden;
}
.art img{
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity .25s ease;
position: relative;
z-index: 2;
}
.meta{
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
gap: 6px;
padding-right: clamp(8px, calc(var(--w) * 0.02), 14px);
flex: 1;
}
.title{
color: var(--text);
font-weight: 700;
font-size: clamp(18px, calc(var(--h) * 0.16), 22px);
letter-spacing: .2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-shadow: 0 2px 10px rgba(0,0,0,.25);
}
.sub{
color: var(--muted);
font-size: 15px;
display:flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.sub span.artist{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 0 1 auto; /* shrinking is ok, growing beyond content isn't forced */
min-width: 0;
max-width: 60%; /* prevent it from starving the album entirely if very long */
}
.sub span.album{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1; /* takes remaining space */
min-width: 0;
}
.dot{
opacity: .7;
flex: 0 0 auto;
}
.statusRow{
margin-top: 6px;
}
.pill{
display:inline-flex;
align-items:center;
gap:8px;
font-size: 12px;
font-weight: 700;
letter-spacing: .25px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.18);
background: rgba(0,0,0,.22);
color: rgba(255,255,255,.80);
text-transform: uppercase;
}
.pill.playing{
background: rgba(0,0,0,.18);
color: rgba(255,255,255,.90);
}
.bubble-container,
.bubble-overlay{
position:absolute;
inset:0;
pointer-events:none;
opacity:0;
transition: opacity .25s ease;
}
.bubble-overlay{
z-index:1;
background: radial-gradient(circle at center, rgba(96,165,250,0.18), transparent 55%, transparent);
}
.bubble-container{
z-index:0;
overflow:hidden;
}
.art.placeholder .bubble-container,
.art.placeholder .bubble-overlay{
opacity:1;
}
.art.placeholder{
background: linear-gradient(to bottom right, #0f172a, #1e3a8a, #0f172a);
}
.art.placeholder img{
opacity: 0;
}
.bubble{
position:absolute;
border-radius:50%;
background: radial-gradient(circle at 30% 30%,
rgba(255,255,255,0.9),
rgba(56,189,248,0.6) 20%,
rgba(37,99,235,0.3) 60%,
transparent 80%);
box-shadow: 0 0 10px rgba(56,189,248,0.4), inset 0 0 10px rgba(56,189,248,0.2);
animation: float-up linear infinite;
filter: blur(1px);
pointer-events: none;
z-index: 0;
}
/* Marquee for overflowing text */
.marquee{
overflow: hidden;
text-overflow: clip;
}
.marquee .marqueeInner{
display: inline-flex;
gap: 32px;
min-width: 100%;
width: max-content;
animation: marquee linear infinite;
}
.marquee .marqueeInner span{
white-space: nowrap;
max-width: none;
}
@keyframes marquee{
0% { transform: translateX(0); }
100% { transform: translateX(calc(-50% - 16px)); }
}
@keyframes float-up{
0% {
top: 110%;
transform: translateX(0) scale(0.8);
opacity: 0;
}
10% {
opacity: var(--bubble-opacity);
}
50% {
transform: translateX(var(--sway-distance)) scale(1.2);
}
90% {
opacity: var(--bubble-opacity);
}
100% {
top: -20%;
transform: translateX(0) scale(0.8);
opacity: 0;
}
}

145
app/main.py Normal file
View file

@ -0,0 +1,145 @@
from __future__ import annotations
import asyncio
import os
import threading
from pathlib import Path
from typing import Optional
from aiohttp import web
from app.providers.gsmtc import run_gsmtc_provider
from app.state import AppState
from app.webserver import make_app
def _configure_asyncio() -> None:
"""
Windows: avoid noisy Proactor transport errors on abrupt socket closes and
improve compatibility by using the selector event loop policy.
"""
if os.name == "nt":
try:
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore[attr-defined]
except Exception:
pass
def _install_loop_exception_handler(loop: asyncio.AbstractEventLoop) -> None:
def handler(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
exc = context.get("exception")
# Ignore common noisy Windows disconnect error (browser/tab closes etc.)
if isinstance(exc, ConnectionResetError) and getattr(exc, "winerror", None) == 10054:
return
_loop.default_exception_handler(context)
loop.set_exception_handler(handler)
async def _run_server(host: str, port: int, state: AppState) -> None:
app = make_app(state)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
# Start providers
asyncio.create_task(run_gsmtc_provider(state))
while True:
await asyncio.sleep(3600)
def run_forever(host: str = "127.0.0.1", port: int = 8765) -> None:
"""
Blocking entrypoint for console usage.
"""
_configure_asyncio()
loop = asyncio.new_event_loop()
_install_loop_exception_handler(loop)
asyncio.set_event_loop(loop)
state = AppState()
try:
loop.run_until_complete(_run_server(host, port, state))
except KeyboardInterrupt:
pass
finally:
loop.stop()
loop.close()
class ServerController:
"""
Starts/stops the asyncio server on a background thread (for tray UI).
"""
def __init__(self, host: str = "127.0.0.1", port: int = 8765) -> None:
self.host = host
self.port = port
self._thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._stop_evt = threading.Event()
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def start(self) -> None:
if self.is_running():
return
self._stop_evt.clear()
def _thread_main() -> None:
_configure_asyncio()
loop = asyncio.new_event_loop()
_install_loop_exception_handler(loop)
self._loop = loop
asyncio.set_event_loop(loop)
state = AppState()
async def runner() -> None:
app = make_app(state)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, self.host, self.port)
await site.start()
provider_task = asyncio.create_task(run_gsmtc_provider(state))
try:
while not self._stop_evt.is_set():
await asyncio.sleep(0.2)
finally:
provider_task.cancel()
# CancelledError may derive from BaseException depending on Python version;
# suppress it so Stop doesn't spam a traceback.
with contextlib.suppress(BaseException):
await provider_task
await runner.cleanup()
import contextlib
try:
loop.run_until_complete(runner())
except BaseException:
# Avoid noisy background-thread tracebacks on intentional shutdown.
pass
finally:
try:
loop.stop()
finally:
loop.close()
self._thread = threading.Thread(target=_thread_main, name="WidgetServer", daemon=True)
self._thread.start()
def stop(self) -> None:
if not self.is_running():
return
self._stop_evt.set()
if self._thread:
# Wait for cleanup so the port is released before a subsequent start().
self._thread.join()
self._thread = None
self._loop = None

27
app/paths.py Normal file
View file

@ -0,0 +1,27 @@
from __future__ import annotations
import os
from pathlib import Path
def get_data_dir() -> Path:
"""
Writable per-user data dir.
"""
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home())
return Path(base) / "StreamerWidgets"
def get_art_dir() -> Path:
d = get_data_dir() / "art"
d.mkdir(parents=True, exist_ok=True)
return d
def get_web_assets_dir() -> Path:
"""
Packaged (read-only) web assets directory.
"""
return Path(__file__).resolve().parent / "assets" / "web"

174
app/providers/gsmtc.py Normal file
View file

@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
import base64
import os
import re
import time
from pathlib import Path
from typing import Any, Tuple
from winsdk.windows.media.control import (
GlobalSystemMediaTransportControlsSessionManager as SessionManager,
GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus,
)
from winsdk.windows.storage.streams import DataReader
from app.paths import get_art_dir
from app.state import AppState, NowPlaying
ART_FILENAME = "album.png" # overwritten when track changes
PLACEHOLDER_FILENAME = "placeholder.png"
PLACEHOLDER_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAwUBAO+X2F8A"
"AAAASUVORK5CYII="
)
def _write_placeholder(out_path: Path) -> None:
data = base64.b64decode(PLACEHOLDER_PNG_B64)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(data)
def ensure_art_files(art_dir: Path) -> None:
placeholder_path = art_dir / PLACEHOLDER_FILENAME
if not placeholder_path.exists():
_write_placeholder(placeholder_path)
album_path = art_dir / ART_FILENAME
if not album_path.exists():
_write_placeholder(album_path)
async def _read_thumbnail_to_file(session: Any, out_path: Path) -> bool:
try:
media_props = await session.try_get_media_properties_async()
thumb_ref = media_props.thumbnail
if thumb_ref is None:
return False
stream = await thumb_ref.open_read_async()
size = int(stream.size or 0)
if size <= 0:
return False
reader = DataReader(stream)
await reader.load_async(size)
buffer = bytearray(size)
reader.read_bytes(buffer)
out_path.write_bytes(buffer)
return True
except Exception:
return False
def _pick_best_session(sessions: Any) -> Any:
best = None
for s in sessions:
try:
info = s.get_playback_info()
status = info.playback_status if info else None
if status == PlaybackStatus.PLAYING:
return s
if best is None:
best = s
except Exception:
continue
return best
def _extract_album_from_artist(artist_raw: str) -> Tuple[str, str]:
if not artist_raw:
return "", ""
m = re.search(r"\s*\[ALBUM:(.*?)\]\s*$", artist_raw, re.IGNORECASE)
if not m:
return artist_raw.strip(), ""
album_hint = m.group(1).strip()
clean_artist = artist_raw[: m.start()].strip()
return clean_artist, album_hint
async def run_gsmtc_provider(state: AppState) -> None:
"""
Poll GSMTC and push state updates + broadcast over websocket.
"""
art_dir = get_art_dir()
ensure_art_files(art_dir)
last_art_sig: str | None = None
last_has_art = False
while True:
try:
manager = await SessionManager.request_async()
sessions = manager.get_sessions()
session = _pick_best_session(sessions)
if session is None:
_write_placeholder(art_dir / ART_FILENAME)
last_art_sig = None
last_has_art = False
np = NowPlaying(updated_unix=int(time.time()))
await state.set_now_playing(np)
await state.broadcast({"type": "nowplaying", "data": np.to_dict()})
await asyncio.sleep(1)
continue
app_id = ""
try:
app_id = session.source_app_user_model_id or ""
except Exception:
pass
info = session.get_playback_info()
status = info.playback_status if info else None
playing = status == PlaybackStatus.PLAYING
props = await session.try_get_media_properties_async()
title = getattr(props, "title", "") or ""
album = getattr(props, "album_title", "") or getattr(props, "album", "") or ""
artist_raw = getattr(props, "artist", "") or getattr(props, "album_artist", "") or ""
artist, album_hint = _extract_album_from_artist(artist_raw)
if not album and album_hint:
album = album_hint
track_key = f"{app_id}||{title}||{album}||{artist}"
has_thumb = getattr(props, "thumbnail", None) is not None
art_sig = f"{track_key}||thumb:{int(has_thumb)}"
art_available = last_has_art
if art_sig != last_art_sig:
out_path = art_dir / ART_FILENAME
if has_thumb:
wrote = await _read_thumbnail_to_file(session, out_path)
if not wrote:
_write_placeholder(out_path)
art_available = wrote
else:
_write_placeholder(out_path)
art_available = False
last_art_sig = art_sig if (title or album or artist) else None
last_has_art = art_available
np = NowPlaying(
title=title,
album=album,
artist=artist,
playing=playing,
source_app=app_id,
art_url=f"/art/{ART_FILENAME}",
has_art=last_has_art,
updated_unix=int(time.time()),
)
await state.set_now_playing(np)
await state.broadcast({"type": "nowplaying", "data": np.to_dict()})
except Exception:
# transient errors: keep last state
pass
await asyncio.sleep(1)

65
app/state.py Normal file
View file

@ -0,0 +1,65 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass, asdict
from typing import Any, Dict, Optional, Set
@dataclass
class NowPlaying:
title: str = ""
album: str = ""
artist: str = ""
playing: bool = False
source_app: str = ""
art_url: str = "/art/album.png"
has_art: bool = False
updated_unix: int = 0
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
class AppState:
"""
Shared state + websocket client tracking.
"""
def __init__(self) -> None:
self.now_playing: NowPlaying = NowPlaying()
self._ws_clients: Set[Any] = set()
self._lock = asyncio.Lock()
async def set_now_playing(self, np: NowPlaying) -> None:
async with self._lock:
self.now_playing = np
async def get_now_playing(self) -> NowPlaying:
async with self._lock:
return self.now_playing
async def register_ws(self, ws: Any) -> None:
async with self._lock:
self._ws_clients.add(ws)
async def unregister_ws(self, ws: Any) -> None:
async with self._lock:
self._ws_clients.discard(ws)
async def broadcast(self, message: Dict[str, Any]) -> None:
async with self._lock:
clients = list(self._ws_clients)
dead: list[Any] = []
for ws in clients:
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
if dead:
async with self._lock:
for ws in dead:
self._ws_clients.discard(ws)

80
app/tray.py Normal file
View file

@ -0,0 +1,80 @@
from __future__ import annotations
import os
def run_tray_app(host: str = "127.0.0.1", port: int = 8765) -> None:
"""
Tray entrypoint.
On Windows, prefer a native pywin32 tray icon (reliable + shows up in Win11 tray settings).
Fallback to pystray for non-Windows platforms.
"""
if os.name == "nt":
from app.win_tray import run_windows_tray
run_windows_tray(host=host, port=port)
return
# Non-Windows fallback (best effort)
from dataclasses import dataclass
from typing import Any
import pyperclip
import pystray
from PIL import Image, ImageDraw
from app.main import ServerController
@dataclass(frozen=True)
class TrayConfig:
host: str = "127.0.0.1"
port: int = 8765
def widget_url(self, widget: str) -> str:
return f"http://{self.host}:{self.port}/widgets/{widget}/"
cfg = TrayConfig(host=host, port=port)
server = ServerController(host=host, port=port)
server.start()
def _make_icon() -> Image.Image:
size = 32
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse((3, 3, size - 4, size - 4), fill=(30, 64, 175, 255))
d.ellipse((9, 8, 20, 19), fill=(191, 219, 254, 200))
return img
def copy_nowplaying(_: Any, __: Any) -> None:
pyperclip.copy(cfg.widget_url("nowplaying"))
def start_server(_: Any, __: Any) -> None:
server.start()
def stop_server(_: Any, __: Any) -> None:
server.stop()
def quit_app(icon: Any, __: Any) -> None:
try:
server.stop()
finally:
icon.stop()
icon = pystray.Icon(
"streamer-widgets",
_make_icon(),
title="Streamer Widgets",
menu=pystray.Menu(
pystray.MenuItem("Copy Now Playing URL", copy_nowplaying),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Start server", start_server),
pystray.MenuItem("Stop server", stop_server),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", quit_app),
),
)
icon.visible = True
icon.run()

117
app/webserver.py Normal file
View file

@ -0,0 +1,117 @@
from __future__ import annotations
from pathlib import Path
from aiohttp import WSMsgType, web
from app.paths import get_art_dir, get_web_assets_dir
from app.state import AppState
# Declare widgets once to avoid duplicated slugs/labels.
WIDGETS = [
{"slug": "nowplaying", "label": "Now Playing"},
]
async def handle_root(request: web.Request) -> web.Response:
index_path = get_web_assets_dir() / "index.html"
if not index_path.exists():
return web.Response(text="Streamer Widgets: /widgets/nowplaying/", content_type="text/plain")
try:
html = index_path.read_text(encoding="utf-8")
hostport = f"http://{request.host}"
widget_items = []
for widget in WIDGETS:
slug = widget.get("slug", "").strip("/")
label = widget.get("label", slug or "Widget")
url = f"http://{request.host}/widgets/{slug}/" if slug else ""
item_html = f"""
<li class="widget-item">
<div class="widget-header">
<a class="widget-name" href="{url}" target="_blank">{label}</a>
</div>
<div class="widget-url-row">{url}</div>
</li>
"""
widget_items.append(item_html)
widget_list_html = "\n".join(widget_items) if widget_items else '<li class="widget-item">No widgets configured</li>'
# Simple placeholder substitution
html = (
html.replace("{{HOSTPORT}}", hostport)
.replace("{{WIDGET_LIST}}", widget_list_html)
)
return web.Response(text=html, content_type="text/html")
except Exception:
return web.FileResponse(path=str(index_path))
async def handle_widget(request: web.Request) -> web.FileResponse:
slug = request.match_info.get("slug")
web_root = get_web_assets_dir()
index_path = web_root / "widgets" / slug / "index.html"
if index_path.exists():
return web.FileResponse(path=str(index_path))
raise web.HTTPNotFound(text="Widget not found")
async def handle_nowplaying(request: web.Request) -> web.Response:
state: AppState = request.app["state"]
np = await state.get_now_playing()
return web.json_response(np.to_dict())
async def handle_ws(request: web.Request) -> web.WebSocketResponse:
state: AppState = request.app["state"]
ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request)
await state.register_ws(ws)
try:
# Send initial snapshot
np = await state.get_now_playing()
await ws.send_json({"type": "nowplaying", "data": np.to_dict()})
async for msg in ws:
if msg.type == WSMsgType.TEXT:
# Currently no client->server messages required
pass
elif msg.type == WSMsgType.ERROR:
break
finally:
await state.unregister_ws(ws)
return ws
def make_app(state: AppState) -> web.Application:
app = web.Application()
app["state"] = state
web_root = get_web_assets_dir()
art_dir = get_art_dir()
# Pages / API
app.router.add_get("/", handle_root)
for widget in WIDGETS:
slug = widget["slug"]
app.router.add_get(f"/widgets/{slug}/", handle_widget)
app.router.add_get("/api/nowplaying", handle_nowplaying)
app.router.add_get("/ws", handle_ws)
# Widget static routing
# e.g. /widgets/nowplaying/ -> web/widgets/nowplaying/index.html
app.router.add_static(
"/widgets/",
path=str(web_root / "widgets"),
show_index=False,
)
# Art assets
app.router.add_static("/art/", path=str(art_dir))
return app

332
app/win_tray.py Normal file
View file

@ -0,0 +1,332 @@
from __future__ import annotations
import os
import shutil
from dataclasses import dataclass
from typing import Any
import pyperclip
from app.main import ServerController
from app.paths import get_data_dir
@dataclass(frozen=True)
class TrayConfig:
host: str = "127.0.0.1"
port: int = 8765
def widget_url(self, widget: str) -> str:
return f"http://{self.host}:{self.port}/widgets/{widget}/"
def run_windows_tray(host: str = "127.0.0.1", port: int = 8765) -> None:
"""
Native Windows tray icon using pywin32 (more reliable than pystray on some Win11 setups).
"""
# Import inside function so non-Windows platforms can still import the module tree.
import win32api
import win32con
import win32gui
cfg = TrayConfig(host=host, port=port)
server = ServerController(host=host, port=port)
server.start()
status = {"running": True}
WM_TRAYICON = win32con.WM_USER + 20
TASKBAR_CREATED = win32gui.RegisterWindowMessage("TaskbarCreated")
ID_COPY = 1000
ID_START = 1001
ID_STOP = 1002
ID_QUIT = 1099
class_name = "StreamerWidgetsTray"
nid_id = 0
def _ensure_menu_bitmaps() -> dict[int, str]:
"""
Create small BMPs for menu item icons and return a mapping of command id -> bmp path.
We use BMPs because Win32 menu item bitmaps are HBITMAP-based.
"""
from PIL import Image, ImageDraw, ImageFont
base = get_data_dir() / "menu_icons"
base.mkdir(parents=True, exist_ok=True)
version_file = base / "_version.txt"
icon_set_version = "shape-v2"
try:
existing = version_file.read_text(encoding="utf-8").strip()
except Exception:
existing = ""
# If the icon set changed, clear old cached BMPs so new ones render.
if existing != icon_set_version:
try:
shutil.rmtree(base)
except Exception:
pass
base.mkdir(parents=True, exist_ok=True)
try:
version_file.write_text(icon_set_version, encoding="utf-8")
except Exception:
pass
def save_icon(name: str, draw_fn) -> str:
path = base / f"{name}.bmp"
if path.exists():
return str(path)
# 32x32 for decent high-DPI scaling; Win32 downscales reasonably well.
img = Image.new("RGBA", (32, 32), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
draw_fn(d)
# Paste onto white background to ensure visibility on standard menus (transparency is iffy with raw BMPs)
# or keep it simple with 255,255,255 background if transparent doesn't work.
# Using a solid white background is safest for standard menus.
final = Image.new("RGB", (32, 32), (255, 255, 255))
final.paste(img, (0, 0), img)
final.save(str(path), format="BMP")
return str(path)
blue = (56, 189, 248)
green = (34, 197, 94)
red = (239, 68, 68)
slate = (100, 116, 139)
# Drawing vector-style icons at 32x32
copy_bmp = save_icon(
"copy",
lambda d: (
# Two overlapping rectangles
d.rounded_rectangle((10, 10, 24, 26), radius=3, outline=blue, width=2),
d.rounded_rectangle((6, 6, 20, 22), radius=3, fill=(255, 255, 255), outline=blue, width=2),
),
)
start_bmp = save_icon(
"start",
lambda d: d.polygon([(10, 6), (24, 16), (10, 26)], fill=green),
)
stop_bmp = save_icon(
"stop",
lambda d: d.rounded_rectangle((8, 8, 24, 24), radius=2, fill=red),
)
quit_bmp = save_icon(
"quit",
lambda d: (
d.line((8, 8, 24, 24), fill=slate, width=3),
d.line((24, 8, 8, 24), fill=slate, width=3),
),
)
return {
ID_COPY: copy_bmp,
ID_START: start_bmp,
ID_STOP: stop_bmp,
ID_QUIT: quit_bmp,
}
return {
ID_COPY: copy_bmp,
ID_START: start_bmp,
ID_STOP: stop_bmp,
ID_QUIT: quit_bmp,
}
def _ensure_tray_ico_path(running: bool) -> str:
"""
Create a small custom .ico on disk so pywin32 can load it reliably.
"""
from PIL import Image, ImageDraw
data_dir = get_data_dir()
data_dir.mkdir(parents=True, exist_ok=True)
ico_path = data_dir / ("tray_running.ico" if running else "tray_stopped.ico")
if ico_path.exists():
return str(ico_path)
def bubble(size: int) -> Image.Image:
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# dark rounded background for visibility in the tray
r = max(2, size // 6)
d.rounded_rectangle((1, 1, size - 2, size - 2), radius=r, fill=(15, 23, 42, 255))
pad = max(2, size // 8)
d.ellipse((pad, pad, size - pad - 1, size - pad - 1), fill=(30, 64, 175, 255))
# highlights
d.ellipse((size * 0.30, size * 0.26, size * 0.62, size * 0.58), fill=(191, 219, 254, 210))
d.ellipse((size * 0.64, size * 0.60, size * 0.86, size * 0.82), fill=(56, 189, 248, 235))
# status dot (bottom-right)
dot_r = max(3, size // 10)
cx = int(size * 0.78)
cy = int(size * 0.78)
dot_color = (34, 197, 94, 255) if running else (148, 163, 184, 255)
ring = (15, 23, 42, 255)
d.ellipse((cx - dot_r - 2, cy - dot_r - 2, cx + dot_r + 2, cy + dot_r + 2), fill=ring)
d.ellipse((cx - dot_r, cy - dot_r, cx + dot_r, cy + dot_r), fill=dot_color)
return img
img = bubble(64)
# Multi-size ICO for crisp rendering at different DPI/scale.
img.save(str(ico_path), format="ICO", sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64)])
return str(ico_path)
def make_hicon() -> int:
try:
ico_path = _ensure_tray_ico_path(running=True)
return win32gui.LoadImage(
0,
ico_path,
win32con.IMAGE_ICON,
0,
0,
win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE,
)
except Exception:
return win32gui.LoadIcon(0, win32con.IDI_APPLICATION)
def _load_hicon_for_status(running: bool) -> int:
try:
ico_path = _ensure_tray_ico_path(running)
return win32gui.LoadImage(
0,
ico_path,
win32con.IMAGE_ICON,
0,
0,
win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE,
)
except Exception:
return win32gui.LoadIcon(0, win32con.IDI_APPLICATION)
def _tip_for_status(running: bool) -> str:
return f"Streamer Widgets ({'Running' if running else 'Stopped'}) - {host}:{port}"
def _modify_icon(hwnd: int, running: bool) -> None:
hicon = _load_hicon_for_status(running)
flags = win32gui.NIF_ICON | win32gui.NIF_TIP | win32gui.NIF_MESSAGE
nid = (hwnd, nid_id, flags, WM_TRAYICON, hicon, _tip_for_status(running))
win32gui.Shell_NotifyIcon(win32gui.NIM_MODIFY, nid)
def add_icon(hwnd: int) -> None:
"""
Add tray icon (tuple-style NOTIFYICONDATA; works across pywin32 versions).
"""
hicon = make_hicon()
flags = win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP
tip = _tip_for_status(status["running"])
nid = (hwnd, nid_id, flags, WM_TRAYICON, hicon, tip)
win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, nid)
def remove_icon(hwnd: int) -> None:
try:
win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, (hwnd, nid_id))
except Exception:
pass
def show_menu(hwnd: int) -> None:
menu = win32gui.CreatePopupMenu()
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_COPY, "Copy Now Playing URL")
win32gui.AppendMenu(menu, win32con.MF_SEPARATOR, 0, "")
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_START, "Start server")
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_STOP, "Stop server")
win32gui.AppendMenu(menu, win32con.MF_SEPARATOR, 0, "")
win32gui.AppendMenu(menu, win32con.MF_STRING, ID_QUIT, "Quit")
# Attach icons to menu items (best-effort)
try:
bmp_map = _ensure_menu_bitmaps()
for cmd_id, bmp_path in bmp_map.items():
hbmp = win32gui.LoadImage(
0,
bmp_path,
win32con.IMAGE_BITMAP,
0,
0,
win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE,
)
if hbmp:
win32gui.SetMenuItemBitmaps(menu, cmd_id, win32con.MF_BYCOMMAND, hbmp, hbmp)
except Exception:
pass
x, y = win32gui.GetCursorPos()
win32gui.SetForegroundWindow(hwnd)
win32gui.TrackPopupMenu(menu, win32con.TPM_LEFTALIGN | win32con.TPM_RIGHTBUTTON, x, y, 0, hwnd, None)
win32gui.PostMessage(hwnd, win32con.WM_NULL, 0, 0)
def wndproc(hwnd: int, msg: int, wparam: int, lparam: int) -> int:
# Always return an int LRESULT.
if msg == TASKBAR_CREATED:
add_icon(hwnd)
_modify_icon(hwnd, status["running"])
return 0
if msg == win32con.WM_DESTROY:
remove_icon(hwnd)
win32gui.PostQuitMessage(0)
return 0
if msg == win32con.WM_COMMAND:
cmd = win32api.LOWORD(wparam)
if cmd == ID_COPY:
pyperclip.copy(cfg.widget_url("nowplaying"))
elif cmd == ID_START:
server.start()
status["running"] = True
_modify_icon(hwnd, True)
elif cmd == ID_STOP:
server.stop()
status["running"] = False
_modify_icon(hwnd, False)
elif cmd == ID_QUIT:
win32gui.DestroyWindow(hwnd)
return 0
if msg == WM_TRAYICON:
if lparam == win32con.WM_RBUTTONUP:
show_menu(hwnd)
elif lparam == win32con.WM_LBUTTONUP:
pyperclip.copy(cfg.widget_url("nowplaying"))
return 0
return win32gui.DefWindowProc(hwnd, msg, wparam, lparam)
wc = win32gui.WNDCLASS()
wc.hInstance = win32api.GetModuleHandle(None)
wc.lpszClassName = class_name
wc.lpfnWndProc = wndproc
wc.hIcon = make_hicon()
try:
win32gui.RegisterClass(wc)
except win32gui.error:
pass
hwnd = win32gui.CreateWindow(
class_name,
"Streamer Widgets",
0,
0,
0,
0,
0,
0,
0,
wc.hInstance,
None,
)
add_icon(hwnd)
try:
win32gui.PumpMessages()
finally:
# Stop server after the UI loop finishes, avoiding deadlock in wndproc
try:
server.stop()
except Exception:
pass