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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}">●</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 chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||||
<textarea class="rule-apps" rows="3" placeholder="firefox.exe 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">▶</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">▶</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
@@ -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;
|
||||
|
||||
@@ -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": "恢复到默认值前的静默秒数。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user