feat(displays): expose DDC/CI contrast, input source, color preset, picture mode
Backend (routes/display.py, services/display_service.py):
- Probe DDC/CI capabilities per monitor at enumeration time
- New endpoints POST /api/display/{contrast,input_source,color_preset,picture_mode}/{id}
- Picture mode goes through raw VCP 0xDC since monitorcontrol has no
high-level wrapper; labels follow MCCS spec with vendor-friendly fallbacks
- Each capability reports a *_supported flag so the UI can hide rows that
the hardware does not advertise
Frontend (links.js, app.js, styles.css, locales):
- Monitor cards grow a contrast slider (same editorial copper treatment
as brightness) and a "PICTURE TUNING" section beneath
- Picture tuning uses the IconSelect widget (matching the audio device
selector): per-port icons (HDMI, DisplayPort, DVI, VGA, USB-C),
thermometer for color temps, per-mode icons (movie reel, gamepad,
ball, etc.) for picture modes
- Humanizers turn SHOUT_CASE enum names into readable labels
(COLOR_TEMP_6500K -> "6500 K", HDMI1 -> "HDMI 1")
- 14 new i18n keys per locale (en/ru)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""Display brightness and power control API endpoints."""
|
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -9,6 +9,10 @@ from ..auth import verify_token
|
|||||||
from ..services.display_service import (
|
from ..services.display_service import (
|
||||||
list_monitors,
|
list_monitors,
|
||||||
set_brightness,
|
set_brightness,
|
||||||
|
set_color_preset,
|
||||||
|
set_contrast,
|
||||||
|
set_input_source,
|
||||||
|
set_picture_mode,
|
||||||
set_power,
|
set_power,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,12 +29,35 @@ class PowerRequest(BaseModel):
|
|||||||
on: bool
|
on: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ContrastRequest(BaseModel):
|
||||||
|
contrast: int = Field(ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
|
class InputSourceRequest(BaseModel):
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class ColorPresetRequest(BaseModel):
|
||||||
|
preset: str
|
||||||
|
|
||||||
|
|
||||||
|
class PictureModeRequest(BaseModel):
|
||||||
|
code: int = Field(ge=0, le=255)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/monitors")
|
@router.get("/monitors")
|
||||||
async def get_monitors(
|
async def get_monitors(
|
||||||
refresh: bool = False, _: str = Depends(verify_token)
|
refresh: bool = False,
|
||||||
|
rediscover: bool = False,
|
||||||
|
_: str = Depends(verify_token),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List all connected monitors with brightness and power info."""
|
"""List all connected monitors with their reported DDC/CI capabilities.
|
||||||
monitors = list_monitors(force_refresh=refresh)
|
|
||||||
|
- `refresh=true` bypasses the response TTL cache (re-reads current state).
|
||||||
|
- `rediscover=true` also drops the per-monitor capability cache, forcing
|
||||||
|
a full DDC/CI capability probe. Use after a monitor hot-swap.
|
||||||
|
"""
|
||||||
|
monitors = list_monitors(force_refresh=refresh, rediscover=rediscover)
|
||||||
logger.debug("Found %d monitors", len(monitors))
|
logger.debug("Found %d monitors", len(monitors))
|
||||||
return [m.to_dict() for m in monitors]
|
return [m.to_dict() for m in monitors]
|
||||||
|
|
||||||
@@ -56,3 +83,47 @@ async def set_monitor_power(
|
|||||||
if success:
|
if success:
|
||||||
logger.info("Set monitor %d power %s", monitor_id, action)
|
logger.info("Set monitor %d power %s", monitor_id, action)
|
||||||
return {"success": success}
|
return {"success": success}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contrast/{monitor_id}")
|
||||||
|
async def set_monitor_contrast(
|
||||||
|
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
|
||||||
|
) -> dict:
|
||||||
|
"""Set DDC/CI contrast for a specific monitor."""
|
||||||
|
success = set_contrast(monitor_id, request.contrast)
|
||||||
|
if success:
|
||||||
|
logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast)
|
||||||
|
return {"success": success}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/input_source/{monitor_id}")
|
||||||
|
async def set_monitor_input_source(
|
||||||
|
monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token)
|
||||||
|
) -> dict:
|
||||||
|
"""Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1)."""
|
||||||
|
success = set_input_source(monitor_id, request.source)
|
||||||
|
if success:
|
||||||
|
logger.info("Set monitor %d input source to %s", monitor_id, request.source)
|
||||||
|
return {"success": success}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/color_preset/{monitor_id}")
|
||||||
|
async def set_monitor_color_preset(
|
||||||
|
monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token)
|
||||||
|
) -> dict:
|
||||||
|
"""Apply a DDC/CI color preset (color temperature) to the monitor."""
|
||||||
|
success = set_color_preset(monitor_id, request.preset)
|
||||||
|
if success:
|
||||||
|
logger.info("Set monitor %d color preset to %s", monitor_id, request.preset)
|
||||||
|
return {"success": success}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/picture_mode/{monitor_id}")
|
||||||
|
async def set_monitor_picture_mode(
|
||||||
|
monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token)
|
||||||
|
) -> dict:
|
||||||
|
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||||
|
success = set_picture_mode(monitor_id, request.code)
|
||||||
|
if success:
|
||||||
|
logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code)
|
||||||
|
return {"success": success}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Display brightness and power control service."""
|
"""Display brightness, power, contrast, input-source and color-preset control."""
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import ctypes.wintypes
|
import ctypes.wintypes
|
||||||
@@ -6,10 +6,33 @@ import logging
|
|||||||
import platform
|
import platform
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# VCP 0xDC "Display Application" — picture / scene mode.
|
||||||
|
# Vendors deviate from the MCCS spec, but these labels match the standard
|
||||||
|
# meanings and cover what most monitors report through their capability
|
||||||
|
# string. Unknown codes fall back to "Mode <n>".
|
||||||
|
PICTURE_MODE_VCP = 0xDC
|
||||||
|
PICTURE_MODE_LABELS: dict[int, str] = {
|
||||||
|
0x00: "Default",
|
||||||
|
0x01: "Standalone",
|
||||||
|
0x02: "Mixed",
|
||||||
|
0x03: "Productivity",
|
||||||
|
0x04: "Movie",
|
||||||
|
0x05: "Game",
|
||||||
|
0x06: "Sports",
|
||||||
|
0x07: "Professional",
|
||||||
|
0x08: "Standard",
|
||||||
|
0x09: "Default",
|
||||||
|
0x0A: "Movie (Reduced Effects)",
|
||||||
|
0x0B: "Movie (Enhanced)",
|
||||||
|
0x0C: "User 1",
|
||||||
|
0x0D: "User 2",
|
||||||
|
0x0E: "User 3",
|
||||||
|
}
|
||||||
|
|
||||||
_sbc = None
|
_sbc = None
|
||||||
_monitorcontrol = None
|
_monitorcontrol = None
|
||||||
|
|
||||||
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
|
|||||||
import monitorcontrol
|
import monitorcontrol
|
||||||
_monitorcontrol = monitorcontrol
|
_monitorcontrol = monitorcontrol
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("monitorcontrol not installed - display power control unavailable")
|
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
|
||||||
return _monitorcontrol
|
return _monitorcontrol
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +87,18 @@ class MonitorInfo:
|
|||||||
manufacturer: str = ""
|
manufacturer: str = ""
|
||||||
resolution: str | None = None
|
resolution: str | None = None
|
||||||
is_primary: bool = False
|
is_primary: bool = False
|
||||||
|
contrast: int | None = None
|
||||||
|
contrast_supported: bool = False
|
||||||
|
input_source: str | None = None
|
||||||
|
available_input_sources: list[str] = field(default_factory=list)
|
||||||
|
input_source_supported: bool = False
|
||||||
|
color_preset: str | None = None
|
||||||
|
available_color_presets: list[str] = field(default_factory=list)
|
||||||
|
color_preset_supported: bool = False
|
||||||
|
picture_mode: str | None = None
|
||||||
|
picture_mode_code: int | None = None
|
||||||
|
available_picture_modes: list[dict] = field(default_factory=list)
|
||||||
|
picture_mode_supported: bool = False
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -76,6 +111,18 @@ class MonitorInfo:
|
|||||||
"manufacturer": self.manufacturer,
|
"manufacturer": self.manufacturer,
|
||||||
"resolution": self.resolution,
|
"resolution": self.resolution,
|
||||||
"is_primary": self.is_primary,
|
"is_primary": self.is_primary,
|
||||||
|
"contrast": self.contrast,
|
||||||
|
"contrast_supported": self.contrast_supported,
|
||||||
|
"input_source": self.input_source,
|
||||||
|
"available_input_sources": self.available_input_sources,
|
||||||
|
"input_source_supported": self.input_source_supported,
|
||||||
|
"color_preset": self.color_preset,
|
||||||
|
"available_color_presets": self.available_color_presets,
|
||||||
|
"color_preset_supported": self.color_preset_supported,
|
||||||
|
"picture_mode": self.picture_mode,
|
||||||
|
"picture_mode_code": self.picture_mode_code,
|
||||||
|
"available_picture_modes": self.available_picture_modes,
|
||||||
|
"picture_mode_supported": self.picture_mode_supported,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -137,17 +184,176 @@ def _mark_primary(monitors: list[MonitorInfo]) -> None:
|
|||||||
monitors[0].is_primary = True
|
monitors[0].is_primary = True
|
||||||
|
|
||||||
|
|
||||||
# Cache for monitor list
|
# Short TTL cache of the assembled monitor list (full response).
|
||||||
_monitor_cache: list[MonitorInfo] | None = None
|
_monitor_cache: list[MonitorInfo] | None = None
|
||||||
_cache_time: float = 0
|
_cache_time: float = 0
|
||||||
_CACHE_TTL = 5.0 # seconds
|
_CACHE_TTL = 5.0 # seconds
|
||||||
|
|
||||||
|
# Per-monitor cache of static capabilities (option lists + support flags).
|
||||||
|
# DDC/CI capability discovery is the slow part — it only changes when a
|
||||||
|
# monitor is replaced or rewired, so we probe it once per monitor and reuse
|
||||||
|
# it across refreshes. Cleared on explicit `rediscover` or when the monitor
|
||||||
|
# count changes (cheap stale-detection for hot-plug events).
|
||||||
|
_static_cache: dict[int, dict] = {}
|
||||||
|
_static_cache_monitor_count: int = -1
|
||||||
|
|
||||||
def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
|
||||||
"""List all connected monitors with their current brightness."""
|
|
||||||
global _monitor_cache, _cache_time
|
|
||||||
|
|
||||||
if not force_refresh and _monitor_cache is not None and (time.time() - _cache_time) < _CACHE_TTL:
|
def _enum_name(value, enum_cls=None) -> str | None:
|
||||||
|
"""Best-effort name for an enum or raw int returned by monitorcontrol.
|
||||||
|
|
||||||
|
monitorcontrol's getters sometimes hand back raw ints when the monitor
|
||||||
|
reports a value the library wraps incompletely. Re-map those through the
|
||||||
|
matching enum class so HA selects still receive symbolic option names.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
name = getattr(value, "name", None)
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
if enum_cls is not None:
|
||||||
|
try:
|
||||||
|
return enum_cls(value).name
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_static_open(mon, mc, monitor_id: int) -> dict:
|
||||||
|
"""Probe per-monitor static capabilities.
|
||||||
|
|
||||||
|
Must be called inside an open `with mon:` DDC/CI context. Tries each
|
||||||
|
feature once to confirm the monitor responds, and enumerates option
|
||||||
|
lists from the capability string. Heavy: this is what the cache is for.
|
||||||
|
"""
|
||||||
|
static = {
|
||||||
|
"contrast_supported": False,
|
||||||
|
"input_source_supported": False,
|
||||||
|
"available_input_sources": [],
|
||||||
|
"color_preset_supported": False,
|
||||||
|
"available_color_presets": [],
|
||||||
|
"picture_mode_supported": False,
|
||||||
|
"available_picture_modes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
caps = mon.get_vcp_capabilities() or {}
|
||||||
|
except Exception as e:
|
||||||
|
caps = {}
|
||||||
|
logger.debug("Monitor %d: get_vcp_capabilities failed: %s", monitor_id, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mon.get_contrast()
|
||||||
|
static["contrast_supported"] = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: contrast unsupported: %s", monitor_id, e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mon.get_input_source()
|
||||||
|
static["input_source_supported"] = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: input_source unsupported: %s", monitor_id, e)
|
||||||
|
|
||||||
|
inputs = caps.get("inputs") or []
|
||||||
|
input_enum = mc.InputSource if mc else None
|
||||||
|
static["available_input_sources"] = [
|
||||||
|
n for n in (_enum_name(s, input_enum) for s in inputs) if n is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
mon.get_color_preset()
|
||||||
|
static["color_preset_supported"] = True
|
||||||
|
if mc is not None:
|
||||||
|
static["available_color_presets"] = [p.name for p in mc.ColorPreset]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
|
||||||
|
|
||||||
|
# Picture / scene mode (VCP 0xDC) — not exposed by monitorcontrol's
|
||||||
|
# high-level API, so probe via raw VCP transport.
|
||||||
|
try:
|
||||||
|
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||||
|
static["picture_mode_supported"] = True
|
||||||
|
cmds = caps.get("cmds") or {}
|
||||||
|
declared = cmds.get(PICTURE_MODE_VCP)
|
||||||
|
codes = sorted(declared) if declared else sorted(PICTURE_MODE_LABELS.keys())
|
||||||
|
static["available_picture_modes"] = [
|
||||||
|
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
|
||||||
|
for c in codes
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: picture_mode unsupported: %s", monitor_id, e)
|
||||||
|
|
||||||
|
return static
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_dynamic_open(mon, mc, monitor_id: int, static: dict) -> dict:
|
||||||
|
"""Read current values for features known to be supported.
|
||||||
|
|
||||||
|
Must be called inside an open `with mon:` context. Skips reads for
|
||||||
|
unsupported features (saves one I2C roundtrip each), so the warm path
|
||||||
|
only touches features the monitor actually responds to.
|
||||||
|
"""
|
||||||
|
dynamic = {
|
||||||
|
"power_on": True,
|
||||||
|
"contrast": None,
|
||||||
|
"input_source": None,
|
||||||
|
"color_preset": None,
|
||||||
|
"picture_mode": None,
|
||||||
|
"picture_mode_code": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
dynamic["power_on"] = mon.get_power_mode() == mc.PowerMode.on
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: power readback failed: %s", monitor_id, e)
|
||||||
|
|
||||||
|
if static.get("contrast_supported"):
|
||||||
|
try:
|
||||||
|
dynamic["contrast"] = mon.get_contrast()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: contrast readback failed: %s", monitor_id, e)
|
||||||
|
|
||||||
|
if static.get("input_source_supported"):
|
||||||
|
try:
|
||||||
|
src = mon.get_input_source()
|
||||||
|
dynamic["input_source"] = _enum_name(src, mc.InputSource if mc else None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: input_source readback failed: %s", monitor_id, e)
|
||||||
|
|
||||||
|
if static.get("color_preset_supported"):
|
||||||
|
try:
|
||||||
|
preset = mon.get_color_preset()
|
||||||
|
dynamic["color_preset"] = _enum_name(preset, mc.ColorPreset if mc else None)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: color_preset readback failed: %s", monitor_id, e)
|
||||||
|
|
||||||
|
if static.get("picture_mode_supported"):
|
||||||
|
try:
|
||||||
|
current, _maximum = mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||||
|
dynamic["picture_mode_code"] = current
|
||||||
|
dynamic["picture_mode"] = PICTURE_MODE_LABELS.get(current, f"Mode {current}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: picture_mode readback failed: %s", monitor_id, e)
|
||||||
|
|
||||||
|
return dynamic
|
||||||
|
|
||||||
|
|
||||||
|
def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list[MonitorInfo]:
|
||||||
|
"""List all connected monitors with their current state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force_refresh: bypass the short TTL response cache.
|
||||||
|
rediscover: also drop the per-monitor static capability cache, so the
|
||||||
|
next probe re-runs DDC/CI capability discovery. Use after hot-plug
|
||||||
|
or when a monitor's reported capabilities change.
|
||||||
|
"""
|
||||||
|
global _monitor_cache, _cache_time, _static_cache_monitor_count
|
||||||
|
|
||||||
|
if (
|
||||||
|
not force_refresh
|
||||||
|
and not rediscover
|
||||||
|
and _monitor_cache is not None
|
||||||
|
and (time.time() - _cache_time) < _CACHE_TTL
|
||||||
|
):
|
||||||
return _monitor_cache
|
return _monitor_cache
|
||||||
|
|
||||||
sbc = _load_sbc()
|
sbc = _load_sbc()
|
||||||
@@ -159,7 +365,13 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
|||||||
info_list = sbc.list_monitors_info()
|
info_list = sbc.list_monitors_info()
|
||||||
brightnesses = sbc.get_brightness()
|
brightnesses = sbc.get_brightness()
|
||||||
|
|
||||||
# Get DDC/CI monitors for power state
|
# Invalidate the static cache on explicit rediscover OR on topology
|
||||||
|
# change (hot-plug / disconnect). Both indicate the cached probe is
|
||||||
|
# potentially stale.
|
||||||
|
if rediscover or len(info_list) != _static_cache_monitor_count:
|
||||||
|
_static_cache.clear()
|
||||||
|
_static_cache_monitor_count = len(info_list)
|
||||||
|
|
||||||
mc = _load_monitorcontrol()
|
mc = _load_monitorcontrol()
|
||||||
ddc_monitors = []
|
ddc_monitors = []
|
||||||
if mc:
|
if mc:
|
||||||
@@ -181,25 +393,44 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
|
|||||||
edid = info.get("edid", "")
|
edid = info.get("edid", "")
|
||||||
resolution = _parse_edid_resolution(edid) if edid else None
|
resolution = _parse_edid_resolution(edid) if edid else None
|
||||||
|
|
||||||
# Read power state via DDC/CI
|
static: dict = {}
|
||||||
power_on = True
|
dynamic: dict = {}
|
||||||
|
|
||||||
|
# Open the DDC handle ONCE; do static probe (if needed) + dynamic
|
||||||
|
# readback inside the same context. Opening the handle is the
|
||||||
|
# expensive part — keep both phases under one open.
|
||||||
if power_supported and i < len(ddc_monitors):
|
if power_supported and i < len(ddc_monitors):
|
||||||
try:
|
try:
|
||||||
with ddc_monitors[i] as mon:
|
with ddc_monitors[i] as mon:
|
||||||
power_mode = mon.get_power_mode()
|
if i not in _static_cache:
|
||||||
power_on = power_mode == mc.PowerMode.on
|
_static_cache[i] = _probe_static_open(mon, mc, i)
|
||||||
except Exception:
|
static = _static_cache[i]
|
||||||
pass
|
dynamic = _probe_dynamic_open(mon, mc, i, static)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
|
||||||
|
static = _static_cache.get(i, {})
|
||||||
|
|
||||||
monitors.append(MonitorInfo(
|
monitors.append(MonitorInfo(
|
||||||
id=i,
|
id=i,
|
||||||
name=name,
|
name=name,
|
||||||
brightness=brightness,
|
brightness=brightness,
|
||||||
power_supported=power_supported,
|
power_supported=power_supported,
|
||||||
power_on=power_on,
|
power_on=dynamic.get("power_on", True),
|
||||||
model=model,
|
model=model,
|
||||||
manufacturer=manufacturer,
|
manufacturer=manufacturer,
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
|
contrast=dynamic.get("contrast"),
|
||||||
|
contrast_supported=static.get("contrast_supported", False),
|
||||||
|
input_source=dynamic.get("input_source"),
|
||||||
|
available_input_sources=static.get("available_input_sources", []),
|
||||||
|
input_source_supported=static.get("input_source_supported", False),
|
||||||
|
color_preset=dynamic.get("color_preset"),
|
||||||
|
available_color_presets=static.get("available_color_presets", []),
|
||||||
|
color_preset_supported=static.get("color_preset_supported", False),
|
||||||
|
picture_mode=dynamic.get("picture_mode"),
|
||||||
|
picture_mode_code=dynamic.get("picture_mode_code"),
|
||||||
|
available_picture_modes=static.get("available_picture_modes", []),
|
||||||
|
picture_mode_supported=static.get("picture_mode_supported", False),
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to enumerate monitors: %s", e)
|
logger.error("Failed to enumerate monitors: %s", e)
|
||||||
@@ -234,9 +465,7 @@ def set_brightness(monitor_id: int, value: int) -> bool:
|
|||||||
value = max(0, min(100, value))
|
value = max(0, min(100, value))
|
||||||
try:
|
try:
|
||||||
sbc.set_brightness(value, display=monitor_id)
|
sbc.set_brightness(value, display=monitor_id)
|
||||||
# Invalidate cache
|
_invalidate_cache()
|
||||||
global _monitor_cache
|
|
||||||
_monitor_cache = None
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
|
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
|
||||||
@@ -262,10 +491,106 @@ def set_power(monitor_id: int, on: bool) -> bool:
|
|||||||
else:
|
else:
|
||||||
monitor.set_power_mode(mc.PowerMode.off_soft)
|
monitor.set_power_mode(mc.PowerMode.off_soft)
|
||||||
|
|
||||||
# Invalidate cache
|
_invalidate_cache()
|
||||||
global _monitor_cache
|
|
||||||
_monitor_cache = None
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_contrast(monitor_id: int, value: int) -> bool:
|
||||||
|
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
|
||||||
|
mc = _load_monitorcontrol()
|
||||||
|
if mc is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
value = max(0, min(100, value))
|
||||||
|
try:
|
||||||
|
ddc_monitors = mc.get_monitors()
|
||||||
|
if monitor_id >= len(ddc_monitors):
|
||||||
|
return False
|
||||||
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
|
monitor.set_contrast(value)
|
||||||
|
_invalidate_cache()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set contrast for monitor %d: %s", monitor_id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_input_source(monitor_id: int, source: str) -> bool:
|
||||||
|
"""Set the DDC/CI input source by enum name (e.g. 'HDMI1', 'DP1')."""
|
||||||
|
mc = _load_monitorcontrol()
|
||||||
|
if mc is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = mc.InputSource[source]
|
||||||
|
except KeyError:
|
||||||
|
logger.error("Unknown input source: %s", source)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
ddc_monitors = mc.get_monitors()
|
||||||
|
if monitor_id >= len(ddc_monitors):
|
||||||
|
return False
|
||||||
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
|
monitor.set_input_source(target)
|
||||||
|
_invalidate_cache()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set input source for monitor %d: %s", monitor_id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_color_preset(monitor_id: int, preset: str) -> bool:
|
||||||
|
"""Set the DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
|
||||||
|
mc = _load_monitorcontrol()
|
||||||
|
if mc is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
target = mc.ColorPreset[preset]
|
||||||
|
except KeyError:
|
||||||
|
logger.error("Unknown color preset: %s", preset)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
ddc_monitors = mc.get_monitors()
|
||||||
|
if monitor_id >= len(ddc_monitors):
|
||||||
|
return False
|
||||||
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
|
monitor.set_color_preset(target)
|
||||||
|
_invalidate_cache()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set color preset for monitor %d: %s", monitor_id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_picture_mode(monitor_id: int, code: int) -> bool:
|
||||||
|
"""Set the DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
|
||||||
|
mc = _load_monitorcontrol()
|
||||||
|
if mc is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not 0 <= code <= 255:
|
||||||
|
logger.error("Picture mode code %d out of range", code)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
ddc_monitors = mc.get_monitors()
|
||||||
|
if monitor_id >= len(ddc_monitors):
|
||||||
|
return False
|
||||||
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
|
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
|
||||||
|
_invalidate_cache()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set picture mode for monitor %d: %s", monitor_id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_cache() -> None:
|
||||||
|
global _monitor_cache
|
||||||
|
_monitor_cache = None
|
||||||
|
|||||||
@@ -8022,24 +8022,36 @@ select option {
|
|||||||
background: rgba(var(--copper-rgb), 0.06) !important;
|
background: rgba(var(--copper-rgb), 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brightness control row */
|
/* Slider rows (brightness + contrast share this layout) */
|
||||||
.display-container .display-brightness-control {
|
.display-container .display-slider-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, auto) 1fr auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.display-container .display-slider-row.display-brightness-control {
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid var(--rule);
|
border-top: 1px solid var(--rule);
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-icon {
|
.display-container .display-slider-icon {
|
||||||
color: var(--ink-mute);
|
color: var(--ink-mute);
|
||||||
width: 18px !important;
|
width: 18px !important;
|
||||||
height: 18px !important;
|
height: 18px !important;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider {
|
.display-container .display-slider-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.display-container .display-slider {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
height: 2px !important;
|
height: 2px !important;
|
||||||
background: var(--rule-strong);
|
background: var(--rule-strong);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
@@ -8049,7 +8061,7 @@ select option {
|
|||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider::-webkit-slider-thumb {
|
.display-container .display-slider::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -8059,20 +8071,24 @@ select option {
|
|||||||
box-shadow: 0 0 12px var(--copper-glow);
|
box-shadow: 0 0 12px var(--copper-glow);
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
transition: transform 140ms var(--ease);
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider::-moz-range-thumb {
|
.display-container .display-slider::-moz-range-thumb {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
background: var(--copper);
|
background: var(--copper);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
transition: transform 140ms var(--ease);
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-slider:disabled {
|
.display-container .display-slider:hover::-webkit-slider-thumb { transform: scale(1.15); }
|
||||||
|
.display-container .display-slider:hover::-moz-range-thumb { transform: scale(1.15); }
|
||||||
|
.display-container .display-slider:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.display-container .display-brightness-value {
|
.display-container .display-slider-value {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--copper);
|
color: var(--copper);
|
||||||
@@ -8082,6 +8098,57 @@ select option {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Picture tuning section — input source, color preset, picture mode.
|
||||||
|
The underlying <select> is hidden by IconSelect; the visible trigger
|
||||||
|
inherits the editorial .icon-select-trigger overrides defined later
|
||||||
|
in this file. */
|
||||||
|
.display-container .display-tuning {
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-title::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, var(--rule), transparent);
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.display-container .display-tuning-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-mute);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Make the IconSelect trigger fill the field width (cards are narrow) */
|
||||||
|
.display-container .display-tuning-field .icon-select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
.display-container .display-monitors > .empty-state-illustration {
|
.display-container .display-monitors > .empty-state-illustration {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||||
|
onDisplayContrastInput, onDisplayContrastChange,
|
||||||
|
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||||
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
|
||||||
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
|
||||||
linkFormDirty, setLinkFormDirty,
|
linkFormDirty, setLinkFormDirty,
|
||||||
@@ -127,6 +129,8 @@ Object.assign(window, {
|
|||||||
saveLink, deleteLinkConfirm,
|
saveLink, deleteLinkConfirm,
|
||||||
// Display
|
// Display
|
||||||
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
|
||||||
|
onDisplayContrastInput, onDisplayContrastChange,
|
||||||
|
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
|
||||||
toggleDisplayPower,
|
toggleDisplayPower,
|
||||||
// Audio device
|
// Audio device
|
||||||
onAudioDeviceChanged,
|
onAudioDeviceChanged,
|
||||||
|
|||||||
@@ -1,12 +1,107 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Display Brightness & Power Control + Links Management
|
// Display Brightness, Power, Contrast, Input Source, Color Preset,
|
||||||
|
// Picture Mode Control + Links Management
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
|
import { t, showToast, escapeHtml, closeDialog, showConfirm, resolveMdiIcons, fetchMdiIcon, getAuthHeaders, hasCredentials } from './core.js';
|
||||||
|
import { IconSelect } from './icon-select.js';
|
||||||
|
|
||||||
let displayBrightnessTimers = {};
|
let displayBrightnessTimers = {};
|
||||||
|
let displayContrastTimers = {};
|
||||||
|
let _displayIconSelects = [];
|
||||||
const DISPLAY_THROTTLE_MS = 50;
|
const DISPLAY_THROTTLE_MS = 50;
|
||||||
|
|
||||||
|
// ─── Icon palette for the tuning IconSelects ───────────────────────────
|
||||||
|
// All SVGs are 24x24 monochrome — IconSelect's CSS fills them with currentColor.
|
||||||
|
|
||||||
|
const ICON_PORT_GENERIC =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8h16v8H4V8zm2 2v4h12v-4H6zm2 1h2v2H8v-2zm4 0h2v2h-2v-2zm-9 6h18v2H3v-2z"/></svg>';
|
||||||
|
const ICON_PORT_HDMI =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 9l2-2h14l2 2v5l-2 2h-3l-1 1H9l-1-1H5l-2-2V9zm2.5.5v4l1 1h2l1 1h7l1-1h2l1-1v-4l-1-.5H6.5l-1 .5z"/></svg>';
|
||||||
|
const ICON_PORT_DP =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 8l2-2h12l2 2v8l-2 2H6l-2-2V8zm2 .5V15l1 1h10l1-1V8.5L17 8H7l-1 .5zM8 10h2v4H8v-4zm6 0h2v4h-2v-4z"/></svg>';
|
||||||
|
const ICON_PORT_DVI =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 8h18v8H3V8zm2 1.5v5h14v-5H5zM7 11h1.5v2H7v-2zm3 0h1.5v2H10v-2zm3 0h1.5v2H13v-2zm3 0h1.5v2H16v-2z"/></svg>';
|
||||||
|
const ICON_PORT_VGA =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 7h14a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2zm0 2v6h14V9H5zm2 1.5a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5zm3 0a.75.75 0 100 1.5.75.75 0 000-1.5z"/></svg>';
|
||||||
|
const ICON_PORT_USBC =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 10a3 3 0 013-3h8a3 3 0 013 3v4a3 3 0 01-3 3H8a3 3 0 01-3-3v-4zm3-1.5A1.5 1.5 0 006.5 10v4A1.5 1.5 0 008 15.5h8a1.5 1.5 0 001.5-1.5v-4A1.5 1.5 0 0016 8.5H8zm1 2h6v3H9v-3z"/></svg>';
|
||||||
|
|
||||||
|
const ICON_THERMOMETER =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a3 3 0 00-3 3v8.17A4 4 0 1015 14.17V6a3 3 0 00-3-3zm-1.5 3a1.5 1.5 0 113 0v8.76a2.5 2.5 0 11-3 0V6zm1.5 5a1 1 0 011 1v2.27a1.5 1.5 0 11-2 0V12a1 1 0 011-1z"/></svg>';
|
||||||
|
|
||||||
|
const ICON_MODE_MOVIE =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 5h16v14H4V5zm2 2v2h2V7H6zm0 4v2h2v-2H6zm0 4v2h2v-2H6zm10-8v2h2V7h-2zm0 4v2h2v-2h-2zm0 4v2h2v-2h-2zm-6-7h4v8h-4V8z"/></svg>';
|
||||||
|
const ICON_MODE_GAME =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 8a5 5 0 00-5 5 4 4 0 007.4 2.1L11 14h2l1.6 1.1A4 4 0 0022 13a5 5 0 00-5-5H7zm1 3v1H7v-1H6v-1h1V9h1v1h1v1H8zm7 0a1 1 0 110-2 1 1 0 010 2zm2 2a1 1 0 110-2 1 1 0 010 2z"/></svg>';
|
||||||
|
const ICON_MODE_SPORT =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 100 20 10 10 0 000-20zm0 2c1.7 0 3.3.5 4.6 1.4l-1.4 2.4L12 6.5l-3.2 1.3-1.4-2.4A8 8 0 0112 4zm-7.6 4l2.5 1.3-.5 3.5L4 16.4A8 8 0 014.4 8zm15.2 0a8 8 0 01.4 8.4l-2.4-1.6-.5-3.5L19.6 8zM12 8.7l3 1.2.6 3.2L13 15h-2l-2.6-1.9.6-3.2L12 8.7zm-5.3 8.8L9 16.5l2.4 1h1.2l2.4-1 2.3 1A8 8 0 016.7 17.5z"/></svg>';
|
||||||
|
const ICON_MODE_PRO =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 4h18v12H13v2h4v2H7v-2h4v-2H3V4zm2 2v8h14V6H5zm2 2h6v2H7V8zm0 3h10v2H7v-2z"/></svg>';
|
||||||
|
const ICON_MODE_DOCS =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M6 3h9l4 4v14H6V3zm2 2v14h10V9h-4V5H8zm2 6h6v2h-6v-2zm0 3h6v2h-6v-2z"/></svg>';
|
||||||
|
const ICON_MODE_USER =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 3a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4zm0 8c-3.3 0-7 1.5-7 4.5V20h14v-2.5c0-3-3.7-4.5-7-4.5z"/></svg>';
|
||||||
|
const ICON_MODE_DEFAULT =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v12H3V5zm2 2v8h14V7H5zm-2 12h18v2H3v-2z"/></svg>';
|
||||||
|
const ICON_MODE_MIXED =
|
||||||
|
'<svg viewBox="0 0 24 24"><path fill="currentColor" d="M3 5h18v14H3V5zm2 2v10h7V7H5zm9 0v10h5V7h-5z"/></svg>';
|
||||||
|
|
||||||
|
function inputSourceIcon(src) {
|
||||||
|
const s = String(src || '').toUpperCase();
|
||||||
|
if (s.startsWith('HDMI')) return ICON_PORT_HDMI;
|
||||||
|
if (s.startsWith('DP')) return ICON_PORT_DP;
|
||||||
|
if (s.startsWith('DVI')) return ICON_PORT_DVI;
|
||||||
|
if (s.startsWith('VGA')) return ICON_PORT_VGA;
|
||||||
|
if (s.startsWith('USB')) return ICON_PORT_USBC;
|
||||||
|
return ICON_PORT_GENERIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pictureModeIcon(label) {
|
||||||
|
const k = String(label || '').toLowerCase();
|
||||||
|
if (k.includes('movie')) return ICON_MODE_MOVIE;
|
||||||
|
if (k.includes('game')) return ICON_MODE_GAME;
|
||||||
|
if (k.includes('sport')) return ICON_MODE_SPORT;
|
||||||
|
if (k.includes('professional')) return ICON_MODE_PRO;
|
||||||
|
if (k.includes('productivity')) return ICON_MODE_DOCS;
|
||||||
|
if (k.includes('user')) return ICON_MODE_USER;
|
||||||
|
if (k.includes('mixed')) return ICON_MODE_MIXED;
|
||||||
|
return ICON_MODE_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Humanise enum-style identifiers returned by monitorcontrol so users
|
||||||
|
// don't see SHOUT_CASE strings in the UI.
|
||||||
|
function humanizeInputSource(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
// OFF / RESERVED → "Off" / "Reserved"
|
||||||
|
// VGA1 → "VGA 1", HDMI1 → "HDMI 1", DP1 → "DisplayPort 1"
|
||||||
|
const map = { DP: 'DisplayPort', DVI: 'DVI', HDMI: 'HDMI', VGA: 'VGA', USBC: 'USB-C' };
|
||||||
|
const m = String(raw).toUpperCase().match(/^(DP|DVI|HDMI|VGA|USBC|USB_C)(\d*)$/);
|
||||||
|
if (m) {
|
||||||
|
const key = m[1] === 'USB_C' ? 'USBC' : m[1];
|
||||||
|
return `${map[key]}${m[2] ? ' ' + m[2] : ''}`;
|
||||||
|
}
|
||||||
|
return String(raw)
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeColorPreset(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
// COLOR_TEMP_6500K → "6500 K", COLOR_TEMP_NATIVE → "Native",
|
||||||
|
// COLOR_TEMP_USER1 → "User 1"
|
||||||
|
const s = String(raw).replace(/^COLOR_TEMP_?/i, '');
|
||||||
|
const kelvin = s.match(/^(\d{4,5})K?$/);
|
||||||
|
if (kelvin) return `${kelvin[1]} K`;
|
||||||
|
const user = s.match(/^USER\s*_?(\d+)$/i);
|
||||||
|
if (user) return `User ${user[1]}`;
|
||||||
|
return s
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadDisplayMonitors() {
|
export async function loadDisplayMonitors() {
|
||||||
if (!hasCredentials()) return;
|
if (!hasCredentials()) return;
|
||||||
|
|
||||||
@@ -14,7 +109,7 @@ export async function loadDisplayMonitors() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/display/monitors?refresh=true', {
|
const response = await fetch('/api/display/monitors', {
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +131,13 @@ export async function loadDisplayMonitors() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Destroy IconSelects from a previous render so listeners + popups
|
||||||
|
// don't pile up.
|
||||||
|
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
|
||||||
|
_displayIconSelects = [];
|
||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
const pendingIconSelects = [];
|
||||||
monitors.forEach(monitor => {
|
monitors.forEach(monitor => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'display-monitor-card';
|
card.className = 'display-monitor-card';
|
||||||
@@ -65,6 +166,116 @@ export async function loadDisplayMonitors() {
|
|||||||
</span>`
|
</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Contrast (DDC/CI) — render only if the monitor reports it.
|
||||||
|
let contrastRow = '';
|
||||||
|
if (monitor.contrast_supported) {
|
||||||
|
const contrastValue = monitor.contrast !== null && monitor.contrast !== undefined
|
||||||
|
? monitor.contrast : 50;
|
||||||
|
contrastRow = `
|
||||||
|
<div class="display-slider-row">
|
||||||
|
<svg class="display-slider-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
|
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18V4c4.41 0 8 3.59 8 8s-3.59 8-8 8z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="display-slider-label" data-i18n="display.contrast">${t('display.contrast')}</span>
|
||||||
|
<input type="range" class="display-slider display-contrast-slider"
|
||||||
|
min="0" max="100" value="${contrastValue}"
|
||||||
|
oninput="onDisplayContrastInput(${monitor.id}, this.value)"
|
||||||
|
onchange="onDisplayContrastChange(${monitor.id}, this.value)">
|
||||||
|
<span class="display-slider-value" id="contrast-val-${monitor.id}">${contrastValue}%</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the picture-tuning selects (input source / color preset / picture mode).
|
||||||
|
const tuningRows = [];
|
||||||
|
|
||||||
|
// Each tuning field renders a hidden <select> (state holder)
|
||||||
|
// which IconSelect then enhances after the card lands in the DOM.
|
||||||
|
const tuningTargets = [];
|
||||||
|
|
||||||
|
if (monitor.input_source_supported && monitor.available_input_sources.length > 0) {
|
||||||
|
const current = monitor.input_source;
|
||||||
|
const options = monitor.available_input_sources.map(src => {
|
||||||
|
const selected = src === current ? 'selected' : '';
|
||||||
|
return `<option value="${escapeHtml(src)}" ${selected}>${escapeHtml(humanizeInputSource(src))}</option>`;
|
||||||
|
}).join('');
|
||||||
|
tuningRows.push(`
|
||||||
|
<div class="display-tuning-field">
|
||||||
|
<span class="display-tuning-label" data-i18n="display.input_source">${t('display.input_source')}</span>
|
||||||
|
<select data-display-select="input" data-monitor-id="${monitor.id}"
|
||||||
|
aria-label="${t('display.input_source')}">
|
||||||
|
${options}
|
||||||
|
</select>
|
||||||
|
</div>`);
|
||||||
|
tuningTargets.push({
|
||||||
|
kind: 'input',
|
||||||
|
monitorId: monitor.id,
|
||||||
|
items: monitor.available_input_sources.map(src => ({
|
||||||
|
value: src,
|
||||||
|
icon: inputSourceIcon(src),
|
||||||
|
label: humanizeInputSource(src),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitor.color_preset_supported && monitor.available_color_presets.length > 0) {
|
||||||
|
const current = monitor.color_preset;
|
||||||
|
const options = monitor.available_color_presets.map(p => {
|
||||||
|
const selected = p === current ? 'selected' : '';
|
||||||
|
return `<option value="${escapeHtml(p)}" ${selected}>${escapeHtml(humanizeColorPreset(p))}</option>`;
|
||||||
|
}).join('');
|
||||||
|
tuningRows.push(`
|
||||||
|
<div class="display-tuning-field">
|
||||||
|
<span class="display-tuning-label" data-i18n="display.color_preset">${t('display.color_preset')}</span>
|
||||||
|
<select data-display-select="color" data-monitor-id="${monitor.id}"
|
||||||
|
aria-label="${t('display.color_preset')}">
|
||||||
|
${options}
|
||||||
|
</select>
|
||||||
|
</div>`);
|
||||||
|
tuningTargets.push({
|
||||||
|
kind: 'color',
|
||||||
|
monitorId: monitor.id,
|
||||||
|
items: monitor.available_color_presets.map(p => ({
|
||||||
|
value: p,
|
||||||
|
icon: ICON_THERMOMETER,
|
||||||
|
label: humanizeColorPreset(p),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monitor.picture_mode_supported && monitor.available_picture_modes.length > 0) {
|
||||||
|
const current = monitor.picture_mode_code;
|
||||||
|
const options = monitor.available_picture_modes.map(m => {
|
||||||
|
const selected = m.code === current ? 'selected' : '';
|
||||||
|
return `<option value="${m.code}" ${selected}>${escapeHtml(m.label)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
tuningRows.push(`
|
||||||
|
<div class="display-tuning-field">
|
||||||
|
<span class="display-tuning-label" data-i18n="display.picture_mode">${t('display.picture_mode')}</span>
|
||||||
|
<select data-display-select="mode" data-monitor-id="${monitor.id}"
|
||||||
|
aria-label="${t('display.picture_mode')}">
|
||||||
|
${options}
|
||||||
|
</select>
|
||||||
|
</div>`);
|
||||||
|
tuningTargets.push({
|
||||||
|
kind: 'mode',
|
||||||
|
monitorId: monitor.id,
|
||||||
|
items: monitor.available_picture_modes.map(m => ({
|
||||||
|
value: String(m.code),
|
||||||
|
icon: pictureModeIcon(m.label),
|
||||||
|
label: m.label,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingIconSelects.push(...tuningTargets);
|
||||||
|
|
||||||
|
const tuningBlock = tuningRows.length > 0
|
||||||
|
? `<div class="display-tuning">
|
||||||
|
<div class="display-tuning-title" data-i18n="display.tuning">${t('display.tuning')}</div>
|
||||||
|
<div class="display-tuning-grid">${tuningRows.join('')}</div>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="display-monitor-header">
|
<div class="display-monitor-header">
|
||||||
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
|
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
|
||||||
@@ -76,18 +287,40 @@ export async function loadDisplayMonitors() {
|
|||||||
</div>
|
</div>
|
||||||
${powerBtn}
|
${powerBtn}
|
||||||
</div>
|
</div>
|
||||||
<div class="display-brightness-control">
|
<div class="display-slider-row display-brightness-control">
|
||||||
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
|
<svg class="display-slider-icon display-brightness-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
|
<path fill="currentColor" d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<input type="range" class="display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
<span class="display-slider-label" data-i18n="display.brightness">${t('display.brightness')}</span>
|
||||||
|
<input type="range" class="display-slider display-brightness-slider" min="0" max="100" value="${brightnessValue}" ${brightnessDisabled}
|
||||||
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
oninput="onDisplayBrightnessInput(${monitor.id}, this.value)"
|
||||||
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
|
||||||
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
|
||||||
</div>`;
|
</div>
|
||||||
|
${contrastRow}
|
||||||
|
${tuningBlock}`;
|
||||||
|
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enhance every tuning <select> with an IconSelect now that the
|
||||||
|
// cards are in the DOM (IconSelect needs offsetParent + sibling).
|
||||||
|
pendingIconSelects.forEach(({ kind, monitorId, items }) => {
|
||||||
|
const sel = container.querySelector(
|
||||||
|
`select[data-display-select="${kind}"][data-monitor-id="${monitorId}"]`
|
||||||
|
);
|
||||||
|
if (!sel) return;
|
||||||
|
const handler = kind === 'input' ? onDisplayInputSourceChange
|
||||||
|
: kind === 'color' ? onDisplayColorPresetChange
|
||||||
|
: onDisplayPictureModeChange;
|
||||||
|
_displayIconSelects.push(new IconSelect({
|
||||||
|
target: sel,
|
||||||
|
items,
|
||||||
|
columns: 1,
|
||||||
|
horizontal: true,
|
||||||
|
onChange: (value) => handler(monitorId, value),
|
||||||
|
}));
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load display monitors:', e);
|
console.error('Failed to load display monitors:', e);
|
||||||
}
|
}
|
||||||
@@ -124,6 +357,90 @@ async function sendDisplayBrightness(monitorId, brightness) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function onDisplayContrastInput(monitorId, value) {
|
||||||
|
const label = document.getElementById(`contrast-val-${monitorId}`);
|
||||||
|
if (label) label.textContent = `${value}%`;
|
||||||
|
|
||||||
|
if (displayContrastTimers[monitorId]) clearTimeout(displayContrastTimers[monitorId]);
|
||||||
|
displayContrastTimers[monitorId] = setTimeout(() => {
|
||||||
|
sendDisplayContrast(monitorId, parseInt(value));
|
||||||
|
displayContrastTimers[monitorId] = null;
|
||||||
|
}, DISPLAY_THROTTLE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onDisplayContrastChange(monitorId, value) {
|
||||||
|
if (displayContrastTimers[monitorId]) {
|
||||||
|
clearTimeout(displayContrastTimers[monitorId]);
|
||||||
|
displayContrastTimers[monitorId] = null;
|
||||||
|
}
|
||||||
|
sendDisplayContrast(monitorId, parseInt(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDisplayContrast(monitorId, contrast) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/display/contrast/${monitorId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||||
|
body: JSON.stringify({ contrast })
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!data.success) showToast(t('display.msg.contrast_failed'), 'error');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set contrast:', e);
|
||||||
|
showToast(t('display.msg.contrast_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onDisplayInputSourceChange(monitorId, source) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/display/input_source/${monitorId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||||
|
body: JSON.stringify({ source })
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (data.success) showToast(t('display.msg.input_changed'), 'success');
|
||||||
|
else showToast(t('display.msg.input_failed'), 'error');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set input source:', e);
|
||||||
|
showToast(t('display.msg.input_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onDisplayColorPresetChange(monitorId, preset) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/display/color_preset/${monitorId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||||
|
body: JSON.stringify({ preset })
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (data.success) showToast(t('display.msg.color_changed'), 'success');
|
||||||
|
else showToast(t('display.msg.color_failed'), 'error');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set color preset:', e);
|
||||||
|
showToast(t('display.msg.color_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onDisplayPictureModeChange(monitorId, codeRaw) {
|
||||||
|
const code = parseInt(codeRaw, 10);
|
||||||
|
if (Number.isNaN(code)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/display/picture_mode/${monitorId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||||
|
body: JSON.stringify({ code })
|
||||||
|
});
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (data.success) showToast(t('display.msg.mode_changed'), 'success');
|
||||||
|
else showToast(t('display.msg.mode_failed'), 'error');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set picture mode:', e);
|
||||||
|
showToast(t('display.msg.mode_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleDisplayPower(monitorId, monitorName) {
|
export async function toggleDisplayPower(monitorId, monitorName) {
|
||||||
const btn = document.getElementById(`power-btn-${monitorId}`);
|
const btn = document.getElementById(`power-btn-${monitorId}`);
|
||||||
const isOn = btn && btn.classList.contains('on');
|
const isOn = btn && btn.classList.contains('on');
|
||||||
|
|||||||
@@ -161,6 +161,19 @@
|
|||||||
"display.power_on": "Turn on",
|
"display.power_on": "Turn on",
|
||||||
"display.power_off": "Turn off",
|
"display.power_off": "Turn off",
|
||||||
"display.primary": "Primary",
|
"display.primary": "Primary",
|
||||||
|
"display.brightness": "Brightness",
|
||||||
|
"display.contrast": "Contrast",
|
||||||
|
"display.tuning": "Picture tuning",
|
||||||
|
"display.input_source": "Input",
|
||||||
|
"display.color_preset": "Color temp",
|
||||||
|
"display.picture_mode": "Picture mode",
|
||||||
|
"display.msg.contrast_failed": "Failed to set contrast",
|
||||||
|
"display.msg.input_changed": "Input source switched",
|
||||||
|
"display.msg.input_failed": "Failed to switch input source",
|
||||||
|
"display.msg.color_changed": "Color preset applied",
|
||||||
|
"display.msg.color_failed": "Failed to apply color preset",
|
||||||
|
"display.msg.mode_changed": "Picture mode applied",
|
||||||
|
"display.msg.mode_failed": "Failed to apply picture mode",
|
||||||
"browser.title": "Media Browser",
|
"browser.title": "Media Browser",
|
||||||
"browser.home": "Home",
|
"browser.home": "Home",
|
||||||
"browser.manage_folders": "Manage Folders",
|
"browser.manage_folders": "Manage Folders",
|
||||||
|
|||||||
@@ -161,6 +161,19 @@
|
|||||||
"display.power_on": "Включить",
|
"display.power_on": "Включить",
|
||||||
"display.power_off": "Выключить",
|
"display.power_off": "Выключить",
|
||||||
"display.primary": "Основной",
|
"display.primary": "Основной",
|
||||||
|
"display.brightness": "Яркость",
|
||||||
|
"display.contrast": "Контраст",
|
||||||
|
"display.tuning": "Настройка изображения",
|
||||||
|
"display.input_source": "Вход",
|
||||||
|
"display.color_preset": "Цветовая температура",
|
||||||
|
"display.picture_mode": "Режим изображения",
|
||||||
|
"display.msg.contrast_failed": "Не удалось установить контраст",
|
||||||
|
"display.msg.input_changed": "Источник входа переключён",
|
||||||
|
"display.msg.input_failed": "Не удалось переключить источник",
|
||||||
|
"display.msg.color_changed": "Цветовая температура применена",
|
||||||
|
"display.msg.color_failed": "Не удалось применить цветовую температуру",
|
||||||
|
"display.msg.mode_changed": "Режим изображения применён",
|
||||||
|
"display.msg.mode_failed": "Не удалось применить режим изображения",
|
||||||
"browser.title": "Медиа Браузер",
|
"browser.title": "Медиа Браузер",
|
||||||
"browser.home": "Главная",
|
"browser.home": "Главная",
|
||||||
"browser.manage_folders": "Управление папками",
|
"browser.manage_folders": "Управление папками",
|
||||||
|
|||||||
Reference in New Issue
Block a user