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

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;
}
}