From 77d5f16fd58361bd9cd95688ea71c68df7634f91 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 7 Jan 2026 14:02:10 -0700 Subject: [PATCH] Add viewer count --- app/assets/web/index.html | 29 ++++ app/assets/web/widgets/viewercount/app.js | 139 +++++++++++++++ app/assets/web/widgets/viewercount/index.html | 21 +++ app/assets/web/widgets/viewercount/style.css | 161 ++++++++++++++++++ app/webserver.py | 124 ++++++++++++++ 5 files changed, 474 insertions(+) create mode 100644 app/assets/web/widgets/viewercount/app.js create mode 100644 app/assets/web/widgets/viewercount/index.html create mode 100644 app/assets/web/widgets/viewercount/style.css diff --git a/app/assets/web/index.html b/app/assets/web/index.html index 6487f05..f6772db 100644 --- a/app/assets/web/index.html +++ b/app/assets/web/index.html @@ -208,6 +208,34 @@ openLink.href = url; } + // Viewer Count URL generator + function updateViewerCountUrl() { + const baseUrl = document.getElementById('viewercount-base-url'); + if (!baseUrl) return; + + const theme = document.getElementById('viewercount-theme').value; + const fontsize = document.getElementById('viewercount-fontsize').value; + const hidelabel = document.getElementById('viewercount-hidelabel').value; + const livedot = document.getElementById('viewercount-livedot').value; + const urlInput = document.getElementById('viewercount-url'); + const openLink = document.getElementById('viewercount-open'); + + let url = baseUrl.value; + const params = []; + + if (theme !== 'dark') params.push(`theme=${theme}`); + if (fontsize !== 'medium') params.push(`fontsize=${fontsize}`); + if (hidelabel === 'true') params.push(`hidelabel=true`); + if (livedot === 'true') params.push(`livedot=true`); + + if (params.length > 0) { + url += '?' + params.join('&'); + } + + urlInput.value = url; + openLink.href = url; + } + function copyUrl(inputId) { const input = document.getElementById(inputId); input.select(); @@ -222,6 +250,7 @@ // Initialize on page load document.addEventListener('DOMContentLoaded', () => { updateLiveChatUrl(); + updateViewerCountUrl(); }); diff --git a/app/assets/web/widgets/viewercount/app.js b/app/assets/web/widgets/viewercount/app.js new file mode 100644 index 0000000..e42867a --- /dev/null +++ b/app/assets/web/widgets/viewercount/app.js @@ -0,0 +1,139 @@ +class ViewerCountWidget { + constructor() { + this.countElement = document.getElementById('count-value'); + this.viewerCount = document.getElementById('viewer-count'); + this.statusMessage = document.getElementById('status-message'); + this.iconElement = document.getElementById('count-icon'); + this.labelElement = document.getElementById('count-label'); + + this.currentCount = 0; + this.refreshInterval = 30000; // 30 seconds + this.intervalId = null; + + // Parse URL parameters + const urlParams = new URLSearchParams(window.location.search); + + // Theme: dark (default), light, minimal + const theme = urlParams.get('theme') || 'dark'; + document.body.classList.add(`theme-${theme}`); + + // Font size: small, medium (default), large, xlarge + const fontSize = urlParams.get('fontsize') || 'medium'; + document.body.classList.add(`font-${fontSize}`); + + // Hide label option + const hideLabel = urlParams.get('hidelabel'); + if (hideLabel === 'true' || hideLabel === '1') { + document.body.classList.add('hide-label'); + } + + // Show live dot + const showDot = urlParams.get('livedot'); + if (showDot === 'true' || showDot === '1') { + document.body.classList.add('show-live-dot'); + } + + // Custom refresh interval (in seconds) + const interval = parseInt(urlParams.get('interval')); + if (interval && interval >= 10) { + this.refreshInterval = interval * 1000; + } + + this.init(); + } + + init() { + this.fetchViewerCount(); + this.intervalId = setInterval(() => this.fetchViewerCount(), this.refreshInterval); + } + + async fetchViewerCount() { + try { + const response = await fetch('/api/viewercount'); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + this.updateDisplay(data); + } catch (error) { + console.error('Failed to fetch viewer count:', error); + this.showError('Failed to load'); + } + } + + updateDisplay(data) { + const { twitch, youtube, total } = data; + + // Determine which platform(s) have data + const hasTwitch = twitch !== null; + const hasYoutube = youtube !== null; + + // Set platform class for icon color based on what's configured/available + document.body.classList.remove('platform-twitch', 'platform-youtube', 'platform-combined'); + if (hasTwitch && hasYoutube) { + document.body.classList.add('platform-combined'); + this.setIcon('eye'); + } else if (hasTwitch) { + document.body.classList.add('platform-twitch'); + this.setIcon('twitch'); + } else if (hasYoutube) { + document.body.classList.add('platform-youtube'); + this.setIcon('youtube'); + } else { + // No platform configured, use generic eye icon + document.body.classList.add('platform-combined'); + this.setIcon('eye'); + } + + // Update count with animation + const newCount = total || 0; + if (newCount !== this.currentCount) { + this.viewerCount.classList.add('updating'); + setTimeout(() => this.viewerCount.classList.remove('updating'), 300); + } + + this.currentCount = newCount; + this.countElement.textContent = this.formatNumber(newCount); + + // Update label + this.labelElement.textContent = newCount === 1 ? 'viewer' : 'viewers'; + + // Show the widget, hide status + this.viewerCount.classList.remove('hidden'); + this.statusMessage.classList.add('hidden'); + } + + formatNumber(num) { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } else if (num >= 10000) { + return (num / 1000).toFixed(1) + 'K'; + } else if (num >= 1000) { + return num.toLocaleString(); + } + return num.toString(); + } + + setIcon(type) { + const icons = { + twitch: ``, + youtube: ``, + eye: `` + }; + this.iconElement.innerHTML = icons[type] || icons.eye; + } + + showError(message) { + this.viewerCount.classList.add('hidden'); + this.statusMessage.textContent = message; + this.statusMessage.classList.remove('hidden'); + this.statusMessage.classList.add('error'); + } +} + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + new ViewerCountWidget(); +}); + diff --git a/app/assets/web/widgets/viewercount/index.html b/app/assets/web/widgets/viewercount/index.html new file mode 100644 index 0000000..726a7e0 --- /dev/null +++ b/app/assets/web/widgets/viewercount/index.html @@ -0,0 +1,21 @@ + + + + + + Viewer Count + + + +
+ +
Loading...
+
+ + + + diff --git a/app/assets/web/widgets/viewercount/style.css b/app/assets/web/widgets/viewercount/style.css new file mode 100644 index 0000000..6b11c43 --- /dev/null +++ b/app/assets/web/widgets/viewercount/style.css @@ -0,0 +1,161 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: transparent; + overflow: hidden; +} + +#viewer-container { + display: flex; + align-items: flex-start; + justify-content: flex-start; + min-height: 100vh; + padding: 10px; +} + +#viewer-count { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + border-radius: 8px; + font-weight: 600; + transition: all 0.3s ease; +} + +#viewer-count.hidden { + display: none; +} + +/* Theme: Dark (default) */ +body.theme-dark #viewer-count { + background: rgba(0, 0, 0, 0.7); + color: #fff; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); +} + +/* Theme: Light */ +body.theme-light #viewer-count { + background: rgba(255, 255, 255, 0.9); + color: #1a1a1a; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +/* Theme: Minimal (no background) */ +body.theme-minimal #viewer-count { + background: transparent; + color: #fff; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); + padding: 0; +} + +body.theme-minimal.light-text #viewer-count { + color: #1a1a1a; + text-shadow: 2px 2px 4px rgba(255, 255, 255, 0.8); +} + +/* Icon */ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.icon svg { + width: 20px; + height: 20px; +} + +/* Platform-specific colors */ +body.platform-twitch .icon svg { + fill: #9146FF; +} + +body.platform-youtube .icon svg { + fill: #FF0000; +} + +body.platform-combined .icon svg { + fill: currentColor; +} + +/* Count value */ +#count-value { + font-size: var(--count-font-size, 24px); + font-variant-numeric: tabular-nums; + min-width: 2ch; + text-align: center; +} + +/* Label */ +#count-label { + font-size: calc(var(--count-font-size, 24px) * 0.6); + opacity: 0.8; +} + +body.hide-label #count-label { + display: none; +} + +/* Font sizes */ +body.font-small { + --count-font-size: 18px; +} + +body.font-medium { + --count-font-size: 24px; +} + +body.font-large { + --count-font-size: 36px; +} + +body.font-xlarge { + --count-font-size: 48px; +} + +/* Animations */ +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +#viewer-count.updating #count-value { + animation: pulse 0.3s ease; +} + +/* Live indicator dot */ +body.show-live-dot #viewer-count::before { + content: ''; + width: 10px; + height: 10px; + background: #e91916; + border-radius: 50%; + margin-right: 4px; + animation: live-pulse 2s infinite; +} + +@keyframes live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Status message */ +.status { + color: #aaa; + font-size: 14px; +} + +.status.error { + color: #ff6b6b; +} + +.status.hidden { + display: none; +} + diff --git a/app/webserver.py b/app/webserver.py index ddf5be8..33fff7f 100644 --- a/app/webserver.py +++ b/app/webserver.py @@ -13,6 +13,7 @@ from app.state import AppState WIDGETS = [ {"slug": "nowplaying", "label": "Now Playing"}, {"slug": "livechat", "label": "Live Chat"}, + {"slug": "viewercount", "label": "Viewer Count"}, ] @@ -76,6 +77,53 @@ async def handle_root(request: web.Request) -> web.Response: """ + elif slug == "viewercount": + # Viewer Count widget with options + item_html = f""" +
  • +
    + {label} +
    +
    + + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
  • + """ else: # Standard widget without options item_html = f""" @@ -237,6 +285,81 @@ async def handle_open_config_dir(request: web.Request) -> web.Response: ) +async def handle_viewer_count(request: web.Request) -> web.Response: + """Get viewer count from Twitch and/or YouTube.""" + import aiohttp + from app.chat_models import Platform + + state: AppState = request.app["state"] + app_config = load_config() + chat_config = state.chat_config + + twitch_count: int | None = None + youtube_count: int | None = None + + # Fetch Twitch viewer count + if chat_config.twitch_channel: + try: + twitch_tokens = await state.get_auth_tokens(Platform.TWITCH) + if twitch_tokens and app_config.twitch_oauth.client_id: + headers = { + "Client-ID": app_config.twitch_oauth.client_id, + "Authorization": f"Bearer {twitch_tokens.access_token}", + } + async with aiohttp.ClientSession() as session: + url = f"https://api.twitch.tv/helix/streams?user_login={chat_config.twitch_channel}" + async with session.get(url, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + streams = data.get("data", []) + if streams: + twitch_count = streams[0].get("viewer_count", 0) + else: + # Channel configured but not live + twitch_count = 0 + except Exception as e: + print(f"Error fetching Twitch viewer count: {e}") + + # Fetch YouTube viewer count + if chat_config.youtube_video_id: + try: + youtube_tokens = await state.get_auth_tokens(Platform.YOUTUBE) + if youtube_tokens: + async with aiohttp.ClientSession() as session: + url = ( + f"https://www.googleapis.com/youtube/v3/videos" + f"?part=liveStreamingDetails&id={chat_config.youtube_video_id}" + ) + headers = {"Authorization": f"Bearer {youtube_tokens.access_token}"} + async with session.get(url, headers=headers) as resp: + if resp.status == 200: + data = await resp.json() + items = data.get("items", []) + if items: + live_details = items[0].get("liveStreamingDetails", {}) + concurrent = live_details.get("concurrentViewers") + if concurrent is not None: + youtube_count = int(concurrent) + else: + # Video exists but not live + youtube_count = 0 + except Exception as e: + print(f"Error fetching YouTube viewer count: {e}") + + # Calculate total + total = 0 + if twitch_count is not None: + total += twitch_count + if youtube_count is not None: + total += youtube_count + + return web.json_response({ + "twitch": twitch_count, + "youtube": youtube_count, + "total": total, + }) + + async def handle_ws(request: web.Request) -> web.WebSocketResponse: state: AppState = request.app["state"] ws = web.WebSocketResponse(heartbeat=30) @@ -287,6 +410,7 @@ def make_app(state: AppState) -> web.Application: app.router.add_get("/api/oauth/status", handle_oauth_status) app.router.add_get("/api/auth/status", handle_auth_status) app.router.add_post("/api/config/open-directory", handle_open_config_dir) + app.router.add_get("/api/viewercount", handle_viewer_count) app.router.add_get("/ws", handle_ws) # Register OAuth routes