feat: game integration system

Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive
LED effects through the existing color strip and value source pipelines.

Core:
- GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary
- GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven)
- Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook
- Community YAML adapters: Minecraft, Valorant, Rocket League
- GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing)
- GameEventValueSource with EMA smoothing and timeout
- 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert)
- Auto-setup for Valve GSI games (Steam path detection, cfg file writing)
- Demo capture engine exposed to non-demo mode

Frontend:
- Game tab in Streams tree navigation with integration cards
- Game integration editor modal with adapter picker, config fields, event mappings
- game_event source type in CSS and ValueSource editors
- Setup instructions overlay (markdown rendered)
- Live event monitor and connection test

API:
- Full CRUD for game integrations
- Event ingestion endpoint (adapter-level auth)
- Adapter metadata, presets, auto-setup, status/diagnostics endpoints
This commit is contained in:
2026-03-31 13:17:52 +03:00
parent b6713be390
commit 492bdb95e3
87 changed files with 12170 additions and 912 deletions
@@ -39,6 +39,8 @@ import {
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
gameIntegrationsCache, gameAdaptersCache,
_cachedGameIntegrations, _cachedGameAdapters,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -55,12 +57,14 @@ import { createHASourceCard, initHASourceDelegation } from './home-assistant-sou
import { createAssetCard, initAssetDelegation } from './assets.ts';
import { createColorStripCard } from './color-strips.ts';
import { initAudioSourceDelegation } from './audio-sources.ts';
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
import {
getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
ICON_GAMEPAD,
getAssetTypeIcon,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
@@ -292,6 +296,8 @@ export async function loadPictureSources() {
colorStripSourcesCache.fetch(),
csptCache.fetch(),
gradientsCache.fetch(),
gameIntegrationsCache.fetch(),
gameAdaptersCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
renderPictureSourcesList(streams);
@@ -346,6 +352,7 @@ const _streamSectionMap = {
sync: [csSyncClocks],
weather: [csWeatherSources],
home_assistant: [csHASources],
game: [csGameIntegrations],
};
type StreamCardRenderer = (stream: any) => string;
@@ -574,6 +581,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
];
// Build tree navigation structure
@@ -626,6 +634,7 @@ function renderPictureSourcesList(streams: any) {
children: [
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
]
},
{
@@ -797,6 +806,7 @@ function renderPictureSourcesList(streams: any) {
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
@@ -817,6 +827,7 @@ function renderPictureSourcesList(streams: any) {
weather: _cachedWeatherSources.length,
home_assistant: _cachedHASources.length,
assets: _cachedAssets.length,
game: _cachedGameIntegrations.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
@@ -836,6 +847,7 @@ function renderPictureSourcesList(streams: any) {
csWeatherSources.reconcile(weatherSourceItems);
csHASources.reconcile(haSourceItems);
csAssets.reconcile(assetItems);
csGameIntegrations.reconcile(gameIntegrationItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
@@ -856,13 +868,14 @@ function renderPictureSourcesList(streams: any) {
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets, csGameIntegrations]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
@@ -889,6 +902,7 @@ function renderPictureSourcesList(streams: any) {
'weather-sources': 'weather',
'ha-sources': 'home_assistant',
'assets': 'assets',
'game-integrations': 'game',
});
}
}