Compare commits
9 Commits
v0.2.1
...
d1f621f0b4
| Author | SHA1 | Date | |
|---|---|---|---|
| d1f621f0b4 | |||
| 6120625fa9 | |||
| 57fdeb70fb | |||
| 0d07f7f1f4 | |||
| 372e4eb11f | |||
| d27484a46d | |||
| 261a14c575 | |||
| e7372b0ccb | |||
| ec5178142e |
@@ -53,3 +53,5 @@ Thumbs.db
|
||||
# Node.js / esbuild
|
||||
node_modules/
|
||||
media_server/static/dist/
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"code-review-graph": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"code-review-graph",
|
||||
"serve"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 & 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‑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>
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user