From d24e4cfd103c52612cbc01a30fc7a40ccef7c9d9 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 14:31:32 +0200 Subject: [PATCH 1/4] fix: refresh the visualizer while only peaks are active --- sendspin/tui/ui.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 1d0f671..df342ab 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -224,7 +224,11 @@ def _needs_visualizer_refresh(self) -> bool: """Check if the visualizer needs periodic refreshes for interpolation.""" if not self._state.visualizer_enabled: return False - return self._state.visualizer_state.is_active or self._state.beat_state.is_active + return ( + self._state.visualizer_state.is_active + or self._state.beat_state.is_active + or self._state.peak_state.is_active + ) def _next_refresh_interval(self) -> float | None: """Return the next periodic refresh interval, if any.""" From fc53f0e55fbdea6bdd901033a17e3f7c51d04afc Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 14:31:34 +0200 Subject: [PATCH 2/4] docs: correct the visualizer row and playhead glyph docs --- sendspin/tui/ui.py | 5 +++-- sendspin/tui/visualizer.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index df342ab..6b4bac4 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -788,8 +788,9 @@ def _build_server_panel(self, *, expand: bool = False, min_info_rows: int = 0) - def _build_visualizer_rows(self, height: int) -> list[Text]: """Build the visualizer as raw Text rows, totaling `height`. - Reserves the top row for the beat timeline strip when there's room; - the remaining rows are the spectrum. + Stacks the peak and beat strips above the spectrum and the f_peak + arrow, pitch arrow, and footer below it, dropping lowest-priority + rows first on short terminals. The spectrum fills the rest. """ state = self._state.visualizer_state state.step() diff --git a/sendspin/tui/visualizer.py b/sendspin/tui/visualizer.py index c05201c..cd9c78a 100644 --- a/sendspin/tui/visualizer.py +++ b/sendspin/tui/visualizer.py @@ -473,7 +473,7 @@ def place(timestamp_us: int, glyph: str, style: str, *, downbeat: bool) -> None: place(beat.timestamp_us, glyph, upcoming_color, downbeat=beat.is_downbeat) # Playhead overlays whatever was at center. Grows on beat pulse: - # idle = thin ┃, mid pulse = heavy ┃, peak pulse = full block █. + # idle = thin │, mid pulse = heavy ┃, peak pulse = full block █. playhead_color = ph_color if pulse >= 0.6: playhead_glyph = "█" From e48ef26404b3ebc6863c074065f32b4e8002ebc3 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 14:31:34 +0200 Subject: [PATCH 3/4] fix: hide the visualizer when the server lacks visualizer@v1 --- sendspin/tui/app.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index dd332ce..7961e5d 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -21,6 +21,7 @@ from aiosendspin.models.core import ( GroupUpdateServerPayload, ServerCommandPayload, + ServerHelloPayload, ServerStatePayload, StreamStartMessage, ) @@ -334,9 +335,13 @@ def _attach_client(self) -> None: self._client.add_controller_state_listener(self._handle_server_state), self._client.add_server_command_listener(self._handle_server_command), self._client.add_color_listener(self._handle_color_update), + self._client.add_server_hello_listener(self._handle_server_hello), ] self._audio_handler.attach_client(self._client) + if self._ui is not None: + self._ui.set_visualizer_enabled(self._visualizer_enabled) + if self._visualizer_enabled: self._visualizer_handler = VisualizerHandler( on_frame=self._handle_visualizer_frame, @@ -904,6 +909,20 @@ def _server_now_us(self) -> int: assert self._client is not None return self._client.compute_server_time(self._client.now_us()) + def _handle_server_hello(self, payload: ServerHelloPayload) -> None: + """Hide the visualizer panel when the server didn't activate visualizer@v1.""" + if not self._visualizer_enabled: + return + if Roles.VISUALIZER.value in payload.active_roles: + return + logger.warning( + "Server did not activate %s (active_roles=%s); hiding the visualizer panel.", + Roles.VISUALIZER.value, + payload.active_roles, + ) + if self._ui is not None: + self._ui.set_visualizer_enabled(False) + def _handle_stream_start(self, message: StreamStartMessage) -> None: """Record which visualizer types the server negotiated for this stream.""" if self._ui is None: From fc509a93cc6a73e4270e2f093e36d862af70f96b Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 14:43:55 +0200 Subject: [PATCH 4/4] test: cover hiding the visualizer when visualizer@v1 is inactive --- tests/tui/test_role_negotiation.py | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/tui/test_role_negotiation.py diff --git a/tests/tui/test_role_negotiation.py b/tests/tui/test_role_negotiation.py new file mode 100644 index 0000000..7ceffe6 --- /dev/null +++ b/tests/tui/test_role_negotiation.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from aiosendspin.models.core import ServerHelloPayload +from aiosendspin.models.types import ConnectionReason, Roles + +from sendspin.settings import ClientSettings +from sendspin.tui.app import AppArgs, SendspinApp + + +class _FakeUI: + def __init__(self) -> None: + self.visualizer_enabled_calls: list[bool] = [] + + def set_visualizer_enabled(self, enabled: bool) -> None: + self.visualizer_enabled_calls.append(enabled) + + +def _make_app(tmp_path: Path) -> SendspinApp: + args = AppArgs( + audio_device=SimpleNamespace(index=0, name="Fake Device"), + client_id="test-client", + client_name="Test Client", + settings=ClientSettings(_settings_file=tmp_path / "settings.json"), + use_mpris=False, + ) + return SendspinApp(args) + + +def _payload(active_roles: list[str]) -> ServerHelloPayload: + return ServerHelloPayload( + server_id="srv", + name="srv", + version=1, + active_roles=active_roles, + connection_reason=ConnectionReason.DISCOVERY, + ) + + +def test_server_hello_without_visualizer_role_hides_panel(tmp_path: Path) -> None: + app = _make_app(tmp_path) + app._visualizer_enabled = True + app._ui = _FakeUI() + + app._handle_server_hello(_payload(active_roles=["player@v1", "controller@v1"])) + + assert app._ui.visualizer_enabled_calls == [False] + + +def test_server_hello_with_visualizer_role_leaves_panel(tmp_path: Path) -> None: + app = _make_app(tmp_path) + app._visualizer_enabled = True + app._ui = _FakeUI() + + app._handle_server_hello(_payload(active_roles=[Roles.VISUALIZER.value, "player@v1"])) + + assert app._ui.visualizer_enabled_calls == [] + + +def test_server_hello_ignored_when_visualizer_disabled(tmp_path: Path) -> None: + app = _make_app(tmp_path) + app._visualizer_enabled = False + app._ui = _FakeUI() + + app._handle_server_hello(_payload(active_roles=["player@v1"])) + + assert app._ui.visualizer_enabled_calls == []