diff --git a/pyproject.toml b/pyproject.toml index a769501..094f33a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,11 +18,13 @@ dependencies = [ "aiosendspin-mpris~=2.1.1", "av>=15.0.0", "numpy>=1.26.0", + "pillow>=10.0.0", "pulsectl-asyncio>=1.2.2; platform_system == 'Linux'", "qrcode>=8.0", "readchar>=4.0.0", "rich>=13.0.0", "sounddevice>=0.4.6", + "textual-image>=0.13.0", ] description = "Synchronized audio player for Sendspin servers" diff --git a/sendspin/artwork_connector.py b/sendspin/artwork_connector.py new file mode 100644 index 0000000..94d3710 --- /dev/null +++ b/sendspin/artwork_connector.py @@ -0,0 +1,70 @@ +"""Artwork connector for bridging Sendspin client to the TUI.""" + +from __future__ import annotations + +import io +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING + +from PIL import Image, UnidentifiedImageError + +if TYPE_CHECKING: + from aiosendspin.client import SendspinClient + +logger = logging.getLogger(__name__) + + +class ArtworkHandler: + """Bridges between SendspinClient artwork frames and the TUI. + + Subscribes to artwork binary frames (album channel only), decodes them via + Pillow, and routes the latest image to a callback. Empty payloads, stream + end, and stream clear all collapse to ``on_image(None)``. + """ + + def __init__( + self, + on_image: Callable[[Image.Image | None], None], + ) -> None: + self._on_image = on_image + self._unsubscribes: list[Callable[[], None]] = [] + + def attach_client(self, client: SendspinClient) -> None: + """Register artwork, stream_end, and stream_clear listeners.""" + self._unsubscribes = [ + client.add_artwork_listener(self._on_artwork_frame), + client.add_stream_end_listener(self._on_stream_end), + client.add_stream_clear_listener(self._on_stream_clear), + ] + + def detach(self) -> None: + """Unregister listeners. Silent: never fires the callback.""" + for unsub in self._unsubscribes: + unsub() + self._unsubscribes = [] + + def _on_artwork_frame(self, channel: int, payload: bytes) -> None: + if channel != 0: + return + if not payload: + self._on_image(None) + return + try: + image = Image.open(io.BytesIO(payload)) + image.load() + except (UnidentifiedImageError, OSError) as exc: + logger.warning("Failed to decode artwork payload: %s", exc) + self._on_image(None) + return + self._on_image(image) + + def _on_stream_end(self, roles: list[str] | None) -> None: + if roles is not None and "artwork" not in roles: + return + self._on_image(None) + + def _on_stream_clear(self, roles: list[str] | None) -> None: + if roles is not None and "artwork" not in roles: + return + self._on_image(None) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 7961e5d..ad6eb63 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -13,11 +13,12 @@ if TYPE_CHECKING: from aiosendspin.models.metadata import SessionUpdateMetadata + from sendspin.volume_controller import VolumeController from aiohttp import ClientError from aiosendspin.client import SendspinClient -from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris +from aiosendspin.models.artwork import ArtworkChannel, ClientHelloArtworkSupport from aiosendspin.models.core import ( GroupUpdateServerPayload, ServerCommandPayload, @@ -30,27 +31,33 @@ PlayerCommandPayload, SupportedAudioFormat, ) -from aiosendspin.models.visualizer import ( - BeatTiming, - ClientHelloVisualizerSpectrum, - ClientHelloVisualizerSupport, - StreamStartVisualizer, - VisualizerFrame, -) from aiosendspin.models.types import ( + ArtworkSource, MediaCommand, + PictureFormat, PlaybackStateType, PlayerCommand, RepeatMode, Roles, UndefinedField, ) +from aiosendspin.models.visualizer import ( + BeatTiming, + ClientHelloVisualizerSpectrum, + ClientHelloVisualizerSupport, + StreamStartVisualizer, + VisualizerFrame, +) +from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris +from PIL.Image import Image as PILImage -from sendspin.audio_devices import AudioDevice, detect_supported_audio_formats +from sendspin.artwork_connector import ArtworkHandler from sendspin.audio_connector import AudioStreamHandler -from sendspin.discovery import ServiceDiscovery, DiscoveredServer +from sendspin.audio_devices import AudioDevice, detect_supported_audio_formats +from sendspin.discovery import DiscoveredServer, ServiceDiscovery from sendspin.hooks import run_hook from sendspin.settings import ClientSettings +from sendspin.tui.artwork import detect_support as detect_artwork_support from sendspin.tui.keyboard import keyboard_loop from sendspin.tui.ui import ColorMode, SendspinUI from sendspin.tui.visualizer import ( @@ -264,8 +271,11 @@ def __init__(self, args: AppArgs) -> None: self._visualizer_handler: VisualizerHandler | None = None self._beat_handler: BeatHandler | None = None self._peak_handler: PeakHandler | None = None + self._artwork_handler: ArtworkHandler | None = None self._settings = args.settings self._visualizer_enabled: bool = args.settings.visualizer + # Probe terminal graphics support before Rich Live takes the tty. + self._supports_artwork: bool = detect_artwork_support() # Currently-applied static delay in milliseconds, mirroring # `SendspinClient.static_delay_ms`. Tracked separately from settings # because CLI overrides aren't persisted to settings, so @@ -278,6 +288,20 @@ def __init__(self, args: AppArgs) -> None: self._mpris: SendspinMpris | None = None self._listener_unsubscribes: list[Callable[[], None]] = [] + @staticmethod + def _build_artwork_support() -> ClientHelloArtworkSupport: + """Build artwork support payload for client/hello (artwork@v1).""" + return ClientHelloArtworkSupport( + channels=[ + ArtworkChannel( + source=ArtworkSource.ALBUM, + format=PictureFormat.PNG, + media_width=128, + media_height=128, + ), + ], + ) + @staticmethod def _build_visualizer_support() -> ClientHelloVisualizerSupport: """Build visualizer support payload for client/hello (visualizer@v1).""" @@ -302,6 +326,11 @@ def _create_client(self) -> SendspinClient: visualizer_support = self._build_visualizer_support() roles.append(Roles.VISUALIZER) + artwork_support: ClientHelloArtworkSupport | None = None + if self._supports_artwork: + artwork_support = self._build_artwork_support() + roles.append(Roles.ARTWORK) + assert self._audio_handler is not None return SendspinClient( @@ -318,6 +347,7 @@ def _create_client(self) -> SendspinClient: supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE], ), visualizer_support=visualizer_support, + artwork_support=artwork_support, static_delay_ms=self._applied_delay_ms, state_supported_commands=[PlayerCommand.SET_STATIC_DELAY], initial_volume=self._audio_handler.volume, @@ -363,6 +393,12 @@ def _attach_client(self) -> None: if self._ui is not None: self._ui.set_server_clock(self._server_now_us) + if self._supports_artwork: + self._artwork_handler = ArtworkHandler( + on_image=self._handle_artwork_update, + ) + self._artwork_handler.attach_client(self._client) + if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(self._client) self._mpris.start() @@ -392,6 +428,13 @@ def _detach_client(self) -> None: self._ui.set_server_clock(None) self._ui.set_visualizer_types(frozenset()) + if self._artwork_handler is not None: + self._artwork_handler.detach() + self._artwork_handler = None + if self._ui is not None: + self._ui.state.artwork_image = None + self._ui.state.artwork_generation += 1 + if self._mpris: self._mpris.stop() self._mpris = None @@ -933,6 +976,14 @@ def _handle_stream_start(self, message: StreamStartMessage) -> None: ) self._ui.set_visualizer_types(types) + def _handle_artwork_update(self, image: PILImage | None) -> None: + """Receive a decoded artwork image (or None to clear) from the handler.""" + if self._ui is None: + return + self._ui.state.artwork_image = image + self._ui.state.artwork_generation += 1 + self._ui.refresh() + def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None: """Handle a visualizer frame from the connector.""" if self._ui is not None: diff --git a/sendspin/tui/artwork.py b/sendspin/tui/artwork.py new file mode 100644 index 0000000..c8721a0 --- /dev/null +++ b/sendspin/tui/artwork.py @@ -0,0 +1,50 @@ +"""Artwork rendering helpers for the Sendspin TUI.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual_image.renderable import Image as TIImage +from textual_image.renderable import SixelImage, TGPImage + +if TYPE_CHECKING: + from PIL.Image import Image as PILImage + from rich.console import RenderableType + +_cache: tuple[tuple[int, int, int], "RenderableType"] | None = None + + +def clear_cache() -> None: + """Drop the cached renderable.""" + global _cache # noqa: PLW0603 + _cache = None + + +def render_artwork( + image: "PILImage | None", + generation: int, + height_rows: int, + width_cells: int, +) -> "RenderableType | None": + """Return a Rich renderable for the given image, cached by (generation, height_rows, width_cells). + + Returns None when image is None so the layout can collapse the image column. + """ + global _cache # noqa: PLW0603 + if image is None: + return None + key = (generation, height_rows, width_cells) + if _cache is not None and _cache[0] == key: + return _cache[1] + renderable = TIImage(image, width=width_cells, height=height_rows) + _cache = (key, renderable) + return renderable + + +def detect_support() -> bool: + """True when a real terminal graphics protocol (Kitty or Sixel) is available. + + textual-image runs its terminal probe at module import. This function just + inspects the resolved Image class. Halfcell and Unicode fallbacks return False. + """ + return TIImage is SixelImage or TIImage is TGPImage diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 6b4bac4..1ecef8e 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -12,6 +12,7 @@ from dataclasses import dataclass, field from typing import Any, Self +from PIL.Image import Image as PILImage from aiosendspin.models.color import SessionUpdateColor from aiosendspin.models.types import PlaybackStateType, RepeatMode, UndefinedField from aiosendspin.models.visualizer import BeatTiming @@ -22,6 +23,7 @@ from rich.text import Text from sendspin.discovery import DiscoveredServer +from sendspin.tui.artwork import render_artwork from sendspin.tui.visualizer import ( BeatState, PeakEvent, @@ -142,6 +144,12 @@ class UIState: palette_available: bool = False color_mode: ColorMode = ColorMode.DARK + # Album artwork: None when unsupported or not yet received. + artwork_image: PILImage | None = None + # Bumped on every artwork update, used to key the renderable cache and + # the now_playing panel cache. + artwork_generation: int = 0 + # Shortcut highlight highlighted_shortcut: str | None = None highlight_time: float = 0.0 @@ -350,13 +358,12 @@ def _build_now_playing_panel(self, *, expand: bool = False) -> Panel: info.add_row("", Text("No metadata available", style=self._themed("dim"))) info.add_row("") - # Vertical container for info + shortcuts (5 lines total) - content = Table.grid() - content.add_column() - content.add_row(info) - content.add_row("") # Line 4: spacing + # Metadata + shortcuts column (what the panel showed before this change) + metadata_col = Table.grid() + metadata_col.add_column() + metadata_col.add_row(info) + metadata_col.add_row("") # spacing - # Line 5: playback shortcuts (always show when active) space_label = "pause" if self._state.playback_state == PlaybackStateType.PLAYING else "play" shortcuts = Text() shortcuts.append("←", style=self._shortcut_style("prev")) @@ -365,7 +372,25 @@ def _build_now_playing_panel(self, *, expand: bool = False) -> Panel: shortcuts.append(f" {space_label} ", style=self._themed("dim")) shortcuts.append("→", style=self._shortcut_style("next")) shortcuts.append(" next", style=self._themed("dim")) - content.add_row(shortcuts) + metadata_col.add_row(shortcuts) + + # Wrap with an artwork column on the left when artwork is available + # and the layout is wide enough. + artwork = render_artwork( + self._state.artwork_image, + self._state.artwork_generation, + height_rows=5, + width_cells=10, + ) + narrow = self._console.width - 1 < 80 + if artwork is not None and not narrow: + outer = Table.grid(padding=(0, 2)) + outer.add_column() + outer.add_column() + outer.add_row(artwork, metadata_col) + content = outer + else: + content = metadata_col return self._make_panel(content, title="Now Playing", default_border="blue", expand=expand) @@ -1052,6 +1077,8 @@ def _build_layout(self) -> Table: self._state.title, self._state.artist, self._state.album, + self._state.artwork_generation, + narrow, self._is_highlighted("prev"), self._is_highlighted("space"), self._is_highlighted("next"), diff --git a/tests/test_artwork_connector.py b/tests/test_artwork_connector.py new file mode 100644 index 0000000..21b0afc --- /dev/null +++ b/tests/test_artwork_connector.py @@ -0,0 +1,169 @@ +"""Tests for ArtworkHandler.""" + +from __future__ import annotations + +import io +import logging + +from PIL import Image as PILImage + +from sendspin.artwork_connector import ArtworkHandler + + +class _FakeClient: + def __init__(self) -> None: + self.artwork_listeners: list[object] = [] + self.stream_end_listeners: list[object] = [] + self.stream_clear_listeners: list[object] = [] + + def add_artwork_listener(self, callback: object) -> object: + return self._add(self.artwork_listeners, callback) + + def add_stream_end_listener(self, callback: object) -> object: + return self._add(self.stream_end_listeners, callback) + + def add_stream_clear_listener(self, callback: object) -> object: + return self._add(self.stream_clear_listeners, callback) + + @staticmethod + def _add(callbacks: list[object], callback: object) -> object: + callbacks.append(callback) + return lambda: callbacks.remove(callback) + + +def _make_png_bytes( + size: tuple[int, int] = (32, 32), color: tuple[int, int, int] = (255, 0, 0) +) -> bytes: + img = PILImage.new("RGB", size, color=color) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def test_channel_0_bytes_decode_to_pil_image() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_artwork = client.artwork_listeners[0] + + on_artwork(0, _make_png_bytes(size=(48, 48))) + + assert len(received) == 1 + image = received[0] + assert image is not None + assert image.size == (48, 48) + + +def test_empty_payload_emits_none() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_artwork = client.artwork_listeners[0] + + on_artwork(0, b"") + + assert received == [None] + + +def test_corrupt_bytes_log_and_emit_none(caplog: object) -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_artwork = client.artwork_listeners[0] + + with caplog.at_level(logging.WARNING): # type: ignore[attr-defined] + on_artwork(0, b"not a valid png") + + assert received == [None] + assert any("artwork" in record.message.lower() for record in caplog.records) # type: ignore[attr-defined] + + +def test_channels_1_to_3_are_ignored() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_artwork = client.artwork_listeners[0] + + on_artwork(1, _make_png_bytes()) + on_artwork(2, _make_png_bytes()) + on_artwork(3, _make_png_bytes()) + + assert received == [] + + +def test_stream_clear_with_artwork_role_emits_none() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_clear = client.stream_clear_listeners[0] + + on_clear(["artwork"]) + + assert received == [None] + + +def test_stream_clear_with_unrelated_role_does_nothing() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_clear = client.stream_clear_listeners[0] + + on_clear(["player"]) + + assert received == [] + + +def test_stream_clear_with_none_clears() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_clear = client.stream_clear_listeners[0] + + on_clear(None) + + assert received == [None] + + +def test_stream_end_with_artwork_role_emits_none() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_end = client.stream_end_listeners[0] + + on_end(["artwork"]) + + assert received == [None] + + +def test_stream_end_with_none_clears() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + on_end = client.stream_end_listeners[0] + + on_end(None) + + assert received == [None] + + +def test_detach_is_silent_and_unsubscribes() -> None: + received: list[PILImage.Image | None] = [] + handler = ArtworkHandler(on_image=received.append) + client = _FakeClient() + handler.attach_client(client) + + handler.detach() + + assert received == [] # detach must not fire callbacks + assert client.artwork_listeners == [] + assert client.stream_end_listeners == [] + assert client.stream_clear_listeners == [] diff --git a/tests/tui/test_artwork.py b/tests/tui/test_artwork.py new file mode 100644 index 0000000..d446e2b --- /dev/null +++ b/tests/tui/test_artwork.py @@ -0,0 +1,48 @@ +"""Tests for the TUI artwork render helper.""" + +from __future__ import annotations + +from PIL import Image as PILImage + +from sendspin.tui import artwork + + +def _dummy_image(size: tuple[int, int] = (16, 16)) -> PILImage.Image: + return PILImage.new("RGB", size, color=(10, 10, 10)) + + +def test_render_artwork_returns_none_for_none_image() -> None: + assert artwork.render_artwork(None, generation=1, height_rows=4, width_cells=8) is None + + +def test_render_artwork_returns_same_renderable_for_same_generation_and_height() -> None: + artwork.clear_cache() + img = _dummy_image() + first = artwork.render_artwork(img, generation=1, height_rows=4, width_cells=8) + second = artwork.render_artwork(img, generation=1, height_rows=4, width_cells=8) + assert first is not None + assert first is second + + +def test_render_artwork_rebuilds_on_new_generation() -> None: + artwork.clear_cache() + img = _dummy_image() + first = artwork.render_artwork(img, generation=1, height_rows=4, width_cells=8) + second = artwork.render_artwork(img, generation=2, height_rows=4, width_cells=8) + assert first is not second + + +def test_render_artwork_rebuilds_on_new_height() -> None: + artwork.clear_cache() + img = _dummy_image() + first = artwork.render_artwork(img, generation=1, height_rows=4, width_cells=8) + second = artwork.render_artwork(img, generation=1, height_rows=6, width_cells=8) + assert first is not second + + +def test_render_artwork_rebuilds_on_new_width() -> None: + artwork.clear_cache() + img = _dummy_image() + first = artwork.render_artwork(img, generation=1, height_rows=4, width_cells=8) + second = artwork.render_artwork(img, generation=1, height_rows=4, width_cells=10) + assert first is not second diff --git a/uv.lock b/uv.lock index 57b7286..0506d21 100644 --- a/uv.lock +++ b/uv.lock @@ -1280,11 +1280,13 @@ dependencies = [ { name = "aiosendspin-mpris" }, { name = "av" }, { name = "numpy" }, + { name = "pillow" }, { name = "pulsectl-asyncio", marker = "sys_platform == 'linux'" }, { name = "qrcode" }, { name = "readchar" }, { name = "rich" }, { name = "sounddevice" }, + { name = "textual-image" }, ] [package.optional-dependencies] @@ -1309,6 +1311,7 @@ requires-dist = [ { name = "codespell", marker = "extra == 'test'", specifier = "==2.4.1" }, { name = "mypy", marker = "extra == 'test'", specifier = "==1.18.2" }, { name = "numpy", specifier = ">=1.26.0" }, + { name = "pillow", specifier = ">=10.0.0" }, { name = "pre-commit", marker = "extra == 'test'", specifier = "==4.4.0" }, { name = "pre-commit-hooks", marker = "extra == 'test'", specifier = "==6.0.0" }, { name = "pulsectl-asyncio", marker = "sys_platform == 'linux'", specifier = ">=1.2.2" }, @@ -1320,6 +1323,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'test'", specifier = "==0.14.5" }, { name = "sounddevice", specifier = ">=0.4.6" }, + { name = "textual-image", specifier = ">=0.13.0" }, ] provides-extras = ["cast", "test"] @@ -1339,6 +1343,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, ] +[[package]] +name = "textual-image" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/77/b2128ced69556bfbb8e1c19d8f013e621cf12531eaba4e9b09e1cfa81e37/textual_image-0.13.2.tar.gz", hash = "sha256:8ca0cee2bfcd7734de5b16a1936da226b77b745e28830d9cf2bc202cb70e43ee", size = 2185178, upload-time = "2026-05-30T21:42:33.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/be/60aafd7263a6daa8cc4017e8bd4452c85b63ccd82a64f02636ca46e073b7/textual_image-0.13.2-py3-none-any.whl", hash = "sha256:41635f545ce2d0b3544763a2d10f25ada7f64c51acef77483b80fbfcf7ab22ee", size = 118469, upload-time = "2026-05-30T21:42:31.964Z" }, +] + [[package]] name = "tunit" version = "1.7.2"