Compare commits
2 Commits
6120625fa9
...
v0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 770bba7e60 | |||
| d1f621f0b4 |
+15
-8
@@ -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))
|
- **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))
|
||||||
- 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))
|
|
||||||
|
|
||||||
### Bug Fixes
|
### 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 |
|
| 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 |
|
| [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 |
|
||||||
| [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 |
|
| [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>
|
</details>
|
||||||
|
|||||||
@@ -267,20 +267,27 @@ def _probe_static_open(mon, mc, monitor_id: int) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
|
logger.debug("Monitor %d: color_preset unsupported: %s", monitor_id, e)
|
||||||
|
|
||||||
# Picture / scene mode (VCP 0xDC) — not exposed by monitorcontrol's
|
# Picture / scene mode (VCP 0xDC). Trickier than color preset because
|
||||||
# high-level API, so probe via raw VCP transport.
|
# many monitors (LG ultrawides included) respond to READS but silently
|
||||||
try:
|
# drop every WRITE - they implement the register but not the feature.
|
||||||
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
# The capability string is the most reliable signal: a monitor that
|
||||||
static["picture_mode_supported"] = True
|
# really implements picture mode declares its supported codes under
|
||||||
cmds = caps.get("cmds") or {}
|
# cmds[0xDC]. If 0xDC isn't declared, treat the feature as unsupported
|
||||||
declared = cmds.get(PICTURE_MODE_VCP)
|
# to avoid exposing a non-functional select.
|
||||||
codes = sorted(declared) if declared else sorted(PICTURE_MODE_LABELS.keys())
|
cmds = caps.get("cmds") or {}
|
||||||
static["available_picture_modes"] = [
|
declared = cmds.get(PICTURE_MODE_VCP)
|
||||||
{"code": c, "label": PICTURE_MODE_LABELS.get(c, f"Mode {c}")}
|
if declared:
|
||||||
for c in codes
|
try:
|
||||||
]
|
mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
|
||||||
except Exception as e:
|
static["picture_mode_supported"] = True
|
||||||
logger.debug("Monitor %d: picture_mode unsupported: %s", monitor_id, e)
|
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
|
return static
|
||||||
|
|
||||||
@@ -498,6 +505,31 @@ def set_power(monitor_id: int, on: bool) -> bool:
|
|||||||
return False
|
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:
|
def set_contrast(monitor_id: int, value: int) -> bool:
|
||||||
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
|
"""Set contrast for a specific monitor (0-100) via DDC/CI."""
|
||||||
mc = _load_monitorcontrol()
|
mc = _load_monitorcontrol()
|
||||||
@@ -511,6 +543,9 @@ def set_contrast(monitor_id: int, value: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
with ddc_monitors[monitor_id] as monitor:
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
monitor.set_contrast(value)
|
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()
|
_invalidate_cache()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -536,6 +571,11 @@ def set_input_source(monitor_id: int, source: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
with ddc_monitors[monitor_id] as monitor:
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
monitor.set_input_source(target)
|
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()
|
_invalidate_cache()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -561,6 +601,12 @@ def set_color_preset(monitor_id: int, preset: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
with ddc_monitors[monitor_id] as monitor:
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
monitor.set_color_preset(target)
|
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()
|
_invalidate_cache()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -584,6 +630,16 @@ def set_picture_mode(monitor_id: int, code: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
with ddc_monitors[monitor_id] as monitor:
|
with ddc_monitors[monitor_id] as monitor:
|
||||||
monitor.vcp.set_vcp_feature(PICTURE_MODE_VCP, code)
|
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()
|
_invalidate_cache()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.27.4"
|
"esbuild": "^0.27.4"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "media-server-frontend",
|
"name": "media-server-frontend",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Frontend build tooling for media server WebUI",
|
"description": "Frontend build tooling for media server WebUI",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "media-server"
|
name = "media-server"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
description = "REST API server for controlling system-wide media playback"
|
description = "REST API server for controlling system-wide media playback"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|||||||
Reference in New Issue
Block a user