diff --git a/media_server/main.py b/media_server/main.py index ef1af00..306ebb3 100644 --- a/media_server/main.py +++ b/media_server/main.py @@ -16,7 +16,7 @@ from fastapi.staticfiles import StaticFiles from . import __version__ from .auth import get_token_label, token_label_var from .config import settings, generate_default_config, get_config_dir -from .routes import audio_router, browser_router, callbacks_router, health_router, media_router, scripts_router +from .routes import audio_router, browser_router, callbacks_router, display_router, health_router, media_router, scripts_router from .services import get_media_controller from .services.websocket_manager import ws_manager @@ -116,6 +116,7 @@ def create_app() -> FastAPI: app.include_router(audio_router) app.include_router(browser_router) app.include_router(callbacks_router) + app.include_router(display_router) app.include_router(health_router) app.include_router(media_router) app.include_router(scripts_router) diff --git a/media_server/routes/__init__.py b/media_server/routes/__init__.py index e855572..05efa46 100644 --- a/media_server/routes/__init__.py +++ b/media_server/routes/__init__.py @@ -3,8 +3,9 @@ from .audio import router as audio_router from .browser import router as browser_router from .callbacks import router as callbacks_router +from .display import router as display_router from .health import router as health_router from .media import router as media_router from .scripts import router as scripts_router -__all__ = ["audio_router", "browser_router", "callbacks_router", "health_router", "media_router", "scripts_router"] +__all__ = ["audio_router", "browser_router", "callbacks_router", "display_router", "health_router", "media_router", "scripts_router"] diff --git a/media_server/routes/display.py b/media_server/routes/display.py new file mode 100644 index 0000000..cc9b434 --- /dev/null +++ b/media_server/routes/display.py @@ -0,0 +1,59 @@ +"""Display brightness and power control API endpoints.""" + +import logging + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from ..auth import verify_token +from ..services.display_service import ( + get_brightness, + list_monitors, + set_brightness, + set_power, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/display", tags=["display"]) + + +class BrightnessRequest(BaseModel): + brightness: int = Field(ge=0, le=100) + + +class PowerRequest(BaseModel): + on: bool + + +@router.get("/monitors") +async def get_monitors( + refresh: bool = False, _: str = Depends(verify_token) +) -> list[dict]: + """List all connected monitors with brightness and power info.""" + monitors = list_monitors(force_refresh=refresh) + logger.debug("Found %d monitors", len(monitors)) + return [m.to_dict() for m in monitors] + + +@router.post("/brightness/{monitor_id}") +async def set_monitor_brightness( + monitor_id: int, request: BrightnessRequest, _: str = Depends(verify_token) +) -> dict: + """Set brightness for a specific monitor.""" + success = set_brightness(monitor_id, request.brightness) + if success: + logger.info("Set monitor %d brightness to %d", monitor_id, request.brightness) + return {"success": success} + + +@router.post("/power/{monitor_id}") +async def set_monitor_power( + monitor_id: int, request: PowerRequest, _: str = Depends(verify_token) +) -> dict: + """Turn a monitor on or off.""" + action = "on" if request.on else "off" + success = set_power(monitor_id, request.on) + if success: + logger.info("Set monitor %d power %s", monitor_id, action) + return {"success": success} diff --git a/media_server/services/display_service.py b/media_server/services/display_service.py new file mode 100644 index 0000000..3d23193 --- /dev/null +++ b/media_server/services/display_service.py @@ -0,0 +1,208 @@ +"""Display brightness and power control service.""" + +import logging +import platform +import struct +import time +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +_sbc = None +_monitorcontrol = None + + +def _load_sbc(): + global _sbc + if _sbc is None: + try: + import screen_brightness_control as sbc + _sbc = sbc + except ImportError: + logger.warning("screen_brightness_control not installed - brightness control unavailable") + return _sbc + + +def _load_monitorcontrol(): + global _monitorcontrol + if _monitorcontrol is None: + try: + import monitorcontrol + _monitorcontrol = monitorcontrol + except ImportError: + logger.warning("monitorcontrol not installed - display power control unavailable") + return _monitorcontrol + + +def _parse_edid_resolution(edid_hex: str) -> str | None: + """Parse resolution from EDID hex string (first detailed timing descriptor).""" + try: + edid = bytes.fromhex(edid_hex) + if len(edid) < 58: + return None + dtd = edid[54:] + pixel_clock = struct.unpack('> 4) << 8) + v_active = dtd[5] | ((dtd[7] >> 4) << 8) + return f"{h_active}x{v_active}" + except Exception: + return None + + +@dataclass +class MonitorInfo: + id: int + name: str + brightness: int | None + power_supported: bool + power_on: bool = True + model: str = "" + manufacturer: str = "" + resolution: str | None = None + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "brightness": self.brightness, + "power_supported": self.power_supported, + "power_on": self.power_on, + "model": self.model, + "manufacturer": self.manufacturer, + "resolution": self.resolution, + } + + +# Cache for monitor list +_monitor_cache: list[MonitorInfo] | None = None +_cache_time: float = 0 +_CACHE_TTL = 5.0 # seconds + + +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: + return _monitor_cache + + sbc = _load_sbc() + if sbc is None: + return [] + + monitors = [] + try: + info_list = sbc.list_monitors_info() + brightnesses = sbc.get_brightness() + + # Get DDC/CI monitors for power state + mc = _load_monitorcontrol() + ddc_monitors = [] + if mc: + try: + ddc_monitors = mc.get_monitors() + except Exception: + pass + + for i, info in enumerate(info_list): + name = info.get("name", f"Monitor {i}") + model = info.get("model", "") + manufacturer = info.get("manufacturer", "") + brightness = brightnesses[i] if i < len(brightnesses) else None + # VCP method monitors support DDC/CI power control + method = str(info.get("method", "")).lower() + power_supported = "vcp" in method + + # Parse resolution from EDID + edid = info.get("edid", "") + resolution = _parse_edid_resolution(edid) if edid else None + + # Read power state via DDC/CI + power_on = True + if power_supported and i < len(ddc_monitors): + try: + with ddc_monitors[i] as mon: + power_mode = mon.get_power_mode() + power_on = power_mode == mc.PowerMode.on + except Exception: + pass + + monitors.append(MonitorInfo( + id=i, + name=name, + brightness=brightness, + power_supported=power_supported, + power_on=power_on, + model=model, + manufacturer=manufacturer, + resolution=resolution, + )) + except Exception as e: + logger.error("Failed to enumerate monitors: %s", e) + + _monitor_cache = monitors + _cache_time = time.time() + return monitors + + +def get_brightness(monitor_id: int) -> int | None: + """Get brightness for a specific monitor.""" + sbc = _load_sbc() + if sbc is None: + return None + try: + result = sbc.get_brightness(display=monitor_id) + if isinstance(result, list): + return result[0] if result else None + return result + except Exception as e: + logger.error("Failed to get brightness for monitor %d: %s", monitor_id, e) + return None + + +def set_brightness(monitor_id: int, value: int) -> bool: + """Set brightness for a specific monitor (0-100).""" + sbc = _load_sbc() + if sbc is None: + return False + + value = max(0, min(100, value)) + try: + sbc.set_brightness(value, display=monitor_id) + # Invalidate cache + global _monitor_cache + _monitor_cache = None + return True + except Exception as e: + logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e) + return False + + +def set_power(monitor_id: int, on: bool) -> bool: + """Turn a monitor on or off via DDC/CI.""" + mc = _load_monitorcontrol() + if mc is None: + logger.error("monitorcontrol not available for power control") + return False + + try: + ddc_monitors = mc.get_monitors() + if monitor_id >= len(ddc_monitors): + logger.error("Monitor %d not found in DDC/CI monitors", monitor_id) + return False + + with ddc_monitors[monitor_id] as monitor: + if on: + monitor.set_power_mode(mc.PowerMode.on) + else: + monitor.set_power_mode(mc.PowerMode.off_soft) + + # Invalidate cache + global _monitor_cache + _monitor_cache = None + return True + except Exception as e: + logger.error("Failed to set power for monitor %d: %s", monitor_id, e) + return False diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index aa9fb3d..050da55 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -41,7 +41,8 @@ :root[data-theme="light"] .player-container, :root[data-theme="light"] .browser-container, :root[data-theme="light"] .scripts-container, -:root[data-theme="light"] .script-management { +:root[data-theme="light"] .script-management, +:root[data-theme="light"] .display-container { box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); } @@ -886,6 +887,163 @@ button:disabled { color: var(--text-primary); } +/* Display Control Section */ + .display-container { + background: var(--bg-secondary); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + } + + .display-monitors { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .display-monitor-card { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + } + + .display-monitor-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + .display-monitor-icon { + color: var(--text-muted); + flex-shrink: 0; + } + + .display-monitor-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + .display-monitor-name { + font-weight: 500; + color: var(--text-primary); + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .display-monitor-details { + font-size: 0.75rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .display-power-btn { + width: 30px; + height: 30px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; + } + + .display-power-btn.on { + color: var(--accent); + border-color: var(--accent); + } + + .display-power-btn.on:hover { + color: var(--error); + border-color: var(--error); + } + + .display-power-btn.off { + color: var(--error); + border-color: var(--error); + opacity: 0.6; + } + + .display-power-btn.off:hover { + color: var(--accent); + border-color: var(--accent); + opacity: 1; + } + + .display-brightness-control { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .display-brightness-icon { + color: var(--text-muted); + flex-shrink: 0; + } + + .display-brightness-slider { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 6px; + border-radius: 3px; + background: var(--bg-primary); + outline: none; + cursor: pointer; + } + + .display-brightness-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + transition: transform 0.15s; + } + + .display-brightness-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: none; + } + + .display-brightness-slider:hover::-webkit-slider-thumb { + transform: scale(1.2); + } + + .display-brightness-slider:hover::-moz-range-thumb { + transform: scale(1.2); + } + + .display-brightness-slider:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .display-brightness-value { + font-size: 0.813rem; + color: var(--text-secondary); + min-width: 36px; + text-align: right; + font-variant-numeric: tabular-nums; + } + .add-card { display: flex; align-items: center; diff --git a/media_server/static/index.html b/media_server/static/index.html index c6703d3..490eb66 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -101,6 +101,10 @@ Player + `; + } + + const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' · '); + const detailsHtml = details ? `${details}` : ''; + + card.innerHTML = ` +
+ + + +
+ ${monitor.name} + ${detailsHtml} +
+ ${powerBtn} +
+
+ + + + + ${brightnessValue}% +
`; + + container.appendChild(card); + }); + } catch (e) { + console.error('Failed to load display monitors:', e); + } +} + +function onDisplayBrightnessInput(monitorId, value) { + const label = document.getElementById(`brightness-val-${monitorId}`); + if (label) label.textContent = `${value}%`; + + if (displayBrightnessTimers[monitorId]) clearTimeout(displayBrightnessTimers[monitorId]); + displayBrightnessTimers[monitorId] = setTimeout(() => { + sendDisplayBrightness(monitorId, parseInt(value)); + displayBrightnessTimers[monitorId] = null; + }, DISPLAY_THROTTLE_MS); +} + +function onDisplayBrightnessChange(monitorId, value) { + if (displayBrightnessTimers[monitorId]) { + clearTimeout(displayBrightnessTimers[monitorId]); + displayBrightnessTimers[monitorId] = null; + } + sendDisplayBrightness(monitorId, parseInt(value)); +} + +async function sendDisplayBrightness(monitorId, brightness) { + const token = localStorage.getItem('media_server_token'); + try { + await fetch(`/api/display/brightness/${monitorId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ brightness }) + }); + } catch (e) { + console.error('Failed to set brightness:', e); + } +} + +async function toggleDisplayPower(monitorId, monitorName) { + const btn = document.getElementById(`power-btn-${monitorId}`); + const isOn = btn && btn.classList.contains('on'); + const newState = !isOn; + + const token = localStorage.getItem('media_server_token'); + try { + const response = await fetch(`/api/display/power/${monitorId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ on: newState }) + }); + const data = await response.json(); + if (data.success) { + if (btn) { + btn.classList.toggle('on', newState); + btn.classList.toggle('off', !newState); + btn.title = newState ? t('display.power_off') : t('display.power_on'); + } + showToast(newState ? 'Monitor turned on' : 'Monitor turned off', 'success'); + } else { + showToast('Failed to change monitor power', 'error'); + } + } catch (e) { + console.error('Failed to set display power:', e); + showToast('Failed to change monitor power', 'error'); + } +} + diff --git a/media_server/static/locales/en.json b/media_server/static/locales/en.json index e72b373..684b188 100644 --- a/media_server/static/locales/en.json +++ b/media_server/static/locales/en.json @@ -119,6 +119,12 @@ "tab.quick_actions": "Actions", "tab.scripts": "Scripts", "tab.callbacks": "Callbacks", + "tab.display": "Display", + "display.loading": "Loading monitors...", + "display.error": "Failed to load monitors", + "display.no_monitors": "No monitors detected", + "display.power_on": "Turn on", + "display.power_off": "Turn off", "browser.title": "Media Browser", "browser.home": "Home", "browser.manage_folders": "Manage Folders", diff --git a/media_server/static/locales/ru.json b/media_server/static/locales/ru.json index bed6387..c23b06a 100644 --- a/media_server/static/locales/ru.json +++ b/media_server/static/locales/ru.json @@ -119,6 +119,12 @@ "tab.quick_actions": "Действия", "tab.scripts": "Скрипты", "tab.callbacks": "Колбэки", + "tab.display": "Дисплей", + "display.loading": "Загрузка мониторов...", + "display.error": "Не удалось загрузить мониторы", + "display.no_monitors": "Мониторы не обнаружены", + "display.power_on": "Включить", + "display.power_off": "Выключить", "browser.title": "Медиа Браузер", "browser.home": "Главная", "browser.manage_folders": "Управление папками", diff --git a/pyproject.toml b/pyproject.toml index dd0ffd0..bcfd886 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,8 @@ windows = [ "pywin32>=306", "comtypes>=1.2.0", "pycaw>=20230407", + "screen-brightness-control>=0.20.0", + "monitorcontrol>=3.0.0", ] dev = [ "pytest>=7.0",