Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions cider/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
85 changes: 71 additions & 14 deletions cider/scripts/cider_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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))
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading