From d9e8fef376cfb35d4dd7650393cc4f592885e076 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 11 May 2026 18:54:44 -0300 Subject: [PATCH 01/10] Add Home Assistant network control API --- AGENTS.md | 2 + README.md | 81 +++++++++++++++++++++++++++++ luz-musica.py | 110 +++++++++++++++++++++++++++++++++++++++- music-light.env.example | 3 ++ 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 802bec9..e8f3476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,10 +33,12 @@ Then run: - The service loads optional environment overrides from `~/.config/music-light/env`. - Use `make status-service`, `make logs-service`, `make restart-service`, and `make uninstall-service`. - Keep it as a user service because `playerctl` needs access to the logged-in desktop user's MPRIS session. +- The running script exposes an HTTP control API for Home Assistant: `GET /status`, `POST /pause`, `POST /resume`, `POST /toggle`. ## Configuration Edit constants at the top of `luz-musica.py`: - `HA_URL`, `HA_TOKEN`, `LIGHT_ENTITY` +- control API: `CONTROL_HOST`, `CONTROL_PORT`, `CONTROL_TOKEN` - tuning: `BRIGHTNESS_PCT`, `SATURATION_BOOST`, `MIN_SATURATION`, `MIN_VALUE`, `REQUEST_TIMEOUT` ## Expected behavior / troubleshooting diff --git a/README.md b/README.md index d732e26..417e8a2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ You can also tune: - `MIN_VALUE` - `REQUEST_TIMEOUT` +Network control settings: + +- `CONTROL_HOST`: bind address for the control API, default `0.0.0.0` +- `CONTROL_PORT`: bind port for the control API, default `8765` +- `CONTROL_TOKEN`: optional shared token required for control requests + ## Run ```bash @@ -96,9 +102,84 @@ make uninstall-service This is installed as a user service because `playerctl` needs access to the desktop user's MPRIS session. +## Home Assistant Control + +The running script exposes a small HTTP API that Home Assistant can call over the local network: + +- `GET /status` +- `POST /pause` +- `POST /resume` +- `POST /toggle` + +When paused, the systemd service stays running but the script stops changing the light. When resumed, it forces an update from the current track. + +Example requests from another machine on the same network: + +```bash +curl http://ubuntu-host:8765/status +curl -X POST http://ubuntu-host:8765/pause +curl -X POST http://ubuntu-host:8765/resume +curl -X POST http://ubuntu-host:8765/toggle +``` + +If you set `CONTROL_TOKEN`, include it as a header: + +```bash +curl -X POST -H "X-Music-Light-Token: your-token" http://ubuntu-host:8765/toggle +``` + +Home Assistant `configuration.yaml` example: + +```yaml +rest_command: + music_light_pause: + url: "http://ubuntu-host:8765/pause" + method: post + music_light_resume: + url: "http://ubuntu-host:8765/resume" + method: post + music_light_toggle: + url: "http://ubuntu-host:8765/toggle" + method: post +``` + +With `CONTROL_TOKEN`: + +```yaml +rest_command: + music_light_toggle: + url: "http://ubuntu-host:8765/toggle" + method: post + headers: + X-Music-Light-Token: "your-token" +``` + +Create Home Assistant scripts so they can be shown as dashboard buttons: + +```yaml +script: + pause_music_light: + alias: Pause Music Light + sequence: + - service: rest_command.music_light_pause + + resume_music_light: + alias: Resume Music Light + sequence: + - service: rest_command.music_light_resume + + toggle_music_light: + alias: Toggle Music Light + sequence: + - service: rest_command.music_light_toggle +``` + +Then add `script.pause_music_light`, `script.resume_music_light`, or `script.toggle_music_light` to a Home Assistant dashboard as button cards. + ## Troubleshooting - If nothing is playing, or `playerctl` cannot read metadata, the loop sleeps and retries. - If `mpris:artUrl` is missing, the script logs it and waits for the next track change. - If Home Assistant is unreachable or returns an error, the script logs the exception and retries. - If the service cannot see the media player, make sure it is running under the same logged-in desktop user. +- If Home Assistant cannot reach the control API, check the Ubuntu host firewall and confirm `CONTROL_HOST=0.0.0.0`. diff --git a/luz-musica.py b/luz-musica.py index 93bf128..2ac8aef 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -2,10 +2,13 @@ from __future__ import annotations import colorsys +import json import math import os import subprocess +import threading import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from io import BytesIO from urllib.parse import parse_qs, urlparse @@ -31,6 +34,9 @@ OKLCH_LIGHTNESS = 0.72 OKLCH_CHROMA = 0.24 MAX_BRIGHTNESS_STEP = 0.10 PLAYERCTL_BIN = os.environ.get("PLAYERCTL_BIN", "playerctl") +CONTROL_HOST = os.environ.get("CONTROL_HOST", "0.0.0.0") +CONTROL_PORT = int(os.environ.get("CONTROL_PORT", "8765")) +CONTROL_TOKEN = os.environ.get("CONTROL_TOKEN", "") YELLOW_HUE_START = 0.12 YELLOW_HUE_END = 0.18 AMBER_TARGET_HUE = 0.10 @@ -41,6 +47,101 @@ NEUTRAL_SATURATION_THRESHOLD = 0.18 PURPLE_FALLBACK_HUE = 0.78 +class ControlState: + def __init__(self) -> None: + self._lock = threading.Lock() + self._paused = False + self._force_update = False + + def pause(self) -> None: + with self._lock: + self._paused = True + + def resume(self) -> None: + with self._lock: + self._paused = False + self._force_update = True + + def toggle(self) -> bool: + with self._lock: + self._paused = not self._paused + if not self._paused: + self._force_update = True + return self._paused + + def is_paused(self) -> bool: + with self._lock: + return self._paused + + def consume_force_update(self) -> bool: + with self._lock: + force_update = self._force_update + self._force_update = False + return force_update + + def snapshot(self) -> dict[str, object]: + with self._lock: + return { + "paused": self._paused, + "running": True, + } + + +CONTROL_STATE = ControlState() + + +class ControlHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return + + def do_GET(self) -> None: + if self.path == "/status": + self.write_json(200, CONTROL_STATE.snapshot()) + return + self.write_json(404, {"error": "not found"}) + + def do_POST(self) -> None: + if not self.authorized(): + self.write_json(401, {"error": "unauthorized"}) + return + + if self.path == "/pause": + CONTROL_STATE.pause() + elif self.path == "/resume": + CONTROL_STATE.resume() + elif self.path == "/toggle": + CONTROL_STATE.toggle() + else: + self.write_json(404, {"error": "not found"}) + return + + self.write_json(200, CONTROL_STATE.snapshot()) + + def authorized(self) -> bool: + if not CONTROL_TOKEN: + return True + + header_token = self.headers.get("X-Music-Light-Token", "") + bearer_token = self.headers.get("Authorization", "").removeprefix("Bearer ") + return CONTROL_TOKEN in {header_token, bearer_token} + + def write_json(self, status: int, payload: dict[str, object]) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def start_control_server() -> ThreadingHTTPServer: + server = ThreadingHTTPServer((CONTROL_HOST, CONTROL_PORT), ControlHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(f"Controle HTTP ouvindo em {CONTROL_HOST}:{CONTROL_PORT}") + return server + + def run_playerctl(field: str, player: str | None = None) -> str: command = [PLAYERCTL_BIN] if player: @@ -399,11 +500,16 @@ def set_light_color(rgb: tuple[int, int, int]) -> None: def main() -> None: print("Monitorando música atual. Ctrl+C para sair.") + control_server = start_control_server() last_track = "" last_rgb: tuple[int, int, int] | None = None while True: try: + if CONTROL_STATE.is_paused(): + time.sleep(2) + continue + player = choose_active_player() if not player: time.sleep(2) @@ -415,7 +521,7 @@ def main() -> None: time.sleep(2) continue - if track_id != last_track: + if CONTROL_STATE.consume_force_update() or track_id != last_track: art_url = get_album_art_url(player) if not art_url: print("Sem capa disponível para a faixa atual.") @@ -438,6 +544,8 @@ def main() -> None: except KeyboardInterrupt: print("\nEncerrado.") + control_server.shutdown() + control_server.server_close() break except Exception as exc: print(f"Erro: {exc}") diff --git a/music-light.env.example b/music-light.env.example index 2cef63e..411f87c 100644 --- a/music-light.env.example +++ b/music-light.env.example @@ -3,3 +3,6 @@ # HA_TOKEN=replace-with-your-home-assistant-token # LIGHT_ENTITY=light.luzsala_luz_sala # PLAYERCTL_BIN=/usr/bin/playerctl +# CONTROL_HOST=0.0.0.0 +# CONTROL_PORT=8765 +# CONTROL_TOKEN=optional-shared-token From 4344ca10d1549ae0717f354e8d1a889a1848f246 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 11 May 2026 19:13:56 -0300 Subject: [PATCH 02/10] Add local change --- a | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 a diff --git a/a b/a new file mode 100644 index 0000000..e69de29 From e8128cad80fbaaf227842a1d71027877fa0af5d2 Mon Sep 17 00:00:00 2001 From: vini Date: Mon, 11 May 2026 19:18:10 -0300 Subject: [PATCH 03/10] a --- a | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 a diff --git a/a b/a deleted file mode 100644 index e69de29..0000000 From 38acb8bccb6c0719ce418e8acacd759245113dd1 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Sun, 7 Jun 2026 10:35:23 -0300 Subject: [PATCH 04/10] feat: add /art proxy endpoint and fix /current to expose rgb+art_url Co-Authored-By: Claude Sonnet 4.6 --- luz-musica.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/luz-musica.py b/luz-musica.py index 2ac8aef..51f6bb5 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -86,6 +86,18 @@ class ControlState: "running": True, } + def set_current(self, art_url: str, rgb: tuple[int, int, int]) -> None: + with self._lock: + self._art_url = art_url + self._rgb = rgb + + def current(self) -> dict[str, object]: + with self._lock: + return { + "art_url": getattr(self, "_art_url", None), + "rgb": list(getattr(self, "_rgb", [0, 200, 255])), + } + CONTROL_STATE = ControlState() @@ -98,6 +110,27 @@ class ControlHandler(BaseHTTPRequestHandler): if self.path == "/status": self.write_json(200, CONTROL_STATE.snapshot()) return + if self.path == "/current": + self.write_json(200, CONTROL_STATE.current()) + return + if self.path.startswith("/art"): + art_url = CONTROL_STATE.current().get("art_url") + if not art_url: + self.write_json(404, {"error": "no art"}) + return + try: + resp = requests.get(art_url, timeout=5) + body = resp.content + ctype = resp.headers.get("Content-Type", "image/jpeg") + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + except Exception: + self.write_json(502, {"error": "fetch failed"}) + return self.write_json(404, {"error": "not found"}) def do_POST(self) -> None: @@ -537,6 +570,7 @@ def main() -> None: print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}") print(f"Aplicando cor: {rgb}") set_light_color(rgb) + CONTROL_STATE.set_current(art_url, rgb) last_track = track_id last_rgb = rgb From 9738b4a6afc1dacb4082bd05fd9b1b6232e70ed2 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Sun, 7 Jun 2026 12:27:55 -0300 Subject: [PATCH 05/10] fix: serve file:// art URLs in /art endpoint for YouTube/Firefox MPRIS --- luz-musica.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/luz-musica.py b/luz-musica.py index 51f6bb5..e5fd54a 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -119,9 +119,13 @@ class ControlHandler(BaseHTTPRequestHandler): self.write_json(404, {"error": "no art"}) return try: - resp = requests.get(art_url, timeout=5) - body = resp.content - ctype = resp.headers.get("Content-Type", "image/jpeg") + if art_url.startswith("file://"): + body = open(art_url[7:], "rb").read() + ctype = "image/png" if art_url.endswith(".png") else "image/jpeg" + else: + resp = requests.get(art_url, timeout=5) + body = resp.content + ctype = resp.headers.get("Content-Type", "image/jpeg") self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) @@ -163,6 +167,7 @@ class ControlHandler(BaseHTTPRequestHandler): self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") self.end_headers() self.wfile.write(body) From ad35be37c7ad1575b6d08f2fc7d4d4463e6f7211 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 16:53:23 -0300 Subject: [PATCH 06/10] feat: return lyric_context (5-line window) from /current endpoint current() now finds the active lyric in a non-empty-only display list and returns a lyric_context array: [{text, offset}] for offsets -2..+2 centered on the active line. lyric_line kept for backwards compat. Co-Authored-By: Claude Sonnet 4.6 --- luz-musica.py | 177 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 165 insertions(+), 12 deletions(-) diff --git a/luz-musica.py b/luz-musica.py index e5fd54a..b6bd64d 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -5,6 +5,7 @@ import colorsys import json import math import os +import re import subprocess import threading import time @@ -86,16 +87,56 @@ class ControlState: "running": True, } - def set_current(self, art_url: str, rgb: tuple[int, int, int]) -> None: + def set_current(self, art_url: str, rgb: tuple[int, int, int], rgb2: tuple[int, int, int]) -> None: with self._lock: self._art_url = art_url self._rgb = rgb + self._rgb2 = rgb2 + + def set_lyrics(self, lyrics: list[tuple[float, str]]) -> None: + with self._lock: + self._lyrics = lyrics + + def set_position(self, position: float) -> None: + with self._lock: + self._position = position def current(self) -> dict[str, object]: with self._lock: + lyrics = getattr(self, "_lyrics", []) + position = getattr(self, "_position", 0.0) + MAX_DISPLAY = 5.0 + + active_idx = -1 + line = "" + for i, (ts, text) in enumerate(lyrics): + if ts > position: + break + next_ts = lyrics[i + 1][0] if i + 1 < len(lyrics) else ts + MAX_DISPLAY + if position <= min(next_ts, ts + MAX_DISPLAY): + active_idx = i + line = text + else: + active_idx = -1 + line = "" + + # Build 5-line context from non-empty lyrics, centered on active + context: list[dict[str, object]] = [] + if active_idx >= 0 and line: + display_idxs = [i for i, (_, t) in enumerate(lyrics) if t.strip()] + if active_idx in display_idxs: + pos = display_idxs.index(active_idx) + for offset in range(-2, 3): + di = pos + offset + t = lyrics[display_idxs[di]][1] if 0 <= di < len(display_idxs) else "" + context.append({"text": t, "offset": offset}) + return { - "art_url": getattr(self, "_art_url", None), - "rgb": list(getattr(self, "_rgb", [0, 200, 255])), + "art_url": getattr(self, "_art_url", None), + "rgb": list(getattr(self, "_rgb", [0, 200, 255])), + "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), + "lyric_line": line, + "lyric_context": context, } @@ -180,6 +221,89 @@ def start_control_server() -> ThreadingHTTPServer: return server +_LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]') + + +def parse_lrc(text: str) -> list[tuple[float, str]]: + lines = [] + for raw in text.splitlines(): + m = _LRC_TS.match(raw) + if not m: + continue + ts = int(m.group(1)) * 60 + float(m.group(2)) + line = raw[m.end():].strip() + lines.append((ts, line)) + return lines + + +_TITLE_JUNK = re.compile( + r"[\s\-–]+(" + r"\d{4}\s*remaster(ed)?|remaster(ed)?(\s+\d{4})?|" + r"deluxe(\s+edition)?|expanded(\s+edition)?|" + r"anniversary(\s+edition)?|bonus\s+track|" + r"live(\s+version)?|acoustic(\s+version)?|" + r"radio\s+edit|single\s+version|album\s+version" + r")[\s\)\]]*$", + re.IGNORECASE, +) + + +def clean_title(title: str) -> str: + # strip parenthesised/bracketed suffixes first, then dash-separated junk + t = re.sub(r"\s*[\(\[][^\)\]]*[\)\]]$", "", title).strip() + t = _TITLE_JUNK.sub("", t).strip() + return t + + +def fetch_lyrics(artist: str, title: str) -> list[tuple[float, str]]: + if not artist or not title: + return [] + clean = clean_title(title) + for t in ([clean, title] if clean != title else [title]): + try: + resp = requests.get( + "https://lrclib.net/api/get", + params={"artist_name": artist, "track_name": t}, + timeout=20, + ) + if resp.status_code == 200: + synced = resp.json().get("syncedLyrics") or "" + if synced: + return parse_lrc(synced) + except Exception: + pass + return [] + + +def fetch_lyrics_async(artist: str, title: str) -> None: + def _run() -> None: + lyrics = fetch_lyrics(artist, title) + CONTROL_STATE.set_lyrics(lyrics) + print(f"Letras: {len(lyrics)} linhas") + threading.Thread(target=_run, daemon=True).start() + + +def start_position_thread() -> threading.Thread: + def _run() -> None: + while True: + try: + player = choose_active_player() + if player: + result = subprocess.run( + [PLAYERCTL_BIN, "--player", player, "position"], + capture_output=True, text=True, timeout=1, + ) + if result.returncode == 0 and result.stdout.strip(): + CONTROL_STATE.set_position(float(result.stdout.strip())) + except Exception: + pass + time.sleep(0.5) + + t = threading.Thread(target=_run, daemon=True) + t.start() + return t + + def run_playerctl(field: str, player: str | None = None) -> str: command = [PLAYERCTL_BIN] if player: @@ -479,21 +603,24 @@ def should_use_purple_fallback(pixels: list[tuple[int, int, int]]) -> bool: return avg_chroma < NEUTRAL_CHROMA_THRESHOLD or avg_saturation < NEUTRAL_SATURATION_THRESHOLD +def build_buckets(img: Image.Image) -> dict[tuple[int, int, int], int]: + pixels = good_pixels(img) + buckets: dict[tuple[int, int, int], int] = {} + for r, g, b in pixels: + key = (r // 16 * 16, g // 16 * 16, b // 16 * 16) + buckets[key] = buckets.get(key, 0) + 1 + return buckets + + def dominant_color(img: Image.Image) -> tuple[int, int, int]: pixels = good_pixels(img) if not pixels: - # fallback simples return PURPLE_FALLBACK_RGB if should_use_purple_fallback(pixels): return PURPLE_FALLBACK_RGB - # Quantiza para agrupar tons parecidos - buckets: dict[tuple[int, int, int], int] = {} - for r, g, b in pixels: - key = (r // 16 * 16, g // 16 * 16, b // 16 * 16) - buckets[key] = buckets.get(key, 0) + 1 - + buckets = build_buckets(img) best = max( buckets.items(), key=lambda item: item[1] * bucket_weight(item[0]), @@ -501,6 +628,26 @@ def dominant_color(img: Image.Image) -> tuple[int, int, int]: return boost_color(best) +def secondary_color(img: Image.Image, primary: tuple[int, int, int]) -> tuple[int, int, int]: + buckets = build_buckets(img) + primary_h = rgb_to_hsv(primary)[0] + MIN_HUE_DIST = 0.12 # at least ~43° apart + + ranked = sorted( + buckets.items(), + key=lambda item: item[1] * bucket_weight(item[0]), + reverse=True, + ) + for bucket, _ in ranked: + h = rgb_to_hsv(bucket)[0] + if hue_distance(h, primary_h) >= MIN_HUE_DIST: + return boost_color(bucket) + + # fallback: complementary hue of primary + h, s, v = rgb_to_hsv(primary) + return hsv_to_rgb((h + 0.5) % 1.0, max(s, 0.5), max(v, 0.5)) + + def bucket_weight(rgb: tuple[int, int, int]) -> float: _, saturation, value = rgb_to_hsv(rgb) @@ -539,6 +686,7 @@ def set_light_color(rgb: tuple[int, int, int]) -> None: def main() -> None: print("Monitorando música atual. Ctrl+C para sair.") control_server = start_control_server() + start_position_thread() last_track = "" last_rgb: tuple[int, int, int] | None = None @@ -572,10 +720,15 @@ def main() -> None: raw_rgb = dominant_color(img) palette_rgb = snap_to_palette(raw_rgb) rgb = clamp_brightness(palette_rgb, last_rgb) + rgb2 = secondary_color(img, rgb) print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}") - print(f"Aplicando cor: {rgb}") + print(f"Aplicando cor: {rgb} secundária: {rgb2}") set_light_color(rgb) - CONTROL_STATE.set_current(art_url, rgb) + CONTROL_STATE.set_current(art_url, rgb, rgb2) + artist = run_playerctl("artist", player) + title = run_playerctl("title", player) + CONTROL_STATE.set_lyrics([]) + fetch_lyrics_async(artist, title) last_track = track_id last_rgb = rgb From 2560a4fce62a66d9f88889fcc33d5cc1fe9c8389 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:33:18 -0300 Subject: [PATCH 07/10] feat: verse-based lyric grouping and active-verse tracking group_into_verses() splits LRC lyrics on empty lines or timestamp gaps >3.5s; find_active_verse() returns the current verse lines and active index with INTER_VERSE_CAP (8s) cap on the last line of each verse. ControlState.set_lyrics() now pre-computes verses; current() exposes verse_lines and verse_active instead of the old 5-line lyric_context. Co-Authored-By: Claude Sonnet 4.6 --- luz-musica.py | 85 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/luz-musica.py b/luz-musica.py index b6bd64d..a4651f4 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -96,6 +96,7 @@ class ControlState: def set_lyrics(self, lyrics: list[tuple[float, str]]) -> None: with self._lock: self._lyrics = lyrics + self._verses = group_into_verses(lyrics) def set_position(self, position: float) -> None: with self._lock: @@ -103,40 +104,17 @@ class ControlState: def current(self) -> dict[str, object]: with self._lock: - lyrics = getattr(self, "_lyrics", []) + verses = getattr(self, "_verses", []) position = getattr(self, "_position", 0.0) - MAX_DISPLAY = 5.0 - - active_idx = -1 - line = "" - for i, (ts, text) in enumerate(lyrics): - if ts > position: - break - next_ts = lyrics[i + 1][0] if i + 1 < len(lyrics) else ts + MAX_DISPLAY - if position <= min(next_ts, ts + MAX_DISPLAY): - active_idx = i - line = text - else: - active_idx = -1 - line = "" - - # Build 5-line context from non-empty lyrics, centered on active - context: list[dict[str, object]] = [] - if active_idx >= 0 and line: - display_idxs = [i for i, (_, t) in enumerate(lyrics) if t.strip()] - if active_idx in display_idxs: - pos = display_idxs.index(active_idx) - for offset in range(-2, 3): - di = pos + offset - t = lyrics[display_idxs[di]][1] if 0 <= di < len(display_idxs) else "" - context.append({"text": t, "offset": offset}) - + verse_lines, verse_active = find_active_verse(verses, position) + line = verse_lines[verse_active] if verse_active >= 0 and verse_lines else "" return { - "art_url": getattr(self, "_art_url", None), - "rgb": list(getattr(self, "_rgb", [0, 200, 255])), - "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), - "lyric_line": line, - "lyric_context": context, + "art_url": getattr(self, "_art_url", None), + "rgb": list(getattr(self, "_rgb", [0, 200, 255])), + "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), + "lyric_line": line, + "verse_lines": verse_lines, + "verse_active": verse_active, } @@ -221,6 +199,9 @@ def start_control_server() -> ThreadingHTTPServer: return server +VERSE_GAP_SECS = 3.5 # gap between lines that splits a verse +INTER_VERSE_CAP = 8.0 # how long to keep last line of a verse visible + _LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]') @@ -255,6 +236,46 @@ def clean_title(title: str) -> str: return t +def group_into_verses( + lyrics: list[tuple[float, str]], +) -> list[list[tuple[float, str]]]: + """Split non-empty lyrics into verses on empty LRC lines or large timestamp gaps.""" + verses: list[list[tuple[float, str]]] = [] + current: list[tuple[float, str]] = [] + for ts, text in lyrics: + if not text.strip(): + if current: + verses.append(current) + current = [] + continue + if current and (ts - current[-1][0]) > VERSE_GAP_SECS: + verses.append(current) + current = [] + current.append((ts, text)) + if current: + verses.append(current) + return verses + + +def find_active_verse( + verses: list[list[tuple[float, str]]], + position: float, +) -> tuple[list[str], int]: + """Return (verse_text_lines, active_index) or ([], -1) when in a gap.""" + for v_idx, verse in enumerate(verses): + for l_idx, (ts, _) in enumerate(verse): + if ts > position: + break + if l_idx + 1 < len(verse): + deadline = verse[l_idx + 1][0] + else: + nxt = verses[v_idx + 1][0][0] if v_idx + 1 < len(verses) else ts + INTER_VERSE_CAP + deadline = min(nxt, ts + INTER_VERSE_CAP) + if position <= deadline: + return [t for _, t in verse], l_idx + return [], -1 + + def fetch_lyrics(artist: str, title: str) -> list[tuple[float, str]]: if not artist or not title: return [] From ee563f9ffb9d135cc1444994707d2736a207b21d Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:44:15 -0300 Subject: [PATCH 08/10] =?UTF-8?q?tweak:=20lower=20VERSE=5FGAP=5FSECS=203.5?= =?UTF-8?q?s=20=E2=86=92=202.0s=20to=20reduce=20over-splitting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- luz-musica.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/luz-musica.py b/luz-musica.py index a4651f4..203f943 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -199,7 +199,7 @@ def start_control_server() -> ThreadingHTTPServer: return server -VERSE_GAP_SECS = 3.5 # gap between lines that splits a verse +VERSE_GAP_SECS = 2.0 # gap between lines that splits a verse INTER_VERSE_CAP = 8.0 # how long to keep last line of a verse visible _LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]') From 1453c3ba3ce2e7a5b6b81bbada2b6d0206d456fe Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:51:41 -0300 Subject: [PATCH 09/10] =?UTF-8?q?tweak:=20raise=20VERSE=5FGAP=5FSECS=202.0?= =?UTF-8?q?s=20=E2=86=92=205.0s=20to=20avoid=20splitting=20within-verse=20?= =?UTF-8?q?lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- luz-musica.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/luz-musica.py b/luz-musica.py index 203f943..d884c3b 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -199,7 +199,7 @@ def start_control_server() -> ThreadingHTTPServer: return server -VERSE_GAP_SECS = 2.0 # gap between lines that splits a verse +VERSE_GAP_SECS = 5.0 # gap between lines that splits a verse INTER_VERSE_CAP = 8.0 # how long to keep last line of a verse visible _LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]') From db2fda70bde70cc10c73d57d0f99d5646d8639ed Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Wed, 10 Jun 2026 15:00:46 -0300 Subject: [PATCH 10/10] refactor: remove skeleton toggle/shortcut feature from control server show_skeleton is now managed by the Plasma wallpaper config directly, so the server no longer needs toggle-skeleton, set-shortcut endpoints, pynput hotkey listener, or the show_skeleton field in /current. Also fixes unclosed file handle in the /art handler. Co-Authored-By: Claude Sonnet 4.6 --- luz-musica.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/luz-musica.py b/luz-musica.py index d884c3b..b566b6d 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -109,12 +109,12 @@ class ControlState: verse_lines, verse_active = find_active_verse(verses, position) line = verse_lines[verse_active] if verse_active >= 0 and verse_lines else "" return { - "art_url": getattr(self, "_art_url", None), - "rgb": list(getattr(self, "_rgb", [0, 200, 255])), - "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), - "lyric_line": line, - "verse_lines": verse_lines, - "verse_active": verse_active, + "art_url": getattr(self, "_art_url", None), + "rgb": list(getattr(self, "_rgb", [0, 200, 255])), + "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), + "lyric_line": line, + "verse_lines": verse_lines, + "verse_active": verse_active, } @@ -139,7 +139,8 @@ class ControlHandler(BaseHTTPRequestHandler): return try: if art_url.startswith("file://"): - body = open(art_url[7:], "rb").read() + with open(art_url[7:], "rb") as f: + body = f.read() ctype = "image/png" if art_url.endswith(".png") else "image/jpeg" else: resp = requests.get(art_url, timeout=5)