Various improvements
This commit is contained in:
parent
77d5f16fd5
commit
c0bc2298cc
8 changed files with 232 additions and 50 deletions
103
README.md
103
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
|
- **Now Playing**: Display currently playing music from Windows Media
|
||||||
- **Live Chat**: Display Twitch and YouTube live chat with emote support (FFZ, BTTV, 7TV)
|
- **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)
|
### Run (dev)
|
||||||
|
|
||||||
|
|
@ -18,15 +19,105 @@ Then add as OBS Browser Sources:
|
||||||
|
|
||||||
- **Now Playing**: `http://127.0.0.1:8765/widgets/nowplaying/`
|
- **Now Playing**: `http://127.0.0.1:8765/widgets/nowplaying/`
|
||||||
- **Live Chat**: `http://127.0.0.1:8765/widgets/livechat/`
|
- **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`
|
- **Configuration**: `http://127.0.0.1:8765/config`
|
||||||
|
|
||||||
### Live Chat Setup
|
---
|
||||||
|
|
||||||
See [CHAT_SETUP.md](CHAT_SETUP.md) for detailed instructions on:
|
## Widget Documentation
|
||||||
- Connecting to Twitch and YouTube
|
|
||||||
- Setting up OAuth authentication
|
### Now Playing Widget
|
||||||
- Configuring emote providers (FFZ, BTTV, 7TV)
|
|
||||||
- Customizing the chat 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)
|
### Build a standalone `.exe` (PyInstaller)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,12 @@ class LiveChatWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Font size: small, medium (default), large, xlarge
|
// Font size: small, medium (default), large, xlarge
|
||||||
const fontSize = urlParams.get('fontsize') || 'medium';
|
this.fontSize = urlParams.get('fontsize') || 'medium';
|
||||||
document.body.classList.add(`font-${fontSize}`);
|
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
|
// Hide timestamp option
|
||||||
const hideTime = urlParams.get('hidetime');
|
const hideTime = urlParams.get('hidetime');
|
||||||
|
|
@ -227,6 +231,39 @@ class LiveChatWidget {
|
||||||
return msg;
|
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) {
|
parseMessageWithEmotes(message, emotes) {
|
||||||
if (!emotes || emotes.length === 0) {
|
if (!emotes || emotes.length === 0) {
|
||||||
return this.escapeHtml(message);
|
return this.escapeHtml(message);
|
||||||
|
|
@ -244,7 +281,8 @@ class LiveChatWidget {
|
||||||
if (emoteMap[word]) {
|
if (emoteMap[word]) {
|
||||||
const emote = emoteMap[word];
|
const emote = emoteMap[word];
|
||||||
const animatedClass = emote.is_animated ? 'animated' : '';
|
const animatedClass = emote.is_animated ? 'animated' : '';
|
||||||
return `<img class="emote ${animatedClass}" src="${emote.url}" alt="${emote.code}" title="${emote.code} (${emote.provider})">`;
|
const url = this.getEmoteUrl(emote);
|
||||||
|
return `<img class="emote ${animatedClass}" src="${url}" alt="${emote.code}" title="${emote.code} (${emote.provider})">`;
|
||||||
}
|
}
|
||||||
return this.escapeHtml(word);
|
return this.escapeHtml(word);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,28 +48,28 @@ body.font-small {
|
||||||
--chat-font-size: 12px;
|
--chat-font-size: 12px;
|
||||||
--chat-username-size: 12px;
|
--chat-username-size: 12px;
|
||||||
--chat-badge-size: 14px;
|
--chat-badge-size: 14px;
|
||||||
--chat-emote-size: 20px;
|
--chat-emote-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.font-medium {
|
body.font-medium {
|
||||||
--chat-font-size: 14px;
|
--chat-font-size: 14px;
|
||||||
--chat-username-size: 14px;
|
--chat-username-size: 14px;
|
||||||
--chat-badge-size: 16px;
|
--chat-badge-size: 16px;
|
||||||
--chat-emote-size: 24px;
|
--chat-emote-size: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.font-large {
|
body.font-large {
|
||||||
--chat-font-size: 18px;
|
--chat-font-size: 18px;
|
||||||
--chat-username-size: 18px;
|
--chat-username-size: 18px;
|
||||||
--chat-badge-size: 20px;
|
--chat-badge-size: 20px;
|
||||||
--chat-emote-size: 32px;
|
--chat-emote-size: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.font-xlarge {
|
body.font-xlarge {
|
||||||
--chat-font-size: 24px;
|
--chat-font-size: 24px;
|
||||||
--chat-username-size: 24px;
|
--chat-username-size: 24px;
|
||||||
--chat-badge-size: 26px;
|
--chat-badge-size: 26px;
|
||||||
--chat-emote-size: 40px;
|
--chat-emote-size: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide timestamp option */
|
/* Hide timestamp option */
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,8 @@
|
||||||
|
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<div id="title" class="title">—</div>
|
<div id="title" class="title">—</div>
|
||||||
<div class="sub">
|
<div id="artist" class="artist">—</div>
|
||||||
<span id="artist" class="artist">—</span>
|
<div id="album" class="album">—</div>
|
||||||
<span class="dot">•</span>
|
|
||||||
<span id="album" class="album">—</span>
|
|
||||||
</div>
|
|
||||||
<div class="statusRow">
|
<div class="statusRow">
|
||||||
<span id="statusPill" class="pill">Paused</span>
|
<span id="statusPill" class="pill">Paused</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ html, body {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 6px;
|
gap: 3px;
|
||||||
padding-right: clamp(8px, calc(var(--w) * 0.02), 14px);
|
padding-right: clamp(8px, calc(var(--w) * 0.02), 14px);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -156,36 +156,30 @@ html, body {
|
||||||
text-shadow: 0 2px 10px rgba(0,0,0,.25);
|
text-shadow: 0 2px 10px rgba(0,0,0,.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sub{
|
.artist{
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
display:flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.sub span.artist{
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex: 0 1 auto; /* shrinking is ok, growing beyond content isn't forced */
|
|
||||||
min-width: 0;
|
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;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex: 1; /* takes remaining space */
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
max-width: 100%;
|
||||||
.dot{
|
font-style: italic;
|
||||||
opacity: .7;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusRow{
|
.statusRow{
|
||||||
margin-top: 6px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pill{
|
.pill{
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class Emote:
|
||||||
url: str
|
url: str
|
||||||
provider: str # "twitch", "ffz", "bttv", "7tv", "youtube"
|
provider: str # "twitch", "ffz", "bttv", "7tv", "youtube"
|
||||||
is_animated: bool = False
|
is_animated: bool = False
|
||||||
scale: int = 1
|
emote_id: Optional[str] = None # For dynamic resolution selection
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -84,7 +84,7 @@ class ChatMessage:
|
||||||
"url": e.url,
|
"url": e.url,
|
||||||
"provider": e.provider,
|
"provider": e.provider,
|
||||||
"is_animated": e.is_animated,
|
"is_animated": e.is_animated,
|
||||||
"scale": e.scale,
|
"emote_id": e.emote_id,
|
||||||
}
|
}
|
||||||
for e in self.emotes
|
for e in self.emotes
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -80,15 +80,45 @@ def _pick_best_session(sessions: Any) -> Any:
|
||||||
|
|
||||||
|
|
||||||
def _extract_album_from_artist(artist_raw: str) -> Tuple[str, str]:
|
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:
|
if not artist_raw:
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|
||||||
|
# First, check for [ALBUM:...] pattern
|
||||||
m = re.search(r"\s*\[ALBUM:(.*?)\]\s*$", artist_raw, re.IGNORECASE)
|
m = re.search(r"\s*\[ALBUM:(.*?)\]\s*$", artist_raw, re.IGNORECASE)
|
||||||
if not m:
|
if m:
|
||||||
return artist_raw.strip(), ""
|
|
||||||
album_hint = m.group(1).strip()
|
album_hint = m.group(1).strip()
|
||||||
clean_artist = artist_raw[: m.start()].strip()
|
clean_artist = artist_raw[: m.start()].strip()
|
||||||
return clean_artist, album_hint
|
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:
|
async def run_gsmtc_provider(state: AppState) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -378,6 +378,7 @@ class TwitchChatClient:
|
||||||
code=code,
|
code=code,
|
||||||
url=f"https://static-cdn.jtvnw.net/emoticons/v2/{emote_id}/default/dark/1.0",
|
url=f"https://static-cdn.jtvnw.net/emoticons/v2/{emote_id}/default/dark/1.0",
|
||||||
provider="twitch",
|
provider="twitch",
|
||||||
|
emote_id=emote_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -431,11 +432,16 @@ class TwitchChatClient:
|
||||||
for set_id, set_data in data.get("sets", {}).items():
|
for set_id, set_data in data.get("sets", {}).items():
|
||||||
for emote in set_data.get("emoticons", []):
|
for emote in set_data.get("emoticons", []):
|
||||||
code = emote.get("name")
|
code = emote.get("name")
|
||||||
|
emote_id = str(emote.get("id", ""))
|
||||||
urls = emote.get("urls", {})
|
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:
|
if code and url:
|
||||||
self.global_emotes[code] = Emote(
|
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
|
loaded_global += 1
|
||||||
|
|
||||||
|
|
@ -448,11 +454,16 @@ class TwitchChatClient:
|
||||||
for set_id, set_data in data.get("sets", {}).items():
|
for set_id, set_data in data.get("sets", {}).items():
|
||||||
for emote in set_data.get("emoticons", []):
|
for emote in set_data.get("emoticons", []):
|
||||||
code = emote.get("name")
|
code = emote.get("name")
|
||||||
|
emote_id = str(emote.get("id", ""))
|
||||||
urls = emote.get("urls", {})
|
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:
|
if code and url:
|
||||||
self.channel_emotes[code] = Emote(
|
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
|
loaded_channel += 1
|
||||||
|
|
||||||
|
|
@ -562,6 +573,7 @@ class TwitchChatClient:
|
||||||
code=code,
|
code=code,
|
||||||
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
||||||
provider="bttv",
|
provider="bttv",
|
||||||
|
emote_id=emote_id,
|
||||||
)
|
)
|
||||||
loaded += 1
|
loaded += 1
|
||||||
return loaded
|
return loaded
|
||||||
|
|
@ -589,6 +601,7 @@ class TwitchChatClient:
|
||||||
code=code,
|
code=code,
|
||||||
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
||||||
provider="bttv",
|
provider="bttv",
|
||||||
|
emote_id=emote_id,
|
||||||
)
|
)
|
||||||
loaded_global += 1
|
loaded_global += 1
|
||||||
|
|
||||||
|
|
@ -634,6 +647,7 @@ class TwitchChatClient:
|
||||||
code=code,
|
code=code,
|
||||||
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
url=f"https://cdn.betterttv.net/emote/{emote_id}/1x",
|
||||||
provider="bttv",
|
provider="bttv",
|
||||||
|
emote_id=emote_id,
|
||||||
)
|
)
|
||||||
loaded_channel += 1
|
loaded_channel += 1
|
||||||
|
|
||||||
|
|
@ -644,7 +658,7 @@ class TwitchChatClient:
|
||||||
print(f"BTTV emote load error: {e}")
|
print(f"BTTV emote load error: {e}")
|
||||||
|
|
||||||
def _get_7tv_emote_url(self, emote: dict) -> Optional[str]:
|
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)
|
# Try to get from data.host structure (v3 API format)
|
||||||
emote_data = emote.get("data", {})
|
emote_data = emote.get("data", {})
|
||||||
host = emote_data.get("host", {})
|
host = emote_data.get("host", {})
|
||||||
|
|
@ -653,11 +667,16 @@ class TwitchChatClient:
|
||||||
base_url = host.get("url", "")
|
base_url = host.get("url", "")
|
||||||
files = host.get("files", [])
|
files = host.get("files", [])
|
||||||
|
|
||||||
# Find best quality webp file
|
# Use 1x as default, frontend will upgrade based on font size
|
||||||
for f in files:
|
for f in files:
|
||||||
if f.get("name") == "1x.webp":
|
if f.get("name") == "1x.webp":
|
||||||
return f"https:{base_url}/{f.get('name')}"
|
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
|
# Fallback to first webp file
|
||||||
for f in files:
|
for f in files:
|
||||||
if f.get("format") == "WEBP":
|
if f.get("format") == "WEBP":
|
||||||
|
|
@ -670,16 +689,23 @@ class TwitchChatClient:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_7tv_emote_url_v4(self, emote: dict) -> Optional[str]:
|
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", [])
|
images = emote.get("images", [])
|
||||||
|
|
||||||
# Find 1x scale image
|
# Use 1x as default, frontend will upgrade based on font size
|
||||||
for img in images:
|
for img in images:
|
||||||
if img.get("scale") == 1:
|
if img.get("scale") == 1:
|
||||||
url = img.get("url")
|
url = img.get("url")
|
||||||
if url:
|
if url:
|
||||||
return url if url.startswith("http") else f"https:{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
|
# Fallback to first image
|
||||||
if images:
|
if images:
|
||||||
url = images[0].get("url")
|
url = images[0].get("url")
|
||||||
|
|
@ -804,6 +830,7 @@ class TwitchChatClient:
|
||||||
loaded = 0
|
loaded = 0
|
||||||
for emote in emotes:
|
for emote in emotes:
|
||||||
code = emote.get("defaultName")
|
code = emote.get("defaultName")
|
||||||
|
emote_id = emote.get("id")
|
||||||
if code and code not in self.global_emotes:
|
if code and code not in self.global_emotes:
|
||||||
url = self._get_7tv_emote_url_v4(emote)
|
url = self._get_7tv_emote_url_v4(emote)
|
||||||
if url:
|
if url:
|
||||||
|
|
@ -815,6 +842,7 @@ class TwitchChatClient:
|
||||||
img.get("frameCount", 1) > 1
|
img.get("frameCount", 1) > 1
|
||||||
for img in emote.get("images", [])
|
for img in emote.get("images", [])
|
||||||
),
|
),
|
||||||
|
emote_id=emote_id,
|
||||||
)
|
)
|
||||||
loaded += 1
|
loaded += 1
|
||||||
return loaded
|
return loaded
|
||||||
|
|
@ -836,6 +864,7 @@ class TwitchChatClient:
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
for emote in data.get("emotes", []):
|
for emote in data.get("emotes", []):
|
||||||
code = emote.get("name")
|
code = emote.get("name")
|
||||||
|
emote_id = emote.get("id")
|
||||||
url = self._get_7tv_emote_url(emote)
|
url = self._get_7tv_emote_url(emote)
|
||||||
if code and url:
|
if code and url:
|
||||||
emote_data = emote.get("data", {})
|
emote_data = emote.get("data", {})
|
||||||
|
|
@ -844,6 +873,7 @@ class TwitchChatClient:
|
||||||
url=url,
|
url=url,
|
||||||
provider="7tv",
|
provider="7tv",
|
||||||
is_animated=emote_data.get("animated", False),
|
is_animated=emote_data.get("animated", False),
|
||||||
|
emote_id=emote_id,
|
||||||
)
|
)
|
||||||
loaded_global += 1
|
loaded_global += 1
|
||||||
|
|
||||||
|
|
@ -889,6 +919,7 @@ class TwitchChatClient:
|
||||||
emote_set = data.get("emote_set", {})
|
emote_set = data.get("emote_set", {})
|
||||||
for emote in emote_set.get("emotes", []):
|
for emote in emote_set.get("emotes", []):
|
||||||
code = emote.get("name")
|
code = emote.get("name")
|
||||||
|
emote_id = emote.get("id")
|
||||||
url = self._get_7tv_emote_url(emote)
|
url = self._get_7tv_emote_url(emote)
|
||||||
if code and url:
|
if code and url:
|
||||||
emote_data = emote.get("data", {})
|
emote_data = emote.get("data", {})
|
||||||
|
|
@ -896,6 +927,7 @@ class TwitchChatClient:
|
||||||
code=code,
|
code=code,
|
||||||
url=url,
|
url=url,
|
||||||
provider="7tv",
|
provider="7tv",
|
||||||
|
emote_id=emote_id,
|
||||||
is_animated=emote_data.get("animated", False),
|
is_animated=emote_data.get("animated", False),
|
||||||
)
|
)
|
||||||
loaded_channel += 1
|
loaded_channel += 1
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue