Skip to content
Merged
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
19 changes: 19 additions & 0 deletions sendspin/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from aiosendspin.models.core import (
GroupUpdateServerPayload,
ServerCommandPayload,
ServerHelloPayload,
ServerStatePayload,
StreamStartMessage,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Comment thread
maximmaxim345 marked this conversation as resolved.
"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:
Expand Down
11 changes: 8 additions & 3 deletions sendspin/tui/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion sendspin/tui/visualizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "█"
Expand Down
69 changes: 69 additions & 0 deletions tests/tui/test_role_negotiation.py
Original file line number Diff line number Diff line change
@@ -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 == []