Compare commits

...

2 Commits

Author SHA1 Message Date
alexei.dolgolyov 770bba7e60 chore: release v0.2.4
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 23s
Release / build-windows (push) Successful in 50s
2026-05-15 14:50:28 +03:00
alexei.dolgolyov d1f621f0b4 fix(displays): verify DDC/CI writes and trust capability string for picture mode
Lint & Test / test (push) Successful in 10s
DDC/CI writes are fire-and-forget at the protocol level: a successful send
does not mean the monitor honored the value. Many monitors (LG ultrawides
in particular) silently drop writes for VCP codes whose registers exist
but whose feature isn't really implemented in firmware.

- New _verify_after_set helper polls readback after every DDC/CI write and
  reports {success: false} when the monitor didn't apply the value. Wired
  into set_contrast, set_input_source, set_color_preset, set_picture_mode.
  Input source uses a longer settle window since switching can briefly
  disrupt the DDC/CI link.

- Picture mode (VCP 0xDC) now requires the capability string to declare
  supported codes under cmds[0xDC]. Without that declaration we treat the
  feature as unsupported even when reads succeed - the LG case where reads
  return a stuck value and every write is silently ignored.
2026-05-15 14:45:40 +03:00
5 changed files with 89 additions and 26 deletions
+15 -8
View File
@@ -1,14 +1,20 @@
## v0.2.3 (2026-05-01)
## v0.2.4 (2026-05-15)
### UI / Player
### Features
- Square the vinyl stage (`1:0.85``1:1`) and pin the tonearm to `height: 36%` instead of `aspect-ratio: 1` so its vertical span tracks the stage on resize. Refines the geometry shipped in v0.2.2. ([d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a))
- Brighten the tonearm SVG: lighter pivot/arm gradient stops, thicker stroke widths, stronger cartridge highlight. ([d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a))
- Tilt the sleeve `-2deg` so it reads as resting on the disc rather than rectilinearly composed. ([d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a))
- **Displays — DDC/CI picture controls:** Monitor cards now expose contrast (slider, same editorial copper treatment as brightness) plus a new "PICTURE TUNING" section with input source, color preset, and picture mode pickers built on the IconSelect widget — HDMI/DisplayPort/DVI/VGA/USB-C glyphs for inputs, thermometer for color temperatures, per-mode icons (movie reel, gamepad, ball, etc.) for picture modes. Backend probes DDC/CI capabilities per monitor at enumeration time and exposes `*_supported` flags so the UI hides rows the hardware doesn't advertise. New endpoints: `POST /api/display/{contrast,input_source,color_preset,picture_mode}/{id}`. Picture mode uses raw VCP `0xDC` with MCCS-spec labels and vendor-friendly fallbacks. 14 new i18n keys per locale (en/ru). ([57fdeb7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/57fdeb7))
### Bug Fixes
- **Displays:** keep the primary-display star visible on long monitor names. Move `overflow: hidden` + ellipsis off the parent flex container onto a new inner span, and add `flex-shrink: 0` to the badge so the favourite indicator no longer gets clipped when the model name truncates. ([372e4eb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/372e4eb))
- **Displays — verify DDC/CI writes before reporting success:** DDC/CI writes are fire-and-forget at the protocol level — a successful send does not mean the monitor honored the value. A new `_verify_after_set` helper polls readback after every write and reports `{success: false}` when the monitor silently dropped it (common on LG ultrawides for VCP codes whose registers exist but whose feature isn't really implemented in firmware). Wired into `set_contrast`, `set_input_source`, `set_color_preset`, `set_picture_mode`; input source uses a longer settle window since switching can briefly disrupt the DDC/CI link. Picture mode (VCP `0xDC`) additionally requires the capability string to declare supported codes under `cmds[0xDC]` — without that declaration we treat the feature as unsupported even when reads succeed (the LG case where reads return a stuck value and every write is silently ignored). ([d1f621f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1f621f))
---
### Development / Internal
#### Chores
- **`restart-server.ps1` — installer vs dev launches:** Previously only killed processes named `media-server`, silently missing the installer-bundled process (which runs as plain `python.exe` via `media-server.bat`). The script now kills whatever currently owns the listen port regardless of process name, adds `-Mode auto|dev|installer` with auto-detection based on whether the installer launcher exists in `%LOCALAPPDATA%\Media Server`, verifies the port is listening after start, and merges registry PATH so newly-installed dev tools are visible. ([6120625](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6120625))
---
@@ -17,7 +23,8 @@
| Hash | Message | Author |
|------|---------|--------|
| [d27484a](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d27484a) | ui(player): square vinyl stage, brighter tonearm, tilted sleeve | alexei.dolgolyov |
| [372e4eb](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/372e4eb) | fix(displays): keep primary-display star visible on long monitor names | alexei.dolgolyov |
| [57fdeb7](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/57fdeb7) | feat(displays): expose DDC/CI contrast, input source, color preset, picture mode | alexei.dolgolyov |
| [6120625](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/6120625) | chore(scripts): harden restart-server.ps1 against installer vs dev launches | alexei.dolgolyov |
| [d1f621f](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/d1f621f) | fix(displays): verify DDC/CI writes and trust capability string for picture mode | alexei.dolgolyov |
</details>
+70 -14
View File
@@ -267,20 +267,27 @@ def _probe_static_open(mon, mc, monitor_id: int) -> dict:
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)
# Picture / scene mode (VCP 0xDC). Trickier than color preset because
# many monitors (LG ultrawides included) respond to READS but silently
# drop every WRITE - they implement the register but not the feature.
# The capability string is the most reliable signal: a monitor that
# really implements picture mode declares its supported codes under
# cmds[0xDC]. If 0xDC isn't declared, treat the feature as unsupported
# to avoid exposing a non-functional select.
cmds = caps.get("cmds") or {}
declared = cmds.get(PICTURE_MODE_VCP)
if declared:
try:
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
static["picture_mode_supported"] = True
static["available_picture_modes"] = [
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
for c in sorted(declared)
]
except Exception as e:
logger.debug("Monitor %d: picture_mode declared but unreadable: %s", monitor_id, e)
else:
logger.debug("Monitor %d: picture_mode (VCP 0xDC) not declared in capability string", monitor_id)
return static
@@ -498,6 +505,31 @@ def set_power(monitor_id: int, on: bool) -> bool:
return False
def _verify_after_set(getter, expected, *, retries: int = 3, delay: float = 0.1) -> bool:
"""Poll a DDC/CI getter to confirm the monitor actually applied a write.
DDC/CI writes are fire-and-forget at the protocol level: a successful
send does not mean the monitor honored the value. Many monitors silently
drop writes for codes their firmware doesn't really implement (LG's
ColorPreset / Picture Mode are common offenders). Without this check the
API would report `success: true` while the monitor sat unchanged.
Compares both raw and `.value` forms so enum/int mismatches don't flag a
spurious failure.
"""
expected_int = getattr(expected, "value", expected)
for _ in range(retries):
time.sleep(delay)
try:
actual = getter()
except Exception:
continue
actual_int = getattr(actual, "value", actual)
if actual == expected or actual_int == expected_int:
return True
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()
@@ -511,6 +543,9 @@ def set_contrast(monitor_id: int, value: int) -> bool:
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_contrast(value)
if not _verify_after_set(monitor.get_contrast, value):
logger.warning("Monitor %d: contrast %d not applied", monitor_id, value)
return False
_invalidate_cache()
return True
except Exception as e:
@@ -536,6 +571,11 @@ def set_input_source(monitor_id: int, source: str) -> bool:
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_input_source(target)
# Source switches can briefly disrupt the DDC/CI link; allow a
# longer settle window before declaring failure.
if not _verify_after_set(monitor.get_input_source, target, retries=5, delay=0.2):
logger.warning("Monitor %d: input source %s not applied", monitor_id, source)
return False
_invalidate_cache()
return True
except Exception as e:
@@ -561,6 +601,12 @@ def set_color_preset(monitor_id: int, preset: str) -> bool:
return False
with ddc_monitors[monitor_id] as monitor:
monitor.set_color_preset(target)
if not _verify_after_set(monitor.get_color_preset, target):
logger.warning(
"Monitor %d: color preset %s not applied (monitor silently rejected)",
monitor_id, preset,
)
return False
_invalidate_cache()
return True
except Exception as e:
@@ -584,6 +630,16 @@ def set_picture_mode(monitor_id: int, code: int) -> bool:
return False
with ddc_monitors[monitor_id] as monitor:
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
# Raw VCP read returns (current, maximum) — only compare current.
def _read_picture_mode():
current, _ = monitor.vcp.get_vcp_feature(PICTURE_MODE_VCP)
return current
if not _verify_after_set(_read_picture_mode, code):
logger.warning(
"Monitor %d: picture mode code %d not applied (monitor silently rejected)",
monitor_id, code,
)
return False
_invalidate_cache()
return True
except Exception as e:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "media-server-frontend",
"version": "0.2.3",
"version": "0.2.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-server-frontend",
"version": "0.2.3",
"version": "0.2.4",
"devDependencies": {
"esbuild": "^0.27.4"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "media-server-frontend",
"version": "0.2.3",
"version": "0.2.4",
"private": true,
"description": "Frontend build tooling for media server WebUI",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "media-server"
version = "0.2.3"
version = "0.2.4"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }