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:
2026-05-15 14:28:04 +03:00
parent 0d07f7f1f4
commit 57fdeb70fb
7 changed files with 853 additions and 43 deletions
+324 -7
View File
@@ -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 { IconSelect } from './icon-select.js';
let displayBrightnessTimers = {};
let displayContrastTimers = {};
let _displayIconSelects = [];
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() {
if (!hasCredentials()) return;
@@ -14,7 +109,7 @@ export async function loadDisplayMonitors() {
if (!container) return;
try {
const response = await fetch('/api/display/monitors?refresh=true', {
const response = await fetch('/api/display/monitors', {
headers: getAuthHeaders()
});
@@ -36,7 +131,13 @@ export async function loadDisplayMonitors() {
return;
}
// Destroy IconSelects from a previous render so listeners + popups
// don't pile up.
_displayIconSelects.forEach(inst => { try { inst.destroy(); } catch (_) {} });
_displayIconSelects = [];
container.innerHTML = '';
const pendingIconSelects = [];
monitors.forEach(monitor => {
const card = document.createElement('div');
card.className = 'display-monitor-card';
@@ -65,6 +166,116 @@ export async function loadDisplayMonitors() {
</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 = `
<div class="display-monitor-header">
<svg class="display-monitor-icon" viewBox="0 0 24 24" width="20" height="20">
@@ -76,18 +287,40 @@ export async function loadDisplayMonitors() {
</div>
${powerBtn}
</div>
<div class="display-brightness-control">
<svg class="display-brightness-icon" viewBox="0 0 24 24" width="16" height="16">
<div class="display-slider-row display-brightness-control">
<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"/>
</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)"
onchange="onDisplayBrightnessChange(${monitor.id}, this.value)">
<span class="display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
</div>`;
<span class="display-slider-value display-brightness-value" id="brightness-val-${monitor.id}">${brightnessValue}%</span>
</div>
${contrastRow}
${tuningBlock}`;
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) {
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) {
const btn = document.getElementById(`power-btn-${monitorId}`);
const isOn = btn && btn.classList.contains('on');