Add transient preview WS endpoint and test button in CSS editor modal
- Add /color-strip-sources/preview/ws endpoint for ad-hoc source preview
without saving (accepts full config JSON, streams RGB frames)
- Add test preview button (flask icon) to CSS editor modal footer
- For self-contained types (static, gradient, color_cycle, effect, daylight,
candlelight), always previews current form values via transient WS
- For non-previewable types, falls back to saved source test endpoint
- Fix route ordering: preview/ws registered before {source_id}/ws
- Fix css-test-led-control label alignment (display: inline globally)
- Add gradient onChange callback for future live-update support
- Add i18n keys for preview (en/ru/zh)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -488,6 +488,186 @@ async def os_notification_history(_auth: AuthRequired):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transient Preview WebSocket ────────────────────────────────────────
|
||||||
|
|
||||||
|
_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/api/v1/color-strip-sources/preview/ws")
|
||||||
|
async def preview_color_strip_ws(
|
||||||
|
websocket: WebSocket,
|
||||||
|
token: str = Query(""),
|
||||||
|
led_count: int = Query(100),
|
||||||
|
fps: int = Query(20),
|
||||||
|
):
|
||||||
|
"""Transient preview WebSocket — stream frames for an ad-hoc source config.
|
||||||
|
|
||||||
|
Auth via ``?token=<api_key>&led_count=100&fps=20``.
|
||||||
|
|
||||||
|
After accepting, waits for a text message containing the full source config
|
||||||
|
JSON (must include ``source_type``). Responds with a JSON metadata message,
|
||||||
|
then streams binary RGB frames at the requested FPS.
|
||||||
|
|
||||||
|
Subsequent text messages are treated as config updates: if the source_type
|
||||||
|
changed the old stream is replaced; otherwise ``update_source()`` is used.
|
||||||
|
"""
|
||||||
|
from wled_controller.api.auth import verify_ws_token
|
||||||
|
if not verify_ws_token(token):
|
||||||
|
await websocket.close(code=4001, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
led_count = max(1, min(1000, led_count))
|
||||||
|
fps = max(1, min(60, fps))
|
||||||
|
frame_interval = 1.0 / fps
|
||||||
|
|
||||||
|
stream = None
|
||||||
|
clock_id = None
|
||||||
|
current_source_type = None
|
||||||
|
|
||||||
|
# Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_sync_clock_manager():
|
||||||
|
"""Return the SyncClockManager if available."""
|
||||||
|
try:
|
||||||
|
mgr = get_processor_manager()
|
||||||
|
return getattr(mgr, "_sync_clock_manager", None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_source(config: dict):
|
||||||
|
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
|
||||||
|
from wled_controller.storage.color_strip_source import ColorStripSource
|
||||||
|
config.setdefault("id", "__preview__")
|
||||||
|
config.setdefault("name", "__preview__")
|
||||||
|
return ColorStripSource.from_dict(config)
|
||||||
|
|
||||||
|
def _create_stream(source):
|
||||||
|
"""Instantiate and start the appropriate stream class for *source*."""
|
||||||
|
from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
|
||||||
|
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
|
||||||
|
if not stream_cls:
|
||||||
|
raise ValueError(f"Unsupported preview source_type: {source.source_type}")
|
||||||
|
s = stream_cls(source)
|
||||||
|
if hasattr(s, "configure"):
|
||||||
|
s.configure(led_count)
|
||||||
|
# Inject sync clock if requested
|
||||||
|
cid = getattr(source, "clock_id", None)
|
||||||
|
if cid and hasattr(s, "set_clock"):
|
||||||
|
scm = _get_sync_clock_manager()
|
||||||
|
if scm:
|
||||||
|
try:
|
||||||
|
clock_rt = scm.acquire(cid)
|
||||||
|
s.set_clock(clock_rt)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Preview: could not acquire clock {cid}: {e}")
|
||||||
|
cid = None
|
||||||
|
else:
|
||||||
|
cid = None
|
||||||
|
else:
|
||||||
|
cid = None
|
||||||
|
s.start()
|
||||||
|
return s, cid
|
||||||
|
|
||||||
|
def _stop_stream(s, cid):
|
||||||
|
"""Stop a stream and release its clock."""
|
||||||
|
try:
|
||||||
|
s.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if cid:
|
||||||
|
scm = _get_sync_clock_manager()
|
||||||
|
if scm:
|
||||||
|
try:
|
||||||
|
scm.release(cid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _send_meta(source_type: str):
|
||||||
|
meta = {"type": "meta", "led_count": led_count, "source_type": source_type}
|
||||||
|
await websocket.send_text(_json.dumps(meta))
|
||||||
|
|
||||||
|
# Wait for initial config ────────────────────────────────────────────
|
||||||
|
|
||||||
|
try:
|
||||||
|
initial_text = await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = _json.loads(initial_text)
|
||||||
|
source_type = config.get("source_type")
|
||||||
|
if source_type not in _PREVIEW_ALLOWED_TYPES:
|
||||||
|
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
|
||||||
|
await websocket.close(code=4003, reason="Invalid source_type")
|
||||||
|
return
|
||||||
|
source = _build_source(config)
|
||||||
|
stream, clock_id = _create_stream(source)
|
||||||
|
current_source_type = source_type
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Preview WS: bad initial config: {e}")
|
||||||
|
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||||
|
await websocket.close(code=4003, reason=str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
await _send_meta(current_source_type)
|
||||||
|
logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}")
|
||||||
|
|
||||||
|
# Frame loop ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Non-blocking check for incoming config updates
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
msg = None
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
|
||||||
|
if msg is not None:
|
||||||
|
try:
|
||||||
|
new_config = _json.loads(msg)
|
||||||
|
new_type = new_config.get("source_type")
|
||||||
|
if new_type not in _PREVIEW_ALLOWED_TYPES:
|
||||||
|
await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"}))
|
||||||
|
continue
|
||||||
|
new_source = _build_source(new_config)
|
||||||
|
if new_type != current_source_type:
|
||||||
|
# Source type changed — recreate stream
|
||||||
|
_stop_stream(stream, clock_id)
|
||||||
|
stream, clock_id = _create_stream(new_source)
|
||||||
|
current_source_type = new_type
|
||||||
|
else:
|
||||||
|
stream.update_source(new_source)
|
||||||
|
if hasattr(stream, "configure"):
|
||||||
|
stream.configure(led_count)
|
||||||
|
await _send_meta(current_source_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Preview WS: bad config update: {e}")
|
||||||
|
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
|
||||||
|
|
||||||
|
# Send frame
|
||||||
|
colors = stream.get_latest_colors()
|
||||||
|
if colors is not None:
|
||||||
|
await websocket.send_bytes(colors.tobytes())
|
||||||
|
else:
|
||||||
|
# Stream hasn't produced a frame yet — send black
|
||||||
|
await websocket.send_bytes(b'\x00' * led_count * 3)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Preview WS error: {e}")
|
||||||
|
finally:
|
||||||
|
if stream is not None:
|
||||||
|
_stop_stream(stream, clock_id)
|
||||||
|
logger.info("Preview WS disconnected")
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
|
@router.websocket("/api/v1/color-strip-sources/{source_id}/ws")
|
||||||
async def css_api_input_ws(
|
async def css_api_input_ws(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
|
|||||||
@@ -342,6 +342,11 @@
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.css-test-led-control label {
|
||||||
|
display: inline;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.css-test-led-input {
|
.css-test-led-input {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
flex: 0 0 70px;
|
flex: 0 0 70px;
|
||||||
@@ -1263,3 +1268,4 @@
|
|||||||
height: 26px;
|
height: 26px;
|
||||||
flex: 0 0 26px;
|
flex: 0 0 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import {
|
|||||||
applyGradientPreset,
|
applyGradientPreset,
|
||||||
cloneColorStrip,
|
cloneColorStrip,
|
||||||
toggleCSSOverlay,
|
toggleCSSOverlay,
|
||||||
|
previewCSSFromEditor,
|
||||||
copyEndpointUrl,
|
copyEndpointUrl,
|
||||||
onNotificationFilterModeChange,
|
onNotificationFilterModeChange,
|
||||||
notificationAddAppColor, notificationRemoveAppColor,
|
notificationAddAppColor, notificationRemoveAppColor,
|
||||||
@@ -425,6 +426,7 @@ Object.assign(window, {
|
|||||||
applyGradientPreset,
|
applyGradientPreset,
|
||||||
cloneColorStrip,
|
cloneColorStrip,
|
||||||
toggleCSSOverlay,
|
toggleCSSOverlay,
|
||||||
|
previewCSSFromEditor,
|
||||||
copyEndpointUrl,
|
copyEndpointUrl,
|
||||||
onNotificationFilterModeChange,
|
onNotificationFilterModeChange,
|
||||||
notificationAddAppColor, notificationRemoveAppColor,
|
notificationAddAppColor, notificationRemoveAppColor,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_OVERLAY,
|
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_OVERLAY,
|
||||||
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||||
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||||
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_EYE,
|
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
|
||||||
ICON_SUN_DIM, ICON_WARNING,
|
ICON_SUN_DIM, ICON_WARNING,
|
||||||
} from '../core/icons.js';
|
} from '../core/icons.js';
|
||||||
import * as P from '../core/icon-paths.js';
|
import * as P from '../core/icon-paths.js';
|
||||||
@@ -1354,7 +1354,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
|||||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
|
||||||
: '';
|
: '';
|
||||||
const testPreviewBtn = !isApiInput
|
const testPreviewBtn = !isApiInput
|
||||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_EYE}</button>`
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return wrapCard({
|
return wrapCard({
|
||||||
@@ -2006,6 +2006,65 @@ export async function stopCSSOverlay(cssId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Editor Preview (opens existing test modal with transient WS) ──── */
|
||||||
|
|
||||||
|
const _PREVIEW_TYPES = new Set(['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight']);
|
||||||
|
|
||||||
|
/** Collect current editor form state into a source config for transient preview. */
|
||||||
|
function _collectPreviewConfig() {
|
||||||
|
const sourceType = document.getElementById('css-editor-type').value;
|
||||||
|
if (!_PREVIEW_TYPES.has(sourceType)) return null;
|
||||||
|
let config;
|
||||||
|
if (sourceType === 'static') {
|
||||||
|
config = { source_type: 'static', color: hexToRgbArray(document.getElementById('css-editor-color').value), animation: _getAnimationPayload() };
|
||||||
|
} else if (sourceType === 'gradient') {
|
||||||
|
const stops = getGradientStops();
|
||||||
|
if (stops.length < 2) return null;
|
||||||
|
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload() };
|
||||||
|
} else if (sourceType === 'color_cycle') {
|
||||||
|
const colors = _colorCycleGetColors();
|
||||||
|
if (colors.length < 2) return null;
|
||||||
|
config = { source_type: 'color_cycle', colors };
|
||||||
|
} else if (sourceType === 'effect') {
|
||||||
|
config = { source_type: 'effect', effect_type: document.getElementById('css-editor-effect-type').value, palette: document.getElementById('css-editor-effect-palette').value, intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value), scale: parseFloat(document.getElementById('css-editor-effect-scale').value), mirror: document.getElementById('css-editor-effect-mirror').checked };
|
||||||
|
if (config.effect_type === 'meteor') { const hex = document.getElementById('css-editor-effect-color').value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
|
||||||
|
} else if (sourceType === 'daylight') {
|
||||||
|
config = { source_type: 'daylight', speed: parseFloat(document.getElementById('css-editor-daylight-speed').value), use_real_time: document.getElementById('css-editor-daylight-real-time').checked, latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value) };
|
||||||
|
} else if (sourceType === 'candlelight') {
|
||||||
|
config = { source_type: 'candlelight', color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value), intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value), num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3, speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value) };
|
||||||
|
}
|
||||||
|
const clockEl = document.getElementById('css-editor-clock');
|
||||||
|
if (clockEl && clockEl.value) config.clock_id = clockEl.value;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the existing Test Preview modal from the CSS editor.
|
||||||
|
* For saved sources, uses the normal test endpoint.
|
||||||
|
* For unsaved/self-contained types, uses the transient preview endpoint.
|
||||||
|
*/
|
||||||
|
export function previewCSSFromEditor() {
|
||||||
|
// Always use transient preview with current form values
|
||||||
|
const config = _collectPreviewConfig();
|
||||||
|
if (!config) {
|
||||||
|
// Non-previewable type (picture, composite, etc.) — fall back to saved source test
|
||||||
|
const cssId = document.getElementById('css-editor-id').value;
|
||||||
|
if (cssId) { testColorStrip(cssId); return; }
|
||||||
|
showToast(t('color_strip.preview.unsupported'), 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cssTestCSPTMode = false;
|
||||||
|
_cssTestCSPTId = null;
|
||||||
|
_cssTestTransientConfig = config;
|
||||||
|
const csptGroup = document.getElementById('css-test-cspt-input-group');
|
||||||
|
if (csptGroup) csptGroup.style.display = 'none';
|
||||||
|
_openTestModal('__preview__');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store transient config so _cssTestConnect and applyCssTestSettings can use it. */
|
||||||
|
let _cssTestTransientConfig = null;
|
||||||
|
|
||||||
/* ── Test / Preview ───────────────────────────────────────────── */
|
/* ── Test / Preview ───────────────────────────────────────────── */
|
||||||
|
|
||||||
const _CSS_TEST_LED_KEY = 'css_test_led_count';
|
const _CSS_TEST_LED_KEY = 'css_test_led_count';
|
||||||
@@ -2124,8 +2183,11 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
|||||||
if (!fps) fps = _getCssTestFps();
|
if (!fps) fps = _getCssTestFps();
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const apiKey = localStorage.getItem('wled_api_key') || '';
|
const apiKey = localStorage.getItem('wled_api_key') || '';
|
||||||
|
const isTransient = sourceId === '__preview__' && _cssTestTransientConfig;
|
||||||
let wsUrl;
|
let wsUrl;
|
||||||
if (_cssTestCSPTMode && _cssTestCSPTId) {
|
if (isTransient) {
|
||||||
|
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
|
||||||
|
} else if (_cssTestCSPTMode && _cssTestCSPTId) {
|
||||||
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
|
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
|
||||||
} else {
|
} else {
|
||||||
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
|
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
|
||||||
@@ -2134,6 +2196,13 @@ function _cssTestConnect(sourceId, ledCount, fps) {
|
|||||||
_cssTestWs = new WebSocket(wsUrl);
|
_cssTestWs = new WebSocket(wsUrl);
|
||||||
_cssTestWs.binaryType = 'arraybuffer';
|
_cssTestWs.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
if (isTransient) {
|
||||||
|
_cssTestWs.onopen = () => {
|
||||||
|
if (gen !== _cssTestGeneration) return;
|
||||||
|
_cssTestWs.send(JSON.stringify(_cssTestTransientConfig));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_cssTestWs.onmessage = (event) => {
|
_cssTestWs.onmessage = (event) => {
|
||||||
// Ignore messages from a stale connection
|
// Ignore messages from a stale connection
|
||||||
if (gen !== _cssTestGeneration) return;
|
if (gen !== _cssTestGeneration) return;
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export function hexToRgbArray(hex) {
|
|||||||
let _gradientStops = [];
|
let _gradientStops = [];
|
||||||
let _gradientSelectedIdx = -1;
|
let _gradientSelectedIdx = -1;
|
||||||
let _gradientDragging = null; // { idx, trackRect } while dragging
|
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||||||
|
let _gradientOnChange = null;
|
||||||
|
|
||||||
|
/** Set a callback that fires whenever stops change. */
|
||||||
|
export function gradientSetOnChange(fn) { _gradientOnChange = fn; }
|
||||||
|
|
||||||
/** Read-only accessor for save/dirty-check from the parent module. */
|
/** Read-only accessor for save/dirty-check from the parent module. */
|
||||||
export function getGradientStops() {
|
export function getGradientStops() {
|
||||||
@@ -183,6 +187,7 @@ export function gradientRenderAll() {
|
|||||||
_gradientRenderCanvas();
|
_gradientRenderCanvas();
|
||||||
_gradientRenderMarkers();
|
_gradientRenderMarkers();
|
||||||
_gradientRenderStopList();
|
_gradientRenderStopList();
|
||||||
|
if (_gradientOnChange) _gradientOnChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _gradientRenderCanvas() {
|
function _gradientRenderCanvas() {
|
||||||
|
|||||||
@@ -1030,6 +1030,11 @@
|
|||||||
"color_strip.test.fps": "FPS:",
|
"color_strip.test.fps": "FPS:",
|
||||||
"color_strip.test.apply": "Apply",
|
"color_strip.test.apply": "Apply",
|
||||||
"color_strip.test.composite": "Composite",
|
"color_strip.test.composite": "Composite",
|
||||||
|
"color_strip.preview.title": "Live Preview",
|
||||||
|
"color_strip.preview.not_connected": "Not connected",
|
||||||
|
"color_strip.preview.connecting": "Connecting...",
|
||||||
|
"color_strip.preview.connected": "Connected",
|
||||||
|
"color_strip.preview.unsupported": "Preview not available for this source type",
|
||||||
"color_strip.type.daylight": "Daylight Cycle",
|
"color_strip.type.daylight": "Daylight Cycle",
|
||||||
"color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours",
|
"color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours",
|
||||||
"color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.",
|
"color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.",
|
||||||
|
|||||||
@@ -979,6 +979,11 @@
|
|||||||
"color_strip.test.fps": "FPS:",
|
"color_strip.test.fps": "FPS:",
|
||||||
"color_strip.test.apply": "Применить",
|
"color_strip.test.apply": "Применить",
|
||||||
"color_strip.test.composite": "Композит",
|
"color_strip.test.composite": "Композит",
|
||||||
|
"color_strip.preview.title": "Предпросмотр",
|
||||||
|
"color_strip.preview.not_connected": "Не подключено",
|
||||||
|
"color_strip.preview.connecting": "Подключение...",
|
||||||
|
"color_strip.preview.connected": "Подключено",
|
||||||
|
"color_strip.preview.unsupported": "Предпросмотр недоступен для этого типа источника",
|
||||||
"color_strip.type.daylight": "Дневной цикл",
|
"color_strip.type.daylight": "Дневной цикл",
|
||||||
"color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа",
|
"color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа",
|
||||||
"color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",
|
"color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",
|
||||||
|
|||||||
@@ -979,6 +979,11 @@
|
|||||||
"color_strip.test.fps": "FPS:",
|
"color_strip.test.fps": "FPS:",
|
||||||
"color_strip.test.apply": "应用",
|
"color_strip.test.apply": "应用",
|
||||||
"color_strip.test.composite": "合成",
|
"color_strip.test.composite": "合成",
|
||||||
|
"color_strip.preview.title": "实时预览",
|
||||||
|
"color_strip.preview.not_connected": "未连接",
|
||||||
|
"color_strip.preview.connecting": "连接中...",
|
||||||
|
"color_strip.preview.connected": "已连接",
|
||||||
|
"color_strip.preview.unsupported": "此源类型不支持预览",
|
||||||
"color_strip.type.daylight": "日光循环",
|
"color_strip.type.daylight": "日光循环",
|
||||||
"color_strip.type.daylight.desc": "模拟24小时自然日光变化",
|
"color_strip.type.daylight.desc": "模拟24小时自然日光变化",
|
||||||
"color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。",
|
"color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。",
|
||||||
|
|||||||
@@ -621,6 +621,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-icon btn-secondary" onclick="closeCSSEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
<button class="btn btn-icon btn-secondary" onclick="closeCSSEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="previewCSSFromEditor()" data-i18n-title="color_strip.test.title" title="Test Preview"><svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg></button>
|
||||||
<button class="btn btn-icon btn-primary" onclick="saveCSSEditor()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
<button class="btn btn-icon btn-primary" onclick="saveCSSEditor()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user