feat: add watch print mode
test: refactor test_watch style: add inline comments for watch
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user