diff --git a/cider/README.md b/cider/README.md index 61c5c21e..090e64ea 100644 --- a/cider/README.md +++ b/cider/README.md @@ -16,7 +16,7 @@ Replaces the sparse stock media OSD **for Cider only**. Other players keep Nocta ## Requirements -- Noctalia v5.0.0-beta.8+ (`plugin_api` 23) +- Noctalia v5.0.0-beta.9+ (`plugin_api` 24 — argv `runAsync`) - Cider with Connectivity / External API enabled - `python3` on `PATH`, with `python-socketio`, `requests`, and `websocket-client` (`pip install -r requirements.txt` from this plugin directory) - Overlay HUD: `gtk3`, `gtk-layer-shell`, and `python-gobject` diff --git a/cider/plugin.toml b/cider/plugin.toml index a33318c5..c2de5f10 100644 --- a/cider/plugin.toml +++ b/cider/plugin.toml @@ -2,8 +2,8 @@ id = "dragged/cider" name = "Cider" -version = "1.8.4" -plugin_api = 23 +version = "1.9.2" +plugin_api = 24 author = "dragged" license = "MIT" dependencies = ["python3", "gtk3", "gtk-layer-shell", "python-gobject"] diff --git a/cider/scripts/cider_bridge.py b/cider/scripts/cider_bridge.py index e39307c7..c54e9b05 100644 --- a/cider/scripts/cider_bridge.py +++ b/cider/scripts/cider_bridge.py @@ -40,7 +40,7 @@ @dataclass class TrackEvent: - type: str # track | time | state | lyrics | clear | status + type: str # track | time | state | lyrics | clear | status | art title: str = "" artist: str = "" album: str = "" @@ -57,6 +57,9 @@ class TrackEvent: lyrics_lrc: str = "" lyrics_lines: list[dict[str, Any]] | None = None message: str = "" + # When True, update state.json metadata but leave position.json alone so the + # overlay keeps extrapolating from the last real Cider time sample. + skip_position: bool = False _EMIT_LOCK = threading.Lock() @@ -82,9 +85,13 @@ def _atomic_write(path: Path, text: str) -> None: _POS_ANCHOR_WALL = 0.0 _POS_PLAYING = False _POS_DURATION_MS = 0 -# Ignore small backward snaps from stale state/poll samples (not real seeks). +# Clock hygiene for position.json (overlay extrapolates from these anchors). +# Untrusted poll/state samples: reject tiny forward spikes, ignore mild behind +# snaps. Trusted time events (real Cider playbackTimeDidChange / new track) +# always re-anchor so scrubbing seeks track immediately. +_AHEAD_REJECT_MS = 400 _STALE_REWIND_MIN_MS = 350 -_STALE_REWIND_MAX_MS = 4000 +_SEEK_ACCEPT_MS = 1500 def _set_position_anchor(position_ms: int, playing: bool, duration_ms: int = 0) -> None: @@ -112,11 +119,22 @@ def _estimated_position_ms() -> int: return est -def _write_position(position_ms: int, playing: bool, duration_ms: int = 0) -> None: +def _write_position( + position_ms: int, + playing: bool, + duration_ms: int = 0, + *, + trust: bool = False, +) -> None: """Write last-known Cider anchor. HUD/Luau extrapolate between ticks. Never store wall-clock-extrapolated values here — that double-counts with - consumers and lets stale state events yank the sing-along clock backward. + consumers. + + trust=True — live playbackTimeDidChange / new track: always re-anchor so + scrubbing seeks move lyrics immediately. + trust=False — poll/state snapshots: reject spurious ahead spikes, ignore + mild behind snaps, but accept |delta| >= SEEK as a seek. """ global _POS_ANCHOR_MS, _POS_ANCHOR_WALL, _POS_PLAYING, _POS_DURATION_MS position_ms = max(0, int(position_ms)) @@ -127,16 +145,23 @@ def _write_position(position_ms: int, playing: bool, duration_ms: int = 0) -> No if duration_ms: _POS_DURATION_MS = duration_ms dur = _POS_DURATION_MS - if _POS_ANCHOR_WALL > 0 and _POS_PLAYING: + if (not trust) and _POS_ANCHOR_WALL > 0 and _POS_PLAYING: elapsed = max(0, int((time.time() - _POS_ANCHOR_WALL) * 1000)) est = _POS_ANCHOR_MS + elapsed if dur > 0: est = min(est, dur) - rewind = est - position_ms - if playing and _STALE_REWIND_MIN_MS <= rewind < _STALE_REWIND_MAX_MS: - # Stale sample while still playing — keep extrapolating from old anchor. - return - if (not playing) and rewind >= _STALE_REWIND_MIN_MS: + delta = position_ms - est # +ahead of clock, -behind + if playing: + if delta > _AHEAD_REJECT_MS: + # Spurious forward spike from a poll. Forward seeks arrive on + # trusted playbackTimeDidChange ticks instead. + return + rewind = -delta + if _STALE_REWIND_MIN_MS <= rewind < _SEEK_ACCEPT_MS: + # Mild behind from a stale poll — don't scrub. + return + # rewind >= SEEK_ACCEPT: treat as scrub/seek backward. + elif (-delta) >= _STALE_REWIND_MIN_MS: # Pause with a stale timestamp: freeze at the live estimate. position_ms = est @@ -358,17 +383,21 @@ def emit(event: TrackEvent) -> None: payload = asdict(event) if payload.get("lyrics_lines") is None: payload.pop("lyrics_lines", None) + skip_position = bool(payload.pop("skip_position", False)) body = json.dumps(payload, ensure_ascii=False) with _EMIT_LOCK: _STATE_DIR.mkdir(parents=True, exist_ok=True) # Continuous snapshot for progress polling if event.type in {"track", "time", "state", "art"}: _atomic_write(_STATE_DIR / "state.json", body) - if event.type in {"track", "time", "state"}: + if event.type in {"track", "time", "state"} and not skip_position: _write_position( int(event.position_ms or 0), str(event.playback_state or "") == "playing", int(event.duration_ms or 0), + # Live time ticks + new tracks are authoritative (seeks). + # Snapshot/state polls stay filtered against sprint/scrub noise. + trust=event.type in {"time", "track"}, ) elif event.type == "clear": _wipe_playback_sidecars() @@ -750,6 +779,9 @@ def disconnect() -> None: emit(TrackEvent(type="status", message="disconnected")) def start(self) -> None: + # Drop leftovers from a previous session until a live snapshot arrives. + # Otherwise Luau can rehydrate a stale playing track after Cider quit. + _wipe_playback_sidecars() threading.Thread(target=self._run_sio, name="cider-sio", daemon=True).start() if self.poll_interval_sec > 0: threading.Thread(target=self._poll_loop, name="cider-poll", daemon=True).start() @@ -766,9 +798,20 @@ def stop(self) -> None: def _window_loop(self) -> None: last_body = "" + was_present = False while not self._stop.is_set(): try: payload = probe_cider_window() + present = payload.get("present") is True + # Closing Cider removes its window — clear immediately instead of + # waiting for socket/API death (that lag left the bar chip stuck). + if was_present and not present: + self._track_key = "" + self._lyrics_key = "" + self._last = {} + emit(TrackEvent(type="clear")) + emit(TrackEvent(type="status", message="cider_closed")) + was_present = present body = json.dumps(payload, ensure_ascii=False) if body != last_body: _write_window(payload) @@ -790,6 +833,12 @@ def _run_sio(self) -> None: wait_timeout=10, ) except Exception as exc: + # Wipe durable snapshots — otherwise Luau rehydrates a stale + # "playing" track from state.json and fires ghost notifications. + self._track_key = "" + self._lyrics_key = "" + self._last = {} + emit(TrackEvent(type="clear")) emit(TrackEvent(type="status", message=f"connect_failed:{exc}")) self._stop.wait(5) @@ -895,14 +944,19 @@ def _emit_from_attrs(self, attrs: dict[str, Any], reason: str) -> None: song_id = str(play_params.get("id") or catalog_id or "") isrc = str(attrs.get("isrc") or "") duration_ms = int(attrs.get("durationInMillis") or 0) + fresh_position = False if attrs.get("currentPlaybackTime") is not None: position_ms = int(float(attrs["currentPlaybackTime"]) * 1000) + fresh_position = True elif attrs.get("remainingTime") is not None and duration_ms: position_ms = max(0, duration_ms - int(float(attrs["remainingTime"]) * 1000)) + fresh_position = True else: - # No fresh Cider timestamp — keep the live extrapolated clock. - # Using a stale _last.position_ms rewinds sing-along by up to seconds. + # No fresh Cider timestamp — keep the live extrapolated clock in + # memory, but do not rewrite position.json (that re-anchored `t` + # and could amplify drift). position_ms = _estimated_position_ms() if self._last else 0 + fresh_position = False artwork = attrs.get("artwork") or {} artwork_url = "" @@ -942,6 +996,8 @@ def _emit_from_attrs(self, attrs: dict[str, Any], reason: str) -> None: event_type = "track" elif reason == "track" and not is_new_track: event_type = "state" + # New tracks always need a position anchor; metadata-only snapshots do not. + skip_position = (not fresh_position) and event_type != "track" event = TrackEvent( type=event_type, title=title, @@ -957,6 +1013,7 @@ def _emit_from_attrs(self, attrs: dict[str, Any], reason: str) -> None: isrc=isrc, has_lyrics=bool(attrs.get("hasLyrics")), has_synced=bool(attrs.get("hasTimeSyncedLyrics")), + skip_position=skip_position, ) self._last = asdict(event) self._last["playback_state"] = state diff --git a/cider/scripts/lyrics_overlay.py b/cider/scripts/lyrics_overlay.py index ec801bdd..4f5ba097 100755 --- a/cider/scripts/lyrics_overlay.py +++ b/cider/scripts/lyrics_overlay.py @@ -60,6 +60,9 @@ hud_height_px, is_cue_text, layer_anchors, + line_anim_duration_ms, + line_anim_forward_from_index, + line_anim_interrupt_elapsed_ms, merge_cfg, next_line_y, outro_lyric_alpha, @@ -171,9 +174,11 @@ def _real_words(line: dict[str, Any]) -> list[dict[str, Any]]: return restore_word_spacing(words, text) -def resolve_line(lines: list[dict[str, Any]], pos_ms: int) -> tuple[dict[str, Any] | None, str, bool, float]: +def resolve_line( + lines: list[dict[str, Any]], pos_ms: int +) -> tuple[dict[str, Any] | None, str, bool, float, int]: if not lines: - return None, "", False, 0.0 + return None, "", False, 0.0, 0 pos_ms = max(0, pos_ms) idx = 0 for i, line in enumerate(lines): @@ -190,7 +195,13 @@ def resolve_line(lines: list[dict[str, Any]], pos_ms: int) -> tuple[dict[str, An ft = int(first.get("time") or 0) if ft > pos_ms: progress = _clamp01(pos_ms / ft) if ft > 0 else 0.0 - return {"text": CUE_TEXT, "cue": True, "time": 0, "duration": ft}, _line_text(first), True, progress + return ( + {"text": CUE_TEXT, "cue": True, "time": 0, "duration": ft}, + _line_text(first), + True, + progress, + 0, + ) idx = 1 cur = lines[idx - 1] nxt = "" @@ -211,7 +222,7 @@ def resolve_line(lines: list[dict[str, Any]], pos_ms: int) -> tuple[dict[str, An finish = int(nt) if finish > start: progress = _clamp01((pos_ms - start) / (finish - start)) - return cur, nxt, cue, progress + return cur, nxt, cue, progress, idx class LyricsHud(Gtk.Window): @@ -262,6 +273,9 @@ def __init__(self) -> None: self._width = 800 self._line_key: tuple[str, str, bool] | None = None self._anim_t0 = 0.0 + self._anim_ms = float(LINE_ANIM_MS) + self._anim_forward = True + self._line_idx = 0 self._outgoing_current = "" self._outgoing_next = "" self._incoming_current = "" @@ -388,7 +402,10 @@ def _tick(self) -> bool: self._track_anim_t0 = time.time() self._awaiting_lyrics = True self._anim_t0 = 0.0 + self._anim_ms = float(LINE_ANIM_MS) + self._anim_forward = True self._line_key = None + self._line_idx = 0 if play_id: self._play_id = play_id lyrics_id = display_track_id(lyrics) @@ -400,8 +417,10 @@ def _tick(self) -> bool: self._track_anim_t0 = time.time() self._track_start_u = 0.52 - self._current, self._next, self._is_cue, self._cue_progress = resolve_line(lines, self._pos_ms) - self._note_line_change() + self._current, self._next, self._is_cue, self._cue_progress, line_idx = resolve_line( + lines, int(self._pos_ms) + ) + self._note_line_change(line_idx) # Match monitor width for centered layout. try: @@ -463,20 +482,38 @@ def _current_text(self) -> str: return CUE_TEXT return _line_text(self._current) - def _note_line_change(self) -> None: + def _note_line_change(self, line_idx: int) -> None: incoming = self._current_text() key = (incoming, self._next, self._is_cue) if key == self._line_key: + self._line_idx = int(line_idx) return if self._line_key is not None and self._track_anim_t0 <= 0: + prev_idx = int(self._line_idx) + next_idx = int(line_idx) + forward = line_anim_forward_from_index(prev_idx, next_idx) + prev_u = 1.0 + if self._anim_t0 > 0: + elapsed = (time.time() - self._anim_t0) * 1000.0 + dur = max(1.0, float(self._anim_ms)) + if elapsed < dur: + prev_u = smoothstep(elapsed / dur) self._outgoing_current = self._incoming_current self._outgoing_next = self._incoming_next - self._anim_t0 = time.time() + self._anim_forward = forward + self._anim_ms = float( + line_anim_duration_ms(next_idx - prev_idx, LINE_ANIM_MS) + ) + soft = line_anim_interrupt_elapsed_ms(prev_u, int(self._anim_ms)) + self._anim_t0 = time.time() - (soft / 1000.0) else: self._anim_t0 = 0.0 + self._anim_ms = float(LINE_ANIM_MS) + self._anim_forward = True self._incoming_current = incoming self._incoming_next = self._next self._line_key = key + self._line_idx = int(line_idx) def _mix_alphas(self) -> tuple[float, float, float]: """hold (outgoing track), live (current track), mix-dots.""" @@ -503,10 +540,11 @@ def _anim_u(self) -> float: if self._anim_t0 <= 0: return 1.0 elapsed = (time.time() - self._anim_t0) * 1000.0 - if elapsed >= LINE_ANIM_MS: + dur = max(1.0, float(self._anim_ms or LINE_ANIM_MS)) + if elapsed >= dur: self._anim_t0 = 0.0 return 1.0 - return smoothstep(elapsed / LINE_ANIM_MS) + return smoothstep(elapsed / dur) def _wrap_layout( self, @@ -590,11 +628,12 @@ def _draw_depth_line( bold: bool, max_lines: int, ) -> int: - """One depth step on the 2D plane: smaller/further → larger/nearer.""" + """One depth step on the 2D plane: smaller/further ↔ larger/nearer.""" if not text or rgba[3] <= 0.01: return 0 - scale = promote_scale(u, start_px, dest_px) - layout, cw = self._wrap_layout(text, dest_px, bold, width, max_lines) + layout_px = max(int(start_px), int(dest_px), 1) + scale = depth_layout_scale(u, start_px, dest_px, layout_px) + layout, cw = self._wrap_layout(text, layout_px, bold, width, max_lines) _tw, th = layout.get_pixel_size() if th <= 0: return 0 @@ -638,6 +677,31 @@ def _draw_promote_line( cr.restore() return int(max(1, vis_h)) + def _draw_arrive_from_past( + self, + cr: Any, + width: int, + current_y: float, + u: float, + alpha: float, + ) -> int: + """Past pose → current pose (rewind / skip-back).""" + past_y = current_y - PAST_LIFT_PX + scale = depth_layout_scale(u, PAST_FONT_PX, CURRENT_FONT_PX, CURRENT_FONT_PX) + body_h = max(1.0, float(self._current_body_h(width))) + vis_h = body_h * scale + top = promote_top_y(u, current_y, past_y) + cx = width / 2.0 + rest_cy = current_y + body_h / 2.0 + pose_cy = top + vis_h / 2.0 + cr.save() + cr.translate(cx, pose_cy) + cr.scale(scale, scale) + cr.translate(-cx, -rest_cy) + self._draw_karaoke(cr, width, current_y, alpha) + cr.restore() + return int(max(1, vis_h)) + def _draw_cue_depth( self, cr: Any, @@ -864,59 +928,133 @@ def _on_draw(self, _widget: Gtk.Widget, cr: Any) -> bool: if show_live and not idle: body_h = self._current_body_h(width) next_slot_y = next_line_y(y, body_h) + forward = self._anim_forward if anim_u < 1.0 and self._outgoing_current and not show_hold: out_a = exit_alpha(anim_u) * live_a if is_cue_text(self._outgoing_current): - self._draw_cue_depth( - cr, - width, - y - FAR_DROP_PX, - y, - CUE_PAST_PX, - CUE_BASE_PX, - anim_u, - out_a, - ) + if forward: + self._draw_cue_depth( + cr, + width, + y - FAR_DROP_PX, + y, + CUE_PAST_PX, + CUE_BASE_PX, + anim_u, + out_a, + ) + else: + self._draw_cue_depth( + cr, + width, + y + FAR_DROP_PX, + y, + CUE_FAR_PX, + CUE_BASE_PX, + anim_u, + out_a, + ) else: sung = self._paint["sung"] - self._draw_depth_line( + if forward: + self._draw_depth_line( + cr, + width, + self._outgoing_current, + y - PAST_LIFT_PX, + y, + PAST_FONT_PX, + CURRENT_FONT_PX, + anim_u, + (sung[0], sung[1], sung[2], sung[3] * out_a), + True, + CURRENT_MAX_LINES, + ) + elif self._incoming_next == self._outgoing_current: + # One-line skip-back: demote into the next slot. + nxt = self._paint["next"] + land_a = 1.0 + (float(nxt[3]) - 1.0) * smoothstep( + anim_u + ) + self._draw_depth_line( + cr, + width, + self._outgoing_current, + next_slot_y, + y, + NEXT_FONT_PX, + CURRENT_FONT_PX, + anim_u, + ( + sung[0], + sung[1], + sung[2], + sung[3] * land_a * live_a, + ), + True, + CURRENT_MAX_LINES, + ) + else: + # Multi-line rewind: exit toward far. + self._draw_depth_line( + cr, + width, + self._outgoing_current, + y + FAR_DROP_PX, + y, + FAR_FONT_PX, + CURRENT_FONT_PX, + anim_u, + (sung[0], sung[1], sung[2], sung[3] * out_a), + True, + CURRENT_MAX_LINES, + ) + if self._is_cue: + if anim_u < 1.0: + if forward: + self._draw_cue_depth( + cr, + width, + y, + y + FAR_DROP_PX, + CUE_BASE_PX, + CUE_FAR_PX, + anim_u, + live_a, + ) + else: + self._draw_cue_depth( + cr, + width, + y, + y - FAR_DROP_PX, + CUE_BASE_PX, + CUE_PAST_PX, + anim_u, + live_a, + ) + else: + self._draw_cue_dots(cr, width, y, live_a) + current_h = CURRENT_SLOT_PX + elif anim_u < 1.0: + if forward: + self._draw_promote_line( cr, width, - self._outgoing_current, - y - PAST_LIFT_PX, + self._incoming_current, y, - PAST_FONT_PX, - CURRENT_FONT_PX, + next_slot_y, anim_u, - (sung[0], sung[1], sung[2], sung[3] * out_a), - True, - CURRENT_MAX_LINES, + live_a, ) - if self._is_cue: - if anim_u < 1.0: - self._draw_cue_depth( + else: + self._draw_arrive_from_past( cr, width, y, - y + FAR_DROP_PX, - CUE_BASE_PX, - CUE_FAR_PX, anim_u, live_a, ) - else: - self._draw_cue_dots(cr, width, y, live_a) - current_h = CURRENT_SLOT_PX - elif anim_u < 1.0: - self._draw_promote_line( - cr, - width, - self._incoming_current, - y, - next_slot_y, - anim_u, - live_a, - ) current_h = body_h else: current_h = self._draw_karaoke(cr, width, y, live_a) @@ -941,42 +1079,97 @@ def _on_draw(self, _widget: Gtk.Widget, cr: Any) -> bool: next_max, ) if show_live and not idle: - growing = anim_u < 1.0 and not self._is_cue + demote_fills_next = ( + anim_u < 1.0 + and not self._is_cue + and not self._anim_forward + and bool(self._outgoing_current) + and self._incoming_next == self._outgoing_current + ) + promote_busy = ( + anim_u < 1.0 + and not self._is_cue + and self._anim_forward + ) if ( anim_u < 1.0 and self._outgoing_next and not show_hold - and not growing + and not promote_busy ): r, g, b, a = self._paint["next"] - self._draw_plain_line( - cr, - width, - y, - self._outgoing_next, - (r, g, b, a * (1.0 - anim_u) * live_a), - 13, - False, - next_max, - ) - if self._incoming_next: + if self._anim_forward: + self._draw_plain_line( + cr, + width, + y, + self._outgoing_next, + (r, g, b, a * (1.0 - anim_u) * live_a), + 13, + False, + next_max, + ) + else: + # Rewind: former next sinks further into far. + self._draw_depth_line( + cr, + width, + self._outgoing_next, + y + FAR_DROP_PX, + y, + FAR_FONT_PX, + NEXT_FONT_PX, + anim_u, + (r, g, b, a * (1.0 - anim_u) * live_a), + False, + next_max, + ) + if self._incoming_next and not demote_fills_next: r, g, b, a = self._paint["next"] if anim_u < 1.0: - au = approach_u(anim_u) - if au > 0.01: - self._draw_depth_line( - cr, - width, - self._incoming_next, - y, - y + FAR_DROP_PX, - NEXT_FONT_PX, - FAR_FONT_PX, - au, - (r, g, b, a * successor_next_alpha(anim_u) * live_a), - False, - next_max, - ) + if self._anim_forward: + au = approach_u(anim_u) + if au > 0.01: + self._draw_depth_line( + cr, + width, + self._incoming_next, + y, + y + FAR_DROP_PX, + NEXT_FONT_PX, + FAR_FONT_PX, + au, + ( + r, + g, + b, + a * successor_next_alpha(anim_u) * live_a, + ), + False, + next_max, + ) + else: + # Multi-line rewind: new next settles from past. + au = approach_u(anim_u) + if au > 0.01: + self._draw_depth_line( + cr, + width, + self._incoming_next, + y, + y - PAST_LIFT_PX * 0.45, + NEXT_FONT_PX, + CURRENT_FONT_PX, + au, + ( + r, + g, + b, + a * successor_next_alpha(anim_u) * live_a, + ), + False, + next_max, + ) else: self._draw_plain_line( cr, diff --git a/cider/scripts/lyrics_overlay_cfg.py b/cider/scripts/lyrics_overlay_cfg.py index 38797764..7f8dac4e 100644 --- a/cider/scripts/lyrics_overlay_cfg.py +++ b/cider/scripts/lyrics_overlay_cfg.py @@ -120,6 +120,35 @@ def exit_alpha(u: float) -> float: return 1.0 - smoothstep((float(u) - 0.08) / 0.72) +def line_anim_forward(pos_delta_ms: int, slack_ms: int = 180) -> bool: + """Legacy tick-delta direction. Prefer line_anim_forward_from_index.""" + return int(pos_delta_ms) >= -max(0, int(slack_ms)) + + +def line_anim_forward_from_index(prev_idx: int, next_idx: int) -> bool: + """Stack direction from lyric line index (stable across seek settle ticks).""" + return int(next_idx) >= int(prev_idx) + + +def line_anim_duration_ms(index_delta: int, base_ms: int = LINE_ANIM_MS) -> int: + """Adjacent flips get full ease; scrub jumps shorten so motion stays snappy.""" + gap = abs(int(index_delta)) + base = max(180, int(base_ms)) + if gap <= 1: + return base + if gap == 2: + return int(base * 0.82) + return int(base * 0.68) + + +def line_anim_interrupt_elapsed_ms(prev_u: float, duration_ms: int) -> float: + """Keep a little continuity when a seek cuts an in-flight transition.""" + u = float(prev_u) + if u <= 0.02 or u >= 0.98: + return 0.0 + return 0.12 * max(180.0, float(duration_ms)) + + TRACK_CROSS_MS = 1200 TRACK_FADE_MS = 800 SURFACE_ANIM_MS = 420 diff --git a/cider/scripts/test_lyrics_display.py b/cider/scripts/test_lyrics_display.py index b1529128..fb2ecdae 100644 --- a/cider/scripts/test_lyrics_display.py +++ b/cider/scripts/test_lyrics_display.py @@ -273,6 +273,44 @@ def test_next_promotes_by_growing_forward(self) -> None: self.assertNotIn('mix_rgba(self._paint["next"], self._paint["sung"]', overlay) self.assertIn("self._draw_karaoke(cr, width, current_y, alpha)", overlay) + def test_overlay_python_parses(self) -> None: + import ast + + src = (ROOT / "scripts" / "lyrics_overlay.py").read_text(encoding="utf-8") + ast.parse(src) + + def test_line_anim_direction_follows_seek(self) -> None: + self.assertTrue(cfg.line_anim_forward(0)) + self.assertTrue(cfg.line_anim_forward(40)) + self.assertTrue(cfg.line_anim_forward(-50)) # mild clock catch-up + self.assertFalse(cfg.line_anim_forward(-500)) + self.assertFalse(cfg.line_anim_forward(-5_000)) + self.assertTrue(cfg.line_anim_forward_from_index(3, 4)) + self.assertTrue(cfg.line_anim_forward_from_index(3, 3)) + self.assertFalse(cfg.line_anim_forward_from_index(5, 2)) + self.assertEqual(cfg.line_anim_duration_ms(1), cfg.LINE_ANIM_MS) + self.assertLess(cfg.line_anim_duration_ms(4), cfg.LINE_ANIM_MS) + self.assertGreater(cfg.line_anim_interrupt_elapsed_ms(0.4, 460), 0.0) + self.assertEqual(cfg.line_anim_interrupt_elapsed_ms(1.0, 460), 0.0) + # Shrink path past → current must work (promote_scale cannot). + self.assertAlmostEqual( + cfg.depth_layout_scale( + 0.0, cfg.PAST_FONT_PX, cfg.CURRENT_FONT_PX, cfg.CURRENT_FONT_PX + ), + cfg.PAST_FONT_PX / cfg.CURRENT_FONT_PX, + ) + self.assertAlmostEqual( + cfg.depth_layout_scale( + 1.0, cfg.PAST_FONT_PX, cfg.CURRENT_FONT_PX, cfg.CURRENT_FONT_PX + ), + 1.0, + ) + overlay = (ROOT / "scripts" / "lyrics_overlay.py").read_text(encoding="utf-8") + self.assertIn("_draw_arrive_from_past", overlay) + self.assertIn("line_anim_forward_from_index", overlay) + self.assertIn("self._anim_forward", overlay) + self.assertIn("line_anim_duration_ms", overlay) + def test_cue_dots_grow_forward_not_slide_up(self) -> None: # Layout is always CUE_BASE. Incoming visual size is far → base. self.assertAlmostEqual( diff --git a/cider/scripts/test_write_position.py b/cider/scripts/test_write_position.py index bdcba31f..25a2c245 100644 --- a/cider/scripts/test_write_position.py +++ b/cider/scripts/test_write_position.py @@ -48,6 +48,57 @@ def test_second_write_does_not_unbound_local(self) -> None: ) self.assertGreaterEqual(payload["position_ms"], 1_000) + def test_rejects_spurious_ahead_jump_that_caused_lyrics_sprint(self) -> None: + # Untrusted poll spike +2.5s must not stick the HUD ahead. + cider_bridge._write_position(10_000, True, 180_000) + cider_bridge._POS_ANCHOR_WALL -= 1.0 + cider_bridge._write_position(13_500, True, 180_000, trust=False) + payload = json.loads( + (cider_bridge._STATE_DIR / "position.json").read_text(encoding="utf-8") + ) + self.assertEqual(payload["position_ms"], 10_000) + self.assertEqual(cider_bridge._POS_ANCHOR_MS, 10_000) + + def test_trusted_time_event_accepts_seek_forward(self) -> None: + # Scrubbing fires playbackTimeDidChange — must re-anchor immediately. + cider_bridge._write_position(10_000, True, 180_000) + cider_bridge._POS_ANCHOR_WALL -= 0.5 + cider_bridge._write_position(45_000, True, 180_000, trust=True) + self.assertEqual(cider_bridge._POS_ANCHOR_MS, 45_000) + + def test_trusted_time_event_accepts_seek_backward(self) -> None: + cider_bridge._write_position(40_000, True, 180_000) + cider_bridge._POS_ANCHOR_WALL -= 0.5 + cider_bridge._write_position(12_000, True, 180_000, trust=True) + self.assertEqual(cider_bridge._POS_ANCHOR_MS, 12_000) + + def test_untrusted_large_jump_still_counts_as_seek(self) -> None: + cider_bridge._write_position(10_000, True, 180_000) + cider_bridge._POS_ANCHOR_WALL -= 0.2 + # Forward from polls stays filtered; backward seek still accepted. + cider_bridge._write_position(40_000, True, 180_000, trust=False) + self.assertEqual(cider_bridge._POS_ANCHOR_MS, 10_000) + cider_bridge._write_position(1_000, True, 180_000, trust=False) + self.assertEqual(cider_bridge._POS_ANCHOR_MS, 1_000) + + def test_still_ignores_mild_stale_rewind(self) -> None: + cider_bridge._write_position(20_000, True, 180_000) + cider_bridge._POS_ANCHOR_WALL -= 1.0 + # est ≈ 21000; sample 20500 is ~500ms behind → stale poll, ignore. + cider_bridge._write_position(20_500, True, 180_000, trust=False) + self.assertEqual(cider_bridge._POS_ANCHOR_MS, 20_000) + + def test_accepts_large_seek_backward(self) -> None: + cider_bridge._write_position(40_000, True, 180_000) + cider_bridge._POS_ANCHOR_WALL -= 0.2 + cider_bridge._write_position(5_000, True, 180_000, trust=False) + self.assertEqual(cider_bridge._POS_ANCHOR_MS, 5_000) + + def test_time_events_pass_trust_to_write_position(self) -> None: + source = Path(__file__).resolve().parent / "cider_bridge.py" + text = source.read_text(encoding="utf-8") + self.assertIn('trust=event.type in {"time", "track"}', text) + class OverlayLauncherContractTests(unittest.TestCase): def test_service_does_not_pkill_overlay_by_cmdline_pattern(self) -> None: @@ -60,9 +111,25 @@ def test_service_does_not_pkill_overlay_by_cmdline_pattern(self) -> None: self.assertNotIn( "pkill -f lyrics_overlay.py", line, - "pkill -f matches the runAsync launcher argv and kills the overlay before it starts", + "pkill -f matches a shell launcher cmdline and kills the overlay before it starts", ) + def test_service_uses_runasync_argv_for_process_launches(self) -> None: + service = Path(__file__).resolve().parent.parent / "service.luau" + text = service.read_text(encoding="utf-8") + self.assertIn("plugin_api = 24", (Path(__file__).resolve().parent.parent / "plugin.toml").read_text(encoding="utf-8")) + self.assertIn('runArgv({ "python3", script })', text) + self.assertIn('runArgv({ "bash", launcher, baseUrl })', text) + self.assertIn("noctaliaMsg(", text) + # No shell-string noctalia msg / nohup launches left. + code = "\n".join( + line + for line in text.splitlines() + if not line.lstrip().startswith("--") + ) + self.assertNotIn('noctalia.runAsync("noctalia msg', code) + self.assertNotIn("nohup python3", code) + def test_service_does_not_push_external_lyrics_plugin(self) -> None: service = Path(__file__).resolve().parent.parent / "service.luau" text = service.read_text(encoding="utf-8") @@ -108,6 +175,92 @@ def test_artwork_cdn_fetch_is_tokenless(self) -> None: self.assertIn("resp = requests.get(url, timeout=10)", text) self.assertNotIn("self._session.get(url, timeout=10)", text) + def test_connect_failed_emits_clear_before_status(self) -> None: + source = Path(__file__).resolve().parent / "cider_bridge.py" + text = source.read_text(encoding="utf-8") + failed = text.find('message=f"connect_failed:{exc}"') + self.assertGreater(failed, 0) + window = text[max(0, failed - 400) : failed] + self.assertIn('TrackEvent(type="clear")', window) + self.assertIn("_wipe_playback_sidecars()", text) + self.assertIn("_wipe_playback_sidecars()", text[text.find("def start(self)") :][:500]) + + +class ClearWipesSidecarTests(unittest.TestCase): + def setUp(self) -> None: + self._tmpdir = tempfile.TemporaryDirectory() + self._prev_state = cider_bridge._STATE_DIR + cider_bridge._STATE_DIR = Path(self._tmpdir.name) + + def tearDown(self) -> None: + cider_bridge._STATE_DIR = self._prev_state + self._tmpdir.cleanup() + + def test_clear_removes_stale_playing_state(self) -> None: + state = cider_bridge._STATE_DIR / "state.json" + pos = cider_bridge._STATE_DIR / "position.json" + state.write_text( + json.dumps( + { + "type": "state", + "title": "Ghost", + "artist": "Track", + "playback_state": "playing", + } + ), + encoding="utf-8", + ) + pos.write_text( + json.dumps({"position_ms": 1, "playing": True, "duration_ms": 10}), + encoding="utf-8", + ) + cider_bridge.emit(cider_bridge.TrackEvent(type="clear")) + self.assertFalse(state.exists()) + self.assertFalse(pos.exists()) + + +class GhostNotifyGuardTests(unittest.TestCase): + def test_service_gates_state_rehydrate_while_offline(self) -> None: + service = Path(__file__).resolve().parent.parent / "service.luau" + text = service.read_text(encoding="utf-8") + self.assertIn("local ciderOnline = false", text) + self.assertIn("if not ciderOnline then", text) + self.assertIn("markCiderOffline()", text) + # Kickoff must start at EVENT (clear before any state rehydrate). + self.assertIn( + "-- Event before state: clear/connect_failed must land before any state rehydrate.", + text, + ) + kickoff = text.find( + "-- Event before state: clear/connect_failed must land before any state rehydrate." + ) + tail = text[kickoff:] + self.assertIn("readFileAsync(EVENT_PATH, applyEvent)", tail) + self.assertLess( + tail.find("readFileAsync(EVENT_PATH, applyEvent)"), + tail.find("readFileAsync(STATE_PATH, applyState)") + if "readFileAsync(STATE_PATH, applyState)" in tail + else 10**9, + ) + + def test_widget_hides_when_no_track(self) -> None: + widget = Path(__file__).resolve().parent.parent / "widget.luau" + text = widget.read_text(encoding="utf-8") + self.assertIn("barWidget.setVisible", text) + self.assertIn("setChipVisible(false)", text) + self.assertIn("function hasTrack()", text) + + def test_cider_window_gone_clears_playback(self) -> None: + bridge = Path(__file__).resolve().parent / "cider_bridge.py" + text = bridge.read_text(encoding="utf-8") + self.assertIn("was_present and not present", text) + self.assertIn('message="cider_closed"', text) + service = Path(__file__).resolve().parent.parent / "service.luau" + svc = service.read_text(encoding="utf-8") + self.assertIn("maybeHideWhenCiderClosed", svc) + self.assertIn("cider_closed", svc) + self.assertIn('noctalia.state.set("now_playing", {', svc) + if __name__ == "__main__": unittest.main() diff --git a/cider/service.luau b/cider/service.luau index 190443a7..26ee2901 100644 --- a/cider/service.luau +++ b/cider/service.luau @@ -11,6 +11,12 @@ local nowPlaying = nil local pendingOsdKey = "" local pendingOsdAt = 0 local ART_WAIT_MS = 1200 +-- False until a live bridge track/status "connected". Blocks rehydrate from +-- stale state.json after Cider quit (ghost notifications). +local ciderOnline = false +-- Consecutive update ticks with window.present == false while a track is shown. +local ciderAbsentStreak = 0 +local CIDER_ABSENT_HIDE_TICKS = 3 -- ~600ms at 200ms update interval local STATE_PATH = "~/.cache/noctalia-cider/state.json" local EVENT_PATH = "~/.cache/noctalia-cider/event.json" @@ -219,9 +225,22 @@ local function refreshConfig() end refreshConfig() -local function shellQuote(value) - return "'" .. tostring(value):gsub("'", "'\\''") .. "'" + +-- API 24+: argv form avoids /bin/sh quoting and stops pkill -f from matching +-- the launcher cmdline (lyrics_overlay / bridge paths used to sit in the shell string). +local function runArgv(argv) + return noctalia.runAsync(argv) end + +local function noctaliaMsg(...) + local argv = { "noctalia", "msg" } + local n = select("#", ...) + for i = 1, n do + argv[#argv + 1] = select(i, ...) + end + return runArgv(argv) +end + local function pluginRoot() return noctalia.pluginDir() or "." end @@ -249,8 +268,8 @@ end local function publishNowPlaying(np) nowPlaying = np - noctalia.state.set("now_playing", np) if type(np) == "table" then + noctalia.state.set("now_playing", np) noctalia.state.set("title", np.title or "") noctalia.state.set("artist", np.artist or "") noctalia.state.set("album", np.album or "") @@ -265,8 +284,17 @@ local function publishNowPlaying(np) noctalia.state.set("remaining_text", formatTime(np.remaining_ms)) noctalia.state.set("progress", dur > 0 and math.min(1, pos / dur) or 0) else - -- Cider inactive / cleared — bar chip must go idle, not keep last track. + -- Empty table (not nil) so widget watchers always receive a clear payload. lastTrackKey = "" + noctalia.state.set("now_playing", { + title = "", + artist = "", + album = "", + artwork_path = "", + playback_state = "stopped", + position_ms = 0, + duration_ms = 0, + }) noctalia.state.set("title", "") noctalia.state.set("artist", "") noctalia.state.set("album", "") @@ -290,15 +318,16 @@ local lyricsHudVisible = false -- panel-close is idempotent. togglePanel would invert an already-open -- now-playing toast. Also close a leftover lyrics-osd panel from older builds. local function closeLyricsPanel() - noctalia.runAsync("noctalia msg panel-close " .. LYRICS_PANEL_ID .. " 2>/dev/null || true") + noctaliaMsg("panel-close", LYRICS_PANEL_ID) end local function stopLyricsOverlay() - -- Kill the overlay by pidfile. Never `pkill -f lyrics_overlay.py`: that - -- pattern matches this runAsync argv and can kill the launcher instead. - noctalia.runAsync( - "sh -c 'f=\"$HOME/.cache/noctalia-cider/lyrics_overlay.pid\"; [ -f \"$f\" ] && kill \"$(cat \"$f\")\" 2>/dev/null; rm -f \"$f\"'" - ) + -- Kill the overlay by pidfile. Never `pkill -f lyrics_overlay.py`. + runArgv({ + "sh", + "-c", + 'f="$HOME/.cache/noctalia-cider/lyrics_overlay.pid"; [ -f "$f" ] && kill "$(cat "$f")" 2>/dev/null; rm -f "$f"', + }) end local function persistSurfaceVisible() @@ -316,14 +345,8 @@ local function ensureLyricsOverlay() noctalia.log("cider: missing lyrics_overlay.py") return end - -- Overlay replaces a stale pid itself. Do not pkill by overlay script name: - -- a -f match hits this runAsync shell and kills the launcher first. - noctalia.runAsync( - string.format( - "nohup python3 %s >/tmp/noctalia-cider-lyrics-overlay.log 2>&1 &", - shellQuote(script) - ) - ) + -- Detached argv launch (no shell). Overlay replaces a stale pid itself. + runArgv({ "python3", script }) end local function applyLyricsSurface() @@ -371,13 +394,13 @@ local lastNotifyTrackKey = "" local function closeOsd() osdCloseAt = 0 - noctalia.runAsync("noctalia msg panel-close dragged/cider:osd") + noctaliaMsg("panel-close", "dragged/cider:osd") end local function openOsd() pendingOsdKey = "" pendingOsdAt = 0 - noctalia.runAsync("noctalia msg panel-open dragged/cider:osd") + noctaliaMsg("panel-open", "dragged/cider:osd") -- Always (re)arm dismiss timer from this open. osdCloseAt = os.clock() * 1000 + osdDurationMs end @@ -429,7 +452,7 @@ local function showTrackNotification(np) noctalia.notify(payload.summary, payload.body) return end - local ok = noctalia.runAsync("noctalia msg notification-show " .. shellQuote(encoded)) + local ok = noctaliaMsg("notification-show", encoded) if not ok then noctalia.notify(payload.summary, payload.body) end @@ -576,13 +599,59 @@ local function applyLocalLyrics(event) end end +local function markCiderOffline() + ciderOnline = false + ciderAbsentStreak = 0 + lastTrackKey = "" + lastNotifyTrackKey = "" + lastOsdTrackKey = "" + pendingOsdKey = "" + pendingOsdAt = 0 + clearLocalLyrics() + publishNowPlaying(nil) +end + +local function ciderWindowPresent() + local raw = noctalia.readFile(WINDOW_PATH) + if raw == nil or raw == "" then + return nil + end + local ok, decoded = pcall(function() + return noctalia.json.decode(raw) + end) + if not ok or type(decoded) ~= "table" then + return nil + end + return decoded.present == true +end + +local function maybeHideWhenCiderClosed() + -- Belt-and-suspenders: compositor says Cider's window is gone. Don't wait for + -- socket death — that lag is what left the bar chip stuck on the last song. + local hasSong = type(nowPlaying) == "table" + and ((tostring(nowPlaying.title or "") ~= "") or (tostring(nowPlaying.artist or "") ~= "")) + if not hasSong then + ciderAbsentStreak = 0 + return + end + local present = ciderWindowPresent() + if present == true then + ciderAbsentStreak = 0 + return + end + if present == false then + ciderAbsentStreak = ciderAbsentStreak + 1 + if ciderAbsentStreak >= CIDER_ABSENT_HIDE_TICKS then + markCiderOffline() + end + end +end + local function handleEvent(event) if type(event) ~= "table" or type(event.type) ~= "string" then return end if event.type == "clear" then - lastTrackKey = "" - clearLocalLyrics() - publishNowPlaying(nil) + markCiderOffline() return end @@ -590,15 +659,17 @@ local function handleEvent(event) local msg = tostring(event.message or "") noctalia.state.set("bridge_status", msg) -- Disconnect / dead API: treat as inactive Cider. - if msg == "disconnected" or msg:match("^api_dead") or msg:match("^connect_failed") then - lastTrackKey = "" - clearLocalLyrics() - publishNowPlaying(nil) + if msg == "disconnected" or msg == "cider_closed" + or msg:match("^api_dead") or msg:match("^connect_failed") then + markCiderOffline() + elseif msg == "connected" then + ciderOnline = true end return end if event.type == "time" or event.type == "state" or event.type == "track" or event.type == "art" then + ciderOnline = true local np = { title = event.title or "", artist = event.artist or "", @@ -667,8 +738,8 @@ end local pollInFlight = false local function pollBridgeFiles() - -- Order: track before lyrics so a service start does not flash stale lines. - -- Async chain: STATE → EVENT → LYRICS → POSITION (API 23 readFileAsync). + -- Order: clear/status before state rehydrate so a dead Cider cannot ghost-notify. + -- Async chain: EVENT → STATE → LYRICS → POSITION (API 23 readFileAsync). if pollInFlight then return end @@ -680,7 +751,7 @@ local function pollBridgeFiles() local function applyPosition(posRaw) -- Position last — highest-frequency Cider clock for lyrics lock. - if posRaw and posRaw ~= "" and posRaw ~= lastPositionRaw then + if ciderOnline and posRaw and posRaw ~= "" and posRaw ~= lastPositionRaw then lastPositionRaw = posRaw local decoded = noctalia.json.decode(posRaw) if type(decoded) == "table" then @@ -724,29 +795,15 @@ local function pollBridgeFiles() end end - local function applyEvent(eventRaw) - if eventRaw and eventRaw ~= "" then - local decoded = noctalia.json.decode(eventRaw) - if type(decoded) == "table" then - local eid = tostring(decoded._id or "") - if eid ~= "" and eid ~= lastEventId then - lastEventId = eid - -- Skip lyrics edges here — lyrics.json is the durable source and avoids - -- double-clear races with track events in the same poll. - if decoded.type ~= "lyrics" then - handleEvent(decoded) - end - end - end - end - local queued = noctalia.readFileAsync(LYRICS_PATH, applyLyrics) - if not queued then - finishPoll() - end - end - local function applyState(stateRaw) - if stateRaw and stateRaw ~= "" then + -- Never rehydrate from disk while Cider is offline — stale state.json was + -- the ghost-notification source after quit (STATE poll before CLEAR). + if not ciderOnline then + if type(nowPlaying) == "table" then + publishNowPlaying(nil) + clearLocalLyrics() + end + elseif stateRaw and stateRaw ~= "" then local decoded = noctalia.json.decode(stateRaw) if type(decoded) == "table" and (decoded.title or decoded.artist) then if lastTrackKey == "" then @@ -770,13 +827,35 @@ local function pollBridgeFiles() publishNowPlaying(nil) clearLocalLyrics() end - local queued = noctalia.readFileAsync(EVENT_PATH, applyEvent) + local queued = noctalia.readFileAsync(LYRICS_PATH, applyLyrics) + if not queued then + finishPoll() + end + end + + local function applyEvent(eventRaw) + if eventRaw and eventRaw ~= "" then + local decoded = noctalia.json.decode(eventRaw) + if type(decoded) == "table" then + local eid = tostring(decoded._id or "") + if eid ~= "" and eid ~= lastEventId then + lastEventId = eid + -- Skip lyrics edges here — lyrics.json is the durable source and avoids + -- double-clear races with track events in the same poll. + if decoded.type ~= "lyrics" then + handleEvent(decoded) + end + end + end + end + local queued = noctalia.readFileAsync(STATE_PATH, applyState) if not queued then finishPoll() end end - local queued = noctalia.readFileAsync(STATE_PATH, applyState) + -- Event before state: clear/connect_failed must land before any state rehydrate. + local queued = noctalia.readFileAsync(EVENT_PATH, applyEvent) if not queued then finishPoll() end @@ -793,13 +872,9 @@ local function startBridge() noctalia.mkdirAll("~/.cache/noctalia-cider/art") noctalia.writeFile("~/.cache/noctalia-cider/apptoken", apptoken or "") -- bash, not chmod +x: git-store installs can be read-only. Token is a file, - -- not argv, so `ps` cannot scrape it. - local cmd = string.format( - "bash %s %s ~/.cache/noctalia-cider >/tmp/noctalia-cider-bridge.log 2>&1 &", - shellQuote(launcher), - shellQuote(baseUrl) - ) - local ok = noctalia.runAsync(cmd) + -- not argv, so `ps` cannot scrape it. Omit state-dir arg — start-bridge.sh + -- defaults to $HOME/.cache/noctalia-cider (tilde does not expand in argv). + local ok = runArgv({ "bash", launcher, baseUrl }) if ok then bridgeStarted = true noctalia.state.set("bridge_status", "starting") @@ -826,6 +901,7 @@ end function update() pollBridgeFiles() + maybeHideWhenCiderClosed() maybeCloseOsd() -- Smooth OSD progress from the last trusted Cider anchor (no fake +200 jumps). if type(nowPlaying) == "table" then @@ -844,7 +920,7 @@ function onConfigChanged() applyLyricsSurface() if baseUrl ~= prevUrl or apptoken ~= prevToken then bridgeStarted = false - noctalia.runAsync("pkill -f cider_bridge.py 2>/dev/null || true") + runArgv({ "pkill", "-f", "cider_bridge.py" }) startBridge() end end @@ -882,11 +958,11 @@ end function onExit(_signal, reason) -- Reload keeps the replacement bridge; start-bridge.sh already replaces it. if reason == "disable" or reason == "uninstall" or reason == "shutdown" then - noctalia.runAsync("pkill -f cider_bridge.py >/dev/null 2>&1 || true") + runArgv({ "pkill", "-f", "cider_bridge.py" }) stopLyricsOverlay() end if reason == "disable" or reason == "uninstall" then - noctalia.runAsync("noctalia msg panel-close dragged/cider:osd 2>/dev/null || true") + noctaliaMsg("panel-close", "dragged/cider:osd") closeLyricsPanel() end end diff --git a/cider/translations/en.json b/cider/translations/en.json index 896ed642..cb20a473 100644 --- a/cider/translations/en.json +++ b/cider/translations/en.json @@ -1,106 +1,106 @@ { - "lyrics_osd": { - "disabled": "Lyrics OSD disabled in settings", - "idle": "No lyrics" - }, - "osd": { - "not_playing": "Not playing" - }, "settings": { "apptoken": { - "description": "From Cider → Settings → Connectivity → External Application Access (apptoken header). Leave empty to reuse ~/.config/cider-kde-notifier/config.json.", - "label": "Cider API token" + "label": "Cider API token", + "description": "From Cider → Settings → Connectivity → External Application Access (apptoken header). Leave empty to reuse ~/.config/cider-kde-notifier/config.json." }, "base_url": { - "description": "Usually http://127.0.0.1:10767", - "label": "Cider API URL" - }, - "cover_size": { - "description": "Artwork edge length on the bar chip, in logical pixels (12–32).", - "label": "Cover size" + "label": "Cider API URL", + "description": "Usually http://127.0.0.1:10767" }, "display_mode": { - "description": "Noctalia notification (recommended), rich OSD panel, or silent on track changes.", "label": "Track alert style", + "description": "Noctalia notification (recommended), rich OSD panel, or silent on track changes.", "options": { "notification": "Notification", - "off": "Off", - "osd": "OSD panel" + "osd": "OSD panel", + "off": "Off" } }, - "lyrics_karaoke_active": { - "description": "Hex for the word currently being sung when Custom. Empty keeps Noctalia primary. Example: #83c2c8", - "label": "Active word color" - }, - "lyrics_karaoke_style": { - "description": "Theme follows the live Noctalia palette (on_surface + primary). Custom uses the hex fields below.", - "label": "Karaoke colors", - "options": { - "custom": "Custom hex", - "theme": "Noctalia theme" - } - }, - "lyrics_karaoke_sung": { - "description": "Hex for already-sung words when Karaoke colors is Custom. Empty keeps the theme color. Example: #f2f3f3", - "label": "Sung word color" - }, - "lyrics_karaoke_upcoming": { - "description": "Hex for not-yet-sung words in the current line when Custom. Empty uses sung color at lower opacity.", - "label": "Upcoming word color" + "save_to_history": { + "label": "Save track alerts to history", + "description": "When using notifications, also keep them in notification history. Off by default." }, - "lyrics_osd_animate_cues": { - "description": "Pulse three cue dots through intros, interludes, and song mixes. Off = static dots.", - "label": "Animate cue dots" + "osd_duration_ms": { + "label": "Alert duration (ms)", + "description": "How long the notification or now-playing OSD stays visible after a track change." }, "lyrics_osd_enabled": { - "description": "Bar widget click toggles sticky lyrics. Off = click does nothing.", - "label": "Enable lyrics HUD" - }, - "lyrics_osd_glow": { - "description": "Offset dark shadow under overlay glyphs so they stay readable on wallpaper.", - "label": "Lyric drop shadow" - }, - "lyrics_osd_karaoke": { - "description": "Highlight the currently sung word when Apple syllable timings exist. Line-only lyrics light the whole active line to match Cider.", - "label": "Word sing-along highlight" + "label": "Enable lyrics HUD", + "description": "Bar widget click toggles sticky lyrics. Off = click does nothing." }, "lyrics_osd_position": { - "description": "Where the overlay sits on screen. Reopen the HUD after changing.", "label": "Lyrics HUD position", + "description": "Where the overlay sits on screen. Reopen the HUD after changing.", "options": { "bottom_center": "Bottom center", "bottom_left": "Bottom left", "bottom_right": "Bottom right", - "center": "Center", - "center_left": "Center left", - "center_right": "Center right", "top_center": "Top center", "top_left": "Top left", - "top_right": "Top right" + "top_right": "Top right", + "center": "Center", + "center_left": "Center left", + "center_right": "Center right" } }, - "lyrics_osd_show_idle": { - "description": "When the HUD is open with no lyrics yet, show a muted music/idle message instead of a blank overlay.", - "label": "Show idle placeholder" - }, "lyrics_osd_show_next": { - "description": "Dim preview of the upcoming lyric under the current line.", - "label": "Show next line" + "label": "Show next line", + "description": "Dim preview of the upcoming lyric under the current line." }, - "osd_duration_ms": { - "description": "How long the notification or now-playing OSD stays visible after a track change.", - "label": "Alert duration (ms)" + "lyrics_osd_animate_cues": { + "label": "Animate cue dots", + "description": "Pulse three cue dots through intros, interludes, and song mixes. Off = static dots." }, - "save_to_history": { - "description": "When using notifications, also keep them in notification history. Off by default.", - "label": "Save track alerts to history" + "lyrics_osd_karaoke": { + "label": "Word sing-along highlight", + "description": "Highlight the currently sung word when Apple syllable timings exist. Line-only lyrics light the whole active line to match Cider." + }, + "lyrics_osd_glow": { + "label": "Lyric drop shadow", + "description": "Offset dark shadow under overlay glyphs so they stay readable on wallpaper." + }, + "lyrics_karaoke_style": { + "label": "Karaoke colors", + "description": "Theme follows the live Noctalia palette (on_surface + primary). Custom uses the hex fields below.", + "options": { + "theme": "Noctalia theme", + "custom": "Custom hex" + } + }, + "lyrics_karaoke_sung": { + "label": "Sung word color", + "description": "Hex for already-sung words when Karaoke colors is Custom. Empty keeps the theme color. Example: #f2f3f3" + }, + "lyrics_karaoke_active": { + "label": "Active word color", + "description": "Hex for the word currently being sung when Custom. Empty keeps Noctalia primary. Example: #83c2c8" + }, + "lyrics_karaoke_upcoming": { + "label": "Upcoming word color", + "description": "Hex for not-yet-sung words in the current line when Custom. Empty uses sung color at lower opacity." + }, + "lyrics_osd_show_idle": { + "label": "Show idle placeholder", + "description": "When the HUD is open with no lyrics yet, show a muted music/idle message instead of a blank overlay." }, "show_cover": { - "description": "Show the current Cider artwork on the bar chip when a local cover file is available.", - "label": "Show cover" + "label": "Show cover", + "description": "Show the current Cider artwork on the bar chip when a local cover file is available." + }, + "cover_size": { + "label": "Cover size", + "description": "Artwork edge length on the bar chip, in logical pixels (12–32)." } }, "widget": { "idle": "Cider" + }, + "osd": { + "not_playing": "Not playing" + }, + "lyrics_osd": { + "idle": "No lyrics", + "disabled": "Lyrics OSD disabled in settings" } } diff --git a/cider/widget.luau b/cider/widget.luau index 59891469..2626e7a2 100644 --- a/cider/widget.luau +++ b/cider/widget.luau @@ -1,6 +1,7 @@ --!nonstrict -- Compact Cider now-playing bar widget. -- Only re-render on identity/playing changes — position ticks must not flash opacity. +-- Hide entirely when nothing is active (no title/artist). local showCover = noctalia.getConfig("show_cover") if showCover == nil then showCover = true end @@ -11,6 +12,20 @@ local artist = "" local artwork = "" local playing = false local lastRenderKey = "" +local visible = true + +local function hasTrack() + return title ~= "" or artist ~= "" +end + +local function setChipVisible(want) + want = want == true + if want == visible then + return + end + visible = want + barWidget.setVisible(want) +end local function renderKey() return table.concat({ @@ -20,6 +35,7 @@ local function renderKey() playing and "1" or "0", showCover and "1" or "0", tostring(coverSize), + hasTrack() and "1" or "0", }, "|") end @@ -30,6 +46,12 @@ local function render(force) end lastRenderKey = key + if not hasTrack() then + setChipVisible(false) + return + end + setChipVisible(true) + local children = {} if showCover and artwork ~= "" and noctalia.fileExists(artwork) then children[#children + 1] = ui.image({ @@ -50,14 +72,8 @@ local function render(force) if artist ~= "" then label = title ~= "" and (title .. " · " .. artist) or artist end - if label == "" then label = noctalia.tr("widget.idle") end -- Stable color — never opacity-pulse on playback_state flicker. - local color = "on_surface" - if label == noctalia.tr("widget.idle") then - color = "on_surface/0.55" - elseif not playing then - color = "on_surface/0.7" - end + local color = playing and "on_surface" or "on_surface/0.7" children[#children + 1] = ui.label({ text = label, maxLines = 1, @@ -78,6 +94,10 @@ local function applyNowPlaying(np) nextPlaying = np.playback_state == "playing" end if nextTitle == title and nextArtist == artist and nextArtwork == artwork and nextPlaying == playing then + -- Still force-hide if we somehow rendered while empty. + if not hasTrack() then + setChipVisible(false) + end return end title, artist, artwork, playing = nextTitle, nextArtist, nextArtwork, nextPlaying @@ -104,6 +124,9 @@ function onConfigChanged() end function onClick() + if not hasTrack() then + return + end local enabled = noctalia.state.get("lyrics_osd_enabled") if enabled == nil then enabled = noctalia.getConfig("lyrics_osd_enabled") @@ -112,7 +135,14 @@ function onClick() return end -- Text-only HUD: gtk-layer-shell overlay. - noctalia.runAsync("noctalia msg plugin dragged/cider:bridge all toggle-lyrics-hud") + noctalia.runAsync({ + "noctalia", + "msg", + "plugin", + "dragged/cider:bridge", + "all", + "toggle-lyrics-hud", + }) end -- Seed once from cache; no periodic update() — that was flashing grey/white.