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
@@ -15,4 +15,5 @@
@import './tutorials.css';
@import './graph-editor.css';
@import './appearance.css';
@import './game-integration.css';
@import './mobile.css';
@@ -27,7 +27,7 @@
padding: 0 4px;
}
/* Automation condition pills — constrain to card width */
/* Automation rule pills — constrain to card width */
[data-automation-id] .card-meta {
display: flex;
flex-wrap: wrap;
@@ -41,8 +41,8 @@
white-space: nowrap;
}
/* Automation condition editor rows */
.automation-condition-row {
/* Automation rule editor rows */
.automation-rule-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
@@ -50,19 +50,19 @@
background: var(--bg-secondary, var(--bg-color));
}
.condition-header {
.rule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.condition-type-label {
.rule-type-label {
font-weight: 600;
font-size: 0.9rem;
}
.condition-type-select {
.rule-type-select {
font-weight: 600;
font-size: 0.9rem;
padding: 2px 6px;
@@ -72,13 +72,13 @@
color: var(--text-color);
}
.condition-always-desc {
.rule-hint-desc {
display: block;
color: var(--text-muted);
font-size: 0.85rem;
}
.btn-remove-condition {
.btn-remove-rule {
background: none;
border: none;
color: var(--danger-color, #dc3545);
@@ -88,22 +88,22 @@
transition: opacity 0.15s;
}
.btn-remove-condition:hover {
.btn-remove-rule:hover {
opacity: 1;
}
.btn-remove-condition .icon {
.btn-remove-rule .icon {
width: 16px;
height: 16px;
}
.condition-fields {
.rule-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-field label {
.rule-field label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
@@ -202,8 +202,8 @@
}
.condition-field select,
.condition-field textarea {
.rule-field select,
.rule-field textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-color);
@@ -214,12 +214,12 @@
font-family: inherit;
}
.condition-apps {
.rule-apps {
resize: vertical;
min-height: 60px;
}
.condition-apps-header {
.rule-apps-header {
display: flex;
justify-content: space-between;
align-items: center;
@@ -0,0 +1,241 @@
/* ── Game Integration ── */
/* Status indicator badges */
.gi-status-active {
color: var(--success-color);
}
.gi-status-inactive {
color: var(--text-muted);
}
/* Mapping editor toolbar */
.gi-mapping-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.gi-mapping-toolbar select {
flex: 0 0 auto;
max-width: 200px;
}
/* Mapping list */
.gi-mappings-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Single mapping row — collapsible item (matches composite-layer-item) */
.gi-mapping-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary, var(--bg-color));
}
/* Header — always visible summary */
.gi-mapping-header {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.gi-mapping-expand-btn {
font-size: 0.6rem;
color: var(--text-secondary);
transition: transform 0.15s ease;
flex-shrink: 0;
width: 12px;
text-align: center;
}
.gi-mapping-expanded .gi-mapping-expand-btn {
transform: rotate(90deg);
}
.gi-mapping-summary {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.gi-mapping-summary-event {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gi-mapping-summary-effect {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-color);
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
flex-shrink: 0;
}
.gi-mapping-summary-color {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--border-color);
flex-shrink: 0;
}
/* Collapsible body — CSS grid transition (matches composite-layer) */
.gi-mapping-body-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.gi-mapping-expanded .gi-mapping-body-wrapper {
grid-template-rows: 1fr;
}
.gi-mapping-body {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 0;
overflow: hidden;
min-height: 0;
transition: padding-top 0.2s ease;
font-size: 0.85rem;
}
.gi-mapping-expanded .gi-mapping-body {
padding-top: 8px;
}
/* Field rows inside body */
.gi-mapping-field-row {
display: flex;
align-items: center;
gap: 6px;
}
.gi-mapping-field-row label {
font-size: 0.85rem;
color: var(--text-muted);
min-width: 70px;
flex-shrink: 0;
}
.gi-mapping-field-row select,
.gi-mapping-field-row input[type="text"],
.gi-mapping-field-row input[type="number"] {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
font-family: inherit;
}
.gi-mapping-field-row input[type="range"] {
flex: 1;
min-width: 0;
}
.gi-mapping-field-row input[type="color"] {
width: 40px;
height: 30px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
/* Setup instructions pre block */
.gi-instructions-pre {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
font-family: var(--font-mono, monospace);
font-size: 0.85em;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
/* Live event feed */
.gi-event-feed {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px;
background: var(--bg-secondary);
font-size: 0.85em;
}
.gi-event-waiting {
color: var(--text-muted);
text-align: center;
padding: 16px;
}
.gi-event-item {
display: flex;
gap: 8px;
padding: 3px 0;
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono, monospace);
font-size: 0.9em;
}
.gi-event-item:last-child {
border-bottom: none;
}
.gi-event-time {
color: var(--text-muted);
flex-shrink: 0;
}
.gi-event-type {
color: var(--primary-text-color);
font-weight: 600;
}
.gi-event-value {
color: var(--text-secondary);
}
/* Connection test panel */
.gi-test-panel {
margin-top: 8px;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.gi-test-waiting {
color: var(--warning-color);
}
.gi-test-success {
color: var(--success-color);
}
.gi-test-error {
color: var(--danger-color);
}
.gi-test-timeout {
color: var(--text-muted);
}
/* Responsive mapping rows */
@media (max-width: 768px) {
.gi-mapping-field-row {
flex-direction: column;
align-items: stretch;
}
.gi-mapping-field-row label {
min-width: unset;
}
}
+27 -2
View File
@@ -81,9 +81,17 @@ import {
} from './features/pattern-templates.ts';
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition,
saveAutomationEditor, addAutomationRule,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts';
import {
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
cloneGameIntegration, deleteGameIntegration,
addGameMapping, removeGameMapping, onMappingPresetChange,
testGameConnection, showGameEventMonitor,
openSetupInstructions, closeSetupInstructions,
autoSetupGameIntegration,
} from './features/game-integration.ts';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, cloneScenePreset, deleteScenePreset,
@@ -132,6 +140,7 @@ import {
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
} from './features/color-strips.ts';
// Layer 5: audio sources
@@ -369,7 +378,7 @@ Object.assign(window, {
openAutomationEditor,
closeAutomationEditorModal,
saveAutomationEditor,
addAutomationCondition,
addAutomationRule,
toggleAutomationEnabled,
cloneAutomation,
deleteAutomation,
@@ -385,6 +394,21 @@ Object.assign(window, {
deleteScenePreset,
addSceneTarget,
// game integration
showGameIntegrationEditor,
saveGameIntegration,
closeGameIntegrationModal,
cloneGameIntegration,
deleteGameIntegration,
addGameMapping,
removeGameMapping,
onMappingPresetChange,
testGameConnection,
showGameEventMonitor,
openSetupInstructions,
closeSetupInstructions,
autoSetupGameIntegration,
// device-discovery
onDeviceTypeChanged,
updateBaudFpsHint,
@@ -448,6 +472,7 @@ Object.assign(window, {
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
// audio sources
showAudioSourceModal,
@@ -99,3 +99,17 @@ export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.2
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
export const hardDrive = '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>';
export const batteryFull = '<rect width="16" height="10" x="2" y="7" rx="2" ry="2"/><line x1="22" x2="22" y1="11" y2="13"/><line x1="6" x2="6" y1="11" y2="13"/><line x1="10" x2="10" y1="11" y2="13"/><line x1="14" x2="14" y1="11" y2="13"/>';
// Lucide: gamepad-2
export const gamepad2 = '<line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>';
// Lucide: crosshair
export const crosshair = '<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>';
// Lucide: swords
export const swords = '<polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" x2="19" y1="19" y2="13"/><line x1="16" x2="20" y1="16" y2="20"/><line x1="19" x2="21" y1="21" y2="19"/><polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5"/><line x1="5" x2="9" y1="14" y2="18"/><line x1="7" x2="4" y1="17" y2="20"/><line x1="3" x2="5" y1="19" y2="21"/>';
// Lucide: shield
export const shield = '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>';
// Lucide: pickaxe (Minecraft-style)
export const pickaxe = '<path d="M14.531 12.469 6.619 20.38a1 1 0 1 1-3-3l7.912-7.912"/><path d="M15.686 4.314A12.5 12.5 0 0 0 5.461 2.958 1 1 0 0 0 5.58 4.71a22 22 0 0 1 6.318 3.393"/><path d="M17.7 3.7a1 1 0 0 0-1.4 0l-4.6 4.6a1 1 0 0 0 0 1.4l2.6 2.6a1 1 0 0 0 1.4 0l4.6-4.6a1 1 0 0 0 0-1.4z"/><path d="M19.686 8.314a12.501 12.501 0 0 1 1.356 10.225 1 1 0 0 1-1.751-.119 22 22 0 0 0-3.393-6.318"/>';
// Lucide: rocket
export const rocketIcon = '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>';
// Lucide: circle-dot (status indicator)
export const circleDot = '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>';
@@ -29,6 +29,7 @@ const _colorStripTypeIcons = {
weather: _svg(P.cloudSun),
processed: _svg(P.sparkles),
key_colors: _svg(P.palette),
game_event: _svg(P.gamepad2),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -39,6 +40,7 @@ const _valueSourceTypeIcons = {
ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow),
css_extract: _svg(P.droplets),
system_metrics: _svg(P.cpu),
game_event: _svg(P.gamepad2),
};
const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
const _deviceTypeIcons = {
@@ -333,6 +335,31 @@ export const ICON_ASSET = _svg(P.packageIcon);
export const ICON_HEART = _svg(P.heart);
export const ICON_GITHUB = _svg(P.github);
// ── Game integration icons ─────────────────────────────────
export const ICON_GAMEPAD = _svg(P.gamepad2);
export const ICON_CROSSHAIR = _svg(P.crosshair);
export const ICON_SWORDS = _svg(P.swords);
export const ICON_SHIELD = _svg(P.shield);
export const ICON_PICKAXE = _svg(P.pickaxe);
export const ICON_ROCKET_ICON = _svg(P.rocketIcon);
export const ICON_CIRCLE_DOT = _svg(P.circleDot);
/** Game adapter type → icon (fallback: gamepad) */
const _gameAdapterTypeIcons: Record<string, string> = {
cs2_gsi: _svg(P.crosshair),
valorant: _svg(P.crosshair),
lol_live: _svg(P.swords),
dota2_gsi: _svg(P.swords),
minecraft: _svg(P.pickaxe),
rocket_league: _svg(P.rocketIcon),
generic_webhook: _svg(P.globe),
};
export function getGameAdapterIcon(adapterType: string): string {
return _gameAdapterTypeIcons[adapterType] || _svg(P.gamepad2);
}
/** Asset type → icon (fallback: file) */
export function getAssetTypeIcon(assetType: string): string {
const map: Record<string, string> = {
@@ -13,6 +13,7 @@ import type {
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
GameIntegration, GameAdapterInfo,
} from '../types.ts';
export let apiKey: string | null = null;
@@ -348,3 +349,19 @@ export const gradientsCache = new DataCache<GradientEntity[]>({
endpoint: '/gradients',
extractData: json => json.gradients || [],
});
// ── Game Integration caches ──
export let _cachedGameIntegrations: GameIntegration[] = [];
export const gameIntegrationsCache = new DataCache<GameIntegration[]>({
endpoint: '/game-integrations',
extractData: json => json.integrations || [],
});
gameIntegrationsCache.subscribe(v => { _cachedGameIntegrations = v; });
export let _cachedGameAdapters: GameAdapterInfo[] = [];
export const gameAdaptersCache = new DataCache<GameAdapterInfo[]>({
endpoint: '/game-adapters',
extractData: json => json.adapters || [],
});
gameAdaptersCache.subscribe(v => { _cachedGameAdapters = v; });
@@ -1,5 +1,5 @@
/**
* Automations — automation cards, editor, condition builder, process picker, scene selector.
* Automations — automation cards, editor, rule builder, process picker, scene selector.
*/
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
@@ -22,30 +22,33 @@ import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
// ── HA condition entity cache ──
let _haConditionEntities: any[] = [];
// ── HA rule entity cache ──
let _haRuleEntities: any[] = [];
async function _loadHAEntitiesForCondition(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haConditionEntities = []; return; }
async function _loadHAEntitiesForRule(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haRuleEntities = []; return; }
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _haConditionEntities = []; return; }
if (!resp.ok) { _haRuleEntities = []; return; }
const data = await resp.json();
_haConditionEntities = data.entities || [];
_haRuleEntities = data.entities || [];
} catch {
_haConditionEntities = [];
_haRuleEntities = [];
}
// Rebuild entity select options
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement;
if (entitySelect) {
const currentVal = entitySelect.value;
entitySelect.innerHTML = `<option value="">—</option>` +
_haConditionEntities.map((e: any) =>
_haRuleEntities.map((e: any) =>
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).join('');
if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) {
if (currentVal && !_haRuleEntities.some((e: any) => e.entity_id === currentVal)) {
entitySelect.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
}
// Refresh the EntitySelect wrapper so the trigger shows the friendly name
const es = (entitySelect as any)._entitySelect as EntitySelect | undefined;
if (es) es.refresh();
}
}
@@ -61,12 +64,12 @@ function _autoGenerateAutomationName() {
const sceneSel = document.getElementById('automation-scene-id') as HTMLSelectElement | null;
const sceneName = sceneSel?.selectedOptions[0]?.textContent?.trim() || '';
const logic = (document.getElementById('automation-editor-logic') as HTMLSelectElement).value;
const condCount = document.querySelectorAll('#automation-conditions-list .condition-row').length;
const ruleCount = document.querySelectorAll('#automation-rules-list .rule-row').length;
let name = '';
if (sceneName) name = sceneName;
if (condCount > 0) {
if (ruleCount > 0) {
const logicLabel = logic === 'and' ? 'AND' : 'OR';
const suffix = `${condCount} ${logicLabel}`;
const suffix = `${ruleCount} ${logicLabel}`;
name = name ? `${name} · ${suffix}` : suffix;
}
(document.getElementById('automation-editor-name') as HTMLInputElement).value = name || t('automations.add');
@@ -84,7 +87,7 @@ class AutomationEditorModal extends Modal {
name: (document.getElementById('automation-editor-name') as HTMLInputElement).value,
enabled: (document.getElementById('automation-editor-enabled') as HTMLInputElement).checked.toString(),
logic: (document.getElementById('automation-editor-logic') as HTMLSelectElement).value,
conditions: JSON.stringify(getAutomationEditorConditions()),
rules: JSON.stringify(getAutomationEditorRules()),
scenePresetId: (document.getElementById('automation-scene-id') as HTMLSelectElement).value,
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
@@ -162,17 +165,17 @@ export function switchAutomationTab(tabKey: string) {
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _conditionLogicIconSelect: any = null;
let _ruleLogicIconSelect: any = null;
function _ensureConditionLogicIconSelect() {
function _ensureRuleLogicIconSelect() {
const sel = document.getElementById('automation-editor-logic');
if (!sel) return;
const items = [
{ value: 'or', icon: _icon(P.zap), label: t('automations.condition_logic.or'), desc: t('automations.condition_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') },
{ value: 'or', icon: _icon(P.zap), label: t('automations.rule_logic.or'), desc: t('automations.rule_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.rule_logic.and'), desc: t('automations.rule_logic.and.desc') },
];
if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; }
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
if (_ruleLogicIconSelect) { _ruleLogicIconSelect.updateItems(items); return; }
_ruleLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}
// Re-render automations when language changes (only if tab is active)
@@ -255,44 +258,43 @@ function renderAutomations(automations: any, sceneMap: any) {
}
}
type ConditionPillRenderer = (c: any) => string;
type RulePillRenderer = (c: any) => string;
const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
always: (c) => `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`,
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`,
const RULE_PILL_RENDERERS: Record<string, RulePillRenderer> = {
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.rule.startup')}</span>`,
application: (c) => {
const apps = (c.apps || []).join(', ');
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.rule.application')}: ${apps} (${matchLabel})</span>`;
},
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.rule.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
},
display_state: (c) => {
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
const stateLabel = t('automations.rule.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.rule.display_state')}: ${stateLabel}</span>`;
},
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.rule.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.rule.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.rule.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
};
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
let condPills = '';
if (automation.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
let rulePills = '';
if (automation.rules.length === 0) {
rulePills = `<span class="stream-card-prop">${t('automations.rules.empty')}</span>`;
} else {
const parts = automation.conditions.map(c => {
const renderer = CONDITION_PILL_RENDERERS[c.condition_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.condition_type}</span>`;
const parts = automation.rules.map(c => {
const renderer = RULE_PILL_RENDERERS[c.rule_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.rule_type}</span>`;
});
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
const logicLabel = automation.rule_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
rulePills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
}
// Scene info
@@ -334,7 +336,7 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${condPills}</span>
<span class="card-meta">${rulePills}</span>
<span class="card-meta${scene ? ' stream-card-link' : ''}"${scene ? ` onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.scene_preset_id}')"` : ''}>${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationMeta}
</div>
@@ -355,13 +357,13 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
const condList = document.getElementById('automation-conditions-list');
const ruleList = document.getElementById('automation-rules-list');
const errorEl = document.getElementById('automation-editor-error') as HTMLElement;
errorEl.style.display = 'none';
condList!.innerHTML = '';
ruleList!.innerHTML = '';
_ensureConditionLogicIconSelect();
_ensureRuleLogicIconSelect();
_ensureDeactivationModeIconSelect();
// Fetch scenes for selector
@@ -386,11 +388,11 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
idInput.value = automation.id;
nameInput.value = automation.name;
enabledInput.checked = automation.enabled;
logicSelect.value = automation.condition_logic;
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(automation.condition_logic);
logicSelect.value = automation.rule_logic;
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue(automation.rule_logic);
for (const c of automation.conditions) {
addAutomationConditionRow(c);
for (const c of automation.rules) {
addAutomationRuleRow(c);
}
// Scene selector
@@ -413,14 +415,14 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
idInput.value = '';
nameInput.value = (cloneData.name || '') + ' (Copy)';
enabledInput.checked = cloneData.enabled !== false;
logicSelect.value = cloneData.condition_logic || 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(cloneData.condition_logic || 'or');
logicSelect.value = cloneData.rule_logic || 'or';
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue(cloneData.rule_logic || 'or');
// Clone conditions (strip webhook tokens — they must be unique)
for (const c of (cloneData.conditions || [])) {
// Clone rules (strip webhook tokens — they must be unique)
for (const c of (cloneData.rules || [])) {
const clonedCond = { ...c };
if (clonedCond.condition_type === 'webhook') delete clonedCond.token;
addAutomationConditionRow(clonedCond);
if (clonedCond.rule_type === 'webhook') delete clonedCond.token;
addAutomationRuleRow(clonedCond);
}
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
@@ -437,7 +439,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
nameInput.value = '';
enabledInput.checked = true;
logicSelect.value = 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue('or');
_initSceneSelector('automation-scene-id', null);
_initSceneSelector('automation-fallback-scene-id', null);
}
@@ -539,14 +541,14 @@ function _ensureDeactivationModeIconSelect() {
// ===== Condition editor =====
export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
export function addAutomationRule() {
addAutomationRuleRow({ rule_type: 'application', apps: [], match_type: 'running' });
_autoGenerateAutomationName();
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const CONDITION_TYPE_ICONS = {
always: P.refreshCw, startup: P.power, application: P.smartphone,
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
};
@@ -560,17 +562,17 @@ function _buildMatchTypeItems() {
return MATCH_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(MATCH_TYPE_ICONS[k]),
label: t(`automations.condition.application.match_type.${k}`),
desc: t(`automations.condition.application.match_type.${k}.desc`),
label: t(`automations.rule.application.match_type.${k}`),
desc: t(`automations.rule.application.match_type.${k}.desc`),
}));
}
function _buildConditionTypeItems() {
return CONDITION_TYPE_KEYS.map(k => ({
function _buildRuleTypeItems() {
return RULE_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(CONDITION_TYPE_ICONS[k]),
label: t(`automations.condition.${k}`),
desc: t(`automations.condition.${k}.desc`),
icon: _icon(RULE_TYPE_ICONS[k]),
label: t(`automations.rule.${k}`),
desc: t(`automations.rule.${k}.desc`),
}));
}
@@ -580,8 +582,8 @@ function _wireTimeRangePicker(container: HTMLElement) {
const startM = container.querySelector('.tr-start-m') as HTMLInputElement;
const endH = container.querySelector('.tr-end-h') as HTMLInputElement;
const endM = container.querySelector('.tr-end-m') as HTMLInputElement;
const hiddenStart = container.querySelector('.condition-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.condition-end-time') as HTMLInputElement;
const hiddenStart = container.querySelector('.rule-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.rule-end-time') as HTMLInputElement;
if (!startH || !startM || !endH || !endM) return;
const pad = (n: number) => String(n).padStart(2, '0');
@@ -628,39 +630,35 @@ function _wireTimeRangePicker(container: HTMLElement) {
sync();
}
function addAutomationConditionRow(condition: any) {
const list = document.getElementById('automation-conditions-list');
function addAutomationRuleRow(rule: any) {
const list = document.getElementById('automation-rules-list');
const row = document.createElement('div');
row.className = 'automation-condition-row';
const condType = condition.condition_type || 'application';
row.className = 'automation-rule-row';
const ruleType = rule.rule_type || 'application';
row.innerHTML = `
<div class="condition-header">
<select class="condition-type-select">
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
<div class="rule-header">
<select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
</div>
<div class="condition-fields-container"></div>
<div class="rule-fields-container"></div>
`;
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const container = row.querySelector('.condition-fields-container') as HTMLElement;
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const container = row.querySelector('.rule-fields-container') as HTMLElement;
// Attach IconSelect to the condition type dropdown
const condIconSelect = new IconSelect({
// Attach IconSelect to the rule type dropdown
const ruleIconSelect = new IconSelect({
target: typeSelect,
items: _buildConditionTypeItems(),
items: _buildRuleTypeItems(),
columns: 4,
} as any);
function renderFields(type: any, data: any) {
if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return;
}
if (type === 'startup') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.startup.hint')}</small>`;
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
return;
}
if (type === 'time_of_day') {
@@ -670,12 +668,12 @@ function addAutomationConditionRow(condition: any) {
const [eh, em] = endTime.split(':').map(Number);
const pad = (n: number) => String(n).padStart(2, '0');
container.innerHTML = `
<div class="condition-fields">
<input type="hidden" class="condition-start-time" value="${startTime}">
<input type="hidden" class="condition-end-time" value="${endTime}">
<div class="rule-fields">
<input type="hidden" class="rule-start-time" value="${startTime}">
<input type="hidden" class="rule-end-time" value="${endTime}">
<div class="time-range-picker">
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.start_time')}</span>
<span class="time-range-label">${t('automations.rule.time_of_day.start_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-start-h" min="0" max="23" value="${sh}" data-role="hour">
<span class="time-range-colon">:</span>
@@ -684,7 +682,7 @@ function addAutomationConditionRow(condition: any) {
</div>
<div class="time-range-arrow">→</div>
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.end_time')}</span>
<span class="time-range-label">${t('automations.rule.time_of_day.end_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-end-h" min="0" max="23" value="${eh}" data-role="hour">
<span class="time-range-colon">:</span>
@@ -692,7 +690,7 @@ function addAutomationConditionRow(condition: any) {
</div>
</div>
</div>
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
</div>`;
_wireTimeRangePicker(container);
return;
@@ -701,16 +699,16 @@ function addAutomationConditionRow(condition: any) {
const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true;
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.system_idle.idle_minutes')}</label>
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.system_idle.idle_minutes')}</label>
<input type="number" class="rule-idle-minutes" min="1" max="999" value="${idleMinutes}">
</div>
<div class="condition-field">
<label>${t('automations.condition.system_idle.mode')}</label>
<select class="condition-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_active')}</option>
<div class="rule-field">
<label>${t('automations.rule.system_idle.mode')}</label>
<select class="rule-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.rule.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.rule.system_idle.when_active')}</option>
</select>
</div>
</div>`;
@@ -719,12 +717,12 @@ function addAutomationConditionRow(condition: any) {
if (type === 'display_state') {
const dState = data.state || 'on';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.display_state.state')}</label>
<select class="condition-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.condition.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.condition.display_state.off')}</option>
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.display_state.state')}</label>
<select class="rule-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.rule.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.rule.display_state.off')}</option>
</select>
</div>
</div>`;
@@ -735,21 +733,21 @@ function addAutomationConditionRow(condition: any) {
const payload = data.payload || '';
const matchMode = data.match_mode || 'exact';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.mqtt.topic')}</label>
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.mqtt.topic')}</label>
<input type="text" class="rule-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.payload')}</label>
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
<div class="rule-field">
<label>${t('automations.rule.mqtt.payload')}</label>
<input type="text" class="rule-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.match_mode')}</label>
<select class="condition-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
<div class="rule-field">
<label>${t('automations.rule.mqtt.match_mode')}</label>
<select class="rule-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
@@ -764,37 +762,37 @@ function addAutomationConditionRow(condition: any) {
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.ha_source')}</label>
<select class="condition-ha-source-id">
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.home_assistant.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.home_assistant.ha_source')}</label>
<select class="rule-ha-source-id">
<option value="">—</option>
${haOptions}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.entity_id')}</label>
<select class="condition-ha-entity-id">
<div class="rule-field">
<label>${t('automations.rule.home_assistant.entity_id')}</label>
<select class="rule-ha-entity-id">
${entityId ? `<option value="${escapeHtml(entityId)}" selected>${escapeHtml(entityId)}</option>` : '<option value="">—</option>'}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label>
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
<div class="rule-field">
<label>${t('automations.rule.home_assistant.state')}</label>
<input type="text" class="rule-ha-state" value="${escapeHtml(haState)}" placeholder="on">
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.match_mode')}</label>
<select class="condition-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
<div class="rule-field">
<label>${t('automations.rule.home_assistant.match_mode')}</label>
<select class="rule-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
// Wire HA source EntitySelect
const haSrcSelect = container.querySelector('.condition-ha-source-id') as HTMLSelectElement;
const haSrcSelect = container.querySelector('.rule-ha-source-id') as HTMLSelectElement;
new EntitySelect({
target: haSrcSelect,
getItems: () => _cachedHASources.map((s: any) => ({
@@ -802,34 +800,36 @@ function addAutomationConditionRow(condition: any) {
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
})),
placeholder: t('palette.search'),
onChange: (newId: string) => _loadHAEntitiesForCondition(newId, container),
onChange: (newId: string) => _loadHAEntitiesForRule(newId, container),
});
// Wire entity EntitySelect
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement;
const entityES = new EntitySelect({
target: entitySelect,
getItems: () => _haConditionEntities.map((e: any) => ({
getItems: () => _haRuleEntities.map((e: any) => ({
value: e.entity_id, label: e.friendly_name || e.entity_id,
icon: getHAEntityIcon(e), desc: e.state || '',
})),
placeholder: t('ha_light.mapping.search_entity'),
});
// Store ref so _loadHAEntitiesForRule can refresh the trigger display
(entitySelect as any)._entitySelect = entityES;
// Wire match mode IconSelect
const matchSelect = container.querySelector('.condition-ha-match-mode') as HTMLSelectElement;
const matchSelect = container.querySelector('.rule-ha-match-mode') as HTMLSelectElement;
new IconSelect({
target: matchSelect,
items: [
{ value: 'exact', icon: _icon(P.check), label: t('automations.condition.mqtt.match_mode.exact'), desc: t('automations.condition.ha.match_mode.exact.desc') },
{ value: 'contains', icon: _icon(P.search), label: t('automations.condition.mqtt.match_mode.contains'), desc: t('automations.condition.ha.match_mode.contains.desc') },
{ value: 'regex', icon: _icon(P.code), label: t('automations.condition.mqtt.match_mode.regex'), desc: t('automations.condition.ha.match_mode.regex.desc') },
{ value: 'exact', icon: _icon(P.check), label: t('automations.rule.mqtt.match_mode.exact'), desc: t('automations.rule.ha.match_mode.exact.desc') },
{ value: 'contains', icon: _icon(P.search), label: t('automations.rule.mqtt.match_mode.contains'), desc: t('automations.rule.ha.match_mode.contains.desc') },
{ value: 'regex', icon: _icon(P.code), label: t('automations.rule.mqtt.match_mode.regex'), desc: t('automations.rule.ha.match_mode.regex.desc') },
],
columns: 1,
});
// Load entities if source is already selected
if (haSourceId) _loadHAEntitiesForCondition(haSourceId, container);
if (haSourceId) _loadHAEntitiesForRule(haSourceId, container);
return;
}
@@ -837,22 +837,22 @@ function addAutomationConditionRow(condition: any) {
if (data.token) {
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.webhook.url')}</label>
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.webhook.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.webhook.url')}</label>
<div class="webhook-url-row">
<input type="text" class="condition-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.condition.webhook.copy')}</button>
<input type="text" class="rule-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.rule.webhook.copy')}</button>
</div>
</div>
<input type="hidden" class="condition-webhook-token" value="${escapeHtml(data.token)}">
<input type="hidden" class="rule-webhook-token" value="${escapeHtml(data.token)}">
</div>`;
} else {
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.condition.webhook.save_first')}</p>
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.rule.webhook.save_first')}</p>
</div>`;
}
return;
@@ -860,30 +860,30 @@ function addAutomationConditionRow(condition: any) {
const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.fullscreen')}</option>
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.application.match_type')}</label>
<select class="rule-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.rule.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.rule.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.rule.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.rule.application.match_type.fullscreen')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<div class="rule-field">
<div class="rule-apps-header">
<label>${t('automations.rule.application.apps')}</label>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.rule.application.browse')}">${ICON_SEARCH}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<textarea class="rule-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
</div>
</div>
`;
const textarea = container.querySelector('.condition-apps') as HTMLTextAreaElement;
const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement;
attachProcessPicker(container, textarea);
// Attach IconSelect to match type
const matchSel = container.querySelector('.condition-match-type');
const matchSel = container.querySelector('.rule-match-type');
if (matchSel) {
new IconSelect({
target: matchSel,
@@ -893,7 +893,7 @@ function addAutomationConditionRow(condition: any) {
}
}
renderFields(condType, condition);
renderFields(ruleType, rule);
typeSelect.addEventListener('change', () => {
renderFields(typeSelect.value, {});
});
@@ -903,61 +903,59 @@ function addAutomationConditionRow(condition: any) {
function getAutomationEditorConditions() {
const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
const conditions: any[] = [];
function getAutomationEditorRules() {
const rows = document.querySelectorAll('#automation-rules-list .automation-rule-row');
const rules: any[] = [];
rows.forEach(row => {
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const condType = typeSelect ? typeSelect.value : 'application';
if (condType === 'always') {
conditions.push({ condition_type: 'always' });
} else if (condType === 'startup') {
conditions.push({ condition_type: 'startup' });
} else if (condType === 'time_of_day') {
conditions.push({
condition_type: 'time_of_day',
start_time: (row.querySelector('.condition-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.condition-end-time') as HTMLInputElement).value || '23:59',
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const ruleType = typeSelect ? typeSelect.value : 'application';
if (ruleType === 'startup') {
rules.push({ rule_type: 'startup' });
} else if (ruleType === 'time_of_day') {
rules.push({
rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
});
} else if (condType === 'system_idle') {
conditions.push({
condition_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.condition-idle-minutes') as HTMLInputElement).value, 10) || 5,
when_idle: (row.querySelector('.condition-when-idle') as HTMLSelectElement).value === 'true',
} else if (ruleType === 'system_idle') {
rules.push({
rule_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5,
when_idle: (row.querySelector('.rule-when-idle') as HTMLSelectElement).value === 'true',
});
} else if (condType === 'display_state') {
conditions.push({
condition_type: 'display_state',
state: (row.querySelector('.condition-display-state') as HTMLSelectElement).value || 'on',
} else if (ruleType === 'display_state') {
rules.push({
rule_type: 'display_state',
state: (row.querySelector('.rule-display-state') as HTMLSelectElement).value || 'on',
});
} else if (condType === 'mqtt') {
conditions.push({
condition_type: 'mqtt',
topic: (row.querySelector('.condition-mqtt-topic') as HTMLInputElement).value.trim(),
payload: (row.querySelector('.condition-mqtt-payload') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
} else if (ruleType === 'mqtt') {
rules.push({
rule_type: 'mqtt',
topic: (row.querySelector('.rule-mqtt-topic') as HTMLInputElement).value.trim(),
payload: (row.querySelector('.rule-mqtt-payload') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
});
} else if (condType === 'webhook') {
const tokenInput = row.querySelector('.condition-webhook-token') as HTMLInputElement;
const cond: any = { condition_type: 'webhook' };
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
conditions.push(cond);
} else if (condType === 'home_assistant') {
conditions.push({
condition_type: 'home_assistant',
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLSelectElement).value.trim(),
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
} else if (ruleType === 'webhook') {
const tokenInput = row.querySelector('.rule-webhook-token') as HTMLInputElement;
const r: any = { rule_type: 'webhook' };
if (tokenInput && tokenInput.value) r.token = tokenInput.value;
rules.push(r);
} else if (ruleType === 'home_assistant') {
rules.push({
rule_type: 'home_assistant',
ha_source_id: (row.querySelector('.rule-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.rule-ha-entity-id') as HTMLSelectElement).value.trim(),
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
});
} else {
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
conditions.push({ condition_type: 'application', apps, match_type: matchType });
rules.push({ rule_type: 'application', apps, match_type: matchType });
}
});
return conditions;
return rules;
}
export async function saveAutomationEditor() {
@@ -975,8 +973,8 @@ export async function saveAutomationEditor() {
const body = {
name,
enabled: enabledInput.checked,
condition_logic: logicSelect.value,
conditions: getAutomationEditorConditions(),
rule_logic: logicSelect.value,
rules: getAutomationEditorRules(),
scene_preset_id: (document.getElementById('automation-scene-id') as HTMLSelectElement).value || null,
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
@@ -1026,11 +1024,11 @@ export async function toggleAutomationEnabled(automationId: any, enable: any) {
}
export function copyWebhookUrl(btn: any) {
const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url') as HTMLInputElement;
const input = btn.closest('.webhook-url-row').querySelector('.rule-webhook-url') as HTMLInputElement;
if (!input || !input.value) return;
const onCopied = () => {
const orig = btn.textContent;
btn.textContent = t('automations.condition.webhook.copied');
btn.textContent = t('automations.rule.webhook.copied');
setTimeout(() => { btn.textContent = orig; }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
@@ -3,7 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity, _cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -14,13 +14,14 @@ import {
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_TRASH, ICON_PATTERN_TEMPLATE,
ICON_GAMEPAD,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts';
import { bindableValue, bindableColor } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { IconSelect, showTypePicker, type IconSelectItem } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { BindableColorWidget } from '../core/bindable-color.ts';
@@ -86,6 +87,10 @@ class CSSEditorModal extends Modal {
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; }
if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; }
if (_gameEventIdleColorWidget) { _gameEventIdleColorWidget.destroy(); _gameEventIdleColorWidget = null; }
if (_cssGameIntegrationEntitySelect) { _cssGameIntegrationEntitySelect.destroy(); _cssGameIntegrationEntitySelect = null; }
_destroyCSSGameMappingIconSelects();
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
compositeDestroyEntitySelects();
}
@@ -141,6 +146,9 @@ class CSSEditorModal extends Modal {
kc_smoothing: _kcSmoothingWidget ? JSON.stringify(_kcSmoothingWidget.getValue()) : '0.3',
kc_brightness: _kcBrightnessWidget ? JSON.stringify(_kcBrightnessWidget.getValue()) : '1.0',
kc_rects: JSON.stringify(_kcEditorRects),
ge_integration: (document.getElementById('css-editor-game-integration') as HTMLInputElement)?.value || '',
ge_idle_color: _gameEventIdleColorWidget ? JSON.stringify(_gameEventIdleColorWidget.getValue()) : '[]',
ge_mappings: JSON.stringify(_cssGameMappings),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -168,6 +176,7 @@ let _audioColorWidget: BindableColorWidget | null = null;
let _audioColorPeakWidget: BindableColorWidget | null = null;
let _apiInputFallbackColorWidget: BindableColorWidget | null = null;
let _candlelightColorWidget: BindableColorWidget | null = null;
let _gameEventIdleColorWidget: BindableColorWidget | null = null;
// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect: any = null;
@@ -251,6 +260,7 @@ const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event',
];
function _buildCSSTypeItems() {
@@ -298,6 +308,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
'weather': 'css-editor-weather-section',
'processed': 'css-editor-processed-section',
'key_colors': 'css-editor-key-colors-section',
'game_event': 'css-editor-game-event-section',
};
const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
@@ -309,6 +320,7 @@ const CSS_TYPE_SETUP: Record<string, () => void> = {
gradient: () => { _ensureGradientPresetEntitySelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
candlelight: () => _ensureCandleTypeIconSelect(),
game_event: () => { _populateGameIntegrationDropdownCSS(); _initCSSGamePresetIconSelect(); },
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
composite: () => compositeRenderList(),
mapped: () => _mappedRenderList(),
@@ -569,6 +581,269 @@ function _ensureKcBrightnessWidget(): BindableScalarWidget {
return _kcBrightnessWidget;
}
// ── Game Event CSS helpers ──
function _ensureGameEventIdleColorWidget(): BindableColorWidget {
if (!_gameEventIdleColorWidget) {
_gameEventIdleColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-game-event-idle-color-container')!,
default: [0, 0, 0],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-ge-idle-color',
});
}
return _gameEventIdleColorWidget;
}
let _cssGameIntegrationEntitySelect: EntitySelect | null = null;
function _populateGameIntegrationDropdownCSS(selectedId: string = '') {
const sel = document.getElementById('css-editor-game-integration') as HTMLSelectElement;
const integrations = _cachedGameIntegrations || [];
const prev = selectedId || sel.value;
sel.innerHTML = `<option value="">${t('common.none_no_input')}</option>` +
integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).join('');
sel.value = prev || '';
if (_cssGameIntegrationEntitySelect) _cssGameIntegrationEntitySelect.destroy();
_cssGameIntegrationEntitySelect = new EntitySelect({
target: sel,
getItems: () => integrations.map(gi => ({
value: gi.id,
label: gi.name,
icon: ICON_GAMEPAD,
desc: gi.adapter_type,
})),
allowNone: true,
noneLabel: t('common.none_no_input'),
placeholder: t('palette.search'),
});
}
let _cssGameMappings: any[] = [];
let _cssGameMappingIconSelects: IconSelect[] = [];
let _cssGamePresetIconSelect: IconSelect | null = null;
function _destroyCSSGameMappingIconSelects() {
_cssGameMappingIconSelects.forEach(is => is.destroy());
_cssGameMappingIconSelects = [];
}
function _hexToRgbCSS(hex: string): number[] {
const m = hex.replace('#', '').match(/.{2}/g);
if (!m) return [255, 0, 0];
return m.map(c => parseInt(c, 16));
}
function _rgbToHexCSS(rgb: number[]): string {
return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join('');
}
function _getCSSGameAvailableEventTypes(): string[] {
const giId = (document.getElementById('css-editor-game-integration') as HTMLSelectElement)?.value;
if (giId) {
const gi = (_cachedGameIntegrations || []).find(g => g.id === giId);
if (gi) {
const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type);
if (adapter && adapter.supported_events.length > 0) return adapter.supported_events;
}
}
return ['kill', 'death', 'health', 'armor', 'round_start', 'round_end', 'bomb_planted', 'bomb_defused', 'assist', 'headshot'];
}
const _CSS_GE_EFFECT_TYPES: IconSelectItem[] = [
{ value: 'flash', label: 'Flash', icon: `<svg class="icon" viewBox="0 0 24 24">${P.zap}</svg>` },
{ value: 'pulse', label: 'Pulse', icon: `<svg class="icon" viewBox="0 0 24 24">${P.activity}</svg>` },
{ value: 'sweep', label: 'Sweep', icon: `<svg class="icon" viewBox="0 0 24 24">${P.fastForward}</svg>` },
{ value: 'color_shift', label: 'Color Shift', icon: `<svg class="icon" viewBox="0 0 24 24">${P.rainbow}</svg>` },
{ value: 'breathing', label: 'Breathing', icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
];
const _CSS_GE_EVENT_ICONS: Record<string, string> = {
kill: P.crosshair, death: P.xIcon, health: P.heart, armor: P.shield,
round_start: P.play, round_end: P.square, bomb_planted: P.flame, bomb_defused: P.circleCheck,
assist: P.swords, headshot: P.target, damage: P.zap, gold: P.star,
level_up: P.trendingUp, respawn: P.refreshCw, item_pickup: P.packageIcon,
};
function _buildCSSGameEventTypeItems(): IconSelectItem[] {
return _getCSSGameAvailableEventTypes().map(et => ({
value: et,
label: et,
icon: `<svg class="icon" viewBox="0 0 24 24">${_CSS_GE_EVENT_ICONS[et] || P.circleDot}</svg>`,
}));
}
function _renderCSSGameMappingRow(mapping: any, index: number): string {
const eventTypes = _getCSSGameAvailableEventTypes();
const eventOptions = eventTypes.map(et =>
`<option value="${et}"${et === mapping.event_type ? ' selected' : ''}>${et}</option>`
).join('');
const effectOptions = _CSS_GE_EFFECT_TYPES.map(ef =>
`<option value="${ef.value}"${ef.value === mapping.effect_type ? ' selected' : ''}>${ef.label}</option>`
).join('');
const effectLabel = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type;
const hexColor = _rgbToHexCSS(mapping.color || [255, 0, 0]);
return `
<div class="gi-mapping-row" data-mapping-index="${index}">
<div class="gi-mapping-header">
<span class="gi-mapping-expand-btn">&#x25B6;</span>
<span class="gi-mapping-summary">
<span class="gi-mapping-summary-event">${escapeHtml(mapping.event_type)}</span>
<span class="gi-mapping-summary-effect">${escapeHtml(effectLabel)}</span>
<span class="gi-mapping-summary-color" style="background:${hexColor}"></span>
</span>
<button type="button" class="btn-remove-rule" onclick="event.stopPropagation(); removeCSSGameMapping(${index})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="gi-mapping-body-wrapper">
<div class="gi-mapping-body">
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.event_type')}</label>
<select data-field="event_type">${eventOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.effect_type')}</label>
<select data-field="effect_type">${effectOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.color')}</label>
<input type="color" data-field="color" value="${hexColor}">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.duration')}</label>
<input type="number" data-field="duration_ms" value="${mapping.duration_ms || 500}" min="50" max="10000" step="50">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.intensity')}</label>
<input type="range" data-field="intensity" value="${mapping.intensity ?? 1.0}" min="0" max="1" step="0.05"
oninput="this.title = this.value">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.priority')}</label>
<input type="number" data-field="priority" value="${mapping.priority || 5}" min="1" max="10">
</div>
</div>
</div>
</div>`;
}
function _wireCSSGameMappingRows(container: HTMLElement) {
container.querySelectorAll('.gi-mapping-header').forEach(header => {
const item = header.closest('.gi-mapping-row') as HTMLElement;
header.addEventListener('click', (e: Event) => {
if ((e.target as HTMLElement).closest('.btn-remove-rule')) return;
item.classList.toggle('gi-mapping-expanded');
});
});
container.querySelectorAll('.gi-mapping-row').forEach(row => {
const eventSel = row.querySelector('[data-field="event_type"]') as HTMLSelectElement | null;
const effectSel = row.querySelector('[data-field="effect_type"]') as HTMLSelectElement | null;
const colorInput = row.querySelector('input[data-field="color"]') as HTMLInputElement | null;
const summaryEvent = row.querySelector('.gi-mapping-summary-event') as HTMLElement | null;
const summaryEffect = row.querySelector('.gi-mapping-summary-effect') as HTMLElement | null;
const summaryColor = row.querySelector('.gi-mapping-summary-color') as HTMLElement | null;
if (eventSel) {
const is = new IconSelect({ target: eventSel, items: _buildCSSGameEventTypeItems(), columns: 4 });
_cssGameMappingIconSelects.push(is);
if (summaryEvent) {
eventSel.addEventListener('change', () => { summaryEvent.textContent = eventSel.value; });
}
}
if (effectSel) {
const is = new IconSelect({ target: effectSel, items: _CSS_GE_EFFECT_TYPES, columns: 3 });
_cssGameMappingIconSelects.push(is);
if (summaryEffect) {
effectSel.addEventListener('change', () => {
const label = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === effectSel.value)?.label || effectSel.value;
summaryEffect.textContent = label;
});
}
}
if (colorInput && summaryColor) {
colorInput.addEventListener('input', () => { summaryColor.style.background = colorInput.value; });
}
});
}
function _renderCSSGameMappings(mappings: any[]) {
_cssGameMappings = [...mappings];
_destroyCSSGameMappingIconSelects();
const container = document.getElementById('css-editor-ge-mappings-list');
if (!container) return;
container.innerHTML = mappings.map((m, i) => _renderCSSGameMappingRow(m, i)).join('');
_wireCSSGameMappingRows(container);
}
function _collectCSSGameMappings(): any[] {
const rows = document.querySelectorAll('#css-editor-ge-mappings-list .gi-mapping-row');
return Array.from(rows).map(row => {
const eventType = (row.querySelector('[data-field="event_type"]') as HTMLSelectElement)?.value || 'kill';
const effectType = (row.querySelector('[data-field="effect_type"]') as HTMLSelectElement)?.value || 'flash';
const colorInput = (row.querySelector('input[data-field="color"]') as HTMLInputElement)?.value || '#ff0000';
const duration = parseFloat((row.querySelector('[data-field="duration_ms"]') as HTMLInputElement)?.value) || 500;
const intensity = parseFloat((row.querySelector('[data-field="intensity"]') as HTMLInputElement)?.value) || 1.0;
const priority = parseInt((row.querySelector('[data-field="priority"]') as HTMLInputElement)?.value) || 5;
return { event_type: eventType, effect_type: effectType, color: _hexToRgbCSS(colorInput), duration_ms: duration, intensity, priority };
});
}
export function addCSSGameMapping() {
const collected = _collectCSSGameMappings();
collected.push({
event_type: _getCSSGameAvailableEventTypes()[0] || 'kill',
effect_type: 'flash',
color: [255, 0, 0],
duration_ms: 500,
intensity: 1.0,
priority: 5,
});
_renderCSSGameMappings(collected);
}
export function removeCSSGameMapping(index: number) {
const collected = _collectCSSGameMappings();
collected.splice(index, 1);
_renderCSSGameMappings(collected);
}
export function onCSSGameMappingPresetChange() {
const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement;
if (!sel.value) return;
const presets: Record<string, any[]> = {
fps_combat: [
{ event_type: 'kill', effect_type: 'flash', color: [0, 255, 0], duration_ms: 400, intensity: 1.0, priority: 8 },
{ event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 1500, intensity: 1.0, priority: 10 },
{ event_type: 'headshot', effect_type: 'flash', color: [255, 215, 0], duration_ms: 300, intensity: 1.0, priority: 9 },
{ event_type: 'health', effect_type: 'breathing', color: [255, 50, 50], duration_ms: 2000, intensity: 0.6, priority: 3 },
{ event_type: 'round_start', effect_type: 'sweep', color: [0, 100, 255], duration_ms: 800, intensity: 0.8, priority: 5 },
],
moba_health: [
{ event_type: 'health', effect_type: 'color_shift', color: [0, 255, 0], duration_ms: 1000, intensity: 0.7, priority: 4 },
{ event_type: 'kill', effect_type: 'flash', color: [255, 215, 0], duration_ms: 500, intensity: 1.0, priority: 8 },
{ event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 2000, intensity: 1.0, priority: 10 },
{ event_type: 'assist', effect_type: 'flash', color: [100, 200, 255], duration_ms: 300, intensity: 0.8, priority: 6 },
],
};
const preset = presets[sel.value];
if (preset) _renderCSSGameMappings(preset);
sel.value = '';
}
function _initCSSGamePresetIconSelect() {
const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement | null;
if (!sel) return;
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
const items: IconSelectItem[] = [
{ value: '', label: t('game_integration.mapping.select_preset'), icon: '' },
{ value: 'fps_combat', label: t('game_integration.preset.fps_combat'), icon: `<svg class="icon" viewBox="0 0 24 24">${P.crosshair}</svg>` },
{ value: 'moba_health', label: t('game_integration.preset.moba_health'), icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
];
_cssGamePresetIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureAudioSensitivityWidget(): BindableScalarWidget {
if (!_audioSensitivityWidget) {
_audioSensitivityWidget = new BindableScalarWidget({
@@ -2107,6 +2382,31 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
};
},
},
game_event: {
load(css: any) {
_populateGameIntegrationDropdownCSS(css.game_integration_id || '');
_ensureGameEventIdleColorWidget().setValue(css.idle_color);
_renderCSSGameMappings(css.event_mappings || []);
},
reset() {
_populateGameIntegrationDropdownCSS('');
_ensureGameEventIdleColorWidget().setValue([0, 0, 0]);
_renderCSSGameMappings([]);
},
getPayload(name: any) {
const giId = (document.getElementById('css-editor-game-integration') as HTMLSelectElement).value;
if (!giId) {
cssEditorModal.showError(t('color_strip.game_event.error.no_integration'));
return null;
}
return {
name,
game_integration_id: giId,
idle_color: _ensureGameEventIdleColorWidget().getValue(),
event_mappings: _collectCSSGameMappings(),
};
},
},
};
/* ── Editor open/close ────────────────────────────────────────── */
@@ -649,18 +649,18 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
const isDisabled = !automation.enabled;
let condSummary = '';
if (automation.conditions.length > 0) {
const parts = automation.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running');
if (automation.rules.length > 0) {
const parts = automation.rules.map(r => {
if (r.rule_type === 'application') {
const apps = (r.apps || []).join(', ');
const matchLabel = r.match_type === 'topmost' ? t('automations.rule.application.match_type.topmost') : t('automations.rule.application.match_type.running');
return `${apps} (${matchLabel})`;
}
if (c.condition_type === 'startup') return t('automations.condition.startup');
if (c.condition_type === 'time_of_day') return t('automations.condition.time_of_day');
return t(`automations.condition.${c.condition_type}`) || c.condition_type;
if (r.rule_type === 'startup') return t('automations.rule.startup');
if (r.rule_type === 'time_of_day') return t('automations.rule.time_of_day');
return t(`automations.rule.${r.rule_type}`) || r.rule_type;
});
const logic = automation.condition_logic === 'and' ? ' & ' : ' | ';
const logic = automation.rule_logic === 'and' ? ' & ' : ' | ';
condSummary = parts.join(logic);
}
@@ -0,0 +1,761 @@
/**
* Game Integration — CRUD, cards, modal handlers, live event monitor.
*/
import {
gameIntegrationsCache, gameAdaptersCache,
_cachedGameIntegrations, _cachedGameAdapters,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { CardSection } from '../core/card-sections.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
import {
ICON_GAMEPAD, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_TRASH,
getGameAdapterIcon, ICON_CIRCLE_DOT,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import type {
GameIntegration, GameAdapterInfo, GameEventMapping, GameEventRecord, GameIntegrationStatus,
EffectPreset,
} from '../types.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Bulk actions ──
function _bulkDeleteGameIntegrations(ids: string[]) {
return Promise.allSettled(ids.map(id =>
fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' })
)).then(results => {
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate();
loadGameIntegrations();
});
}
const _gameIntegrationBulkActions = [{
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger',
confirm: 'bulk.confirm_delete', handler: _bulkDeleteGameIntegrations,
}];
// ── CardSection ──
export const csGameIntegrations = new CardSection('game-integrations', {
titleKey: 'game_integration.section_title',
gridClass: 'templates-grid',
addCardOnclick: "showGameIntegrationEditor()",
keyAttr: 'data-gi-id',
emptyKey: 'section.empty.game_integrations',
bulkActions: _gameIntegrationBulkActions,
});
// ── Modal ──
let _giTagsInput: TagInput | null = null;
let _adapterTypeIconSelect: IconSelect | null = null;
let _mappingIconSelects: IconSelect[] = [];
let _presetIconSelect: IconSelect | null = null;
let _eventMonitorTimer: ReturnType<typeof setInterval> | null = null;
class GameIntegrationModal extends Modal {
constructor() { super('game-integration-modal'); }
snapshotValues() {
return {
name: (this.$('gi-name') as HTMLInputElement)?.value || '',
description: (this.$('gi-description') as HTMLInputElement)?.value || '',
adapterType: (this.$('gi-adapter-type') as HTMLSelectElement)?.value || '',
enabled: (this.$('gi-enabled') as HTMLInputElement)?.checked ? '1' : '0',
mappings: JSON.stringify(_collectMappings()),
tags: JSON.stringify(_giTagsInput ? _giTagsInput.getValue() : []),
config: JSON.stringify(_collectAdapterConfig()),
};
}
onForceClose() {
if (_giTagsInput) { _giTagsInput.destroy(); _giTagsInput = null; }
if (_adapterTypeIconSelect) { _adapterTypeIconSelect.destroy(); _adapterTypeIconSelect = null; }
if (_presetIconSelect) { _presetIconSelect.destroy(); _presetIconSelect = null; }
_destroyMappingIconSelects();
_stopEventMonitor();
}
}
const giModal = new GameIntegrationModal();
// ── Adapter config helpers ──
function _collectAdapterConfig(): Record<string, any> {
const container = document.getElementById('gi-adapter-config-fields');
if (!container) return {};
const config: Record<string, any> = {};
container.querySelectorAll('[data-config-key]').forEach(el => {
const key = (el as HTMLElement).dataset.configKey!;
if (el instanceof HTMLInputElement) {
if (el.type === 'number') config[key] = parseFloat(el.value) || 0;
else if (el.type === 'checkbox') config[key] = el.checked;
else config[key] = el.value;
}
});
return config;
}
function _renderAdapterConfigFields(adapter: GameAdapterInfo, existingConfig: Record<string, any> = {}) {
const container = document.getElementById('gi-adapter-config-fields')!;
if (!adapter.config_schema || adapter.config_schema.length === 0) {
container.innerHTML = `<p class="text-muted">${t('game_integration.no_config')}</p>`;
return;
}
container.innerHTML = adapter.config_schema.map(field => {
const val = existingConfig[field.name] ?? field.default ?? '';
const inputType = field.type === 'number' ? 'number' : field.type === 'boolean' ? 'checkbox' : 'text';
const checked = field.type === 'boolean' && val ? ' checked' : '';
const inputVal = field.type === 'boolean' ? '' : ` value="${escapeHtml(String(val))}"`;
return `
<div class="form-group">
<div class="label-row">
<label for="gi-config-${escapeHtml(field.name)}">${escapeHtml(field.label || field.name)}${field.required ? ' *' : ''}</label>
${field.hint ? `<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>` : ''}
</div>
${field.hint ? `<small class="input-hint" style="display:none">${escapeHtml(field.hint)}</small>` : ''}
<input type="${inputType}" id="gi-config-${escapeHtml(field.name)}"
data-config-key="${escapeHtml(field.name)}"${inputVal}${checked}>
</div>`;
}).join('');
}
let _currentSetupInstructions = '';
let _currentAdapterSupportsAutoSetup = false;
function _renderSetupInstructions(adapter: GameAdapterInfo) {
const btnWrapper = document.getElementById('gi-setup-instructions-btn-wrapper')!;
_currentSetupInstructions = adapter.setup_instructions || '';
_currentAdapterSupportsAutoSetup = adapter.supports_auto_setup || false;
const visible = _currentSetupInstructions || _currentAdapterSupportsAutoSetup;
btnWrapper.style.display = visible ? 'flex' : 'none';
btnWrapper.style.gap = '0.5rem';
const autoSetupBtn = document.getElementById('gi-auto-setup-btn');
if (autoSetupBtn) {
autoSetupBtn.style.display = _currentAdapterSupportsAutoSetup ? '' : 'none';
}
}
export function openSetupInstructions() {
if (!_currentSetupInstructions) return;
const overlay = document.getElementById('gi-setup-overlay');
const content = document.getElementById('gi-setup-overlay-content');
if (overlay && content) {
import('marked').then(({ marked }) => {
content.innerHTML = marked.parse(_currentSetupInstructions) as string;
overlay.style.display = 'flex';
});
}
}
export function closeSetupInstructions() {
const overlay = document.getElementById('gi-setup-overlay');
if (overlay) overlay.style.display = 'none';
}
export async function autoSetupGameIntegration() {
const id = (document.getElementById('gi-id') as HTMLInputElement)?.value;
if (!id) {
showToast(t('game_integration.auto_setup.save_first'), 'warning');
return;
}
try {
const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' });
if (!res || !res.ok) {
const err = await res!.json();
showToast(err.detail || t('game_integration.auto_setup.failed'), 'error');
return;
}
const data = await res.json();
if (data.success) {
let msg = t('game_integration.auto_setup.success');
if (data.file_path) msg += `\n${data.file_path}`;
if (data.token_generated) msg += `\n${t('game_integration.auto_setup.token_generated')}`;
showToast(msg, 'success');
// Reload integration data in case auth token was generated
if (data.token_generated) {
gameIntegrationsCache.invalidate();
const integrations = await gameIntegrationsCache.fetch();
const gi = integrations.find(g => g.id === id);
if (gi) {
const adapters = await gameAdaptersCache.fetch();
const adapter = adapters.find(a => a.adapter_type === gi.adapter_type);
if (adapter) _renderAdapterConfigFields(adapter, gi.adapter_config || {});
}
}
} else {
showToast(data.message || t('game_integration.auto_setup.failed'), 'error');
}
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('game_integration.auto_setup.failed'), 'error');
}
}
// ── Event mapping helpers ──
let _currentMappings: GameEventMapping[] = [];
function _collectMappings(): GameEventMapping[] {
const rows = document.querySelectorAll('#gi-mappings-list .gi-mapping-row');
return Array.from(rows).map(row => {
const eventType = (row.querySelector('[data-field="event_type"]') as HTMLSelectElement)?.value || 'kill';
const effectType = (row.querySelector('[data-field="effect_type"]') as HTMLSelectElement)?.value || 'flash';
const colorInput = (row.querySelector('input[data-field="color"]') as HTMLInputElement)?.value || '#ff0000';
const duration = parseFloat((row.querySelector('[data-field="duration_ms"]') as HTMLInputElement)?.value) || 500;
const intensity = parseFloat((row.querySelector('[data-field="intensity"]') as HTMLInputElement)?.value) || 1.0;
const priority = parseInt((row.querySelector('[data-field="priority"]') as HTMLInputElement)?.value) || 5;
const rgb = _hexToRgb(colorInput);
return { event_type: eventType, effect_type: effectType, color: rgb, duration_ms: duration, intensity, priority };
});
}
function _hexToRgb(hex: string): number[] {
const m = hex.replace('#', '').match(/.{2}/g);
if (!m) return [255, 0, 0];
return m.map(c => parseInt(c, 16));
}
function _rgbToHex(rgb: number[]): string {
return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join('');
}
function _destroyMappingIconSelects() {
_mappingIconSelects.forEach(is => is.destroy());
_mappingIconSelects = [];
}
const EFFECT_TYPES: IconSelectItem[] = [
{ value: 'flash', label: 'Flash', icon: _icon(P.zap) },
{ value: 'pulse', label: 'Pulse', icon: _icon(P.activity) },
{ value: 'sweep', label: 'Sweep', icon: _icon(P.fastForward) },
{ value: 'color_shift', label: 'Color Shift', icon: _icon(P.rainbow) },
{ value: 'breathing', label: 'Breathing', icon: _icon(P.heart) },
];
/** Map well-known game event types to icons. Falls back to a generic icon. */
const _EVENT_TYPE_ICONS: Record<string, string> = {
kill: P.crosshair, death: P.xIcon, health: P.heart, armor: P.shield,
round_start: P.play, round_end: P.square, bomb_planted: P.flame, bomb_defused: P.circleCheck,
assist: P.swords, headshot: P.target, damage: P.zap, gold: P.star,
level_up: P.trendingUp, respawn: P.refreshCw, item_pickup: P.packageIcon,
};
function _buildEventTypeItems(): IconSelectItem[] {
return _getAvailableEventTypes().map(et => ({
value: et,
label: et,
icon: _icon(_EVENT_TYPE_ICONS[et] || P.circleDot),
}));
}
function _getAvailableEventTypes(): string[] {
const adapterType = (document.getElementById('gi-adapter-type') as HTMLSelectElement)?.value;
const adapter = _cachedGameAdapters.find(a => a.adapter_type === adapterType);
if (adapter && adapter.supported_events.length > 0) return adapter.supported_events;
return ['kill', 'death', 'health', 'armor', 'round_start', 'round_end', 'bomb_planted', 'bomb_defused', 'assist', 'headshot'];
}
function _renderMappingRow(mapping: GameEventMapping, index: number): string {
const eventTypes = _getAvailableEventTypes();
const eventOptions = eventTypes.map(et =>
`<option value="${et}"${et === mapping.event_type ? ' selected' : ''}>${et}</option>`
).join('');
const effectOptions = EFFECT_TYPES.map(ef =>
`<option value="${ef.value}"${ef.value === mapping.effect_type ? ' selected' : ''}>${ef.label}</option>`
).join('');
const effectLabel = EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type;
const hexColor = _rgbToHex(mapping.color);
return `
<div class="gi-mapping-row" data-mapping-index="${index}">
<div class="gi-mapping-header">
<span class="gi-mapping-expand-btn">&#x25B6;</span>
<span class="gi-mapping-summary">
<span class="gi-mapping-summary-event">${escapeHtml(mapping.event_type)}</span>
<span class="gi-mapping-summary-effect">${escapeHtml(effectLabel)}</span>
<span class="gi-mapping-summary-color" style="background:${hexColor}"></span>
</span>
<button type="button" class="btn-remove-rule" onclick="event.stopPropagation(); removeGameMapping(${index})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="gi-mapping-body-wrapper">
<div class="gi-mapping-body">
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.event_type')}</label>
<select data-field="event_type">${eventOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.effect_type')}</label>
<select data-field="effect_type" id="gi-effect-type-${index}">${effectOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.color')}</label>
<input type="color" data-field="color" value="${hexColor}">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.duration')}</label>
<input type="number" data-field="duration_ms" value="${mapping.duration_ms}" min="50" max="10000" step="50">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.intensity')}</label>
<input type="range" data-field="intensity" value="${mapping.intensity}" min="0" max="1" step="0.05"
oninput="this.title = this.value">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.priority')}</label>
<input type="number" data-field="priority" value="${mapping.priority}" min="1" max="10">
</div>
</div>
</div>
</div>`;
}
function _renderMappings(mappings: GameEventMapping[]) {
_currentMappings = [...mappings];
const container = document.getElementById('gi-mappings-list')!;
_destroyMappingIconSelects();
container.innerHTML = mappings.map((m, i) => _renderMappingRow(m, i)).join('');
_wireMappingRows(container);
}
function _wireMappingRows(container: HTMLElement) {
// Expand/collapse on header click
container.querySelectorAll('.gi-mapping-header').forEach(header => {
const item = header.closest('.gi-mapping-row') as HTMLElement;
header.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
if (target.closest('.btn-remove-rule')) return;
item.classList.toggle('gi-mapping-expanded');
});
});
// Wire IconSelect + summary sync on each row
container.querySelectorAll('.gi-mapping-row').forEach(row => {
const eventSel = row.querySelector('[data-field="event_type"]') as HTMLSelectElement | null;
const effectSel = row.querySelector('[data-field="effect_type"]') as HTMLSelectElement | null;
const colorInput = row.querySelector('input[data-field="color"]') as HTMLInputElement | null;
const summaryEvent = row.querySelector('.gi-mapping-summary-event') as HTMLElement | null;
const summaryEffect = row.querySelector('.gi-mapping-summary-effect') as HTMLElement | null;
const summaryColor = row.querySelector('.gi-mapping-summary-color') as HTMLElement | null;
// Event type IconSelect
if (eventSel) {
const is = new IconSelect({ target: eventSel, items: _buildEventTypeItems(), columns: 4 });
_mappingIconSelects.push(is);
if (summaryEvent) {
eventSel.addEventListener('change', () => { summaryEvent.textContent = eventSel.value; });
}
}
// Effect type IconSelect
if (effectSel) {
const is = new IconSelect({ target: effectSel, items: EFFECT_TYPES, columns: 3 });
_mappingIconSelects.push(is);
if (summaryEffect) {
effectSel.addEventListener('change', () => {
const label = EFFECT_TYPES.find(ef => ef.value === effectSel.value)?.label || effectSel.value;
summaryEffect.textContent = label;
});
}
}
// Color swatch sync
if (colorInput && summaryColor) {
colorInput.addEventListener('input', () => { summaryColor.style.background = colorInput.value; });
}
});
}
export function addGameMapping() {
const newMapping: GameEventMapping = {
event_type: _getAvailableEventTypes()[0] || 'kill',
effect_type: 'flash',
color: [255, 0, 0],
duration_ms: 500,
intensity: 1.0,
priority: 5,
};
const collected = _collectMappings();
collected.push(newMapping);
_renderMappings(collected);
}
export function removeGameMapping(index: number) {
const collected = _collectMappings();
collected.splice(index, 1);
_renderMappings(collected);
}
let _cachedPresets: EffectPreset[] = [];
async function _loadPresets(): Promise<EffectPreset[]> {
if (_cachedPresets.length > 0) return _cachedPresets;
try {
const res = await fetchWithAuth('/game-integrations/presets');
if (res && res.ok) {
const data = await res.json();
_cachedPresets = data.presets || [];
}
} catch { /* ignore */ }
return _cachedPresets;
}
function _applyMappingPreset(presetKey: string) {
const preset = _cachedPresets.find(p => p.key === presetKey);
if (!preset) return;
// Map API effect field to frontend effect_type field
const mappings: GameEventMapping[] = preset.event_mappings.map(m => ({
event_type: m.event_type,
effect_type: (m as any).effect || (m as any).effect_type || 'flash',
color: m.color,
duration_ms: m.duration_ms,
intensity: m.intensity,
priority: m.priority,
}));
_renderMappings(mappings);
}
export function onMappingPresetChange() {
const sel = document.getElementById('gi-mapping-preset') as HTMLSelectElement;
if (sel.value) {
_applyMappingPreset(sel.value);
sel.value = '';
}
}
async function _populatePresetSelector() {
const sel = document.getElementById('gi-mapping-preset') as HTMLSelectElement;
if (!sel) return;
if (_presetIconSelect) { _presetIconSelect.destroy(); _presetIconSelect = null; }
const presets = await _loadPresets();
sel.innerHTML = `<option value="">${t('game_integration.mapping.select_preset')}</option>` +
presets.map(p => `<option value="${p.key}">${escapeHtml(p.name)}</option>`).join('');
if (presets.length > 0) {
const items: IconSelectItem[] = [
{ value: '', label: t('game_integration.mapping.select_preset'), icon: '' },
...presets.map(p => ({
value: p.key,
label: p.name,
icon: _icon(P.sparkles),
desc: p.description,
})),
];
_presetIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
}
// ── Live event monitor ──
function _stopEventMonitor() {
if (_eventMonitorTimer) {
clearInterval(_eventMonitorTimer);
_eventMonitorTimer = null;
}
}
function _startEventMonitor(integrationId: string) {
_stopEventMonitor();
const feed = document.getElementById('gi-event-feed');
if (!feed) return;
feed.innerHTML = `<div class="gi-event-waiting">${t('game_integration.events.waiting')}</div>`;
const poll = async () => {
try {
const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`);
if (!res || !res.ok) return;
const data = await res.json();
const events: GameEventRecord[] = data.events || [];
if (events.length === 0) return;
feed.innerHTML = events.slice(0, 20).map(ev => {
const ts = new Date(ev.timestamp).toLocaleTimeString();
const valStr = ev.value !== undefined ? ` = ${ev.value}` : '';
return `<div class="gi-event-item">
<span class="gi-event-time">${ts}</span>
<span class="gi-event-type">${escapeHtml(ev.event_type)}</span>
<span class="gi-event-value">${valStr}</span>
</div>`;
}).join('');
} catch { /* ignore polling errors */ }
};
poll();
_eventMonitorTimer = setInterval(poll, 2000);
}
// ── Connection test ──
let _connectionTestTimer: ReturnType<typeof setInterval> | null = null;
export function testGameConnection() {
const id = (document.getElementById('gi-id') as HTMLInputElement)?.value;
if (!id) {
showToast(t('game_integration.error.save_first'), 'warning');
return;
}
const panel = document.getElementById('gi-test-panel')!;
panel.style.display = '';
panel.innerHTML = `<div class="gi-test-waiting">${ICON_CIRCLE_DOT} ${t('game_integration.test.waiting')}</div>`;
if (_connectionTestTimer) clearInterval(_connectionTestTimer);
let attempts = 0;
_connectionTestTimer = setInterval(async () => {
attempts++;
try {
const res = await fetchWithAuth(`/game-integrations/${id}/status`);
if (!res || !res.ok) return;
const status: GameIntegrationStatus = await res.json();
if (status.event_count > 0) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-success">${t('game_integration.test.success')} (${status.event_count})</div>`;
} else if (status.error) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-error">${t('game_integration.test.error')}: ${escapeHtml(status.error)}</div>`;
}
} catch { /* ignore */ }
if (attempts >= 30) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-timeout">${t('game_integration.test.timeout')}</div>`;
}
}, 2000);
}
// ── Card renderer ──
export function createGameIntegrationCard(gi: GameIntegration): string {
const adapterIcon = getGameAdapterIcon(gi.adapter_type);
const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type;
const enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive';
const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive');
const mappingCount = gi.event_mappings?.length || 0;
return wrapCard({
type: 'template-card',
dataAttr: 'data-gi-id',
id: gi.id,
removeOnclick: `deleteGameIntegration('${gi.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(gi.name)}">${adapterIcon} ${escapeHtml(gi.name)}</div>
</div>
${gi.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(gi.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('game_integration.adapter')}">${ICON_GAMEPAD} ${escapeHtml(adapterName)}</span>
<span class="stream-card-prop ${enabledClass}" title="${t('game_integration.status')}">${ICON_CIRCLE_DOT} ${enabledLabel}</span>
${mappingCount > 0 ? `<span class="stream-card-prop" title="${t('game_integration.mappings')}">${_icon(P.listChecks)} ${mappingCount}</span>` : ''}
</div>
${renderTagChips(gi.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showGameEventMonitor('${gi.id}')" title="${t('game_integration.events.monitor')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneGameIntegration('${gi.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showGameIntegrationEditor('${gi.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── CRUD ──
export async function showGameIntegrationEditor(editId: string | null = null) {
const titleEl = document.getElementById('gi-title')!;
const idInput = document.getElementById('gi-id') as HTMLInputElement;
const nameInput = document.getElementById('gi-name') as HTMLInputElement;
const descInput = document.getElementById('gi-description') as HTMLInputElement;
const adapterSel = document.getElementById('gi-adapter-type') as HTMLSelectElement;
const enabledCheck = document.getElementById('gi-enabled') as HTMLInputElement;
const testPanel = document.getElementById('gi-test-panel')!;
// Reset form
idInput.value = '';
nameInput.value = '';
descInput.value = '';
enabledCheck.checked = true;
testPanel.style.display = 'none';
document.getElementById('gi-error')!.style.display = 'none';
// Ensure adapters are loaded
const adapters = await gameAdaptersCache.fetch();
adapterSel.innerHTML = adapters.map(a =>
`<option value="${a.adapter_type}">${escapeHtml(a.display_name)}</option>`
).join('');
// Setup adapter type IconSelect
if (_adapterTypeIconSelect) { _adapterTypeIconSelect.destroy(); _adapterTypeIconSelect = null; }
const adapterItems: IconSelectItem[] = adapters.map(a => ({
value: a.adapter_type,
label: a.display_name,
icon: getGameAdapterIcon(a.adapter_type),
desc: a.game_name,
}));
_adapterTypeIconSelect = new IconSelect({
target: adapterSel,
items: adapterItems,
columns: 3,
});
// Tags
if (_giTagsInput) { _giTagsInput.destroy(); _giTagsInput = null; }
_giTagsInput = new TagInput(document.getElementById('gi-tags-container')!);
if (editId) {
const integrations = await gameIntegrationsCache.fetch();
const gi = integrations.find(g => g.id === editId);
if (!gi) return;
idInput.value = gi.id;
nameInput.value = gi.name;
descInput.value = gi.description || '';
adapterSel.value = gi.adapter_type;
if (_adapterTypeIconSelect) _adapterTypeIconSelect.setValue(gi.adapter_type);
enabledCheck.checked = gi.enabled;
_giTagsInput.setValue(gi.tags || []);
const adapter = adapters.find(a => a.adapter_type === gi.adapter_type);
if (adapter) {
_renderAdapterConfigFields(adapter, gi.adapter_config || {});
_renderSetupInstructions(adapter);
}
_renderMappings(gi.event_mappings || []);
titleEl.innerHTML = `${ICON_GAMEPAD} ${t('game_integration.edit')}`;
// Start event monitor for existing integration
_startEventMonitor(gi.id);
} else {
titleEl.innerHTML = `${ICON_GAMEPAD} ${t('game_integration.add')}`;
_renderMappings([]);
// Show config for first adapter
if (adapters.length > 0) {
_renderAdapterConfigFields(adapters[0]);
_renderSetupInstructions(adapters[0]);
}
}
// Listen for adapter type changes
adapterSel.onchange = () => {
const adapter = adapters.find(a => a.adapter_type === adapterSel.value);
if (adapter) {
_renderAdapterConfigFields(adapter);
_renderSetupInstructions(adapter);
// Re-render mappings to update available event types
_renderMappings(_collectMappings());
}
};
// Populate preset selector from API
await _populatePresetSelector();
giModal.open();
giModal.snapshot();
}
export async function saveGameIntegration() {
const id = (document.getElementById('gi-id') as HTMLInputElement).value;
const name = (document.getElementById('gi-name') as HTMLInputElement).value.trim();
if (!name) { giModal.showError(t('game_integration.error.name_required')); return; }
const adapterType = (document.getElementById('gi-adapter-type') as HTMLSelectElement).value;
const description = (document.getElementById('gi-description') as HTMLInputElement).value.trim();
const enabled = (document.getElementById('gi-enabled') as HTMLInputElement).checked;
const adapterConfig = _collectAdapterConfig();
const eventMappings = _collectMappings();
const tags = _giTagsInput ? _giTagsInput.getValue() : [];
const payload = {
name, adapter_type: adapterType, adapter_config: adapterConfig,
event_mappings: eventMappings, enabled, description, tags,
};
try {
const url = id ? `/game-integrations/${id}` : '/game-integrations';
const method = id ? 'PUT' : 'POST';
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
if (!res || !res.ok) {
const err = await res!.json();
throw new Error(err.detail || t('game_integration.error.save_failed'));
}
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
gameIntegrationsCache.invalidate();
giModal.forceClose();
loadGameIntegrations();
} catch (e: any) {
if (e.isAuth) return;
giModal.showError(e.message);
}
}
export async function deleteGameIntegration(entityId: string) {
const ok = await showConfirm(t('game_integration.confirm_delete'));
if (!ok) return;
try {
await fetchWithAuth(`/game-integrations/${entityId}`, { method: 'DELETE' });
showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate();
loadGameIntegrations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('game_integration.error.delete_failed'), 'error');
}
}
export async function cloneGameIntegration(entityId: string) {
const integrations = await gameIntegrationsCache.fetch();
const source = integrations.find(g => g.id === entityId);
if (!source) return;
await showGameIntegrationEditor(null);
(document.getElementById('gi-name') as HTMLInputElement).value = source.name + ' (Copy)';
(document.getElementById('gi-description') as HTMLInputElement).value = source.description || '';
const adapterSel = document.getElementById('gi-adapter-type') as HTMLSelectElement;
adapterSel.value = source.adapter_type;
if (_adapterTypeIconSelect) _adapterTypeIconSelect.setValue(source.adapter_type);
(document.getElementById('gi-enabled') as HTMLInputElement).checked = source.enabled;
if (_giTagsInput) _giTagsInput.setValue(source.tags || []);
const adapter = _cachedGameAdapters.find(a => a.adapter_type === source.adapter_type);
if (adapter) {
_renderAdapterConfigFields(adapter, source.adapter_config || {});
_renderSetupInstructions(adapter);
}
_renderMappings(source.event_mappings || []);
giModal.snapshot();
}
export function closeGameIntegrationModal() {
giModal.close();
}
// ── Event monitor (standalone, triggered from card) ──
export function showGameEventMonitor(integrationId: string) {
const gi = _cachedGameIntegrations.find(g => g.id === integrationId);
if (!gi) return;
// Open editor and start monitoring
showGameIntegrationEditor(integrationId);
}
// ── Load function (called from streams.ts) ──
export async function loadGameIntegrations() {
await Promise.all([
gameIntegrationsCache.fetch(),
gameAdaptersCache.fetch(),
]);
// Streams.ts handles rendering via its own renderPictureSourcesList
if (window.loadPictureSources) window.loadPictureSources();
}
@@ -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',
});
}
}
@@ -13,6 +13,7 @@
import {
_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -23,7 +24,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS,
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
} from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -65,6 +66,7 @@ class ValueSourceModal extends Modal {
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
}
snapshotValues() {
@@ -136,13 +138,16 @@ function _autoGenerateVSName() {
} else if (type === 'system_metrics') {
const metric = (document.getElementById('value-source-metric') as HTMLSelectElement).value;
detail = t(`value_source.metric.${metric}`);
} else if (type === 'game_event') {
const eventType = (document.getElementById('value-source-game-event-type') as HTMLSelectElement)?.value;
if (eventType) detail = eventType;
}
(document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}
/* ── Icon-grid type selector ──────────────────────────────────── */
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics'];
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event'];
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
@@ -348,6 +353,61 @@ function _onMetricChange(metric: string) {
if (sensorFields) sensorFields.style.display = sensorMetrics.includes(metric) ? '' : 'none';
}
// ── Game Event Value Source helpers ──
let _vsGameIntegrationEntitySelect: EntitySelect | null = null;
function _populateVSGameIntegrationDropdown(selectedId: string = '') {
const sel = document.getElementById('value-source-game-integration') as HTMLSelectElement;
const integrations = _cachedGameIntegrations || [];
const prev = selectedId || sel.value;
sel.innerHTML = `<option value="">\u2014</option>` +
integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).join('');
sel.value = prev || '';
if (_vsGameIntegrationEntitySelect) _vsGameIntegrationEntitySelect.destroy();
_vsGameIntegrationEntitySelect = new EntitySelect({
target: sel,
getItems: () => integrations.map(gi => ({
value: gi.id,
label: gi.name,
icon: ICON_GAMEPAD,
desc: gi.adapter_type,
})),
placeholder: t('palette.search'),
});
// Update event type dropdown when integration changes
sel.onchange = () => _populateVSGameEventTypeDropdown('');
}
function _populateVSGameEventTypeDropdown(selectedType: string = '') {
const eventTypeSel = document.getElementById('value-source-game-event-type') as HTMLSelectElement;
const giId = (document.getElementById('value-source-game-integration') as HTMLSelectElement)?.value;
// Get continuous events from the selected integration's adapter
const CONTINUOUS_EVENTS = ['health', 'armor', 'mana', 'ammo', 'stamina', 'shield', 'score', 'gold', 'xp', 'level'];
let eventTypes = CONTINUOUS_EVENTS;
if (giId) {
const gi = (_cachedGameIntegrations || []).find(g => g.id === giId);
if (gi) {
const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type);
if (adapter && adapter.supported_events.length > 0) {
// Filter to continuous events only
eventTypes = adapter.supported_events.filter(e => CONTINUOUS_EVENTS.includes(e));
if (eventTypes.length === 0) eventTypes = adapter.supported_events;
}
}
}
const prev = selectedType || eventTypeSel.value;
eventTypeSel.innerHTML = eventTypes.map(et =>
`<option value="${et}"${et === prev ? ' selected' : ''}>${et}</option>`
).join('');
if (prev && eventTypes.includes(prev)) eventTypeSel.value = prev;
}
function _ensureVSTypeIconSelect() {
const sel = document.getElementById('value-source-type');
if (!sel) return;
@@ -484,6 +544,14 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_setSlider('value-source-poll-interval', editData.poll_interval ?? 1.0);
_setSlider('value-source-sysmetric-smoothing', editData.smoothing ?? 0);
_onMetricChange(editData.metric || 'cpu_load');
} else if (editData.source_type === 'game_event') {
_populateVSGameIntegrationDropdown(editData.game_integration_id || '');
_populateVSGameEventTypeDropdown(editData.event_type || 'health');
(document.getElementById('value-source-ge-min') as HTMLInputElement).value = String(editData.min_game_value ?? 0);
(document.getElementById('value-source-ge-max') as HTMLInputElement).value = String(editData.max_game_value ?? 100);
_setSlider('value-source-ge-smoothing', editData.smoothing ?? 0);
_setSlider('value-source-ge-default', editData.default_value ?? 0.5);
_setSlider('value-source-ge-timeout', editData.timeout ?? 5.0);
}
} else {
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
@@ -590,6 +658,10 @@ export function onValueSourceTypeChange() {
_ensureMetricIconSelect();
_onMetricChange((document.getElementById('value-source-metric') as HTMLSelectElement).value);
}
(document.getElementById('value-source-game-event-section') as HTMLElement).style.display = type === 'game_event' ? '' : 'none';
if (type === 'game_event') {
_populateVSGameIntegrationDropdown('');
}
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
@@ -754,6 +826,19 @@ export async function saveValueSource() {
payload.sensor_label = (document.getElementById('value-source-sensor-label') as HTMLInputElement).value;
payload.poll_interval = parseFloat((document.getElementById('value-source-poll-interval') as HTMLInputElement).value) || 1.0;
payload.smoothing = parseFloat((document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value) || 0;
} else if (sourceType === 'game_event') {
payload.game_integration_id = (document.getElementById('value-source-game-integration') as HTMLSelectElement).value;
payload.event_type = (document.getElementById('value-source-game-event-type') as HTMLSelectElement).value;
payload.min_game_value = parseFloat((document.getElementById('value-source-ge-min') as HTMLInputElement).value) || 0;
payload.max_game_value = parseFloat((document.getElementById('value-source-ge-max') as HTMLInputElement).value) || 100;
payload.smoothing = parseFloat((document.getElementById('value-source-ge-smoothing') as HTMLInputElement).value) || 0;
payload.default_value = parseFloat((document.getElementById('value-source-ge-default') as HTMLInputElement).value) || 0.5;
payload.timeout = parseFloat((document.getElementById('value-source-ge-timeout') as HTMLInputElement).value) || 5.0;
if (!payload.game_integration_id) {
errorEl.textContent = t('value_source.game_event.integration') + ' required';
errorEl.style.display = '';
return;
}
}
try {
+19 -1
View File
@@ -178,7 +178,7 @@ interface Window {
openAutomationEditor: (...args: any[]) => any;
closeAutomationEditorModal: (...args: any[]) => any;
saveAutomationEditor: (...args: any[]) => any;
addAutomationCondition: (...args: any[]) => any;
addAutomationRule: (...args: any[]) => any;
toggleAutomationEnabled: (...args: any[]) => any;
cloneAutomation: (...args: any[]) => any;
deleteAutomation: (...args: any[]) => any;
@@ -194,6 +194,21 @@ interface Window {
deleteScenePreset: (...args: any[]) => any;
addSceneTarget: (...args: any[]) => any;
// ─── Game Integration ───
showGameIntegrationEditor: (...args: any[]) => any;
saveGameIntegration: (...args: any[]) => any;
closeGameIntegrationModal: (...args: any[]) => any;
cloneGameIntegration: (...args: any[]) => any;
deleteGameIntegration: (...args: any[]) => any;
addGameMapping: (...args: any[]) => any;
removeGameMapping: (...args: any[]) => any;
onMappingPresetChange: (...args: any[]) => any;
testGameConnection: (...args: any[]) => any;
showGameEventMonitor: (...args: any[]) => any;
openSetupInstructions: (...args: any[]) => any;
closeSetupInstructions: (...args: any[]) => any;
autoSetupGameIntegration: (...args: any[]) => any;
// ─── Device Discovery ───
onDeviceTypeChanged: (...args: any[]) => any;
updateBaudFpsHint: (...args: any[]) => any;
@@ -264,6 +279,9 @@ startTargetOverlay: (...args: any[]) => any;
applyCssTestSettings: (...args: any[]) => any;
fireCssTestNotification: (...args: any[]) => any;
fireCssTestNotificationLayer: (...args: any[]) => any;
addCSSGameMapping: () => void;
removeCSSGameMapping: (index: number) => void;
onCSSGameMappingPresetChange: () => void;
// ─── Audio Sources ───
showAudioSourceModal: (...args: any[]) => any;
+98 -3
View File
@@ -135,7 +135,8 @@ export type CSSSourceType =
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed' | 'weather' | 'key_colors';
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
| 'game_event';
export interface ColorStop {
position: number;
@@ -282,6 +283,11 @@ export interface ColorStripSource {
// Key Colors
rectangles?: KeyColorRectangle[];
brightness?: BindableFloat;
// Game Event
game_integration_id?: string;
idle_color?: BindableColor;
event_mappings?: GameEventMapping[];
}
// ── Pattern Template ──────────────────────────────────────────
@@ -310,7 +316,8 @@ export type ValueSourceType =
| 'static' | 'animated' | 'audio'
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
| 'static_color' | 'animated_color' | 'adaptive_time_color'
| 'ha_entity' | 'gradient_map' | 'css_extract';
| 'ha_entity' | 'gradient_map' | 'css_extract'
| 'system_metrics' | 'game_event';
export interface SchedulePoint {
time: string;
@@ -449,6 +456,18 @@ export interface SystemMetricsValueSource extends ValueSourceBase {
smoothing: number;
}
export interface GameEventValueSource extends ValueSourceBase {
source_type: 'game_event';
return_type: 'float';
game_integration_id: string;
event_type: string;
min_game_value: number;
max_game_value: number;
smoothing: number;
default_value: number;
timeout: number;
}
export type ValueSource =
| StaticValueSource
| AnimatedValueSource
@@ -462,7 +481,8 @@ export type ValueSource =
| HAEntityValueSource
| GradientMapValueSource
| CSSExtractValueSource
| SystemMetricsValueSource;
| SystemMetricsValueSource
| GameEventValueSource;
// ── Audio Source ───────────────────────────────────────────────
@@ -834,6 +854,81 @@ export interface AutomationListResponse {
count: number;
}
// ── Game Integration ─────────────────────────────────────────
export interface GameEventMapping {
event_type: string;
effect_type: string;
color: number[];
duration_ms: number;
intensity: number;
priority: number;
}
export interface GameIntegration {
id: string;
name: string;
adapter_type: string;
adapter_config: Record<string, any>;
event_mappings: GameEventMapping[];
enabled: boolean;
description?: string;
tags: string[];
created_at: string;
updated_at: string;
}
export interface GameIntegrationListResponse {
integrations: GameIntegration[];
count: number;
}
export interface GameAdapterConfigField {
name: string;
type: string;
label?: string;
default?: any;
required?: boolean;
hint?: string;
}
export interface GameAdapterInfo {
adapter_type: string;
display_name: string;
game_name: string;
supported_events: string[];
config_schema: GameAdapterConfigField[];
setup_instructions?: string;
supports_auto_setup?: boolean;
}
export interface GameAdapterListResponse {
adapters: GameAdapterInfo[];
}
export interface GameEventRecord {
timestamp: string;
event_type: string;
value?: number;
data?: Record<string, any>;
}
export interface GameIntegrationStatus {
integration_id: string;
connected: boolean;
last_event_at?: string;
event_count: number;
error?: string;
}
export interface EffectPreset {
key: string;
name: string;
description: string;
target_game_types: string[];
event_mappings: GameEventMapping[];
}
// ── Component Option Types (re-exported from authoritative sources) ───
export type { IconSelectItem, IconSelectOpts } from './core/icon-select.ts';
@@ -2150,5 +2150,91 @@
"donation.about_title": "About LedGrab",
"donation.about_opensource": "LedGrab is open-source software, free to use and modify.",
"donation.about_donate": "Support development",
"donation.about_license": "MIT License"
"donation.about_license": "MIT License",
"streams.group.game": "Game Integration",
"tree.group.game": "Game",
"game_integration.section_title": "Game Integrations",
"section.empty.game_integrations": "No game integrations yet. Click + to create one.",
"game_integration.add": "Add Game Integration",
"game_integration.edit": "Edit Game Integration",
"game_integration.created": "Game integration created",
"game_integration.updated": "Game integration updated",
"game_integration.deleted": "Game integration deleted",
"game_integration.confirm_delete": "Delete this game integration?",
"game_integration.error.name_required": "Name is required",
"game_integration.error.save_failed": "Failed to save game integration",
"game_integration.error.delete_failed": "Failed to delete game integration",
"game_integration.error.save_first": "Save the integration first to test the connection",
"game_integration.name": "Name:",
"game_integration.name.hint": "A descriptive name for this game integration",
"game_integration.description": "Description:",
"game_integration.description.hint": "Optional description of what this integration does",
"game_integration.enabled": "Enabled",
"game_integration.adapter_type": "Game / Adapter:",
"game_integration.adapter_type.hint": "Select the game or adapter type for this integration",
"game_integration.adapter_config": "Adapter Configuration",
"game_integration.no_config": "No configuration required for this adapter.",
"game_integration.setup_instructions": "Setup Instructions",
"game_integration.setup_instructions.hint": "Follow these steps to configure your game to send data to this integration",
"game_integration.event_mappings": "Event Mappings",
"game_integration.event_mappings.hint": "Map game events to LED effects. Each event type can trigger a different visual effect.",
"game_integration.mapping.add": "+ Add Mapping",
"game_integration.mapping.event_type": "Event",
"game_integration.mapping.effect_type": "Effect",
"game_integration.mapping.color": "Color",
"game_integration.mapping.duration": "Duration (ms)",
"game_integration.mapping.intensity": "Intensity",
"game_integration.mapping.priority": "Priority",
"game_integration.mapping.select_preset": "Load preset...",
"game_integration.preset.select": "Load preset...",
"game_integration.preset.fps_combat": "FPS Combat",
"game_integration.preset.moba_health": "MOBA Health",
"game_integration.adapter": "Adapter",
"game_integration.status": "Status",
"game_integration.status.active": "Active",
"game_integration.status.inactive": "Inactive",
"game_integration.mappings": "Mappings",
"game_integration.events.title": "Live Events",
"game_integration.events.waiting": "Waiting for events...",
"game_integration.events.monitor": "Event Monitor",
"game_integration.test.button": "Test Connection",
"game_integration.test.waiting": "Waiting for events from game...",
"game_integration.test.success": "Connection successful! Received events.",
"game_integration.test.error": "Connection error",
"game_integration.test.timeout": "No events received within timeout period.",
"game_integration.auto_setup": "Auto Setup",
"game_integration.auto_setup.success": "Configuration file written successfully",
"game_integration.auto_setup.failed": "Auto setup failed",
"game_integration.auto_setup.not_supported": "This adapter does not support auto setup",
"game_integration.auto_setup.game_not_found": "Game installation not found",
"game_integration.auto_setup.token_generated": "Auth token was automatically generated",
"game_integration.auto_setup.save_first": "Save the integration first before running auto setup",
"color_strip.type.game_event": "Game Event",
"color_strip.type.game_event.desc": "LED effects triggered by game events",
"color_strip.game_event.integration": "Game Integration:",
"color_strip.game_event.integration.hint": "Select the game integration that provides events for this source.",
"color_strip.game_event.idle_color": "Idle Color:",
"color_strip.game_event.idle_color.hint": "LED color when no game events are active.",
"color_strip.game_event.event_mappings": "Event Mappings:",
"color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.",
"color_strip.game_event.error.no_integration": "Please select a game integration.",
"value_source.type.game_event": "Game Event",
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
"value_source.game_event.integration": "Game Integration:",
"value_source.game_event.integration.hint": "Select the game integration that provides events for this value source.",
"value_source.game_event.event_type": "Event Type:",
"value_source.game_event.event_type.hint": "The continuous game event to track (health, mana, ammo, etc.).",
"value_source.game_event.min_game_value": "Min Game Value:",
"value_source.game_event.min_game_value.hint": "Raw game value that maps to output 0.0.",
"value_source.game_event.max_game_value": "Max Game Value:",
"value_source.game_event.max_game_value.hint": "Raw game value that maps to output 1.0.",
"value_source.game_event.smoothing": "Smoothing:",
"value_source.game_event.smoothing.hint": "EMA smoothing factor. 0 = instant, higher = smoother transitions.",
"value_source.game_event.default_value": "Default Value:",
"value_source.game_event.default_value.hint": "Output value when no events received within timeout.",
"value_source.game_event.timeout": "Timeout (s):",
"value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value."
}
@@ -1915,5 +1915,91 @@
"donation.about_title": "О LedGrab",
"donation.about_opensource": "LedGrab — программа с открытым исходным кодом, бесплатная для использования и модификации.",
"donation.about_donate": "Поддержать разработку",
"donation.about_license": "Лицензия MIT"
"donation.about_license": "Лицензия MIT",
"streams.group.game": "Игровая интеграция",
"tree.group.game": "Игры",
"game_integration.section_title": "Игровые интеграции",
"section.empty.game_integrations": "Нет игровых интеграций. Нажмите +, чтобы создать.",
"game_integration.add": "Добавить игровую интеграцию",
"game_integration.edit": "Редактировать игровую интеграцию",
"game_integration.created": "Игровая интеграция создана",
"game_integration.updated": "Игровая интеграция обновлена",
"game_integration.deleted": "Игровая интеграция удалена",
"game_integration.confirm_delete": "Удалить эту игровую интеграцию?",
"game_integration.error.name_required": "Требуется имя",
"game_integration.error.save_failed": "Не удалось сохранить игровую интеграцию",
"game_integration.error.delete_failed": "Не удалось удалить игровую интеграцию",
"game_integration.error.save_first": "Сначала сохраните интеграцию для проверки соединения",
"game_integration.name": "Имя:",
"game_integration.name.hint": "Описательное имя для этой игровой интеграции",
"game_integration.description": "Описание:",
"game_integration.description.hint": "Необязательное описание назначения интеграции",
"game_integration.enabled": "Включено",
"game_integration.adapter_type": "Игра / Адаптер:",
"game_integration.adapter_type.hint": "Выберите тип игры или адаптера",
"game_integration.adapter_config": "Конфигурация адаптера",
"game_integration.no_config": "Конфигурация для этого адаптера не требуется.",
"game_integration.setup_instructions": "Инструкции по настройке",
"game_integration.setup_instructions.hint": "Следуйте этим шагам для настройки отправки данных из игры",
"game_integration.event_mappings": "Привязка событий",
"game_integration.event_mappings.hint": "Привяжите игровые события к LED-эффектам. Каждый тип события может вызывать свой визуальный эффект.",
"game_integration.mapping.add": "+ Добавить привязку",
"game_integration.mapping.event_type": "Событие",
"game_integration.mapping.effect_type": "Эффект",
"game_integration.mapping.color": "Цвет",
"game_integration.mapping.duration": "Длительность (мс)",
"game_integration.mapping.intensity": "Интенсивность",
"game_integration.mapping.priority": "Приоритет",
"game_integration.mapping.select_preset": "Загрузить пресет...",
"game_integration.preset.select": "Загрузить пресет...",
"game_integration.preset.fps_combat": "FPS Бой",
"game_integration.preset.moba_health": "MOBA Здоровье",
"game_integration.adapter": "Адаптер",
"game_integration.status": "Статус",
"game_integration.status.active": "Активна",
"game_integration.status.inactive": "Неактивна",
"game_integration.mappings": "Привязки",
"game_integration.events.title": "События в реальном времени",
"game_integration.events.waiting": "Ожидание событий...",
"game_integration.events.monitor": "Монитор событий",
"game_integration.test.button": "Тестировать соединение",
"game_integration.test.waiting": "Ожидание событий от игры...",
"game_integration.test.success": "Соединение успешно! Получены события.",
"game_integration.test.error": "Ошибка соединения",
"game_integration.test.timeout": "События не получены за отведённое время.",
"game_integration.auto_setup": "Автонастройка",
"game_integration.auto_setup.success": "Файл конфигурации успешно записан",
"game_integration.auto_setup.failed": "Автонастройка не удалась",
"game_integration.auto_setup.not_supported": "Этот адаптер не поддерживает автонастройку",
"game_integration.auto_setup.game_not_found": "Установка игры не найдена",
"game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически",
"game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки",
"color_strip.type.game_event": "Игровое событие",
"color_strip.type.game_event.desc": "LED-эффекты по игровым событиям",
"color_strip.game_event.integration": "Игровая интеграция:",
"color_strip.game_event.integration.hint": "Выберите игровую интеграцию, от которой поступают события.",
"color_strip.game_event.idle_color": "Цвет простоя:",
"color_strip.game_event.idle_color.hint": "Цвет LED, когда нет активных игровых событий.",
"color_strip.game_event.event_mappings": "Привязка событий:",
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
"value_source.type.game_event": "Игровое событие",
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
"value_source.game_event.integration": "Игровая интеграция:",
"value_source.game_event.integration.hint": "Выберите игровую интеграцию для этого источника значений.",
"value_source.game_event.event_type": "Тип события:",
"value_source.game_event.event_type.hint": "Непрерывное игровое событие (здоровье, мана, патроны и т.д.).",
"value_source.game_event.min_game_value": "Мин. игровое значение:",
"value_source.game_event.min_game_value.hint": "Исходное игровое значение, соответствующее 0.0.",
"value_source.game_event.max_game_value": "Макс. игровое значение:",
"value_source.game_event.max_game_value.hint": "Исходное игровое значение, соответствующее 1.0.",
"value_source.game_event.smoothing": "Сглаживание:",
"value_source.game_event.smoothing.hint": "Коэффициент EMA-сглаживания. 0 = мгновенно, выше = плавнее.",
"value_source.game_event.default_value": "Значение по умолчанию:",
"value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.",
"value_source.game_event.timeout": "Таймаут (с):",
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию."
}
@@ -1913,5 +1913,91 @@
"donation.about_title": "关于 LedGrab",
"donation.about_opensource": "LedGrab 是开源软件,可免费使用和修改。",
"donation.about_donate": "支持开发",
"donation.about_license": "MIT 许可证"
"donation.about_license": "MIT 许可证",
"streams.group.game": "游戏集成",
"tree.group.game": "游戏",
"game_integration.section_title": "游戏集成",
"section.empty.game_integrations": "暂无游戏集成。点击 + 创建。",
"game_integration.add": "添加游戏集成",
"game_integration.edit": "编辑游戏集成",
"game_integration.created": "游戏集成已创建",
"game_integration.updated": "游戏集成已更新",
"game_integration.deleted": "游戏集成已删除",
"game_integration.confirm_delete": "删除此游戏集成?",
"game_integration.error.name_required": "名称不能为空",
"game_integration.error.save_failed": "保存游戏集成失败",
"game_integration.error.delete_failed": "删除游戏集成失败",
"game_integration.error.save_first": "请先保存集成以测试连接",
"game_integration.name": "名称:",
"game_integration.name.hint": "为此游戏集成提供一个描述性名称",
"game_integration.description": "描述:",
"game_integration.description.hint": "可选描述此集成的用途",
"game_integration.enabled": "启用",
"game_integration.adapter_type": "游戏/适配器:",
"game_integration.adapter_type.hint": "选择此集成的游戏或适配器类型",
"game_integration.adapter_config": "适配器配置",
"game_integration.no_config": "此适配器无需配置。",
"game_integration.setup_instructions": "设置说明",
"game_integration.setup_instructions.hint": "按照以下步骤配置您的游戏向此集成发送数据",
"game_integration.event_mappings": "事件映射",
"game_integration.event_mappings.hint": "将游戏事件映射到 LED 效果。每种事件类型可触发不同的视觉效果。",
"game_integration.mapping.add": "+ 添加映射",
"game_integration.mapping.event_type": "事件",
"game_integration.mapping.effect_type": "效果",
"game_integration.mapping.color": "颜色",
"game_integration.mapping.duration": "持续时间 (毫秒)",
"game_integration.mapping.intensity": "强度",
"game_integration.mapping.priority": "优先级",
"game_integration.mapping.select_preset": "加载预设...",
"game_integration.preset.select": "加载预设...",
"game_integration.preset.fps_combat": "FPS 战斗",
"game_integration.preset.moba_health": "MOBA 生命值",
"game_integration.adapter": "适配器",
"game_integration.status": "状态",
"game_integration.status.active": "活跃",
"game_integration.status.inactive": "未激活",
"game_integration.mappings": "映射",
"game_integration.events.title": "实时事件",
"game_integration.events.waiting": "等待事件...",
"game_integration.events.monitor": "事件监控",
"game_integration.test.button": "测试连接",
"game_integration.test.waiting": "等待游戏事件...",
"game_integration.test.success": "连接成功!已收到事件。",
"game_integration.test.error": "连接错误",
"game_integration.test.timeout": "在超时期间内未收到事件。",
"game_integration.auto_setup": "自动配置",
"game_integration.auto_setup.success": "配置文件写入成功",
"game_integration.auto_setup.failed": "自动配置失败",
"game_integration.auto_setup.not_supported": "此适配器不支持自动配置",
"game_integration.auto_setup.game_not_found": "未找到游戏安装",
"game_integration.auto_setup.token_generated": "授权令牌已自动生成",
"game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置",
"color_strip.type.game_event": "游戏事件",
"color_strip.type.game_event.desc": "由游戏事件触发的LED效果",
"color_strip.game_event.integration": "游戏集成:",
"color_strip.game_event.integration.hint": "选择为此源提供事件的游戏集成。",
"color_strip.game_event.idle_color": "空闲颜色:",
"color_strip.game_event.idle_color.hint": "没有活动游戏事件时的LED颜色。",
"color_strip.game_event.event_mappings": "事件映射:",
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
"value_source.type.game_event": "游戏事件",
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
"value_source.game_event.integration": "游戏集成:",
"value_source.game_event.integration.hint": "选择为此值源提供事件的游戏集成。",
"value_source.game_event.event_type": "事件类型:",
"value_source.game_event.event_type.hint": "要跟踪的持续游戏事件(生命值、法力、弹药等)。",
"value_source.game_event.min_game_value": "最小游戏值:",
"value_source.game_event.min_game_value.hint": "映射到输出0.0的原始游戏值。",
"value_source.game_event.max_game_value": "最大游戏值:",
"value_source.game_event.max_game_value.hint": "映射到输出1.0的原始游戏值。",
"value_source.game_event.smoothing": "平滑:",
"value_source.game_event.smoothing.hint": "EMA平滑系数。0 = 即时,越高越平滑。",
"value_source.game_event.default_value": "默认值:",
"value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。",
"value_source.game_event.timeout": "超时(秒):",
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。"
}