/** * Learning mode functionality for controller mapping * Manages the process of learning controller inputs and creating mappings (Client-Side) */ import { app } from "../../../../scripts/app.js"; import { contextProvider } from './contextProvider.js'; import { showLearningDialog, hideLearningDialog } from '../ui/dialogs.js'; import { detectMappingType, getDefaultSettingsForWidget } from './mappingTypes.js'; import { showNotification } from '../ui/notifications.js'; import { LearningStateManager } from './learningStateManager.js'; import { eventBus } from './eventBus.js'; // Get instances from the context provider const getContext = () => { const mappingEngine = contextProvider.get('mappingEngine'); const learningState = contextProvider.get('learningState'); const eventBus = contextProvider.get('eventBus'); if (!mappingEngine || !learningState || !eventBus) { console.warn("ControlFreak: Core components not available, functionality may be limited"); } return { mappingEngine, learningState, eventBus }; }; // --- Learning Functions --- /** * Start the learning process for a node parameter * @param {String|Number} nodeId - The node ID * @param {String} paramName - The parameter name */ async function startMappingNode(nodeId, paramName) { try { const { learningState } = getContext(); if (!learningState) { throw new Error("Learning state manager not available"); } // Find the node and parameter info const node = app.graph.getNodeById(+nodeId); if (!node) { throw new Error(`Node ${nodeId} not found`); } // Find the widget const widget = node.widgets.find(w => w.name === paramName); if (!widget) { throw new Error(`Widget ${paramName} not found on node ${nodeId}`); } // Create the target information const target = { type: "widget", nodeId: +nodeId, widgetName: paramName, nodeTitle: node.title, widgetConfig: null, comfyClass: node.comfyClass }; // Add widget configuration for min/max if available if (widget) { target.widgetConfig = { type: widget.type, config: { min: widget.options?.min, max: widget.options?.max, step: widget.options?.step, precision: widget.options?.precision }, current_value: widget.value }; } // Start learning mode with this target LearningStateManager.getInstance().startLearning('widget', target); // Show the learning dialog and store its reference in the state const { dialog } = showLearningDialog(target); LearningStateManager.getInstance().setDialog(dialog); return true; } catch (error) { console.error("Controller: Error starting mapping:", error); return false; } } /** * Start the learning process for a UI element * @param {HTMLElement} element - The UI element to map */ function startMappingUI(element) { try { const { learningState } = getContext(); if (!learningState) { throw new Error("Learning state manager not available"); } // Get element info const elementId = element.id || "unknown-element"; const elementType = element.tagName.toLowerCase(); // Create target info const target = { type: "ui_element", elementId, elementType, elementLabel: element.textContent || element.title || elementId }; // Start learning mode with this target LearningStateManager.getInstance().startLearning('ui', target); // Show the learning dialog and store its reference in the state const { dialog } = showLearningDialog(target); LearningStateManager.getInstance().setDialog(dialog); return true; } catch (error) { console.error("Controller: Error starting mapping for UI element:", error); return false; } } /** * Start the learning process for a command * @param {String} commandId - The command ID * @param {Object} options - Additional options */ function startMappingCommand(commandId, options = {}) { try { const { learningState } = getContext(); if (!learningState) { throw new Error("Learning state manager not available"); } // Create target info const target = { type: "command", commandId, label: options.label || commandId, description: options.description || "" }; // Start learning mode with this target LearningStateManager.getInstance().startLearning('command', target); // Show the learning dialog and store its reference in the state const { dialog } = showLearningDialog(target); LearningStateManager.getInstance().setDialog(dialog); return true; } catch (error) { console.error("Controller: Error starting mapping for command:", error); return false; } } /** * Cancel the learning process */ function cancelLearning() { try { const { learningState } = getContext(); if (!learningState) { throw new Error("Learning state manager not available"); } // Reset learning state learningState.cancelLearning(); // Hide the learning dialog hideLearningDialog(); return true; } catch (error) { console.error("Controller: Error canceling learning:", error); return false; } } /** * Complete the mapping process * @param {Object} selectedControl - The selected controller input * @param {Object} target - The target information * @param {Number} targetMin - Override minimum value * @param {Number} targetMax - Override maximum value * @param {String} mappingType - Type of mapping * @param {Number} stepSize - Step size for increment/decrement mappings * @param {Boolean} isInverted - Whether the output should be inverted */ async function completeMapping(selectedControl, target, targetMin, targetMax, mappingType, stepSize, isInverted) { try { const { mappingEngine, learningState } = getContext(); if (!mappingEngine || !learningState) { throw new Error("Core components not available"); } // Check if there are existing mappings for this widget let existingTransform = null; if (target.type === 'widget') { const existingMappings = mappingEngine.getMappings().filter(m => m.target && m.target.type === 'widget' && m.target.nodeId === target.nodeId && m.target.widgetName === target.widgetName ); // Find the first mapping with transform data as the source of truth const sourceMapping = existingMappings.find(m => m.transform && (typeof m.transform.targetMin === 'number' || typeof m.transform.targetMax === 'number')); if (sourceMapping && sourceMapping.transform) { existingTransform = sourceMapping.transform; // Use existing min/max if provided ones are undefined if (targetMin === undefined && typeof existingTransform.targetMin === 'number') { targetMin = existingTransform.targetMin; } if (targetMax === undefined && typeof existingTransform.targetMax === 'number') { targetMax = existingTransform.targetMax; } } } // Build the mapping object const mapping = { id: `mapping_${Date.now()}_${Math.floor(Math.random() * 1000)}`, profile: mappingEngine.getActiveProfile(), control: { type: selectedControl.type, deviceId: selectedControl.deviceId, id: selectedControl.controlId, name: selectedControl.name || `${selectedControl.type} ${selectedControl.controlId}`, // Add default input range based on control type input_min: 0, // Default input_max: 1 // Default }, target: target, mappingType: mappingType || detectMappingType(selectedControl.type, target), transform: { isInverted: !!isInverted, // Default transform info } }; // Set proper input range based on control type if (selectedControl.type === 'midi' || selectedControl.type === 'midi_cc') { mapping.control.input_min = 0; mapping.control.input_max = 127; } else if (selectedControl.type === 'gamepad_axis') { mapping.control.input_min = -1; mapping.control.input_max = 1; } // Add target range and step size to transform if provided if (targetMin !== undefined) mapping.transform.targetMin = targetMin; if (targetMax !== undefined) mapping.transform.targetMax = targetMax; if (stepSize !== undefined && (mapping.mappingType === "incremental" || mapping.mappingType === "decremental")) { mapping.transform.stepSize = stepSize; } // Add the mapping to the engine mappingEngine.addMapping(mapping); // Save mappings mappingEngine.saveMappings(); // Reset learning state learningState.completeLearning(); // Close dialog hideLearningDialog(); // Trigger highlight of the mapped parameter if (target.type === 'widget') { const node = app.graph.getNodeById(target.nodeId); if (node) { const canvas = app.canvas; if (canvas) { // Center on the node canvas.centerOnNode(node); // Emit event to highlight the parameter const { eventBus } = getContext(); if (eventBus) { eventBus.emit('learning:complete', { mapping: mapping, target: target, node: node }); } } } } return true; } catch (error) { console.error("Controller: Error completing mapping:", error); return false; } } /** * Initiates the Quick Learning process for a specific node widget. * @param {object} node - The LiteGraph node object. * @param {object} widget - The specific widget on the node. */ export function startQuickMapping(node, widget) { const learningState = LearningStateManager.getInstance(); if (!node || !widget) { console.error("ControlFreak: Quick Mapping requires a valid node and widget."); showNotification("Error starting quick map: Node or widget invalid", "error"); return; } try { // Cancel any existing learning process learningState.cancelLearning(); // Get default settings based on the widget type const defaultSettings = getDefaultSettingsForWidget(widget); // Start the quick learning state learningState.startQuickLearning(node, widget, defaultSettings); // Show a notification to the user showNotification(`Quick Mapping: Move controller for '${widget.name}'...`, "info", 5000); // Note: The actual mapping creation happens when the next controller input is received // This will be handled by the global controller input listener } catch (error) { console.error("ControlFreak: Error starting Quick Mapping:", error); showNotification("Error initiating quick mapping. Check console.", "error"); learningState.cancelLearning(); // Ensure state is reset on error } } // --- Moved Function: showDetectedInput --- /** * Show detected input during learning mode * @param {Object} controlInput - The formatted control input data * @param {Object} uiContext - UI context (optional, primarily for direct calls) * @returns {boolean} Whether the input was handled successfully */ function showDetectedInput(controlInput, uiContext = {}) { const { learningState } = getContext(); // Use getContext consistently if (!learningState) { console.error("ControlFreak: Learning state not available in showDetectedInput"); return false; } // Try to get the UI elements from the provided context first let inputContainer = uiContext.inputContainer; let mapButton = uiContext.mapButton; // If not provided, get from the learningDialog stored in the state if ((!inputContainer || !mapButton) && learningState.learningDialog) { const dialog = learningState.learningDialog; inputContainer = dialog.querySelector(".controller-mapping-input-list"); mapButton = dialog.querySelector(".controller-map-button"); } // Last resort: query the DOM directly if still not found (e.g., if dialog wasn't stored in state correctly) if (!inputContainer) { const dialog = document.querySelector(".controller-mapping-dialog"); if (dialog) { inputContainer = dialog.querySelector(".controller-mapping-input-list"); mapButton = dialog.querySelector(".controller-map-button"); } } // If UI elements are still not found, log error and exit if (!inputContainer) { console.error("ControlFreak: Input container could not be found for showDetectedInput."); return false; } const { deviceId, controlId, type, deviceName, rawValue, name } = controlInput; const displayName = name || deviceName || controlId; const instructionEl = inputContainer.querySelector(".controller-mapping-instruction"); if (instructionEl) { inputContainer.removeChild(instructionEl); } let controlElement = inputContainer.querySelector(`[data-control-id="${controlId}"][data-device-id="${deviceId}"]`); if (!controlElement) { controlElement = document.createElement("div"); controlElement.dataset.controlId = controlId; controlElement.dataset.deviceId = deviceId; const isButton = type?.includes("button"); controlElement.dataset.controlType = isButton ? "button" : "axis"; const controlTypeIndicator = isButton ? "🔘" : "↔️"; controlElement.innerHTML = `${controlTypeIndicator} ${displayName}: ${rawValue.toFixed(2)}`; // Apply necessary styles programmatically or ensure CSS handles this controlElement.style.marginBottom = "5px"; controlElement.style.padding = "8px"; controlElement.style.borderRadius = "3px"; controlElement.style.cursor = "pointer"; controlElement.style.transition = "background-color 0.3s"; controlElement.style.backgroundColor = "#2a2a3a"; // Consider using CSS variables controlElement.style.border = "1px solid #444"; // Consider using CSS variables const controlData = { deviceId: deviceId, controlId: controlId, name: displayName, type: type }; controlElement.addEventListener("click", () => { inputContainer.querySelectorAll("[data-control-id]").forEach(el => { el.style.backgroundColor = "#2a2a3a"; // Use CSS variable el.style.border = "1px solid #444"; // Use CSS variable }); controlElement.style.backgroundColor = "#3a546e"; // Use CSS variable for selected state controlElement.style.border = "1px solid #4c8eda"; // Use CSS variable if (learningState) { learningState.selectControl(controlData); } const rangeContainer = document.querySelector(".controller-mapping-range"); if (rangeContainer) rangeContainer.style.display = "flex"; const mappingTypeSelect = document.querySelector(".controller-mapping-type-select"); if (mappingTypeSelect) { if (isButton) { mappingTypeSelect.value = (learningState?.learningTarget?.type === "command") ? "trigger" : "toggle"; } else { mappingTypeSelect.value = "direct"; } mappingTypeSelect.dispatchEvent(new Event("change")); } if (mapButton) mapButton.disabled = false; }); inputContainer.appendChild(controlElement); } else { const valueSpan = controlElement.querySelector(".value"); if (valueSpan) valueSpan.textContent = rawValue.toFixed(2); } const isSelected = learningState?.selectedControl && learningState.selectedControl.deviceId === deviceId && learningState.selectedControl.controlId === controlId; const originalBackground = isSelected ? "#3a546e" : "#2a2a3a"; // Use CSS variables controlElement.style.backgroundColor = "#3a3a5c"; // Highlight color - use variable setTimeout(() => { controlElement.style.backgroundColor = originalBackground; }, 200); return true; } // --- Event Listener for Unhandled Controller Input --- function handleUnhandledInput(controlInput) { const { learningState, mappingEngine, eventBus } = getContext(); if (!learningState || !mappingEngine || !eventBus) { console.warn("ControlFreak: Learning state or mapping engine not ready for unhandled input."); return; } // --- Quick Learning Handling --- if (learningState.isQuickLearning) { const quickContext = learningState.getQuickLearningContext(); if (quickContext) { try { const { node, widget, defaultSettings } = quickContext; const newMapping = { id: `mapping_${Date.now()}_${Math.floor(Math.random() * 1000)}`, profile: mappingEngine.getActiveProfile(), control: { type: controlInput.type, deviceId: controlInput.deviceId, id: controlInput.controlId, name: controlInput.name || `${controlInput.type} ${controlInput.controlId}`, // Keep original input range logic input_min: (controlInput.type === 'midi' || controlInput.type === 'midi_cc') ? 0 : (controlInput.type === 'gamepad_axis' ? -1 : 0), input_max: (controlInput.type === 'midi' || controlInput.type === 'midi_cc') ? 127 : 1, }, target: { type: "widget", nodeId: node.id, widgetName: widget.name, // Include comfyClass for potential future use or consistency comfyClass: node.comfyClass }, // Use the default mapping type determined earlier mappingType: defaultSettings.mappingType, // Add transform with widget's min/max, default if undefined transform: { targetMin: widget.options?.min ?? 0, // Default to 0 if min is not defined targetMax: widget.options?.max ?? 1, // Default to 1 if max is not defined isInverted: false // Default to not inverted for quick map } }; // Add the mapping to the engine mappingEngine.addMapping(newMapping); // Save mappings mappingEngine.saveMappings(); // Reset learning state learningState.completeLearning(); // Close dialog hideLearningDialog(); // Trigger highlight of the mapped parameter if (node) { const canvas = app.canvas; if (canvas) { // Center on the node canvas.centerOnNode(node); // Emit event to highlight the parameter if (eventBus) { eventBus.emit('learning:complete', { mapping: newMapping, target: { type: "widget", nodeId: node.id, widgetName: widget.name, }, node: node }); } } } return true; } catch (error) { console.error("Controller: Error completing quick learning:", error); return false; } } } // --- Standard Learning Dialog Handling --- if (learningState) { if (learningState.isLearning && learningState.learningDialog) { try { showDetectedInput(controlInput); } catch (error) { console.error("ControlFreak: Error updating learning dialog from unhandled input:", error); } } } else { } return false; } // Register the handler with the event bus // Ensure eventBus is available when this module loads if (eventBus) { eventBus.on('controller:unhandledInput', handleUnhandledInput); } else { console.error("ControlFreak: EventBus not available to register unhandled input handler."); // Consider a mechanism to retry registration later if needed } // --- Existing Exported Functions --- (ensure these remain) export { startMappingNode, startMappingUI, startMappingCommand, cancelLearning, completeMapping, // Removed showDetectedInput as it's now internal // Keep startQuickMapping if it exists and is exported // startQuickMapping };