Compare commits

...

9 Commits

Author SHA1 Message Date
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
alexei.dolgolyov 6120625fa9 chore(scripts): harden restart-server.ps1 against installer vs dev launches
Lint & Test / test (push) Successful in 13s
The previous version only killed processes named 'media-server', which
silently missed the installer-bundled process (which runs as plain
python.exe via media-server.bat). The new version:

- Kills whatever currently owns the listen port, regardless of process name
- Supports -Mode auto|dev|installer; auto-detects based on whether the
  installer launcher exists in %LOCALAPPDATA%\Media Server
- Verifies the port is listening after start
- Merges registry PATH so newly-installed dev tools are visible

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:28:14 +03:00
alexei.dolgolyov 57fdeb70fb 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>
2026-05-15 14:28:04 +03:00
alexei.dolgolyov 0d07f7f1f4 chore: release v0.2.3
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 4s
Release / build-linux (push) Successful in 30s
Release / build-windows (push) Successful in 1m21s
2026-05-01 19:41:41 +03:00
alexei.dolgolyov 372e4eb11f fix(displays): keep primary-display star visible on long monitor names
Wrapping overflow:hidden + ellipsis on the parent flex container
clipped the favourite star whenever the monitor name was long enough
to truncate. Move the truncation rules onto a new inner span around
the name text only, and add flex-shrink:0 to the badge so it always
renders in full.
2026-05-01 19:40:12 +03:00
alexei.dolgolyov d27484a46d ui(player): square vinyl stage, brighter tonearm, tilted sleeve
- Restore 1:1 aspect-ratio on .vinyl-stage; the previous 1:0.85
  override created an inconsistent crop on resize. Replace the
  tonearm sibling's aspect-ratio with explicit height:36% so its
  vertical span tracks the stage instead of its own width.
- Brighten the tonearm SVG: lighter pivot/arm gradient stops,
  thicker stroke widths, stronger cartridge highlight.
- Add a subtle -2deg tilt to the sleeve so it reads as physically
  resting on the disc rather than rectilinearly composed.
2026-05-01 19:40:04 +03:00
alexei.dolgolyov 261a14c575 chore: release v0.2.2
Lint & Test / test (push) Has been skipped
Release / create-release (push) Successful in 3s
Release / build-linux (push) Successful in 33s
Release / build-windows (push) Successful in 1m22s
2026-05-01 17:15:24 +03:00
alexei.dolgolyov e7372b0ccb chore: wire up code-review-graph MCP server
Lint & Test / test (push) Successful in 11s
- Add .mcp.json registering code-review-graph (uvx, stdio)
- Document the MCP tools in CLAUDE.md so the assistant prefers
  graph queries over Grep/Glob/Read for structural exploration
- Ignore .code-review-graph/ index directory
2026-05-01 11:28:22 +03:00
alexei.dolgolyov ec5178142e ui(player): replace footer with About dialog + reclaim dead space
- Move colophon (credit/email/source link) from sticky footer into
  a dedicated About dialog, opened from a new header button
- Drop ~64px of bottom container padding now that the footer is gone
- Loosen vinyl-stage aspect-ratio (1:1 -> 1:0.85) so the disc no
  longer leaves a tall empty band below the sleeve
- Switch tonearm height: 36% to aspect-ratio: 1 to keep proportions
  consistent across the new stage ratio
