feat: add watch print mode

test: refactor test_watch
style: add inline comments for watch
This commit is contained in:
2026-04-10 07:45:02 +02:00
parent 633983ed98
commit 60732f2986
13 changed files with 497 additions and 261 deletions
+12
View File
@@ -37,13 +37,16 @@ class LyricView:
line_index = 0
for line in normalized.lines:
if not isinstance(line, LyricLine):
# skip metadata/tag lines that carry no renderable text
continue
text = line.text
lines.append(text)
# use first timestamp; clamp to 0 so bisect always works with non-negative ms
timestamp = line.line_times_ms[0] if line.line_times_ms else 0
entries.append((max(0, timestamp), line_index))
line_index += 1
# extract timestamps into a flat tuple so bisect_right can binary-search it
timestamps = tuple(timestamp for timestamp, _ in entries)
return LyricView(
normalized=normalized,
@@ -55,12 +58,16 @@ class LyricView:
def signature_cursor(self, at_ms: int) -> tuple:
"""Build a stable cursor signature for dedupe decisions."""
if not self.timed_line_entries:
# untimed lyrics: signature is the full line set — changes only on track change
return ("plain", self.lines)
first_ts = self.timed_line_entries[0][0]
if at_ms < first_ts:
# playback hasn't reached the first lyric yet; hold until it does
return ("before_first", first_ts)
# bisect_right gives the insertion point after equal timestamps, so -1 gives
# the last line whose timestamp <= at_ms (i.e. the currently active line)
idx = bisect_right(self.timestamps, at_ms) - 1
if idx < 0:
idx = 0
@@ -82,6 +89,11 @@ class WatchState:
class BaseOutput(ABC):
# When False, the coordinator passes position=0 for signature computation and
# skips tracker-tick-driven emits, so on_state fires at most once per
# track+status transition rather than on every lyric cursor advance.
position_sensitive: bool = True
@abstractmethod
async def on_state(self, state: WatchState) -> None:
"""Render or deliver one watch state frame."""
+11 -2
View File
@@ -1,4 +1,8 @@
"""Pipe output implementation for watch mode."""
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:15:17
Description: Pipe output implementation for watch mode.
"""
from bisect import bisect_right
from dataclasses import dataclass
@@ -38,12 +42,14 @@ class PipeOutput(BaseOutput):
effective_ms = state.position_ms + state.offset_ms
current_line_idx: int | None
if entries and effective_ms < entries[0][0]:
# Before first timestamp, current lyric is empty and after-window shows upcoming lines.
# playback hasn't reached the first lyric yet; treat current slot as empty
# so the after-window can show upcoming lines without a "current" anchor
current_line_idx = None
else:
if not entries:
current_line_idx = 0
else:
# bisect_right - 1 gives the last entry whose timestamp <= effective_ms
current_entry_idx = (
bisect_right(state.lyrics.timestamps, effective_ms) - 1
)
@@ -54,6 +60,8 @@ class PipeOutput(BaseOutput):
out: list[str] = []
for rel in range(-self.before, self.after + 1):
if current_line_idx is None:
# before-first-timestamp: before/current slots are empty; after slots
# show lines starting from index 0 (rel=1 → line 0, rel=2 → line 1, …)
if rel <= 0:
out.append("")
continue
@@ -80,5 +88,6 @@ class PipeOutput(BaseOutput):
lines = self._render_lyrics(state)
for line in lines:
# no_newline mode lets callers use \r to overwrite the previous frame in-place
sys.stdout.write(line + ("\n" if not self.no_newline else ""))
sys.stdout.flush()
+44
View File
@@ -0,0 +1,44 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 08:15:31
Description: Print output implementation for watch mode — one shot per track.
"""
import sys
from . import BaseOutput, WatchState, WatchStatus
class PrintOutput(BaseOutput):
"""Emit full lyrics to stdout once per track transition, then stay silent.
Deduplication is delegated to the coordinator via position_sensitive=False:
the coordinator uses a fixed position for signatures, so on_state fires at
most once per (status, track_key) transition rather than on every tick.
"""
# fixed position=0 in signatures → coordinator calls on_state only on
# track/status transitions, never on lyric cursor advances
position_sensitive = False
plain: bool
def __init__(self, plain: bool = False) -> None:
self.plain = plain
async def on_state(self, state: WatchState) -> None:
if state.status == WatchStatus.FETCHING or state.status == WatchStatus.IDLE:
return
if state.status == WatchStatus.NO_LYRICS:
# emit a blank line as a machine-readable sentinel for "track changed, no lyrics"
sys.stdout.write("\n")
sys.stdout.flush()
elif state.status == WatchStatus.OK and state.lyrics is not None:
lrc = state.lyrics.normalized
if self.plain:
text = lrc.to_plain()
else:
text = str(lrc)
sys.stdout.write(text + "\n")
sys.stdout.flush()