diff --git a/media_server/services/display_service.py b/media_server/services/display_service.py index 3d23193..dbc9bbf 100644 --- a/media_server/services/display_service.py +++ b/media_server/services/display_service.py @@ -1,5 +1,7 @@ """Display brightness and power control service.""" +import ctypes +import ctypes.wintypes import logging import platform import struct @@ -61,6 +63,7 @@ class MonitorInfo: model: str = "" manufacturer: str = "" resolution: str | None = None + is_primary: bool = False def to_dict(self) -> dict: return { @@ -72,9 +75,68 @@ class MonitorInfo: "model": self.model, "manufacturer": self.manufacturer, "resolution": self.resolution, + "is_primary": self.is_primary, } +def _detect_primary_resolution() -> str | None: + """Detect the primary display resolution via Windows API.""" + if platform.system() != "Windows": + return None + try: + class MONITORINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.wintypes.DWORD), + ("rcMonitor", ctypes.wintypes.RECT), + ("rcWork", ctypes.wintypes.RECT), + ("dwFlags", ctypes.wintypes.DWORD), + ] + + MONITORINFOF_PRIMARY = 1 + primary_res = None + + def callback(hmon, hdc, rect, data): + nonlocal primary_res + mi = MONITORINFO() + mi.cbSize = ctypes.sizeof(mi) + ctypes.windll.user32.GetMonitorInfoW(hmon, ctypes.byref(mi)) + if mi.dwFlags & MONITORINFOF_PRIMARY: + w = mi.rcMonitor.right - mi.rcMonitor.left + h = mi.rcMonitor.bottom - mi.rcMonitor.top + primary_res = f"{w}x{h}" + return True + + MONITORENUMPROC = ctypes.WINFUNCTYPE( + ctypes.c_int, + ctypes.wintypes.HMONITOR, + ctypes.wintypes.HDC, + ctypes.POINTER(ctypes.wintypes.RECT), + ctypes.wintypes.LPARAM, + ) + ctypes.windll.user32.EnumDisplayMonitors( + None, None, MONITORENUMPROC(callback), 0 + ) + return primary_res + except Exception: + return None + + +def _mark_primary(monitors: list[MonitorInfo]) -> None: + """Mark the primary display in the monitor list.""" + if not monitors: + return + + primary_res = _detect_primary_resolution() + if primary_res: + for m in monitors: + if m.resolution == primary_res: + m.is_primary = True + return + + # Fallback: mark first monitor as primary + monitors[0].is_primary = True + + # Cache for monitor list _monitor_cache: list[MonitorInfo] | None = None _cache_time: float = 0 @@ -142,6 +204,7 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]: except Exception as e: logger.error("Failed to enumerate monitors: %s", e) + _mark_primary(monitors) _monitor_cache = monitors _cache_time = time.time() return monitors diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 19c1a9e..b77dff8 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -303,12 +303,12 @@ h1 { top: calc(100% + 4px); background: var(--bg-secondary); border: 1px solid var(--border); - border-radius: 8px; - padding: 8px; + border-radius: 12px; + padding: 10px; gap: 6px; z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,0.3); - grid-template-columns: repeat(3, 24px); + grid-template-columns: repeat(3, 28px); } .accent-picker-dropdown.open { @@ -316,8 +316,8 @@ h1 { } .accent-swatch { - width: 24px; - height: 24px; + width: 28px; + height: 28px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; @@ -325,13 +325,57 @@ h1 { } .accent-swatch:hover { - transform: scale(1.2); + transform: scale(1.15); } .accent-swatch.active { border-color: var(--text-primary); } +.accent-custom-row { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 2px 2px; + margin-top: 4px; + border-top: 1px solid var(--border); + cursor: pointer; + border-radius: 4px; +} + +.accent-custom-row:hover .accent-custom-label { + color: var(--text-primary); +} + +.accent-custom-row.active .accent-custom-swatch { + outline: 2px solid var(--text-primary); + outline-offset: 1px; +} + +.accent-custom-swatch { + width: 20px; + height: 20px; + border-radius: 4px; + flex-shrink: 0; +} + +.accent-custom-label { + font-size: 0.75rem; + color: var(--text-muted); + transition: color 0.15s; +} + +.accent-custom-row input[type="color"] { + width: 0; + height: 0; + padding: 0; + border: none; + opacity: 0; + position: absolute; + pointer-events: none; +} + #locale-select { background: var(--bg-tertiary); border: 1px solid var(--border); @@ -1115,6 +1159,20 @@ button:disabled { white-space: nowrap; } + .display-primary-badge { + display: inline-block; + background: var(--accent); + color: #fff; + font-size: 0.625rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; + margin-left: 6px; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .display-monitor-details { font-size: 0.75rem; color: var(--text-muted); diff --git a/media_server/static/js/app.js b/media_server/static/js/app.js index 1ab196e..4b238ce 100644 --- a/media_server/static/js/app.js +++ b/media_server/static/js/app.js @@ -182,11 +182,23 @@ { name: 'Yellow', color: '#eab308', hover: '#facc15' }, ]; + function lightenColor(hex, percent) { + const num = parseInt(hex.replace('#', ''), 16); + const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100)); + const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100)); + const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100)); + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + } + function initAccentColor() { const saved = localStorage.getItem('accentColor'); if (saved) { const preset = accentPresets.find(p => p.color === saved); - if (preset) applyAccentColor(preset.color, preset.hover); + if (preset) { + applyAccentColor(preset.color, preset.hover); + } else { + applyAccentColor(saved, lightenColor(saved, 15)); + } } renderAccentSwatches(); } @@ -195,18 +207,33 @@ document.documentElement.style.setProperty('--accent', color); document.documentElement.style.setProperty('--accent-hover', hover); localStorage.setItem('accentColor', color); + const dot = document.getElementById('accentDot'); + if (dot) dot.style.background = color; } function renderAccentSwatches() { const dropdown = document.getElementById('accentDropdown'); if (!dropdown) return; const current = localStorage.getItem('accentColor') || '#1db954'; - dropdown.innerHTML = accentPresets.map(p => + const isCustom = !accentPresets.some(p => p.color === current); + + const swatches = accentPresets.map(p => `
` ).join(''); + + const customRow = ` +