- Add about.* / dialog.close i18n keys for EN and RU
- Add vinyl-variants-mockup.html as next design reference target
2026-05-01 11:28:10 +03:00
18 changed files with 2118 additions and 189 deletions
+2
View File
@@ -53,3 +53,5 @@ Thumbs.db
# Node.js / esbuild
node_modules/
media_server/static/dist/
# Added by code-review-graph
.code-review-graph/
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"code-review-graph": {
"command": "uvx",
"args": [
"code-review-graph",
"serve"
],
"type": "stdio"
}
}
}
+39
View File
@@ -196,3 +196,42 @@ pytest --tb=short -q
- **ALWAYS ask for user approval before committing and pushing changes.**
- When pushing, always push to all remotes: `git push origin master && git push github master`
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+9 -21
View File
@@ -1,24 +1,14 @@
## v0.2.1 (2026-04-25)
## v0.2.3 (2026-05-01)
A small polish release on top of the Studio Reference redesign — accent picker fix,
visualizer performance work, and a couple of layout refinements for tablet and
small-desktop widths.
### UI / Player
- 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))
### Bug Fixes
- **Accent picker** now wired to the editorial copper palette + visual polish ([f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd))
### Performance
- **Visualizer** — significant CPU cuts on spectrum rendering and track switches ([51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150))
### UI Improvements
- Meaningful caps for tablet / small-desktop range + tighter footer ([25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d))
---
### Development / Internal
#### CI/Build
- Skip test workflow on release commits ([08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80))
- **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))
---
@@ -27,9 +17,7 @@ small-desktop widths.
| Hash | Message | Author |
|------|---------|--------|
| [25a492d](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/25a492d) | ui(player): meaningful caps for tablet/small-desktop range + tighter footer | alexei.dolgolyov |
| [f4be2bd](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/f4be2bd) | fix(player): wire accent picker to editorial copper palette + visual polish | alexei.dolgolyov |
| [51ec150](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/51ec150) | perf(visualizer): cut spectrum + track-switch CPU significantly | alexei.dolgolyov |
| [08c3c80](https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server/commit/08c3c80) | ci: skip test workflow on release commits | alexei.dolgolyov |
| [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 |
</details>
+75 -4
View File
@@ -1,4 +1,4 @@
"""Display brightness and power control API endpoints."""
"""Display brightness, power, contrast, input-source, color-preset and picture-mode API."""
import logging
@@ -9,6 +9,10 @@ from ..auth import verify_token
from ..services.display_service import (
list_monitors,
set_brightness,
set_color_preset,
set_contrast,
set_input_source,
set_picture_mode,
set_power,
)
@@ -25,12 +29,35 @@ class PowerRequest(BaseModel):
on: bool
class ContrastRequest(BaseModel):
contrast: int = Field(ge=0, le=100)
class InputSourceRequest(BaseModel):
source: str
class ColorPresetRequest(BaseModel):
preset: str
class PictureModeRequest(BaseModel):
code: int = Field(ge=0, le=255)
@router.get("/monitors")
async def get_monitors(
refresh: bool = False, _: str = Depends(verify_token)
refresh: bool = False,
rediscover: bool = False,
_: str = Depends(verify_token),
) -> list[dict]:
"""List all connected monitors with brightness and power info."""
monitors = list_monitors(force_refresh=refresh)
"""List all connected monitors with their reported DDC/CI capabilities.
- `refresh=true` bypasses the response TTL cache (re-reads current state).
- `rediscover=true` also drops the per-monitor capability cache, forcing
a full DDC/CI capability probe. Use after a monitor hot-swap.
"""
monitors = list_monitors(force_refresh=refresh, rediscover=rediscover)
logger.debug("Found %d monitors", len(monitors))
return [m.to_dict() for m in monitors]
@@ -56,3 +83,47 @@ async def set_monitor_power(
if success:
logger.info("Set monitor %d power %s", monitor_id, action)
return {"success": success}
@router.post("/contrast/{monitor_id}")
async def set_monitor_contrast(
monitor_id: int, request: ContrastRequest, _: str = Depends(verify_token)
) -> dict:
"""Set DDC/CI contrast for a specific monitor."""
success = set_contrast(monitor_id, request.contrast)
if success:
logger.info("Set monitor %d contrast to %d", monitor_id, request.contrast)
return {"success": success}
@router.post("/input_source/{monitor_id}")
async def set_monitor_input_source(
monitor_id: int, request: InputSourceRequest, _: str = Depends(verify_token)
) -> dict:
"""Switch a monitor's DDC/CI input source (e.g. HDMI1, DP1)."""
success = set_input_source(monitor_id, request.source)
if success:
logger.info("Set monitor %d input source to %s", monitor_id, request.source)
return {"success": success}
@router.post("/color_preset/{monitor_id}")
async def set_monitor_color_preset(
monitor_id: int, request: ColorPresetRequest, _: str = Depends(verify_token)
) -> dict:
"""Apply a DDC/CI color preset (color temperature) to the monitor."""
success = set_color_preset(monitor_id, request.preset)
if success:
logger.info("Set monitor %d color preset to %s", monitor_id, request.preset)
return {"success": success}
@router.post("/picture_mode/{monitor_id}")
async def set_monitor_picture_mode(
monitor_id: int, request: PictureModeRequest, _: str = Depends(verify_token)
) -> dict:
"""Apply a DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
success = set_picture_mode(monitor_id, request.code)
if success:
logger.info("Set monitor %d picture mode to code %d", monitor_id, request.code)
return {"success": success}
+403 -22
View File
@@ -1,4 +1,4 @@
"""Display brightness and power control service."""
"""Display brightness, power, contrast, input-source and color-preset control."""
import ctypes
import ctypes.wintypes
@@ -6,10 +6,33 @@ import logging
import platform
import struct
import time
from dataclasses import dataclass
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
# VCP 0xDC "Display Application" — picture / scene mode.
# Vendors deviate from the MCCS spec, but these labels match the standard
# meanings and cover what most monitors report through their capability
# string. Unknown codes fall back to "Mode <n>".
PICTURE_MODE_VCP = 0xDC
PICTURE_MODE_LABELS: dict[int, str] = {
0x00: "Default",
0x01: "Standalone",
0x02: "Mixed",
0x03: "Productivity",
0x04: "Movie",
0x05: "Game",
0x06: "Sports",
0x07: "Professional",
0x08: "Standard",
0x09: "Default",
0x0A: "Movie (Reduced Effects)",
0x0B: "Movie (Enhanced)",
0x0C: "User 1",
0x0D: "User 2",
0x0E: "User 3",
}
_sbc = None
_monitorcontrol = None
@@ -32,7 +55,7 @@ def _load_monitorcontrol():
import monitorcontrol
_monitorcontrol = monitorcontrol
except ImportError:
logger.warning("monitorcontrol not installed - display power control unavailable")
logger.warning("monitorcontrol not installed - DDC/CI control unavailable")
return _monitorcontrol
@@ -64,6 +87,18 @@ class MonitorInfo:
manufacturer: str = ""
resolution: str | None = None
is_primary: bool = False
contrast: int | None = None
contrast_supported: bool = False
input_source: str | None = None
available_input_sources: list[str] = field(default_factory=list)
input_source_supported: bool = False
color_preset: str | None = None
available_color_presets: list[str] = field(default_factory=list)
color_preset_supported: bool = False
picture_mode: str | None = None
picture_mode_code: int | None = None
available_picture_modes: list[dict] = field(default_factory=list)
picture_mode_supported: bool = False
def to_dict(self) -> dict:
return {
@@ -76,6 +111,18 @@ class MonitorInfo:
"manufacturer": self.manufacturer,
"resolution": self.resolution,
"is_primary": self.is_primary,
"contrast": self.contrast,
"contrast_supported": self.contrast_supported,
"input_source": self.input_source,
"available_input_sources": self.available_input_sources,
"input_source_supported": self.input_source_supported,
"color_preset": self.color_preset,
"available_color_presets": self.available_color_presets,
"color_preset_supported": self.color_preset_supported,
"picture_mode": self.picture_mode,
"picture_mode_code": self.picture_mode_code,
"available_picture_modes": self.available_picture_modes,
"picture_mode_supported": self.picture_mode_supported,
}
@@ -137,17 +184,183 @@ def _mark_primary(monitors: list[MonitorInfo]) -> None:
monitors[0].is_primary = True
# Cache for monitor list
# Short TTL cache of the assembled monitor list (full response).
_monitor_cache: list[MonitorInfo] | None = None
_cache_time: float = 0
_CACHE_TTL = 5.0 # seconds
# Per-monitor cache of static capabilities (option lists + support flags).
# DDC/CI capability discovery is the slow part — it only changes when a
# monitor is replaced or rewired, so we probe it once per monitor and reuse
# it across refreshes. Cleared on explicit `rediscover` or when the monitor
# count changes (cheap stale-detection for hot-plug events).
_static_cache: dict[int, dict] = {}
_static_cache_monitor_count: int = -1
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:
def _enum_name(value, enum_cls=None) -> str | None:
"""Best-effort name for an enum or raw int returned by monitorcontrol.
monitorcontrol's getters sometimes hand back raw ints when the monitor
reports a value the library wraps incompletely. Re-map those through the
matching enum class so HA selects still receive symbolic option names.
"""
if value is None:
return None
name = getattr(value, "name", None)
if name:
return name
if enum_cls is not None:
try:
return enum_cls(value).name
except (ValueError, KeyError):
pass
return str(value)
def _probe_static_open(mon, mc, monitor_id: int) -> dict:
"""Probe per-monitor static capabilities.
Must be called inside an open `with mon:` DDC/CI context. Tries each
feature once to confirm the monitor responds, and enumerates option
lists from the capability string. Heavy: this is what the cache is for.
"""
static = {
"contrast_supported": False,
"input_source_supported": False,
"available_input_sources": [],
"color_preset_supported": False,
"available_color_presets": [],
"picture_mode_supported": False,
"available_picture_modes": [],
}
try:
caps = mon.get_vcp_capabilities() or {}
except Exception as e:
caps = {}
logger.debug("Monitor %d: get_vcp_capabilities failed: %s", monitor_id, e)
try:
mon.get_contrast()
static["contrast_supported"] = True
except Exception as e:
logger.debug("Monitor %d: contrast unsupported: %s", monitor_id, e)
try:
mon.get_input_source()
static["input_source_supported"] = True
except Exception as e:
logger.debug("Monitor %d: input_source unsupported: %s", monitor_id, e)
inputs = caps.get("inputs") or []
input_enum = mc.InputSource if mc else None
static["available_input_sources"] = [
n for n in (_enum_name(s, input_enum) for s in inputs) if n is not None
]
try:
mon.get_color_preset()
static["color_preset_supported"] = True
if mc is not None:
static["available_color_presets"] = [p.name for p in mc.ColorPreset]
except Exception as e:
logger.debug("Monitor %d: color_preset 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
def _probe_dynamic_open(mon, mc, monitor_id: int, static: dict) -> dict:
"""Read current values for features known to be supported.
Must be called inside an open `with mon:` context. Skips reads for
unsupported features (saves one I2C roundtrip each), so the warm path
only touches features the monitor actually responds to.
"""
dynamic = {
"power_on": True,
"contrast": None,
"input_source": None,
"color_preset": None,
"picture_mode": None,
"picture_mode_code": None,
}
try:
dynamic["power_on"] = mon.get_power_mode() == mc.PowerMode.on
except Exception as e:
logger.debug("Monitor %d: power readback failed: %s", monitor_id, e)
if static.get("contrast_supported"):
try:
dynamic["contrast"] = mon.get_contrast()
except Exception as e:
logger.debug("Monitor %d: contrast readback failed: %s", monitor_id, e)
if static.get("input_source_supported"):
try:
src = mon.get_input_source()
dynamic["input_source"] = _enum_name(src, mc.InputSource if mc else None)
except Exception as e:
logger.debug("Monitor %d: input_source readback failed: %s", monitor_id, e)
if static.get("color_preset_supported"):
try:
preset = mon.get_color_preset()
dynamic["color_preset"] = _enum_name(preset, mc.ColorPreset if mc else None)
except Exception as e:
logger.debug("Monitor %d: color_preset readback failed: %s", monitor_id, e)
if static.get("picture_mode_supported"):
try:
current, _maximum = mon.vcp.get_vcp_feature(PICTURE_MODE_VCP)
dynamic["picture_mode_code"] = current
dynamic["picture_mode"] = PICTURE_MODE_LABELS.get(current, f"Mode {current}")
except Exception as e:
logger.debug("Monitor %d: picture_mode readback failed: %s", monitor_id, e)
return dynamic
def list_monitors(force_refresh: bool = False, rediscover: bool = False) -> list[MonitorInfo]:
"""List all connected monitors with their current state.
Args:
force_refresh: bypass the short TTL response cache.
rediscover: also drop the per-monitor static capability cache, so the
next probe re-runs DDC/CI capability discovery. Use after hot-plug
or when a monitor's reported capabilities change.
"""
global _monitor_cache, _cache_time, _static_cache_monitor_count
if (
not force_refresh
and not rediscover
and _monitor_cache is not None
and (time.time() - _cache_time) < _CACHE_TTL
):
return _monitor_cache
sbc = _load_sbc()
@@ -159,7 +372,13 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
info_list = sbc.list_monitors_info()
brightnesses = sbc.get_brightness()
# Get DDC/CI monitors for power state
# Invalidate the static cache on explicit rediscover OR on topology
# change (hot-plug / disconnect). Both indicate the cached probe is
# potentially stale.
if rediscover or len(info_list) != _static_cache_monitor_count:
_static_cache.clear()
_static_cache_monitor_count = len(info_list)
mc = _load_monitorcontrol()
ddc_monitors = []
if mc:
@@ -181,25 +400,44 @@ def list_monitors(force_refresh: bool = False) -> list[MonitorInfo]:
edid = info.get("edid", "")
resolution = _parse_edid_resolution(edid) if edid else None
# Read power state via DDC/CI
power_on = True
static: dict = {}
dynamic: dict = {}
# Open the DDC handle ONCE; do static probe (if needed) + dynamic
# readback inside the same context. Opening the handle is the
# expensive part — keep both phases under one open.
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
if i not in _static_cache:
_static_cache[i] = _probe_static_open(mon, mc, i)
static = _static_cache[i]
dynamic = _probe_dynamic_open(mon, mc, i, static)
except Exception as e:
logger.debug("Monitor %d: DDC/CI session failed: %s", i, e)
static = _static_cache.get(i, {})
monitors.append(MonitorInfo(
id=i,
name=name,
brightness=brightness,
power_supported=power_supported,
power_on=power_on,
power_on=dynamic.get("power_on", True),
model=model,
manufacturer=manufacturer,
resolution=resolution,
contrast=dynamic.get("contrast"),
contrast_supported=static.get("contrast_supported", False),
input_source=dynamic.get("input_source"),
available_input_sources=static.get("available_input_sources", []),
input_source_supported=static.get("input_source_supported", False),
color_preset=dynamic.get("color_preset"),
available_color_presets=static.get("available_color_presets", []),
color_preset_supported=static.get("color_preset_supported", False),
picture_mode=dynamic.get("picture_mode"),
picture_mode_code=dynamic.get("picture_mode_code"),
available_picture_modes=static.get("available_picture_modes", []),
picture_mode_supported=static.get("picture_mode_supported", False),
))
except Exception as e:
logger.error("Failed to enumerate monitors: %s", e)
@@ -234,9 +472,7 @@ def set_brightness(monitor_id: int, value: int) -> bool:
value = max(0, min(100, value))
try:
sbc.set_brightness(value, display=monitor_id)
# Invalidate cache
global _monitor_cache
_monitor_cache = None
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set brightness for monitor %d: %s", monitor_id, e)
@@ -262,10 +498,155 @@ def set_power(monitor_id: int, on: bool) -> bool:
else:
monitor.set_power_mode(mc.PowerMode.off_soft)
# Invalidate cache
global _monitor_cache
_monitor_cache = None
_invalidate_cache()
return True
except Exception as e:
logger.error("Failed to set power for monitor %d: %s", monitor_id, e)
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()
if mc is None:
return False
value = max(0, min(100, value))
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
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:
logger.error("Failed to set contrast for monitor %d: %s", monitor_id, e)
return False
def set_input_source(monitor_id: int, source: str) -> bool:
"""Set the DDC/CI input source by enum name (e.g. 'HDMI1', 'DP1')."""
mc = _load_monitorcontrol()
if mc is None:
return False
try:
target = mc.InputSource[source]
except KeyError:
logger.error("Unknown input source: %s", source)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
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:
logger.error("Failed to set input source for monitor %d: %s", monitor_id, e)
return False
def set_color_preset(monitor_id: int, preset: str) -> bool:
"""Set the DDC/CI color preset by enum name (e.g. 'COLOR_TEMP_6500K')."""
mc = _load_monitorcontrol()
if mc is None:
return False
try:
target = mc.ColorPreset[preset]
except KeyError:
logger.error("Unknown color preset: %s", preset)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
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:
logger.error("Failed to set color preset for monitor %d: %s", monitor_id, e)
return False
def set_picture_mode(monitor_id: int, code: int) -> bool:
"""Set the DDC/CI picture/scene mode (VCP 0xDC) by raw code."""
mc = _load_monitorcontrol()
if mc is None:
return False
if not 0 <= code <= 255:
logger.error("Picture mode code %d out of range", code)
return False
try:
ddc_monitors = mc.get_monitors()
if monitor_id >= len(ddc_monitors):
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:
logger.error("Failed to set picture mode for monitor %d: %s", monitor_id, e)
return False
def _invalidate_cache() -> None:
global _monitor_cache
_monitor_cache = None
+139 -80
View File
@@ -2768,36 +2768,6 @@ button.primary svg {
}
}
/* Footer */
footer {
text-align: center;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
color: var(--text-muted);
font-size: 0.75rem;
transition: padding-bottom 0.3s ease-in-out;
}
body.mini-player-visible footer {
padding-bottom: 70px;
}
footer a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
footer .separator {
margin: 0 0.5rem;
color: var(--text-muted);
}
/* ========================================
Media Browser Styles
======================================== */
@@ -3926,14 +3896,6 @@ html {
padding-right: max(1rem, env(safe-area-inset-right));
}
footer {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
body.mini-player-visible footer {
padding-bottom: calc(70px + env(safe-area-inset-bottom, 0px));
}
.connection-banner {
padding-top: max(10px, env(safe-area-inset-top));
}
@@ -3991,13 +3953,17 @@ body.mini-player-visible footer {
════════════════════════════════════════════════════════════════ */
/* ─── Container & header ────────────────────────────────────── */
/* The footer was removed in favour of an About dialog, so the page
bottom is now whatever the active tab content ends with. A 64px
bottom pad left a visible dead band under the player view; 24px
keeps a breath of breathing room without painting an empty page. */
.container {
max-width: 1280px;
padding: 56px 48px 64px;
padding: 56px 48px 24px;
}
@media (max-width: 720px) {
.container { padding: 48px 18px 56px; }
.container { padding: 48px 18px 24px; }
}
/* ─── Folio marks (page corners, all tabs) ────────────────── */
@@ -4695,6 +4661,12 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
-2px 8px 24px rgba(0, 0, 0, 0.5),
-4px 18px 44px rgba(0, 0, 0, 0.35);
overflow: hidden;
/* Subtle counterclockwise tilt — sleeve rests on the disc as if
casually placed, breaking up the otherwise rigid rectilinear
grid. The shadow above carries the same diagonal so the lean
reads as physical rather than transformed. */
transform: rotate(-2deg);
transform-origin: 50% 60%;
}
:root[data-theme="light"] .vinyl-stage .sleeve {
background: transparent;
@@ -6506,38 +6478,60 @@ dialog::backdrop {
.toast.info { border-color: var(--rule-strong); box-shadow: 0 14px 40px rgba(0,0,0,0.5); }
/* ═══════════════════════════════════════════════════════════════
FOOTER (colophon)
ABOUT DIALOG (colophon)
═══════════════════════════════════════════════════════════════ */
footer {
margin-top: 36px;
padding: 20px 0 0;
border-top: 1px solid var(--rule-strong);
background: transparent;
.about-dialog {
max-width: 520px;
}
.about-credit {
font-family: var(--serif);
font-style: italic;
font-size: 17px;
line-height: 1.5;
color: var(--ink-soft);
margin: 0 0 22px;
font-variation-settings: 'opsz' 30;
}
.about-credit strong {
font-style: italic;
font-weight: 400;
font-size: 22px;
color: var(--ink);
letter-spacing: 0.01em;
font-variation-settings: 'opsz' 60;
display: block;
margin-top: 2px;
}
.about-links {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--rule);
}
.about-links li {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 0;
border-bottom: 1px solid var(--rule);
}
.about-links-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-faint);
text-align: center;
}
footer a {
.about-links a {
font-family: var(--mono);
font-size: 12px;
color: var(--copper);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 200ms var(--ease);
word-break: break-all;
}
footer a:hover { border-bottom-color: var(--copper); }
footer strong {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-size: 14px;
color: var(--ink-soft);
letter-spacing: 0.01em;
text-transform: none;
font-variation-settings: 'opsz' 30;
}
footer .separator { color: var(--ink-ghost); margin: 0 8px; }
.about-links a:hover { border-bottom-color: var(--copper); }
/* ═══════════════════════════════════════════════════════════════
DISPLAY container (monitors tab)
@@ -6597,7 +6591,6 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
.auth-modal h2 { font-size: 28px; }
.settings-section { padding: 20px; }
.settings-section summary { font-size: 22px; }
footer { font-size: 9px; }
}
/* ════════════════════════════════════════════════════════════════
@@ -7957,10 +7950,16 @@ select option {
display: inline-flex;
align-items: center;
gap: 10px;
/* Allow text to wrap so we don't ellipsis-truncate the model name */
min-width: 0;
}
/* Truncate the monitor name itself, not its sibling badge — putting
overflow:hidden on the parent flex container clipped the favourite
star whenever the model name was long enough to ellipsis. */
.display-container .display-monitor-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.display-container .display-monitor-details {
font-family: var(--mono);
@@ -7980,6 +7979,7 @@ select option {
margin: 0;
line-height: 0;
vertical-align: middle;
flex-shrink: 0;
filter: drop-shadow(0 0 4px var(--copper-glow));
}
.display-container .display-primary-badge svg {
@@ -8022,24 +8022,36 @@ select option {
background: rgba(var(--copper-rgb), 0.06) !important;
}
/* Brightness control row */
.display-container .display-brightness-control {
display: flex;
/* Slider rows (brightness + contrast share this layout) */
.display-container .display-slider-row {
display: grid;
grid-template-columns: 18px minmax(0, auto) 1fr auto;
align-items: center;
gap: 14px;
gap: 12px;
}
.display-container .display-slider-row.display-brightness-control {
padding-top: 16px;
border-top: 1px solid var(--rule);
}
.display-container .display-brightness-icon {
.display-container .display-slider-icon {
color: var(--ink-mute);
width: 18px !important;
height: 18px !important;
flex-shrink: 0;
}
.display-container .display-brightness-slider {
.display-container .display-slider-label {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-mute);
white-space: nowrap;
}
.display-container .display-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
width: 100%;
height: 2px !important;
background: var(--rule-strong);
border-radius: 0;
@@ -8049,7 +8061,7 @@ select option {
border: 0 !important;
min-width: 0;
}
.display-container .display-brightness-slider::-webkit-slider-thumb {
.display-container .display-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
@@ -8059,20 +8071,24 @@ select option {
box-shadow: 0 0 12px var(--copper-glow);
border: 0;
cursor: grab;
transition: transform 140ms var(--ease);
}
.display-container .display-brightness-slider::-moz-range-thumb {
.display-container .display-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--copper);
border-radius: 50%;
border: 0;
cursor: grab;
transition: transform 140ms var(--ease);
}
.display-container .display-brightness-slider:disabled {
.display-container .display-slider:hover::-webkit-slider-thumb { transform: scale(1.15); }
.display-container .display-slider:hover::-moz-range-thumb { transform: scale(1.15); }
.display-container .display-slider:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.display-container .display-brightness-value {
.display-container .display-slider-value {
font-family: var(--mono);
font-size: 12px;
color: var(--copper);
@@ -8082,6 +8098,57 @@ select option {
text-align: right;
letter-spacing: 0.04em;
}
/* Picture tuning section — input source, color preset, picture mode.
The underlying <select> is hidden by IconSelect; the visible trigger
inherits the editorial .icon-select-trigger overrides defined later
in this file. */
.display-container .display-tuning {
padding-top: 18px;
border-top: 1px solid var(--rule);
display: flex;
flex-direction: column;
gap: 14px;
}
.display-container .display-tuning-title {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--ink-faint);
display: flex;
align-items: center;
gap: 10px;
}
.display-container .display-tuning-title::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(to right, var(--rule), transparent);
}
.display-container .display-tuning-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.display-container .display-tuning-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.display-container .display-tuning-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
white-space: nowrap;
}
/* Make the IconSelect trigger fill the field width (cards are narrow) */
.display-container .display-tuning-field .icon-select-trigger {
width: 100%;
justify-content: flex-start;
}
.display-container .display-monitors > .empty-state-illustration {
grid-column: 1 / -1;
text-align: center;
@@ -8707,14 +8774,6 @@ select option {
padding: 0 !important;
}
/* ─── Footer: ultra-compact ───────────────────────────── */
footer {
font-size: 10px !important;
padding: 12px 12px !important;
letter-spacing: 0.04em;
}
footer .separator { margin: 0 4px !important; }
/* Auth modal: full-bleed feel on phones */
.auth-modal {
width: 92% !important;
+38 -20
View File
@@ -91,6 +91,9 @@
<a class="header-btn" href="/docs" target="_blank" title="API Documentation" aria-label="API Documentation">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h5v7h7v9H6zm2-4h8v2H8v-2zm0-3h8v2H8v-2z"/></svg>
</a>
<button class="header-btn" onclick="showAboutDialog()" data-i18n-title="about.button_title" title="About" aria-label="About">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M11 7h2v2h-2V7zm0 4h2v6h-2v-6zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>
</button>
<div class="accent-picker">
<button class="header-btn" onclick="toggleAccentPicker()" title="Accent color" aria-label="Accent color">
<span class="accent-dot" id="accentDot"></span>
@@ -203,19 +206,19 @@
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
<defs>
<linearGradient id="armGrad" x1="0" x2="1">
<stop offset="0" stop-color="#3a3528"/>
<stop offset="0.5" stop-color="#9C937F"/>
<stop offset="1" stop-color="#5C5447"/>
<stop offset="0" stop-color="#6d5f44"/>
<stop offset="0.5" stop-color="#d8c39a"/>
<stop offset="1" stop-color="#8a7a5a"/>
</linearGradient>
</defs>
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="3.5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
<circle cx="176" cy="24" r="14" fill="#2a241c" stroke="#9C835A" stroke-width="1.5"/>
<circle cx="176" cy="24" r="6" fill="#5C5447"/>
<circle cx="176" cy="24" r="2.5" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad)" stroke-width="5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#3A3528" stroke="#9C835A" stroke-width="1"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#3A3528" stroke="#9C835A" stroke-width="1" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3.5" fill="#E08038" opacity="0.95"/>
<circle cx="62" cy="138" r="7" fill="none" stroke="#E08038" stroke-width="0.8" opacity="0.5"/>
</svg>
<canvas id="spectrogram-canvas" class="spectrogram-canvas" width="300" height="64"></canvas>
</div>
@@ -788,16 +791,31 @@
<!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div>
<!-- Footer -->
<footer>
<div>
<span data-i18n="footer.created_by">Created by</span> <strong>Alexei Dolgolyov</strong>
<span class="separator"></span>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
<span class="separator"></span>
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="footer.source_code">Source Code</a>
<!-- About Dialog -->
<dialog id="aboutDialog" class="about-dialog">
<div class="dialog-header">
<h3 data-i18n="about.title">About</h3>
</div>
</footer>
<div class="dialog-body">
<p class="about-credit">
<span data-i18n="about.created_by">Created by</span>
<strong>Alexei Dolgolyov</strong>
</p>
<ul class="about-links">
<li>
<span class="about-links-label" data-i18n="about.email">Email</span>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
</li>
<li>
<span class="about-links-label" data-i18n="about.repository">Repository</span>
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/media-player-server" target="_blank" rel="noopener noreferrer" data-i18n="about.source_code">Source Code</a>
</li>
</ul>
</div>
<div class="dialog-footer">
<button type="button" class="btn-secondary" onclick="closeAboutDialog()" data-i18n="dialog.close">Close</button>
</div>
</dialog>
<script src="/static/dist/app.bundle.js"></script>
</body>
+17
View File
@@ -14,6 +14,7 @@ import {
VOLUME_THROTTLE_MS, VOLUME_RELEASE_DELAY_MS,
changeLocale, t,
setAuthRequired,
showAboutDialog, closeAboutDialog,
} from './core.js';
// Layer 1: Player (tabs, theme, accent, vinyl, visualizer, UI)
@@ -62,6 +63,8 @@ import {
import {
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower, loadHeaderLinks, loadLinksTable,
showAddLinkDialog, showEditLinkDialog, closeLinkDialog, saveLink, deleteLinkConfirm,
linkFormDirty, setLinkFormDirty,
@@ -126,9 +129,13 @@ Object.assign(window, {
saveLink, deleteLinkConfirm,
// Display
loadDisplayMonitors, onDisplayBrightnessInput, onDisplayBrightnessChange,
onDisplayContrastInput, onDisplayContrastChange,
onDisplayInputSourceChange, onDisplayColorPresetChange, onDisplayPictureModeChange,
toggleDisplayPower,
// Audio device
onAudioDeviceChanged,
// About
showAboutDialog, closeAboutDialog,
});
// ============================================================
@@ -399,6 +406,16 @@ window.addEventListener('DOMContentLoaded', async () => {
}
});
// About dialog backdrop click to close
const aboutDialog = document.getElementById('aboutDialog');
if (aboutDialog) {
aboutDialog.addEventListener('click', (e) => {
if (e.target === aboutDialog) {
closeAboutDialog();
}
});
}
// Delegated click handlers for link table actions (XSS-safe)
document.getElementById('linksTableBody').addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
+10
View File
@@ -397,6 +397,16 @@ export function closeDialog(dialog) {
}, { once: true });
}
export function showAboutDialog() {
const dialog = document.getElementById('aboutDialog');
if (dialog) dialog.showModal();
}
export function closeAboutDialog() {
const dialog = document.getElementById('aboutDialog');
if (dialog) closeDialog(dialog);
}
export function showConfirm(message) {
return new Promise((resolve) => {
const dialog = document.getElementById('confirmDialog');
+325 -8
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,29 +166,161 @@ 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">
<path fill="currentColor" d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h6v2H8v2h8v-2h-2v-2h6c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/>
</svg>
<div class="display-monitor-info">
<span class="display-monitor-name">${monitor.name}${primaryBadge}</span>
<span class="display-monitor-name"><span class="display-monitor-name-text">${monitor.name}</span>${primaryBadge}</span>
${detailsHtml}
</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');
+20 -2
View File
@@ -161,6 +161,19 @@
"display.power_on": "Turn on",
"display.power_off": "Turn off",
"display.primary": "Primary",
"display.brightness": "Brightness",
"display.contrast": "Contrast",
"display.tuning": "Picture tuning",
"display.input_source": "Input",
"display.color_preset": "Color temp",
"display.picture_mode": "Picture mode",
"display.msg.contrast_failed": "Failed to set contrast",
"display.msg.input_changed": "Input source switched",
"display.msg.input_failed": "Failed to switch input source",
"display.msg.color_changed": "Color preset applied",
"display.msg.color_failed": "Failed to apply color preset",
"display.msg.mode_changed": "Picture mode applied",
"display.msg.mode_failed": "Failed to apply picture mode",
"browser.title": "Media Browser",
"browser.home": "Home",
"browser.manage_folders": "Manage Folders",
@@ -259,8 +272,13 @@
"links.msg.load_failed": "Failed to load link details",
"links.confirm.delete": "Are you sure you want to delete the link \"{name}\"?",
"links.confirm.unsaved": "You have unsaved changes. Are you sure you want to discard them?",
"footer.created_by": "Created by",
"footer.source_code": "Source Code",
"about.button_title": "About",
"about.title": "About",
"about.created_by": "Created by",
"about.email": "Email",
"about.repository": "Repository",
"about.source_code": "Source Code",
"dialog.close": "Close",
"update.available": "Update available: v{version}",
"update.view_release": "View Release"
}
+20 -2
View File
@@ -161,6 +161,19 @@
"display.power_on": "Включить",
"display.power_off": "Выключить",
"display.primary": "Основной",
"display.brightness": "Яркость",
"display.contrast": "Контраст",
"display.tuning": "Настройка изображения",
"display.input_source": "Вход",
"display.color_preset": "Цветовая температура",
"display.picture_mode": "Режим изображения",
"display.msg.contrast_failed": "Не удалось установить контраст",
"display.msg.input_changed": "Источник входа переключён",
"display.msg.input_failed": "Не удалось переключить источник",
"display.msg.color_changed": "Цветовая температура применена",
"display.msg.color_failed": "Не удалось применить цветовую температуру",
"display.msg.mode_changed": "Режим изображения применён",
"display.msg.mode_failed": "Не удалось применить режим изображения",
"browser.title": "Медиа Браузер",
"browser.home": "Главная",
"browser.manage_folders": "Управление папками",
@@ -259,8 +272,13 @@
"links.msg.load_failed": "Не удалось загрузить данные ссылки",
"links.confirm.delete": "Вы уверены, что хотите удалить ссылку \"{name}\"?",
"links.confirm.unsaved": "У вас есть несохраненные изменения. Вы уверены, что хотите отменить их?",
"footer.created_by": "Создано",
"footer.source_code": "Исходный код",
"about.button_title": "О программе",
"about.title": "О программе",
"about.created_by": "Создано",
"about.email": "Эл. почта",
"about.repository": "Репозиторий",
"about.source_code": "Исходный код",
"dialog.close": "Закрыть",
"update.available": "Доступно обновление: v{version}",
"update.view_release": "Перейти к релизу"
}
@@ -0,0 +1,911 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vinyl Variants · Studio Reference</title>
<link rel="icon" type="image/svg+xml" href="/static/icons/icon.svg">
<style>
/* ───────── Local fonts (re-using main app's woff2 files) ───── */
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 300 900;
font-display: swap;
src: url('/static/fonts/Fraunces-italic-latin.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 300 900;
font-display: swap;
src: url('/static/fonts/Fraunces-latin.woff2') format('woff2');
}
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url('/static/fonts/Geist-latin.woff2') format('woff2');
}
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 300 600;
font-display: swap;
src: url('/static/fonts/GeistMono-latin.woff2') format('woff2');
}
/* ───────── Tokens (Studio Reference, dark) ───── */
:root {
--bg-deep: #0E0D0B;
--bg-paper: #18150F;
--bg-card: #211E18;
--bg-card-2: #26211A;
--bg-rule: #2E2820;
--ink: #F2EBDC;
--ink-soft: #D6CDB9;
--ink-mute: #9C937F;
--ink-faint: #5C5447;
--ink-ghost: #3A3528;
--copper: #E08038;
--copper-hi: #F4A064;
--copper-lo: #B0561F;
--copper-glow: rgba(224, 128, 56, 0.35);
--rule: rgba(242, 235, 220, 0.08);
--rule-strong: rgba(242, 235, 220, 0.18);
--serif: 'Fraunces', Georgia, serif;
--sans: 'Geist', system-ui, sans-serif;
--mono: 'Geist Mono', ui-monospace, monospace;
--ease: cubic-bezier(.2, .7, .2, 1);
--ease-out: cubic-bezier(.16, 1, .3, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: var(--bg-deep); }
body {
font-family: var(--sans);
background: var(--bg-deep);
color: var(--ink);
min-height: 100vh;
padding: 56px 36px 80px;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Film grain */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
opacity: 0.05;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.95 0 0 0 0 0.92 0 0 0 0 0.86 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* ───────── Page header (editorial) ───── */
header.page-head {
max-width: 1320px;
margin: 0 auto 48px;
text-align: center;
}
.kicker {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--copper);
display: inline-flex;
align-items: center;
gap: 14px;
margin-bottom: 22px;
}
.kicker::before, .kicker::after {
content: "";
height: 1px;
width: 40px;
background: var(--copper);
opacity: 0.6;
}
h1 {
font-family: var(--serif);
font-style: italic;
font-weight: 400;
font-size: clamp(36px, 5vw, 56px);
line-height: 1;
letter-spacing: -0.02em;
margin-bottom: 14px;
font-variation-settings: 'opsz' 144;
}
.subtitle {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
margin-bottom: 8px;
}
.return-link {
display: inline-block;
margin-top: 24px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-faint);
text-decoration: none;
border-bottom: 1px solid var(--ink-faint);
padding-bottom: 2px;
transition: all 200ms var(--ease);
}
.return-link:hover { color: var(--copper); border-color: var(--copper); }
/* ───────── Variant grid ───── */
.grid {
max-width: 1320px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 56px 40px;
}
article.variant {
display: flex;
flex-direction: column;
align-items: stretch;
}
.stage {
position: relative;
aspect-ratio: 1;
width: 100%;
background:
radial-gradient(ellipse at center, var(--bg-card-2) 0%, var(--bg-deep) 80%);
border: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
margin-bottom: 22px;
}
.label-row {
display: flex;
align-items: baseline;
gap: 10px;
border-bottom: 1px solid var(--rule);
padding-bottom: 10px;
margin-bottom: 14px;
}
.label-num {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.2em;
color: var(--copper);
}
.label-name {
font-family: var(--serif);
font-style: italic;
font-size: 22px;
font-weight: 400;
font-variation-settings: 'opsz' 60;
flex: 1;
}
.label-tag {
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-faint);
padding: 3px 8px;
border: 1px solid var(--rule-strong);
}
.tag-css { color: var(--jade, #7AB294); border-color: rgba(122, 178, 148, 0.3); }
.tag-needs-js { color: var(--copper); border-color: var(--copper-lo); }
p.descr {
font-family: var(--sans);
font-size: 13px;
line-height: 1.6;
color: var(--ink-soft);
}
p.descr strong {
color: var(--ink);
font-weight: 500;
}
/* ───────── Shared vinyl base ───── */
.vinyl {
position: relative;
width: 86%;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%,
#0a0907 0%, #0a0907 18%,
#1a1611 18.3%, #0a0907 18.6%,
#14110c 22%, #0a0907 22.3%,
#14110c 26%, #0a0907 26.3%,
#14110c 30%, #0a0907 30.3%,
#14110c 34%, #0a0907 34.3%,
#14110c 38%, #0a0907 38.3%,
#14110c 42%, #0a0907 42.3%,
#14110c 46%, #0a0907 46.3%,
#1c1812 47%, #0a0907 100%);
box-shadow:
inset 0 0 60px rgba(0, 0, 0, 0.7),
0 30px 80px rgba(0, 0, 0, 0.6),
0 6px 20px rgba(0, 0, 0, 0.5);
animation: spin 14s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.vinyl::before {
content: "";
position: absolute;
inset: 12%;
border-radius: 50%;
background:
conic-gradient(from 0deg,
rgba(255,255,255,0.04) 0deg,
transparent 30deg,
rgba(255,255,255,0.06) 90deg,
transparent 150deg,
rgba(255,255,255,0.03) 210deg,
transparent 270deg,
rgba(255,255,255,0.05) 330deg,
transparent 360deg);
mix-blend-mode: screen;
pointer-events: none;
}
.vinyl-label {
position: absolute;
inset: 28%;
border-radius: 50%;
overflow: hidden;
box-shadow:
inset 0 0 24px rgba(0, 0, 0, 0.4),
0 0 0 4px var(--bg-deep),
0 0 0 5px var(--copper-lo);
background: var(--bg-card);
z-index: 1;
}
.vinyl-label::after {
content: "";
position: absolute;
width: 8%; height: 8%;
top: 46%; left: 46%;
border-radius: 50%;
background: var(--bg-deep);
box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.1);
z-index: 3;
}
.vinyl-label img,
.vinyl-label svg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Album art (shared SVG used by every variant) */
.album-art {
display: block;
width: 100%;
height: 100%;
}
/* Tonearm (decorative, on every stage so they read as "now playing") */
.tonearm {
position: absolute;
top: -4%;
right: -2%;
width: 50%;
height: 50%;
pointer-events: none;
transform-origin: 88% 12%;
transform: rotate(0deg);
z-index: 5;
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
}
/* ════════════════════════════════════════════════════════════════
ORIGINAL — current shipping look (control)
════════════════════════════════════════════════════════════════ */
.v0 .stage { /* nothing extra */ }
/* ════════════════════════════════════════════════════════════════
VARIANT 1 — Sleeve frame
Vinyl peeks out of a square cardstock sleeve.
════════════════════════════════════════════════════════════════ */
.v1 .stage {
background:
radial-gradient(ellipse at center, #1a1611 0%, var(--bg-deep) 80%);
}
.v1 .sleeve-stage {
position: relative;
width: 90%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
}
.v1 .sleeve {
position: absolute;
left: 0;
top: 6%;
width: 70%;
aspect-ratio: 1;
background: var(--bg-card-2);
box-shadow:
inset 0 0 0 1px rgba(0,0,0,0.4),
inset 4px 4px 24px rgba(0,0,0,0.35),
-2px 8px 24px rgba(0,0,0,0.5),
-4px 16px 40px rgba(0,0,0,0.35);
z-index: 3;
/* Casually-placed tilt — like a sleeve set down on a console */
transform: rotate(-3.2deg);
transform-origin: 60% 60%;
/* worn-edge cardstock effect */
filter: contrast(1.05) brightness(0.97);
}
.v1 .sleeve::before {
/* Cardstock paper grain */
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.7 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: multiply;
pointer-events: none;
opacity: 0.6;
}
.v1 .sleeve::after {
/* Ring-wear: faint circle from the LP rubbing the cardstock */
content: "";
position: absolute;
inset: 6%;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.25);
box-shadow:
inset 0 0 12px rgba(0,0,0,0.18),
inset 0 0 0 1px rgba(255,255,255,0.04);
pointer-events: none;
}
.v1 .sleeve-art {
position: absolute;
inset: 6%;
z-index: 1;
filter: contrast(0.88) saturate(0.6) brightness(0.88);
opacity: 0.85;
}
.v1 .sleeve-art svg { width: 100%; height: 100%; }
/* Worn corner notch */
.v1 .sleeve-corner {
position: absolute;
width: 14%;
height: 14%;
bottom: -1px;
right: -1px;
background: var(--bg-deep);
clip-path: polygon(100% 0, 100% 100%, 0 100%);
opacity: 0.7;
z-index: 4;
}
.v1 .vinyl-wrap {
position: absolute;
right: -2%;
top: 16%;
width: 70%;
aspect-ratio: 1;
z-index: 2;
}
.v1 .vinyl-wrap .vinyl {
width: 100%;
}
.v1 .vinyl-label {
/* Smaller label since the disc here is showing; album art lives on sleeve */
inset: 32%;
background: #2E2820;
box-shadow:
inset 0 0 18px rgba(0,0,0,0.4),
0 0 0 3px var(--bg-deep),
0 0 0 4px var(--copper-lo);
}
.v1 .vinyl-label::before {
/* Plain-color label with faux pressing imprint */
content: "REF · 24";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.3em;
color: var(--copper);
z-index: 2;
}
.v1 .tonearm {
right: -8%;
top: 8%;
width: 44%;
height: 44%;
}
/* ════════════════════════════════════════════════════════════════
VARIANT 2 — Sheen + paper grain + dead-wax + off-center
The high-impact variant.
════════════════════════════════════════════════════════════════ */
.v2 .vinyl-label {
/* Slightly off-center spindle for "pressed off-axis" feel */
inset: 27% 27% 29% 29%;
}
.v2 .vinyl-label::after {
/* Spindle hole offset 1.5% from true center */
top: 47%;
left: 47.5%;
}
/* Paper grain on the label, multiplied so it sits inside the print */
.v2 .vinyl-label .label-grain {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.05 0 0 0 0 0.04 0 0 0 0 0.03 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: multiply;
pointer-events: none;
z-index: 4;
}
/* Dead-wax: micro-text engraved between the label and the run-out groove */
.v2 .dead-wax {
position: absolute;
inset: 21%;
border-radius: 50%;
z-index: 0;
pointer-events: none;
/* Animation OFF the disc — engraving is part of the press, so it does spin with the vinyl */
animation: spin 14s linear infinite;
}
.v2 .dead-wax svg { width: 100%; height: 100%; }
/* Reflection sweep — fixed in viewer space, not rotating with the disc */
.v2 .sheen {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
background:
conic-gradient(from 110deg,
transparent 0deg,
rgba(255, 245, 220, 0) 30deg,
rgba(255, 245, 220, 0.07) 60deg,
rgba(255, 245, 220, 0.14) 80deg,
rgba(255, 245, 220, 0.07) 100deg,
transparent 140deg,
transparent 280deg,
rgba(255, 245, 220, 0.04) 305deg,
rgba(255, 245, 220, 0.08) 320deg,
rgba(255, 245, 220, 0.04) 335deg,
transparent 360deg);
mix-blend-mode: screen;
z-index: 4;
}
/* ════════════════════════════════════════════════════════════════
VARIANT 3 — Tone-graded album art (duotone)
════════════════════════════════════════════════════════════════ */
.v3 .vinyl-label .album-art {
filter:
saturate(0.35)
sepia(0.45)
hue-rotate(345deg)
brightness(0.85)
contrast(1.18);
}
.v3 .vinyl-label::before {
/* Subtle copper duotone overlay tints the highlights */
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(135deg,
rgba(224, 128, 56, 0.18) 0%,
rgba(31, 78, 61, 0.10) 50%,
rgba(0,0,0,0.18) 100%);
mix-blend-mode: overlay;
z-index: 2;
pointer-events: none;
}
.v3 .vinyl-label::after {
z-index: 4;
}
.v3 .vinyl-label .vignette {
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 45%,
transparent 35%,
rgba(0,0,0,0.45) 100%);
z-index: 3;
pointer-events: none;
}
/* ════════════════════════════════════════════════════════════════
VARIANT 4 — Sleeve-to-disc reveal animation
(Hover the card to see the disc slide out)
════════════════════════════════════════════════════════════════ */
.v4 .sleeve-stage {
position: relative;
width: 90%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
}
.v4 .sleeve {
position: absolute;
left: 14%;
top: 12%;
width: 72%;
aspect-ratio: 1;
background: var(--bg-card-2);
box-shadow:
inset 0 0 0 1px rgba(0,0,0,0.4),
-2px 6px 18px rgba(0,0,0,0.5);
z-index: 4;
overflow: hidden;
}
.v4 .sleeve::before {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.5 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: multiply;
pointer-events: none;
z-index: 2;
}
.v4 .sleeve-art {
width: 100%; height: 100%;
filter: contrast(0.92) saturate(0.7) brightness(0.92);
position: relative;
z-index: 1;
}
.v4 .vinyl-slot {
position: absolute;
left: 14%;
top: 12%;
width: 72%;
aspect-ratio: 1;
z-index: 3;
transition: transform 1.2s var(--ease-out);
}
.v4 .vinyl-slot .vinyl {
width: 100%;
animation-play-state: paused;
transition: animation-play-state 0.4s;
}
.v4 .stage:hover .vinyl-slot {
transform: translateX(46%);
}
.v4 .stage:hover .vinyl-slot .vinyl {
animation-play-state: running;
}
.v4 .hover-hint {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ink-faint);
pointer-events: none;
z-index: 10;
}
.v4 .stage:hover .hover-hint { opacity: 0.4; }
/* Note row at top of every variant */
.note {
position: absolute;
top: 12px;
left: 14px;
font-family: var(--mono);
font-size: 9px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-faint);
z-index: 10;
}
/* ───────── Mobile ───── */
@media (max-width: 720px) {
body { padding: 36px 16px 60px; }
.grid { gap: 36px 20px; grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header class="page-head">
<div class="kicker">Studio Reference · Album Art Variants</div>
<h1>Vinyl Cover Treatments</h1>
<p class="subtitle">Five renderings of the same disc · Hover variant 04 for the sleeve reveal</p>
<a class="return-link" href="/">← Return to player</a>
</header>
<div class="grid">
<!-- ═════════ ORIGINAL ═════════ -->
<article class="variant v0">
<div class="stage">
<span class="note">As shipping</span>
<div class="vinyl">
<div class="vinyl-label">
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgA" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1F4E3D"/>
<stop offset="55%" stop-color="#143E2F"/>
<stop offset="100%" stop-color="#0a2519"/>
</linearGradient>
<radialGradient id="vigA" cx="50%" cy="50%" r="70%">
<stop offset="55%" stop-color="rgba(0,0,0,0)"/>
<stop offset="100%" stop-color="rgba(0,0,0,0.55)"/>
</radialGradient>
</defs>
<rect width="400" height="400" fill="url(#bgA)"/>
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
<circle cx="200" cy="200" r="60"/>
<circle cx="200" cy="200" r="100"/>
<circle cx="200" cy="200" r="140"/>
<circle cx="200" cy="200" r="180"/>
</g>
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
<rect width="400" height="400" fill="url(#vigA)"/>
</svg>
</div>
</div>
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
<defs>
<linearGradient id="armGrad0" x1="0" x2="1">
<stop offset="0" stop-color="#3a3528"/>
<stop offset="0.5" stop-color="#9C937F"/>
<stop offset="1" stop-color="#5C5447"/>
</linearGradient>
</defs>
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="url(#armGrad0)" stroke-width="3.5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
<circle cx="62" cy="138" r="6" fill="none" stroke="#E08038" stroke-width="0.5" opacity="0.4"/>
</svg>
</div>
<div class="label-row">
<span class="label-num">00</span>
<span class="label-name">Original</span>
<span class="label-tag tag-css">control</span>
</div>
<p class="descr">Current shipping vinyl: pressed grooves, copper-bordered label rim, full album art on the label. Reference baseline for everything below.</p>
</article>
<!-- ═════════ VARIANT 1 — SLEEVE FRAME ═════════ -->
<article class="variant v1">
<div class="stage">
<span class="note">CSS only</span>
<div class="sleeve-stage">
<div class="sleeve">
<div class="sleeve-art">
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgB" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1F4E3D"/>
<stop offset="55%" stop-color="#143E2F"/>
<stop offset="100%" stop-color="#0a2519"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgB)"/>
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
<circle cx="200" cy="200" r="60"/>
<circle cx="200" cy="200" r="100"/>
<circle cx="200" cy="200" r="140"/>
<circle cx="200" cy="200" r="180"/>
</g>
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
</svg>
</div>
<div class="sleeve-corner"></div>
</div>
<div class="vinyl-wrap">
<div class="vinyl">
<div class="vinyl-label"></div>
</div>
</div>
</div>
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
<use href="#armGrad0"/>
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/>
<line x1="176" y1="24" x2="80" y2="120" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
<rect x="72" y="112" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 83 121)"/>
<circle cx="78" cy="122" r="3" fill="#E08038" opacity="0.8"/>
</svg>
</div>
<div class="label-row">
<span class="label-num">01</span>
<span class="label-name">Sleeve Frame</span>
<span class="label-tag tag-css">CSS only</span>
</div>
<p class="descr">Vinyl peeks out of a square cardstock <strong>sleeve</strong> — paper grain, ring-wear circle, worn-corner notch. The album art lives on the sleeve; the disc gets a plain typographic label. Reads instantly as "record on a turntable", not "spinning disc."</p>
</article>
<!-- ═════════ VARIANT 2 — SHEEN + GRAIN + DEAD-WAX ═════════ -->
<article class="variant v2">
<div class="stage">
<span class="note">CSS only · highest ROI</span>
<div class="vinyl">
<div class="dead-wax">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<path id="dwPath" d="M 50,50 m -36,0 a 36,36 0 1,1 72,0 a 36,36 0 1,1 -72,0"/>
</defs>
<text font-family="monospace" font-size="2.4" fill="#3a3528" letter-spacing="0.45" opacity="0.85">
<textPath href="#dwPath">· STUDIO REFERENCE PRESSING · A-SIDE · MASTER LACQUER 24-S · DOLG.AD MASTERED · ½ SPEED</textPath>
</text>
</svg>
</div>
<div class="vinyl-label">
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgC" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1F4E3D"/>
<stop offset="55%" stop-color="#143E2F"/>
<stop offset="100%" stop-color="#0a2519"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgC)"/>
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
<circle cx="200" cy="200" r="60"/>
<circle cx="200" cy="200" r="100"/>
<circle cx="200" cy="200" r="140"/>
<circle cx="200" cy="200" r="180"/>
</g>
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
</svg>
<div class="label-grain"></div>
</div>
<div class="sheen"></div>
</div>
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
</svg>
</div>
<div class="label-row">
<span class="label-num">02</span>
<span class="label-name">Sheen, Grain &amp; Dead-Wax</span>
<span class="label-tag tag-css">CSS only</span>
</div>
<p class="descr">Three layers added to the existing vinyl: a <strong>fixed reflection sweep</strong> (doesn't rotate with the disc — the studio-light look), <strong>paper grain</strong> on the label so the print sits in cardstock, and a <strong>dead-wax engraving</strong> of the master&#8209;lacquer code spinning with the disc. Off-center spindle by 1.5%. Highest visual ROI for the smallest amount of new code.</p>
</article>
<!-- ═════════ VARIANT 3 — TONE-GRADED ═════════ -->
<article class="variant v3">
<div class="stage">
<span class="note">CSS only</span>
<div class="vinyl">
<div class="vinyl-label">
<svg viewBox="0 0 400 400" class="album-art" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgD" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1F4E3D"/>
<stop offset="55%" stop-color="#143E2F"/>
<stop offset="100%" stop-color="#0a2519"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgD)"/>
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
<circle cx="200" cy="200" r="60"/>
<circle cx="200" cy="200" r="100"/>
<circle cx="200" cy="200" r="140"/>
<circle cx="200" cy="200" r="180"/>
</g>
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
</svg>
<div class="vignette"></div>
</div>
</div>
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
</svg>
</div>
<div class="label-row">
<span class="label-num">03</span>
<span class="label-name">Tone-Graded Cover</span>
<span class="label-tag tag-css">CSS only</span>
</div>
<p class="descr">Same disc, but the album art on the label is <strong>color-graded</strong> — duotone copper/emerald, deeper saturation drop, vignette around the label rim. Effect: every album cover ends up looking like it came from the same pressing plant, matching the Studio Reference chrome.</p>
</article>
<!-- ═════════ VARIANT 4 — SLEEVE-TO-DISC REVEAL ═════════ -->
<article class="variant v4">
<div class="stage">
<span class="note">CSS hover · JS in production</span>
<div class="sleeve-stage">
<div class="sleeve">
<div class="sleeve-art">
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bgE" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1F4E3D"/>
<stop offset="55%" stop-color="#143E2F"/>
<stop offset="100%" stop-color="#0a2519"/>
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgE)"/>
<g stroke="#e08038" stroke-width="1" fill="none" opacity="0.55">
<circle cx="200" cy="200" r="60"/>
<circle cx="200" cy="200" r="100"/>
<circle cx="200" cy="200" r="140"/>
<circle cx="200" cy="200" r="180"/>
</g>
<text x="200" y="195" text-anchor="middle" font-family="serif" font-style="italic" fill="#f2ebdc" font-size="34">Reference</text>
<text x="200" y="225" text-anchor="middle" font-family="monospace" fill="#e08038" font-size="11" letter-spacing="4">VOL · I</text>
<text x="200" y="368" text-anchor="middle" font-family="monospace" fill="#9c937f" font-size="9" letter-spacing="6" opacity="0.6">STUDIO PRESSING</text>
</svg>
</div>
</div>
<div class="vinyl-slot">
<div class="vinyl">
<div class="vinyl-label"></div>
</div>
</div>
<span class="hover-hint">Hover to play</span>
</div>
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
<circle cx="176" cy="24" r="14" fill="#1a1611" stroke="#3A3528" stroke-width="1"/>
<circle cx="176" cy="24" r="6" fill="#3A3528"/>
<circle cx="176" cy="24" r="2" fill="#E08038"/>
<line x1="176" y1="24" x2="64" y2="136" stroke="#9C937F" stroke-width="3.5" stroke-linecap="round"/>
<rect x="180" y="14" width="14" height="20" fill="#26211A" stroke="#3A3528"/>
<rect x="56" y="128" width="22" height="18" rx="2" fill="#26211A" stroke="#3A3528" transform="rotate(-45 67 137)"/>
<circle cx="62" cy="138" r="3" fill="#E08038" opacity="0.8"/>
</svg>
</div>
<div class="label-row">
<span class="label-num">04</span>
<span class="label-name">Sleeve-to-Disc Reveal</span>
<span class="label-tag tag-needs-js">needs JS</span>
</div>
<p class="descr"><strong>Hover this card</strong> — the disc slides out of the sleeve and starts spinning. In production, this would be wired to the play/pause state: paused = tucked-in sleeve view, playing = disc revealed and spinning. Most evocative, also the most code (animation choreography + state coupling).</p>
</article>
</div>
</body>
</html>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "media-server-frontend",
"version": "0.2.0",
"version": "0.2.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-server-frontend",
"version": "0.2.0",
"version": "0.2.3",
"devDependencies": {
"esbuild": "^0.27.4"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "media-server-frontend",
"version": "0.2.0",
"version": "0.2.3",
"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.1"
version = "0.2.3"
description = "REST API server for controlling system-wide media playback"
readme = "README.md"
license = { text = "MIT" }
+94 -26
View File
@@ -1,35 +1,103 @@
# Restart the Media Server
# Stop any running instance
$procs = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
foreach ($p in $procs) {
Write-Host "Stopping server (PID $($p.Id))..."
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
if ($procs) { Start-Sleep -Seconds 2 }
# Restart the Media Server.
#
# Robust against the two ways the server gets started:
# - Installer build: %LOCALAPPDATA%\Media Server\media-server.bat
# (runs as python.exe -m media_server.main)
# - Dev editable install: media-server console script on PATH
# (runs as media-server.exe)
#
# The old version of this script only killed processes named 'media-server',
# which silently missed the installer-bundled process (named 'python').
# This version kills whatever currently owns the listen port, so it doesn't
# matter how the previous instance was launched.
# Merge registry PATH with current PATH so newly-installed tools are visible
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($dir in ($regUser -split ';')) {
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir"
}
param(
[ValidateSet('auto', 'dev', 'installer')]
[string]$Mode = 'auto',
[int]$Port = 8765
)
$InstallerLauncher = Join-Path $env:LOCALAPPDATA 'Media Server\media-server.bat'
$InstallerDir = Join-Path $env:LOCALAPPDATA 'Media Server'
# --- Resolve launch mode -----------------------------------------------------
if ($Mode -eq 'auto') {
if (Test-Path $InstallerLauncher) {
$Mode = 'installer'
} else {
$Mode = 'dev'
}
}
# Start server detached
Write-Host "Starting server..."
Start-Process -FilePath 'media-server' `
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
-WindowStyle Hidden
# --- Stop whatever is listening on the port ---------------------------------
$listenerPids = @()
try {
$conns = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if ($conns) {
$listenerPids = $conns | Select-Object -ExpandProperty OwningProcess -Unique
}
} catch {
# Get-NetTCPConnection unavailable (rare); fall back to netstat parsing
$listenerPids = & netstat -ano | Select-String ":$Port\s+.*LISTENING" | ForEach-Object {
($_ -split '\s+')[-1]
} | Sort-Object -Unique
}
foreach ($targetPid in $listenerPids) {
$proc = Get-Process -Id $targetPid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Stopping listener PID $($proc.Id) ($($proc.ProcessName))..."
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}
# Also kill any orphan media-server.exe instances that didn't bind the port.
$orphans = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
foreach ($p in $orphans) {
Write-Host "Stopping orphan media-server PID $($p.Id)..."
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
}
if ($listenerPids -or $orphans) {
# Allow the OS to release the listen socket from TIME_WAIT.
Start-Sleep -Seconds 3
}
# --- Start the chosen flavour ------------------------------------------------
if ($Mode -eq 'installer') {
if (-not (Test-Path $InstallerLauncher)) {
Write-Error "Installer launcher not found: $InstallerLauncher"
exit 1
}
Write-Host "Starting installer build: $InstallerLauncher"
Start-Process -FilePath $InstallerLauncher `
-WorkingDirectory $InstallerDir `
-WindowStyle Hidden
} else {
# Merge registry PATH so newly-installed dev tools are visible.
$regUser = [Environment]::GetEnvironmentVariable('PATH', 'User')
if ($regUser) {
$currentDirs = $env:PATH -split ';' | ForEach-Object { $_.TrimEnd('\') }
foreach ($dir in ($regUser -split ';')) {
if ($dir -and ($currentDirs -notcontains $dir.TrimEnd('\'))) {
$env:PATH = "$env:PATH;$dir"
}
}
}
Write-Host "Starting dev install (PATH media-server)..."
Start-Process -FilePath 'media-server' `
-WorkingDirectory 'c:\Users\Alexei\Documents\haos-integration-media-player\media-server' `
-WindowStyle Hidden
}
Start-Sleep -Seconds 3
# Verify it's running
$check = Get-Process -Name 'media-server' -ErrorAction SilentlyContinue
if ($check) {
Write-Host "Server started (PID $($check[0].Id))"
# --- Verify it's listening ---------------------------------------------------
$verify = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if ($verify) {
$vpid = $verify[0].OwningProcess
$vproc = Get-Process -Id $vpid -ErrorAction SilentlyContinue
Write-Host "Server listening on port $Port (PID $vpid, $($vproc.ProcessName))"
} else {
Write-Host "WARNING: Server does not appear to be running!"
Write-Warning "Server is not listening on port $Port yet - check logs."
}