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: diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 1d0f671..6b4bac4 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.""" @@ -784,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 = "█" 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 == []