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 `
`;
+ const url = this.getEmoteUrl(emote);
+ return `
`;
}
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