From c0bc2298ccd64378f2482b033f3f52579f9acee8 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 7 Jan 2026 15:21:16 -0700 Subject: [PATCH] Various improvements --- README.md | 103 +++++++++++++++++-- app/assets/web/widgets/livechat/app.js | 44 +++++++- app/assets/web/widgets/livechat/style.css | 8 +- app/assets/web/widgets/nowplaying/index.html | 7 +- app/assets/web/widgets/nowplaying/style.css | 28 ++--- app/chat_models.py | 4 +- app/providers/gsmtc.py | 40 ++++++- app/providers/twitch_chat.py | 48 +++++++-- 8 files changed, 232 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 9e177da..2b32857 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This app runs a **single local web server** that hosts multiple streamer widgets - **Now Playing**: Display currently playing music from Windows Media - **Live Chat**: Display Twitch and YouTube live chat with emote support (FFZ, BTTV, 7TV) +- **Viewer Count**: Display live viewer count from Twitch and/or YouTube ### Run (dev) @@ -18,15 +19,105 @@ Then add as OBS Browser Sources: - **Now Playing**: `http://127.0.0.1:8765/widgets/nowplaying/` - **Live Chat**: `http://127.0.0.1:8765/widgets/livechat/` +- **Viewer Count**: `http://127.0.0.1:8765/widgets/viewercount/` - **Configuration**: `http://127.0.0.1:8765/config` -### Live Chat Setup +--- -See [CHAT_SETUP.md](CHAT_SETUP.md) for detailed instructions on: -- Connecting to Twitch and YouTube -- Setting up OAuth authentication -- Configuring emote providers (FFZ, BTTV, 7TV) -- Customizing the chat widget +## Widget Documentation + +### Now Playing Widget + +Displays the currently playing song from Windows Media with album art. + +**Features:** +- Real-time updates via WebSocket +- Album art display with animated bubble fallback +- Auto-hide when nothing is playing +- Marquee scrolling for long titles +- Playing/Paused status indicator + +**OBS Setup:** +- URL: `http://127.0.0.1:8765/widgets/nowplaying/` +- Recommended size: 400×150 px +- Transparent background + +**Note:** This widget reads from Windows Media Session (works with Spotify, Windows Media Player, browsers playing media, etc.) + +--- + +### Live Chat Widget + +Displays Twitch and YouTube live chat with emote support. + +**URL Options:** + +| Parameter | Values | Default | Description | +|-----------|--------|---------|-------------| +| `theme` | `dark`, `light` | `dark` | Background style (dark is transparent) | +| `direction` | `down`, `up` | `down` | Message flow direction | +| `fontsize` | `small`, `medium`, `large`, `xlarge` | `medium` | Text and emote size | +| `hidetime` | `true`, `false` | `false` | Hide message timestamps | + +**Example URLs:** +``` +# Default (dark, scrolls down) +http://127.0.0.1:8765/widgets/livechat/ + +# Light theme for light backgrounds +http://127.0.0.1:8765/widgets/livechat/?theme=light + +# Large font, no timestamps +http://127.0.0.1:8765/widgets/livechat/?fontsize=large&hidetime=true + +# Bubbles up (newest at bottom, anchored) +http://127.0.0.1:8765/widgets/livechat/?direction=up +``` + +**OBS Setup:** +- Recommended size: 400×600 px +- Check "Shutdown source when not visible" for performance + +**Emote Support:** +- Twitch native emotes +- FrankerFaceZ (FFZ) - global + channel +- BetterTTV (BTTV) - global + top shared + trending + channel +- 7TV - global + top + trending + channel + +See [CHAT_SETUP.md](CHAT_SETUP.md) for OAuth setup instructions. + +--- + +### Viewer Count Widget + +The viewer count widget displays your live viewer count from Twitch and/or YouTube. + +**URL Options:** + +| Parameter | Values | Default | Description | +|-----------|--------|---------|-------------| +| `theme` | `dark`, `light`, `minimal` | `dark` | Widget background style | +| `fontsize` | `small`, `medium`, `large`, `xlarge` | `medium` | Text size | +| `hidelabel` | `true`, `false` | `false` | Hide "viewers" label | +| `livedot` | `true`, `false` | `false` | Show animated red live indicator | +| `interval` | Number (seconds, min 10) | `30` | Refresh interval | + +**Example URLs:** +``` +# Default +http://127.0.0.1:8765/widgets/viewercount/ + +# Large with live dot +http://127.0.0.1:8765/widgets/viewercount/?fontsize=large&livedot=true + +# Minimal for clean overlay +http://127.0.0.1:8765/widgets/viewercount/?theme=minimal&hidelabel=true + +# Compact with fast refresh +http://127.0.0.1:8765/widgets/viewercount/?fontsize=small&interval=15 +``` + +**Note:** The widget uses the Twitch channel and YouTube video ID configured in `/config`. Requires OAuth login for API access. ### Build a standalone `.exe` (PyInstaller) diff --git a/app/assets/web/widgets/livechat/app.js b/app/assets/web/widgets/livechat/app.js index 87babcd..60f2ac3 100644 --- a/app/assets/web/widgets/livechat/app.js +++ b/app/assets/web/widgets/livechat/app.js @@ -28,8 +28,12 @@ class LiveChatWidget { } // Font size: small, medium (default), large, xlarge - const fontSize = urlParams.get('fontsize') || 'medium'; - document.body.classList.add(`font-${fontSize}`); + this.fontSize = urlParams.get('fontsize') || 'medium'; + document.body.classList.add(`font-${this.fontSize}`); + + // Emote resolution based on font size + // Small/Medium: 1x (28px), Large: 2x (56px), XLarge: 2x (56px) + this.emoteScale = (this.fontSize === 'medium' || this.fontSize === 'large' || this.fontSize === 'xlarge') ? 2 : 1; // Hide timestamp option const hideTime = urlParams.get('hidetime'); @@ -227,6 +231,39 @@ class LiveChatWidget { return msg; } + getEmoteUrl(emote) { + // If no emote_id, use the default URL + if (!emote.emote_id) { + return emote.url; + } + + const scale = this.emoteScale; + const provider = emote.provider; + + // Upgrade URL based on provider and scale + switch (provider) { + case 'twitch': + // Twitch: 1.0, 2.0, 3.0 + return `https://static-cdn.jtvnw.net/emoticons/v2/${emote.emote_id}/default/dark/${scale}.0`; + + case 'bttv': + // BTTV: 1x, 2x, 3x + return `https://cdn.betterttv.net/emote/${emote.emote_id}/${scale}x`; + + case 'ffz': + // FFZ: 1, 2, 4 (no 3x) + const ffzScale = scale === 2 ? 2 : 1; + return emote.url.replace(/\/[124]$/, `/${ffzScale}`); + + case '7tv': + // 7TV: 1x.webp, 2x.webp, 3x.webp + return emote.url.replace(/\/[123]x\.webp$/, `/${scale}x.webp`); + + default: + return emote.url; + } + } + parseMessageWithEmotes(message, emotes) { if (!emotes || emotes.length === 0) { return this.escapeHtml(message); @@ -244,7 +281,8 @@ class LiveChatWidget { if (emoteMap[word]) { const emote = emoteMap[word]; const animatedClass = emote.is_animated ? 'animated' : ''; - return `${emote.code}`; + const url = this.getEmoteUrl(emote); + return `${emote.code}`; } return this.escapeHtml(word); }); diff --git a/app/assets/web/widgets/livechat/style.css b/app/assets/web/widgets/livechat/style.css index 59acd19..91ae264 100644 --- a/app/assets/web/widgets/livechat/style.css +++ b/app/assets/web/widgets/livechat/style.css @@ -48,28 +48,28 @@ body.font-small { --chat-font-size: 12px; --chat-username-size: 12px; --chat-badge-size: 14px; - --chat-emote-size: 20px; + --chat-emote-size: 28px; } body.font-medium { --chat-font-size: 14px; --chat-username-size: 14px; --chat-badge-size: 16px; - --chat-emote-size: 24px; + --chat-emote-size: 36px; } body.font-large { --chat-font-size: 18px; --chat-username-size: 18px; --chat-badge-size: 20px; - --chat-emote-size: 32px; + --chat-emote-size: 48px; } body.font-xlarge { --chat-font-size: 24px; --chat-username-size: 24px; --chat-badge-size: 26px; - --chat-emote-size: 40px; + --chat-emote-size: 56px; } /* Hide timestamp option */ diff --git a/app/assets/web/widgets/nowplaying/index.html b/app/assets/web/widgets/nowplaying/index.html index 6db7014..957d523 100644 --- a/app/assets/web/widgets/nowplaying/index.html +++ b/app/assets/web/widgets/nowplaying/index.html @@ -17,11 +17,8 @@
-
- - - -
+
+
Paused
diff --git a/app/assets/web/widgets/nowplaying/style.css b/app/assets/web/widgets/nowplaying/style.css index 395a7ed..1bb37d1 100644 --- a/app/assets/web/widgets/nowplaying/style.css +++ b/app/assets/web/widgets/nowplaying/style.css @@ -139,7 +139,7 @@ html, body { flex-direction: column; justify-content: center; min-width: 0; - gap: 6px; + gap: 3px; padding-right: clamp(8px, calc(var(--w) * 0.02), 14px); flex: 1; } @@ -156,36 +156,30 @@ html, body { text-shadow: 0 2px 10px rgba(0,0,0,.25); } -.sub{ +.artist{ 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 */ + max-width: 100%; } -.sub span.album{ + +.album{ + color: var(--muted); + font-size: 13px; + opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 1; /* takes remaining space */ min-width: 0; -} -.dot{ - opacity: .7; - flex: 0 0 auto; + max-width: 100%; + font-style: italic; } .statusRow{ - margin-top: 6px; + margin-top: 4px; } .pill{ diff --git a/app/chat_models.py b/app/chat_models.py index ef8f4dd..7c9eb99 100644 --- a/app/chat_models.py +++ b/app/chat_models.py @@ -26,7 +26,7 @@ class Emote: url: str provider: str # "twitch", "ffz", "bttv", "7tv", "youtube" is_animated: bool = False - scale: int = 1 + emote_id: Optional[str] = None # For dynamic resolution selection @dataclass @@ -84,7 +84,7 @@ class ChatMessage: "url": e.url, "provider": e.provider, "is_animated": e.is_animated, - "scale": e.scale, + "emote_id": e.emote_id, } for e in self.emotes ], diff --git a/app/providers/gsmtc.py b/app/providers/gsmtc.py index fc94ada..6b9ff80 100644 --- a/app/providers/gsmtc.py +++ b/app/providers/gsmtc.py @@ -80,14 +80,44 @@ def _pick_best_session(sessions: Any) -> Any: def _extract_album_from_artist(artist_raw: str) -> Tuple[str, str]: + """ + Extract album info from artist string if embedded. + + Supports formats: + - "Artist [ALBUM:Album Name]" -> ("Artist", "Album Name") + - "Artist — Album Name" -> ("Artist", "Album Name") (em dash) + - "Artist - Album Name" -> ("Artist", "Album Name") (hyphen with spaces) + """ if not artist_raw: return "", "" + + # First, check for [ALBUM:...] pattern 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 + if m: + album_hint = m.group(1).strip() + clean_artist = artist_raw[: m.start()].strip() + return clean_artist, album_hint + + # Check for em dash (—) or en dash (–) separator + for dash in ["—", "–"]: + if dash in artist_raw: + parts = artist_raw.split(dash, 1) + if len(parts) == 2: + artist = parts[0].strip() + album = parts[1].strip() + if artist and album: + return artist, album + + # Check for spaced hyphen " - " separator (but not "Artist-Name") + if " - " in artist_raw: + parts = artist_raw.split(" - ", 1) + if len(parts) == 2: + artist = parts[0].strip() + album = parts[1].strip() + if artist and album: + return artist, album + + return artist_raw.strip(), "" async def run_gsmtc_provider(state: AppState) -> None: diff --git a/app/providers/twitch_chat.py b/app/providers/twitch_chat.py index 64d9be2..14b58bc 100644 --- a/app/providers/twitch_chat.py +++ b/app/providers/twitch_chat.py @@ -378,6 +378,7 @@ class TwitchChatClient: code=code, url=f"https://static-cdn.jtvnw.net/emoticons/v2/{emote_id}/default/dark/1.0", provider="twitch", + emote_id=emote_id, ) ) @@ -431,11 +432,16 @@ class TwitchChatClient: for set_id, set_data in data.get("sets", {}).items(): for emote in set_data.get("emoticons", []): code = emote.get("name") + emote_id = str(emote.get("id", "")) urls = emote.get("urls", {}) - url = urls.get("4") or urls.get("2") or urls.get("1") + # Use 1x as default, frontend will upgrade + url = urls.get("1") or urls.get("2") or urls.get("4") if code and url: self.global_emotes[code] = Emote( - code=code, url=f"https:{url}" if url.startswith("//") else url, provider="ffz" + code=code, + url=f"https:{url}" if url.startswith("//") else url, + provider="ffz", + emote_id=emote_id, ) loaded_global += 1 @@ -448,11 +454,16 @@ class TwitchChatClient: for set_id, set_data in data.get("sets", {}).items(): for emote in set_data.get("emoticons", []): code = emote.get("name") + emote_id = str(emote.get("id", "")) urls = emote.get("urls", {}) - url = urls.get("4") or urls.get("2") or urls.get("1") + # Use 1x as default, frontend will upgrade + url = urls.get("1") or urls.get("2") or urls.get("4") if code and url: self.channel_emotes[code] = Emote( - code=code, url=f"https:{url}" if url.startswith("//") else url, provider="ffz" + code=code, + url=f"https:{url}" if url.startswith("//") else url, + provider="ffz", + emote_id=emote_id, ) loaded_channel += 1 @@ -562,6 +573,7 @@ class TwitchChatClient: code=code, url=f"https://cdn.betterttv.net/emote/{emote_id}/1x", provider="bttv", + emote_id=emote_id, ) loaded += 1 return loaded @@ -589,6 +601,7 @@ class TwitchChatClient: code=code, url=f"https://cdn.betterttv.net/emote/{emote_id}/1x", provider="bttv", + emote_id=emote_id, ) loaded_global += 1 @@ -634,6 +647,7 @@ class TwitchChatClient: code=code, url=f"https://cdn.betterttv.net/emote/{emote_id}/1x", provider="bttv", + emote_id=emote_id, ) loaded_channel += 1 @@ -644,7 +658,7 @@ class TwitchChatClient: print(f"BTTV emote load error: {e}") def _get_7tv_emote_url(self, emote: dict) -> Optional[str]: - """Extract the correct URL from a 7TV emote object.""" + """Extract the correct URL from a 7TV emote object (1x for frontend scaling).""" # Try to get from data.host structure (v3 API format) emote_data = emote.get("data", {}) host = emote_data.get("host", {}) @@ -653,11 +667,16 @@ class TwitchChatClient: base_url = host.get("url", "") files = host.get("files", []) - # Find best quality webp file + # Use 1x as default, frontend will upgrade based on font size for f in files: if f.get("name") == "1x.webp": return f"https:{base_url}/{f.get('name')}" + # Fallback to 2x + for f in files: + if f.get("name") == "2x.webp": + return f"https:{base_url}/{f.get('name')}" + # Fallback to first webp file for f in files: if f.get("format") == "WEBP": @@ -670,16 +689,23 @@ class TwitchChatClient: return None def _get_7tv_emote_url_v4(self, emote: dict) -> Optional[str]: - """Extract the correct URL from a 7TV v4 GraphQL emote object.""" + """Extract the correct URL from a 7TV v4 GraphQL emote object (1x for frontend scaling).""" images = emote.get("images", []) - # Find 1x scale image + # Use 1x as default, frontend will upgrade based on font size for img in images: if img.get("scale") == 1: url = img.get("url") if url: return url if url.startswith("http") else f"https:{url}" + # Fallback to 2x + for img in images: + if img.get("scale") == 2: + url = img.get("url") + if url: + return url if url.startswith("http") else f"https:{url}" + # Fallback to first image if images: url = images[0].get("url") @@ -804,6 +830,7 @@ class TwitchChatClient: loaded = 0 for emote in emotes: code = emote.get("defaultName") + emote_id = emote.get("id") if code and code not in self.global_emotes: url = self._get_7tv_emote_url_v4(emote) if url: @@ -815,6 +842,7 @@ class TwitchChatClient: img.get("frameCount", 1) > 1 for img in emote.get("images", []) ), + emote_id=emote_id, ) loaded += 1 return loaded @@ -836,6 +864,7 @@ class TwitchChatClient: data = await resp.json() for emote in data.get("emotes", []): code = emote.get("name") + emote_id = emote.get("id") url = self._get_7tv_emote_url(emote) if code and url: emote_data = emote.get("data", {}) @@ -844,6 +873,7 @@ class TwitchChatClient: url=url, provider="7tv", is_animated=emote_data.get("animated", False), + emote_id=emote_id, ) loaded_global += 1 @@ -889,6 +919,7 @@ class TwitchChatClient: emote_set = data.get("emote_set", {}) for emote in emote_set.get("emotes", []): code = emote.get("name") + emote_id = emote.get("id") url = self._get_7tv_emote_url(emote) if code and url: emote_data = emote.get("data", {}) @@ -896,6 +927,7 @@ class TwitchChatClient: code=code, url=url, provider="7tv", + emote_id=emote_id, is_animated=emote_data.get("animated", False), ) loaded_channel += 1