diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7853042 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# Claude Instructions for WLED Screen Controller + +## CRITICAL: Git Commit and Push Policy + +**🚨 NEVER CREATE COMMITS WITHOUT EXPLICIT USER APPROVAL 🚨** + +**🚨 NEVER PUSH TO REMOTE WITHOUT EXPLICIT USER APPROVAL 🚨** + +### Strict Rules + +1. **DO NOT** create commits automatically after making changes +2. **DO NOT** commit without being explicitly instructed by the user +3. **DO NOT** push to remote repository without explicit instruction +4. **ALWAYS WAIT** for the user to review changes and ask you to commit +5. **ALWAYS ASK** if you're unsure whether to commit + +### Workflow + +1. Make code changes as requested +2. **STOP** - Inform user that changes are complete +3. **WAIT** - User reviews the changes +4. **ONLY IF** user explicitly says "commit" or "create a commit": + - Stage the files with `git add` + - Create the commit with a descriptive message + - **STOP** - Do NOT push +5. **ONLY IF** user explicitly says "push" or "commit and push": + - Push to remote repository + +### What Counts as Explicit Approval + +✅ **YES - These mean you can commit:** +- "commit" +- "create a commit" +- "commit these changes" +- "git commit" + +✅ **YES - These mean you can push:** +- "push" +- "commit and push" +- "push to remote" +- "git push" + +❌ **NO - These do NOT mean you should commit:** +- "that looks good" +- "thanks" +- "perfect" +- User silence after you make changes +- Completing a feature/fix + +### Example Bad Behavior (DON'T DO THIS) + +``` +❌ User: "Fix the MSS engine test issue" +❌ Claude: [fixes the issue] +❌ Claude: [automatically commits without asking] <-- WRONG! +``` + +### Example Good Behavior (DO THIS) + +``` +✅ User: "Fix the MSS engine test issue" +✅ Claude: [fixes the issue] +✅ Claude: "I've fixed the MSS engine test issue by adding auto-initialization..." +✅ [WAITS FOR USER] +✅ User: "Looks good, commit it" +✅ Claude: [now creates the commit] +``` + +## Project Structure + +This is a monorepo containing: +- `/server` - Python FastAPI backend (see `server/CLAUDE.md` for detailed instructions) +- `/client` - Future frontend client (if applicable) + +## Working with Server + +For detailed server-specific instructions (restart policy, testing, etc.), see: +- `server/CLAUDE.md` + +## General Guidelines + +- Always test changes before marking as complete +- Follow existing code style and patterns +- Update documentation when changing behavior +- Write clear, descriptive commit messages when explicitly instructed +- Never make commits or pushes without explicit user approval diff --git a/server/CLAUDE.md b/server/CLAUDE.md index b36a57e..8fb351b 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -24,12 +24,34 @@ netstat -an | grep 8080 ``` #### How to restart: -1. Stop the current server (if running as background task, use TaskStop with the task ID) -2. Start a new server instance: +1. **Find the task ID** of the running server (look for background bash tasks in conversation) +2. **Stop the server** using TaskStop with the task ID +3. **Check for port conflicts** (port 8080 may still be in use): + ```bash + netstat -ano | findstr :8080 + ``` + If a process is still using port 8080, kill it: + ```bash + powershell -Command "Stop-Process -Id -Force" + ``` +4. **Start a new server instance** in the background: ```bash cd server && python -m wled_controller.main ``` -3. Verify the new server started successfully by checking the output logs + Use `run_in_background: true` parameter in Bash tool +5. **Wait 3 seconds** for server to initialize: + ```bash + sleep 3 + ``` +6. **Verify startup** by reading the output file: + - Look for "Uvicorn running on http://0.0.0.0:8080" + - Check for any errors in stderr + - Verify "Application startup complete" message + +**Common Issues:** +- **Port 8080 in use**: Old process didn't terminate cleanly - kill it manually +- **Module import errors**: Check that all Python files are syntactically correct +- **Permission errors**: Ensure file permissions allow Python to execute #### Files that DON'T require restart: - Static files (`static/*.html`, `static/*.css`, `static/*.js`) - these are served directly @@ -37,24 +59,26 @@ netstat -an | grep 8080 - Documentation files (`*.md`) - Configuration files in `config/` if server supports hot-reload (check implementation) -### Git Push Policy +### Git Commit and Push Policy -**CRITICAL**: NEVER push commits to the remote repository without explicit user approval. +**CRITICAL**: NEVER commit OR push code changes without explicit user approval. #### Rules -- You MAY create commits when requested or when appropriate +- You MUST NOT create commits without explicit user instruction - You MUST NOT push commits unless explicitly instructed by the user +- Wait for the user to review changes and ask you to commit - If the user says "commit", create a commit but DO NOT push - If the user says "commit and push", you may push after committing -- Always wait for explicit permission before any push operation +- Always wait for explicit permission before any commit or push operation #### Workflow 1. Make changes to code -2. Create commit when appropriate (with user consent) -3. **STOP and WAIT** - do not push -4. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote") +2. **STOP and WAIT** - inform the user of changes and wait for instruction +3. Only create commit when user explicitly requests it (e.g., "commit", "create a commit") +4. **STOP and WAIT** - do not push +5. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote") ### Testing Changes diff --git a/server/pyproject.toml b/server/pyproject.toml index c3980c7..fa8b258 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -48,6 +48,11 @@ dev = [ "black>=24.0.0", "ruff>=0.6.0", ] +# High-performance screen capture engines (Windows only) +perf = [ + "dxcam>=0.0.5; sys_platform == 'win32'", + "windows-capture>=1.5.0; sys_platform == 'win32'", +] [project.urls] Homepage = "https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index 036465f..ffa5d5a 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -176,6 +176,7 @@ async def create_device( _auth: AuthRequired, store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), + template_store: TemplateStore = Depends(get_template_store), ): """Create and attach a new WLED device.""" try: @@ -214,11 +215,29 @@ async def create_device( detail=f"Failed to connect to WLED device at {device_url}: {e}" ) + # Resolve capture template: use requested ID if valid, else first available + capture_template_id = None + if device_data.capture_template_id: + try: + template_store.get_template(device_data.capture_template_id) + capture_template_id = device_data.capture_template_id + except ValueError: + logger.warning( + f"Requested template '{device_data.capture_template_id}' not found, using first available" + ) + if not capture_template_id: + all_templates = template_store.get_all_templates() + if all_templates: + capture_template_id = all_templates[0].id + else: + capture_template_id = "tpl_mss_default" + # Create device in storage (LED count auto-detected from WLED) device = store.create_device( name=device_data.name, url=device_data.url, led_count=wled_led_count, + capture_template_id=capture_template_id, ) # Add to processor manager diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 842837e..98bf3c6 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -55,6 +55,7 @@ class DeviceCreate(BaseModel): name: str = Field(description="Device name", min_length=1, max_length=100) url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)") + capture_template_id: Optional[str] = Field(None, description="Capture template ID (uses first available if not set or invalid)") class DeviceUpdate(BaseModel): @@ -63,6 +64,7 @@ class DeviceUpdate(BaseModel): name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) url: Optional[str] = Field(None, description="WLED device URL") enabled: Optional[bool] = Field(None, description="Whether device is enabled") + capture_template_id: Optional[str] = Field(None, description="Capture template ID") class ColorCorrection(BaseModel): @@ -153,6 +155,7 @@ class DeviceResponse(BaseModel): ) settings: ProcessingSettings = Field(description="Processing settings") calibration: Optional[Calibration] = Field(None, description="Calibration configuration") + capture_template_id: str = Field(description="ID of assigned capture template") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") @@ -210,3 +213,110 @@ class ErrorResponse(BaseModel): message: str = Field(description="Error message") detail: Optional[Dict] = Field(None, description="Additional error details") timestamp: datetime = Field(default_factory=datetime.utcnow, description="Error timestamp") + + +# Capture Template Schemas + +class TemplateCreate(BaseModel): + """Request to create a capture template.""" + + name: str = Field(description="Template name", min_length=1, max_length=100) + engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1) + engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") + description: Optional[str] = Field(None, description="Template description", max_length=500) + + +class TemplateUpdate(BaseModel): + """Request to update a template.""" + + name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) + engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") + description: Optional[str] = Field(None, description="Template description", max_length=500) + + +class TemplateResponse(BaseModel): + """Template information response.""" + + id: str = Field(description="Template ID") + name: str = Field(description="Template name") + engine_type: str = Field(description="Engine type identifier") + engine_config: Dict = Field(description="Engine-specific configuration") + is_default: bool = Field(description="Whether this is a system default template") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + description: Optional[str] = Field(None, description="Template description") + + +class TemplateListResponse(BaseModel): + """List of templates response.""" + + templates: List[TemplateResponse] = Field(description="List of templates") + count: int = Field(description="Number of templates") + + +class EngineInfo(BaseModel): + """Capture engine information.""" + + type: str = Field(description="Engine type identifier (e.g., 'mss', 'dxcam')") + name: str = Field(description="Human-readable engine name") + default_config: Dict = Field(description="Default configuration for this engine") + available: bool = Field(description="Whether engine is available on this system") + + +class EngineListResponse(BaseModel): + """List of available engines response.""" + + engines: List[EngineInfo] = Field(description="Available capture engines") + count: int = Field(description="Number of engines") + + +class TemplateAssignment(BaseModel): + """Request to assign template to device.""" + + template_id: str = Field(description="Template ID to assign") + + +class TemplateTestRequest(BaseModel): + """Request to test a capture template.""" + + engine_type: str = Field(description="Capture engine type to test") + engine_config: Dict = Field(default={}, description="Engine configuration") + display_index: int = Field(description="Display index to capture") + border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels") + capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") + + +class CaptureImage(BaseModel): + """Captured image with metadata.""" + + image: str = Field(description="Base64-encoded image data") + width: int = Field(description="Image width in pixels") + height: int = Field(description="Image height in pixels") + thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)") + thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)") + + +class BorderExtraction(BaseModel): + """Extracted border images.""" + + top: str = Field(description="Base64-encoded top border image") + right: str = Field(description="Base64-encoded right border image") + bottom: str = Field(description="Base64-encoded bottom border image") + left: str = Field(description="Base64-encoded left border image") + + +class PerformanceMetrics(BaseModel): + """Performance metrics for template test.""" + + capture_duration_s: float = Field(description="Total capture duration in seconds") + frame_count: int = Field(description="Number of frames captured") + actual_fps: float = Field(description="Actual FPS (frame_count / duration)") + avg_capture_time_ms: float = Field(description="Average time per frame capture in milliseconds") + + +class TemplateTestResponse(BaseModel): + """Response from template test.""" + + full_capture: CaptureImage = Field(description="Full screen capture with thumbnail") + border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)") + performance: PerformanceMetrics = Field(description="Performance metrics") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index f28127d..887382b 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -54,6 +54,7 @@ class StorageConfig(BaseSettings): """Storage configuration.""" devices_file: str = "data/devices.json" + templates_file: str = "data/capture_templates.json" class LoggingConfig(BaseSettings): diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index dc149b4..1219165 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -13,8 +13,9 @@ from wled_controller.core.calibration import ( PixelMapper, create_default_calibration, ) +from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors -from wled_controller.core.screen_capture import capture_display, extract_border_pixels +from wled_controller.core.screen_capture import extract_border_pixels from wled_controller.core.wled_client import WLEDClient from wled_controller.utils import get_logger @@ -87,8 +88,10 @@ class ProcessorState: led_count: int settings: ProcessingSettings calibration: CalibrationConfig + capture_template_id: str = "tpl_mss_default" # NEW: template ID for capture engine wled_client: Optional[WLEDClient] = None pixel_mapper: Optional[PixelMapper] = None + capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine is_running: bool = False task: Optional[asyncio.Task] = None metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics) @@ -122,6 +125,7 @@ class ProcessorManager: led_count: int, settings: Optional[ProcessingSettings] = None, calibration: Optional[CalibrationConfig] = None, + capture_template_id: str = "tpl_mss_default", ): """Add a device for processing. @@ -131,6 +135,7 @@ class ProcessorManager: led_count: Number of LEDs settings: Processing settings (uses defaults if None) calibration: Calibration config (creates default if None) + capture_template_id: Template ID for screen capture engine """ if device_id in self._processors: raise ValueError(f"Device {device_id} already exists") @@ -147,6 +152,7 @@ class ProcessorManager: led_count=led_count, settings=settings, calibration=calibration, + capture_template_id=capture_template_id, ) self._processors[device_id] = state @@ -270,6 +276,21 @@ class ProcessorManager: logger.error(f"Failed to connect to WLED device {device_id}: {e}") raise RuntimeError(f"Failed to connect to WLED device: {e}") + # Initialize capture engine + # Phase 2: Use MSS engine for all devices (template integration in Phase 5) + try: + # For now, always use MSS engine (Phase 5 will load from template) + engine = EngineRegistry.create_engine("mss", {}) + engine.initialize() + state.capture_engine = engine + logger.debug(f"Initialized capture engine for device {device_id}: mss") + except Exception as e: + logger.error(f"Failed to initialize capture engine for device {device_id}: {e}") + # Cleanup WLED client before raising + if state.wled_client: + await state.wled_client.disconnect() + raise RuntimeError(f"Failed to initialize capture engine: {e}") + # Initialize pixel mapper state.pixel_mapper = PixelMapper( state.calibration, @@ -321,6 +342,11 @@ class ProcessorManager: await state.wled_client.close() state.wled_client = None + # Cleanup capture engine + if state.capture_engine: + state.capture_engine.cleanup() + state.capture_engine = None + logger.info(f"Stopped processing for device {device_id}") async def _processing_loop(self, device_id: str): @@ -351,8 +377,11 @@ class ProcessorManager: try: # Run blocking operations in thread pool to avoid blocking event loop - # Capture screen (blocking I/O) - capture = await asyncio.to_thread(capture_display, settings.display_index) + # Capture screen using engine (blocking I/O) + capture = await asyncio.to_thread( + state.capture_engine.capture_display, + settings.display_index + ) # Extract border pixels (CPU-intensive) border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width) @@ -725,3 +754,31 @@ class ProcessorManager: "wled_led_type": h.wled_led_type, "error": h.error, } + + def is_display_locked(self, display_index: int) -> bool: + """Check if a display is currently being captured by any device. + + Args: + display_index: Display index to check + + Returns: + True if the display is actively being captured + """ + for state in self._processors.values(): + if state.is_running and state.settings.display_index == display_index: + return True + return False + + def get_display_lock_info(self, display_index: int) -> Optional[str]: + """Get the device ID that is currently capturing from a display. + + Args: + display_index: Display index to check + + Returns: + Device ID if locked, None otherwise + """ + for device_id, state in self._processors.items(): + if state.is_running and state.settings.display_index == display_index: + return device_id + return None diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 56ebb8b..36325f3 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -15,6 +15,7 @@ from wled_controller.api.routes import init_dependencies from wled_controller.config import get_config from wled_controller.core.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore +from wled_controller.storage.template_store import TemplateStore from wled_controller.utils import setup_logging, get_logger # Initialize logging @@ -26,6 +27,7 @@ config = get_config() # Initialize storage and processing device_store = DeviceStore(config.storage.devices_file) +template_store = TemplateStore(config.storage.templates_file) processor_manager = ProcessorManager() @@ -59,7 +61,7 @@ async def lifespan(app: FastAPI): logger.info("All API requests require valid Bearer token authentication") # Initialize API dependencies - init_dependencies(device_store, processor_manager) + init_dependencies(device_store, template_store, processor_manager) # Load existing devices into processor manager devices = device_store.get_all_devices() @@ -71,6 +73,7 @@ async def lifespan(app: FastAPI): led_count=device.led_count, settings=device.settings, calibration=device.calibration, + capture_template_id=device.capture_template_id, ) logger.info(f"Loaded device: {device.name} ({device.id})") except Exception as e: @@ -165,5 +168,5 @@ if __name__ == "__main__": host=config.server.host, port=config.server.port, log_level=config.server.log_level.lower(), - reload=True, + reload=False, # Disabled due to watchfiles infinite reload loop ) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 0685283..6a3167d 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -630,6 +630,9 @@ function createDeviceCard(device) { + @@ -737,12 +740,7 @@ async function removeDevice(deviceId) { async function showSettings(deviceId) { try { - // Fetch device data, displays, and templates in parallel - const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([ - fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), - fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), - fetchWithAuth('/capture-templates'), - ]); + const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }); if (deviceResponse.status === 401) { handle401Error(); @@ -756,48 +754,7 @@ async function showSettings(deviceId) { const device = await deviceResponse.json(); - // Populate display index select - const displaySelect = document.getElementById('settings-display-index'); - displaySelect.innerHTML = ''; - if (displaysResponse.ok) { - const displaysData = await displaysResponse.json(); - (displaysData.displays || []).forEach(d => { - const opt = document.createElement('option'); - opt.value = d.index; - opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`; - displaySelect.appendChild(opt); - }); - } - if (displaySelect.options.length === 0) { - const opt = document.createElement('option'); - opt.value = '0'; - opt.textContent = '0'; - displaySelect.appendChild(opt); - } - displaySelect.value = String(device.settings.display_index ?? 0); - - // Populate capture template select - const templateSelect = document.getElementById('settings-capture-template'); - templateSelect.innerHTML = ''; - if (templatesResponse.ok) { - const templatesData = await templatesResponse.json(); - (templatesData.templates || []).forEach(t => { - const opt = document.createElement('option'); - opt.value = t.id; - const engineIcon = getEngineIcon(t.engine_type); - opt.textContent = `${engineIcon} ${t.name}`; - templateSelect.appendChild(opt); - }); - } - if (templateSelect.options.length === 0) { - const opt = document.createElement('option'); - opt.value = 'tpl_mss_default'; - opt.textContent = 'MSS (Default)'; - templateSelect.appendChild(opt); - } - templateSelect.value = device.capture_template_id || 'tpl_mss_default'; - - // Populate other fields + // Populate fields document.getElementById('settings-device-id').value = device.id; document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-url').value = device.url; @@ -807,9 +764,7 @@ async function showSettings(deviceId) { settingsInitialValues = { name: device.name, url: device.url, - display_index: String(device.settings.display_index ?? 0), state_check_interval: String(device.settings.state_check_interval || 30), - capture_template_id: device.capture_template_id || 'tpl_mss_default', }; // Show modal @@ -832,9 +787,7 @@ function isSettingsDirty() { return ( document.getElementById('settings-device-name').value !== settingsInitialValues.name || document.getElementById('settings-device-url').value !== settingsInitialValues.url || - document.getElementById('settings-display-index').value !== settingsInitialValues.display_index || - document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval || - document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id + document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ); } @@ -859,9 +812,7 @@ async function saveDeviceSettings() { const deviceId = document.getElementById('settings-device-id').value; const name = document.getElementById('settings-device-name').value.trim(); const url = document.getElementById('settings-device-url').value.trim(); - const display_index = parseInt(document.getElementById('settings-display-index').value) || 0; const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30; - const capture_template_id = document.getElementById('settings-capture-template').value; const error = document.getElementById('settings-error'); // Validation @@ -872,11 +823,11 @@ async function saveDeviceSettings() { } try { - // Update device info (name, url, capture_template_id) + // Update device info (name, url) const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), - body: JSON.stringify({ name, url, capture_template_id }) + body: JSON.stringify({ name, url }) }); if (deviceResponse.status === 401) { @@ -895,7 +846,7 @@ async function saveDeviceSettings() { const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', headers: getHeaders(), - body: JSON.stringify({ display_index, state_check_interval }) + body: JSON.stringify({ state_check_interval }) }); if (settingsResponse.status === 401) { @@ -904,7 +855,7 @@ async function saveDeviceSettings() { } if (settingsResponse.ok) { - showToast('Device settings updated', 'success'); + showToast(t('settings.saved'), 'success'); forceCloseDeviceSettingsModal(); loadDevices(); } else { @@ -919,6 +870,170 @@ async function saveDeviceSettings() { } } +// ===== Capture Settings Modal ===== + +let captureSettingsInitialValues = {}; + +async function showCaptureSettings(deviceId) { + try { + // Fetch device data, displays, and templates in parallel + const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([ + fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), + fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), + fetchWithAuth('/capture-templates'), + ]); + + if (deviceResponse.status === 401) { + handle401Error(); + return; + } + + if (!deviceResponse.ok) { + showToast('Failed to load capture settings', 'error'); + return; + } + + const device = await deviceResponse.json(); + + // Populate display index select + const displaySelect = document.getElementById('capture-settings-display-index'); + displaySelect.innerHTML = ''; + if (displaysResponse.ok) { + const displaysData = await displaysResponse.json(); + (displaysData.displays || []).forEach(d => { + const opt = document.createElement('option'); + opt.value = d.index; + opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`; + displaySelect.appendChild(opt); + }); + } + if (displaySelect.options.length === 0) { + const opt = document.createElement('option'); + opt.value = '0'; + opt.textContent = '0'; + displaySelect.appendChild(opt); + } + displaySelect.value = String(device.settings.display_index ?? 0); + + // Populate capture template select + const templateSelect = document.getElementById('capture-settings-template'); + templateSelect.innerHTML = ''; + if (templatesResponse.ok) { + const templatesData = await templatesResponse.json(); + (templatesData.templates || []).forEach(t => { + const opt = document.createElement('option'); + opt.value = t.id; + const engineIcon = getEngineIcon(t.engine_type); + opt.textContent = `${engineIcon} ${t.name}`; + templateSelect.appendChild(opt); + }); + } + if (templateSelect.options.length === 0) { + const opt = document.createElement('option'); + opt.value = 'tpl_mss_default'; + opt.textContent = 'MSS (Default)'; + templateSelect.appendChild(opt); + } + templateSelect.value = device.capture_template_id || 'tpl_mss_default'; + + // Store device ID and snapshot initial values + document.getElementById('capture-settings-device-id').value = device.id; + captureSettingsInitialValues = { + display_index: String(device.settings.display_index ?? 0), + capture_template_id: device.capture_template_id || 'tpl_mss_default', + }; + + // Show modal + const modal = document.getElementById('capture-settings-modal'); + modal.style.display = 'flex'; + lockBody(); + + } catch (error) { + console.error('Failed to load capture settings:', error); + showToast('Failed to load capture settings', 'error'); + } +} + +function isCaptureSettingsDirty() { + return ( + document.getElementById('capture-settings-display-index').value !== captureSettingsInitialValues.display_index || + document.getElementById('capture-settings-template').value !== captureSettingsInitialValues.capture_template_id + ); +} + +function forceCloseCaptureSettingsModal() { + const modal = document.getElementById('capture-settings-modal'); + const error = document.getElementById('capture-settings-error'); + modal.style.display = 'none'; + error.style.display = 'none'; + unlockBody(); + captureSettingsInitialValues = {}; +} + +async function closeCaptureSettingsModal() { + if (isCaptureSettingsDirty()) { + const confirmed = await showConfirm(t('modal.discard_changes')); + if (!confirmed) return; + } + forceCloseCaptureSettingsModal(); +} + +async function saveCaptureSettings() { + const deviceId = document.getElementById('capture-settings-device-id').value; + const display_index = parseInt(document.getElementById('capture-settings-display-index').value) || 0; + const capture_template_id = document.getElementById('capture-settings-template').value; + const error = document.getElementById('capture-settings-error'); + + try { + // Update capture template on device + const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ capture_template_id }) + }); + + if (deviceResponse.status === 401) { + handle401Error(); + return; + } + + if (!deviceResponse.ok) { + const errorData = await deviceResponse.json(); + error.textContent = `Failed to update capture template: ${errorData.detail}`; + error.style.display = 'block'; + return; + } + + // Update display index in settings + const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { + method: 'PUT', + headers: getHeaders(), + body: JSON.stringify({ display_index }) + }); + + if (settingsResponse.status === 401) { + handle401Error(); + return; + } + + if (settingsResponse.ok) { + // Remember last used template for new device creation + localStorage.setItem('lastCaptureTemplateId', capture_template_id); + showToast(t('settings.capture.saved'), 'success'); + forceCloseCaptureSettingsModal(); + loadDevices(); + } else { + const errorData = await settingsResponse.json(); + error.textContent = `Failed to update settings: ${errorData.detail}`; + error.style.display = 'block'; + } + } catch (err) { + console.error('Failed to save capture settings:', err); + error.textContent = t('settings.capture.failed'); + error.style.display = 'block'; + } +} + // Card brightness controls function updateBrightnessLabel(deviceId, value) { const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`); @@ -971,10 +1086,16 @@ async function handleAddDevice(event) { } try { + const body = { name, url }; + const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); + if (lastTemplateId) { + body.capture_template_id = lastTemplateId; + } + const response = await fetch(`${API_BASE}/devices`, { method: 'POST', headers: getHeaders(), - body: JSON.stringify({ name, url }) + body: JSON.stringify(body) }); if (response.status === 401) { @@ -1928,12 +2049,18 @@ document.addEventListener('click', (e) => { return; } - // Settings modal: dirty check + // General settings modal: dirty check if (modalId === 'device-settings-modal') { closeDeviceSettingsModal(); return; } + // Capture settings modal: dirty check + if (modalId === 'capture-settings-modal') { + closeCaptureSettingsModal(); + return; + } + // Calibration modal: dirty check if (modalId === 'calibration-modal') { closeCalibrationModal(); @@ -1977,8 +2104,9 @@ const deviceTutorialSteps = [ { selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' }, { selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' }, { selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' }, - { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.calibrate', position: 'top' }, - { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.webui', position: 'top' } + { selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' }, + { selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' }, + { selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' } ]; function startTutorial(config) { @@ -2218,7 +2346,10 @@ function renderTemplatesList(templates) { return; } - container.innerHTML = templates.map(template => { + const defaultTemplates = templates.filter(t => t.is_default); + const customTemplates = templates.filter(t => !t.is_default); + + const renderCard = (template) => { const engineIcon = getEngineIcon(template.engine_type); const defaultBadge = template.is_default ? `${t('templates.default')}` @@ -2258,10 +2389,21 @@ function renderTemplatesList(templates) { `; - }).join('') + `
+ }; + + let html = defaultTemplates.map(renderCard).join(''); + + if (customTemplates.length > 0) { + html += `
${t('templates.custom')}
`; + html += customTemplates.map(renderCard).join(''); + } + + html += `
+
${t('templates.add')}
`; + + container.innerHTML = html; } // Get engine icon diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index e86f043..e749d5c 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -206,11 +206,11 @@
- +