Compare commits
2 Commits
ebec1bd16e
...
4c21ae5178
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c21ae5178 | |||
| d229c9a0d5 |
@@ -1,46 +1,55 @@
|
||||
"""DDP (Distributed Display Protocol) client for WLED."""
|
||||
import asyncio
|
||||
import struct
|
||||
from typing import List, Tuple
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# DDP color order codes (matches WLED's order enum)
|
||||
COLOR_ORDER_MAP: Dict[int, Tuple[int, int, int]] = {
|
||||
0: (1, 0, 2), # GRB → indices into (R,G,B)
|
||||
1: (0, 1, 2), # RGB → no reorder
|
||||
2: (2, 0, 1), # BRG
|
||||
3: (0, 2, 1), # RBG
|
||||
4: (2, 1, 0), # BGR
|
||||
5: (1, 2, 0), # GBR
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusConfig:
|
||||
"""Physical LED bus/output configuration from WLED."""
|
||||
start: int # First LED index
|
||||
length: int # Number of LEDs on this bus
|
||||
color_order: int # Color order code (0=GRB, 1=RGB, etc.)
|
||||
|
||||
|
||||
class DDPClient:
|
||||
"""UDP DDP client for sending pixel data to WLED devices."""
|
||||
|
||||
DDP_PORT = 4048
|
||||
DDP_FLAGS_VER1 = 0x40 # VER=1, TIMECODE=0, STORAGE=0, REPLY=0, QUERY=0, PUSH=1
|
||||
DDP_FLAGS_VER1 = 0x40 # VER=1, all other flags 0
|
||||
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
|
||||
DDP_TYPE_RGB = 0x01
|
||||
|
||||
# Color order mappings: input (R,G,B) -> output indices
|
||||
COLOR_ORDER_MAP = {
|
||||
0: (1, 0, 2), # GRB: G=idx1, R=idx0, B=idx2
|
||||
1: (0, 1, 2), # RGB: R=idx0, G=idx1, B=idx2
|
||||
2: (2, 0, 1), # BRG: B=idx2, R=idx0, G=idx1
|
||||
3: (0, 2, 1), # RBG: R=idx0, B=idx2, G=idx1
|
||||
4: (2, 1, 0), # BGR: B=idx2, G=idx1, R=idx0
|
||||
5: (1, 2, 0), # GBR: G=idx1, B=idx2, R=idx0
|
||||
}
|
||||
|
||||
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False, color_order: int = 1):
|
||||
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
|
||||
"""Initialize DDP client.
|
||||
|
||||
Args:
|
||||
host: WLED device IP address or hostname
|
||||
port: DDP port (default 4048)
|
||||
rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED)
|
||||
color_order: Color order (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR)
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.rgbw = rgbw
|
||||
self.color_order = color_order
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
self._sequence = 0
|
||||
self._buses: List[BusConfig] = []
|
||||
|
||||
async def connect(self):
|
||||
"""Establish UDP connection."""
|
||||
@@ -64,32 +73,50 @@ class DDPClient:
|
||||
self._protocol = None
|
||||
logger.debug(f"Closed DDP connection to {self.host}:{self.port}")
|
||||
|
||||
def set_buses(self, buses: List[BusConfig]) -> None:
|
||||
"""Set WLED bus configurations for per-bus color order reordering.
|
||||
|
||||
Args:
|
||||
buses: List of BusConfig from WLED device
|
||||
"""
|
||||
self._buses = buses
|
||||
for bus in buses:
|
||||
order_name = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
|
||||
logger.info(
|
||||
f"DDP bus: LEDs {bus.start}-{bus.start + bus.length - 1}, "
|
||||
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
|
||||
)
|
||||
|
||||
def _build_ddp_packet(
|
||||
self,
|
||||
rgb_data: bytes,
|
||||
offset: int = 0,
|
||||
sequence: int = 1
|
||||
sequence: int = 1,
|
||||
push: bool = False,
|
||||
) -> bytes:
|
||||
"""Build a DDP packet.
|
||||
|
||||
DDP packet format (10-byte header + data):
|
||||
- Byte 0: Flags (0x40 = VER1, PUSH)
|
||||
- Byte 0: Flags (VER1 | PUSH on last packet)
|
||||
- Byte 1: Sequence number
|
||||
- Byte 2: Data type (0x01 = RGB)
|
||||
- Byte 3: Source/Destination ID
|
||||
- Bytes 4-7: Data offset in bytes (4-byte little-endian)
|
||||
- Bytes 8-9: Data length in bytes (2-byte big-endian)
|
||||
- Bytes 4-7: Data offset (4 bytes, big-endian)
|
||||
- Bytes 8-9: Data length (2 bytes, big-endian)
|
||||
- Bytes 10+: Pixel data
|
||||
|
||||
Args:
|
||||
rgb_data: RGB pixel data as bytes
|
||||
offset: Byte offset (pixel_index * 3)
|
||||
sequence: Sequence number (0-255)
|
||||
push: True for the last packet of a frame
|
||||
|
||||
Returns:
|
||||
Complete DDP packet as bytes
|
||||
"""
|
||||
flags = self.DDP_FLAGS_VER1
|
||||
if push:
|
||||
flags |= self.DDP_FLAGS_PUSH
|
||||
data_type = self.DDP_TYPE_RGB
|
||||
source_id = 0x01
|
||||
data_len = len(rgb_data)
|
||||
@@ -107,6 +134,40 @@ class DDPClient:
|
||||
|
||||
return header + rgb_data
|
||||
|
||||
def _reorder_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Apply per-bus color order reordering.
|
||||
|
||||
WLED may not apply per-bus color order conversion for DDP data on
|
||||
all buses (observed in multi-bus setups). We reorder pixel channels
|
||||
here so the hardware receives the correct byte order directly.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples in standard RGB order
|
||||
|
||||
Returns:
|
||||
List of reordered tuples matching each bus's hardware color order
|
||||
"""
|
||||
if not self._buses:
|
||||
return pixels
|
||||
|
||||
result = list(pixels)
|
||||
for bus in self._buses:
|
||||
order_map = COLOR_ORDER_MAP.get(bus.color_order)
|
||||
if not order_map or order_map == (0, 1, 2):
|
||||
continue # RGB order = no reordering needed
|
||||
|
||||
start = bus.start
|
||||
end = min(bus.start + bus.length, len(result))
|
||||
for i in range(start, end):
|
||||
r, g, b = result[i]
|
||||
rgb = (r, g, b)
|
||||
result[i] = (rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]])
|
||||
|
||||
return result
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
@@ -128,21 +189,20 @@ class DDPClient:
|
||||
raise RuntimeError("DDP client not connected")
|
||||
|
||||
try:
|
||||
# Get color order mapping
|
||||
order_map = self.COLOR_ORDER_MAP.get(self.color_order, (0, 1, 2))
|
||||
|
||||
# Convert pixels to flat RGB or RGBW bytes with color reordering
|
||||
# Send plain RGB — WLED handles per-bus color order conversion
|
||||
# internally when outputting to hardware.
|
||||
bpp = 4 if self.rgbw else 3 # bytes per pixel
|
||||
pixel_bytes = bytearray()
|
||||
for r, g, b in pixels:
|
||||
# Reorder color channels based on WLED configuration
|
||||
rgb = [int(r), int(g), int(b)]
|
||||
reordered = [rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]]]
|
||||
pixel_bytes.extend(reordered)
|
||||
pixel_bytes.extend((int(r), int(g), int(b)))
|
||||
if self.rgbw:
|
||||
pixel_bytes.append(0) # White channel = 0
|
||||
|
||||
total_bytes = len(pixel_bytes)
|
||||
bytes_per_packet = max_packet_size - 10 # Account for 10-byte header
|
||||
# Align payload to full pixels (multiple of bpp) to avoid splitting
|
||||
# a pixel's channels across packets
|
||||
max_payload = max_packet_size - 10 # 10-byte header
|
||||
bytes_per_packet = (max_payload // bpp) * bpp
|
||||
|
||||
# Split into multiple packets if needed
|
||||
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
||||
@@ -156,17 +216,22 @@ class DDPClient:
|
||||
start = i * bytes_per_packet
|
||||
end = min(start + bytes_per_packet, total_bytes)
|
||||
chunk = bytes(pixel_bytes[start:end])
|
||||
is_last = (i == num_packets - 1)
|
||||
|
||||
# Increment sequence number
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
|
||||
# Build and send packet
|
||||
packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence)
|
||||
# Build and send packet (set PUSH on last packet)
|
||||
packet = self._build_ddp_packet(
|
||||
chunk, offset=start,
|
||||
sequence=self._sequence, push=is_last,
|
||||
)
|
||||
self._transport.sendto(packet)
|
||||
|
||||
logger.debug(
|
||||
f"Sent DDP packet {i+1}/{num_packets}: "
|
||||
f"{len(chunk)} bytes at offset {start}"
|
||||
f"{' [PUSH]' if is_last else ''}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""WLED client for controlling LED devices via HTTP or DDP."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.core.ddp_client import DDPClient
|
||||
from wled_controller.core.ddp_client import BusConfig, DDPClient
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -26,6 +26,7 @@ class WLEDInfo:
|
||||
ip: str
|
||||
rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel)
|
||||
color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR
|
||||
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
|
||||
|
||||
|
||||
class WLEDClient:
|
||||
@@ -100,8 +101,10 @@ class WLEDClient:
|
||||
|
||||
# Create DDP client if needed
|
||||
if self.use_ddp:
|
||||
# DDP always uses RGB mode (3 bytes/LED) - WLED handles RGBW and color order internally
|
||||
self._ddp_client = DDPClient(self.host, rgbw=False, color_order=1)
|
||||
self._ddp_client = DDPClient(self.host, rgbw=False)
|
||||
# Pass per-bus config so DDP client can apply per-bus color reordering
|
||||
if info.buses:
|
||||
self._ddp_client.set_buses(info.buses)
|
||||
await self._ddp_client.connect()
|
||||
|
||||
# Turn on the device and disable Audio Reactive mode for DDP
|
||||
@@ -230,14 +233,32 @@ class WLEDClient:
|
||||
data = await self._request("GET", "/json/info")
|
||||
leds_info = data.get("leds", {})
|
||||
|
||||
# Get LED configuration for color order
|
||||
# Get LED configuration for color order and per-bus info
|
||||
cfg_data = await self._request("GET", "/json/cfg")
|
||||
color_order = 1 # Default to RGB
|
||||
buses: List[BusConfig] = []
|
||||
if "hw" in cfg_data and "led" in cfg_data["hw"]:
|
||||
led_cfg = cfg_data["hw"]["led"]
|
||||
if "ins" in led_cfg and len(led_cfg["ins"]) > 0:
|
||||
ins_list = led_cfg.get("ins", [])
|
||||
if ins_list:
|
||||
# Use color order from first LED strip
|
||||
color_order = led_cfg["ins"][0].get("order", 1)
|
||||
color_order = ins_list[0].get("order", 1)
|
||||
|
||||
# Parse all buses for per-segment color reordering
|
||||
order_names = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
|
||||
for idx, bus_cfg in enumerate(ins_list):
|
||||
bus = BusConfig(
|
||||
start=bus_cfg.get("start", 0),
|
||||
length=bus_cfg.get("len", 0),
|
||||
color_order=bus_cfg.get("order", 1),
|
||||
)
|
||||
buses.append(bus)
|
||||
logger.info(
|
||||
f"WLED bus {idx}: LEDs {bus.start}-{bus.start + bus.length - 1}, "
|
||||
f"pin={bus_cfg.get('pin', '?')}, "
|
||||
f"order={order_names.get(bus.color_order, '?')} ({bus.color_order}), "
|
||||
f"type={bus_cfg.get('type', '?')}"
|
||||
)
|
||||
|
||||
return WLEDInfo(
|
||||
name=data.get("name", "Unknown"),
|
||||
@@ -247,8 +268,9 @@ class WLEDClient:
|
||||
product=data.get("product", "FOSS"),
|
||||
mac=data.get("mac", ""),
|
||||
ip=data.get("ip", ""),
|
||||
rgbw=leds_info.get("rgbw", False), # Auto-detect RGBW vs RGB
|
||||
color_order=color_order, # Auto-detect color order (0=GRB, 1=RGB, etc.)
|
||||
rgbw=leds_info.get("rgbw", False),
|
||||
color_order=color_order,
|
||||
buses=buses,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,6 +2,16 @@ const API_BASE = '/api/v1';
|
||||
let refreshInterval = null;
|
||||
let apiKey = null;
|
||||
|
||||
// Toggle hint description visibility next to a label
|
||||
function toggleHint(btn) {
|
||||
const hint = btn.closest('.label-row').nextElementSibling;
|
||||
if (hint && hint.classList.contains('input-hint')) {
|
||||
const visible = hint.style.display !== 'none';
|
||||
hint.style.display = visible ? 'none' : 'block';
|
||||
btn.classList.toggle('active', !visible);
|
||||
}
|
||||
}
|
||||
|
||||
// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
|
||||
// Prevents accidental close when user drags text selection outside the dialog.
|
||||
function setupBackdropClose(modal, closeFn) {
|
||||
|
||||
@@ -203,15 +203,21 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
|
||||
<div class="label-row">
|
||||
<label for="settings-device-url" data-i18n="device.url">WLED URL:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
||||
<input type="url" id="settings-device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
|
||||
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||
<div class="label-row">
|
||||
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
|
||||
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
||||
<small class="input-hint" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="error-message" style="display: none;"></div>
|
||||
@@ -236,35 +242,47 @@
|
||||
<input type="hidden" id="stream-selector-device-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
|
||||
<select id="stream-selector-stream"></select>
|
||||
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
|
||||
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
|
||||
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
|
||||
<small class="input-hint" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
||||
<select id="stream-selector-interpolation">
|
||||
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
|
||||
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
|
||||
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
|
||||
</select>
|
||||
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="stream-selector-smoothing">
|
||||
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
|
||||
<span id="stream-selector-smoothing-value">0.3</span>
|
||||
</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-selector-smoothing">
|
||||
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
|
||||
<span id="stream-selector-smoothing-value">0.3</span>
|
||||
</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
||||
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
|
||||
<small class="input-hint" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
||||
</div>
|
||||
|
||||
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
||||
@@ -290,7 +308,11 @@
|
||||
Please enter your API key to authenticate and access the WLED Grab.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
|
||||
<div class="label-row">
|
||||
<label for="api-key-input" data-i18n="auth.label">API Key:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
|
||||
<div class="password-input-wrapper">
|
||||
<input
|
||||
type="password"
|
||||
@@ -303,7 +325,6 @@
|
||||
👁️
|
||||
</button>
|
||||
</div>
|
||||
<small class="input-hint" data-i18n="auth.hint">Your API key will be stored securely in your browser's local storage.</small>
|
||||
</div>
|
||||
<div id="api-key-error" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
@@ -380,7 +401,11 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
|
||||
<div class="label-row">
|
||||
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="templates.engine.hint">Select the screen capture technology to use</small>
|
||||
<select id="template-engine" onchange="onEngineChange()" required>
|
||||
<option value="" data-i18n="templates.engine.select">Select an engine...</option>
|
||||
</select>
|
||||
@@ -506,18 +531,30 @@
|
||||
<!-- Raw stream fields -->
|
||||
<div id="stream-raw-fields">
|
||||
<div class="form-group">
|
||||
<label data-i18n="streams.display">Display:</label>
|
||||
<div class="label-row">
|
||||
<label data-i18n="streams.display">Display:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
|
||||
<input type="hidden" id="stream-display-index" value="">
|
||||
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value)">
|
||||
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.capture_template.hint">Engine template defining how the screen is captured</small>
|
||||
<select id="stream-capture-template"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.target_fps.hint">Target frames per second for capture (10-90)</small>
|
||||
<div class="slider-row">
|
||||
<input type="range" id="stream-target-fps" min="10" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
|
||||
<span id="stream-target-fps-value" class="slider-value">30</span>
|
||||
@@ -528,11 +565,19 @@
|
||||
<!-- Processed stream fields -->
|
||||
<div id="stream-processed-fields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="stream-source" data-i18n="streams.source">Source Stream:</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-source" data-i18n="streams.source">Source Stream:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.source.hint">The stream to apply processing filters to</small>
|
||||
<select id="stream-source"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.pp_template.hint">Filter template to apply to the source stream</small>
|
||||
<select id="stream-pp-template"></select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,9 +585,12 @@
|
||||
<!-- Static image fields -->
|
||||
<div id="stream-static-image-fields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
|
||||
<div class="label-row">
|
||||
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
|
||||
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
|
||||
<small class="form-hint" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
|
||||
</div>
|
||||
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
|
||||
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"templates.description.label": "Description (optional):",
|
||||
"templates.description.placeholder": "Describe this template...",
|
||||
"templates.engine": "Capture Engine:",
|
||||
"templates.engine.hint": "Select the screen capture technology to use",
|
||||
"templates.engine.select": "Select an engine...",
|
||||
"templates.engine.unavailable": "Unavailable",
|
||||
"templates.engine.unavailable.hint": "This engine is not available on your system",
|
||||
@@ -215,10 +216,15 @@
|
||||
"streams.type.raw": "Screen Capture",
|
||||
"streams.type.processed": "Processed",
|
||||
"streams.display": "Display:",
|
||||
"streams.display.hint": "Which screen to capture",
|
||||
"streams.capture_template": "Engine Template:",
|
||||
"streams.capture_template.hint": "Engine template defining how the screen is captured",
|
||||
"streams.target_fps": "Target FPS:",
|
||||
"streams.target_fps.hint": "Target frames per second for capture (10-90)",
|
||||
"streams.source": "Source Stream:",
|
||||
"streams.source.hint": "The stream to apply processing filters to",
|
||||
"streams.pp_template": "Filter Template:",
|
||||
"streams.pp_template.hint": "Filter template to apply to the source stream",
|
||||
"streams.description_label": "Description (optional):",
|
||||
"streams.description_placeholder": "Describe this stream...",
|
||||
"streams.created": "Stream created successfully",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"templates.description.label": "Описание (необязательно):",
|
||||
"templates.description.placeholder": "Опишите этот шаблон...",
|
||||
"templates.engine": "Движок Захвата:",
|
||||
"templates.engine.hint": "Выберите технологию захвата экрана",
|
||||
"templates.engine.select": "Выберите движок...",
|
||||
"templates.engine.unavailable": "Недоступен",
|
||||
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
|
||||
@@ -215,10 +216,15 @@
|
||||
"streams.type.raw": "Захват экрана",
|
||||
"streams.type.processed": "Обработанный",
|
||||
"streams.display": "Дисплей:",
|
||||
"streams.display.hint": "Какой экран захватывать",
|
||||
"streams.capture_template": "Шаблон Движка:",
|
||||
"streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана",
|
||||
"streams.target_fps": "Целевой FPS:",
|
||||
"streams.target_fps.hint": "Целевое количество кадров в секунду (10-90)",
|
||||
"streams.source": "Исходный Поток:",
|
||||
"streams.source.hint": "Поток, к которому применяются фильтры обработки",
|
||||
"streams.pp_template": "Шаблон Фильтра:",
|
||||
"streams.pp_template.hint": "Шаблон фильтра для применения к исходному потоку",
|
||||
"streams.description_label": "Описание (необязательно):",
|
||||
"streams.description_placeholder": "Опишите этот поток...",
|
||||
"streams.created": "Поток успешно создан",
|
||||
|
||||
@@ -966,9 +966,49 @@ input:-webkit-autofill:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.label-row label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hint-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hint-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hint-toggle.active {
|
||||
opacity: 1;
|
||||
color: var(--primary-color, #4CAF50);
|
||||
border-color: var(--primary-color, #4CAF50);
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
margin: 0 0 6px 0;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user