Initial commit with the now playing
This commit is contained in:
commit
de2f9cccb7
25 changed files with 2729 additions and 0 deletions
3
app/__init__.py
Normal file
3
app/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Streamer widgets host application."""
|
||||
|
||||
|
||||
25
app/__main__.py
Normal file
25
app/__main__.py
Normal 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()
|
||||
|
||||
|
||||
BIN
app/assets/web/art/album.png
Normal file
BIN
app/assets/web/art/album.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
app/assets/web/art/placeholder.png
Normal file
BIN
app/assets/web/art/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
125
app/assets/web/index.html
Normal file
125
app/assets/web/index.html
Normal 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>
|
||||
194
app/assets/web/widgets/nowplaying/app.js
Normal file
194
app/assets/web/widgets/nowplaying/app.js
Normal 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();
|
||||
40
app/assets/web/widgets/nowplaying/index.html
Normal file
40
app/assets/web/widgets/nowplaying/index.html
Normal 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>
|
||||
294
app/assets/web/widgets/nowplaying/style.css
Normal file
294
app/assets/web/widgets/nowplaying/style.css
Normal 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
145
app/main.py
Normal 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
27
app/paths.py
Normal 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
174
app/providers/gsmtc.py
Normal 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
65
app/state.py
Normal 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
80
app/tray.py
Normal 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
117
app/webserver.py
Normal 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
332
app/win_tray.py
Normal 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
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue