From 52cecf4012ceb85a92d0aecd5cd1a3e1fa2659b9 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 14:40:44 +0200 Subject: [PATCH 01/11] Add textual-image dependency for TUI artwork --- pyproject.toml | 2 ++ uv.lock | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) 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/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" From abe7f7ba699bcff82c5b80f2a2c46a8615bda2d8 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 14:51:58 +0200 Subject: [PATCH 02/11] Add ArtworkHandler for artwork@v1 binary frames --- sendspin/artwork_connector.py | 73 ++++++++++++++ tests/test_artwork_connector.py | 169 ++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 sendspin/artwork_connector.py create mode 100644 tests/test_artwork_connector.py diff --git a/sendspin/artwork_connector.py b/sendspin/artwork_connector.py new file mode 100644 index 0000000..754b8eb --- /dev/null +++ b/sendspin/artwork_connector.py @@ -0,0 +1,73 @@ +"""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._client: SendspinClient | None = None + self._unsubscribes: list[Callable[[], None]] = [] + + def attach_client(self, client: SendspinClient) -> None: + """Register artwork, stream_end, and stream_clear listeners.""" + self._client = client + 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 = [] + self._client = None + + 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/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 == [] From d036c97718cd2895fcb9823ff25f56d169b8b2a4 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 14:59:16 +0200 Subject: [PATCH 03/11] Add TUI artwork render helper with capability probe --- sendspin/tui/artwork.py | 62 +++++++++++++++++++++++++++++++++++++++ tests/tui/test_artwork.py | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 sendspin/tui/artwork.py create mode 100644 tests/tui/test_artwork.py diff --git a/sendspin/tui/artwork.py b/sendspin/tui/artwork.py new file mode 100644 index 0000000..9b31f55 --- /dev/null +++ b/sendspin/tui/artwork.py @@ -0,0 +1,62 @@ +"""Artwork rendering helpers for the Sendspin TUI.""" + +from __future__ import annotations + +import logging +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 + +logger = logging.getLogger(__name__) + +_cache: tuple[tuple[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, +) -> "RenderableType | None": + """Return a Rich renderable for the given image, cached by (generation, height_rows). + + 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) + if _cache is not None and _cache[0] == key: + return _cache[1] + renderable = TIImage(image, height=height_rows) + _cache = (key, renderable) + return renderable + + +def _probe_graphics_protocol() -> str | None: + """Probe for a real terminal graphics protocol. + + Returns "kitty" (TGP), "sixel", or None for halfcell/unicode fallbacks. + textual-image runs its terminal probe at module import; this function only + inspects the resolved Image class. + """ + if TIImage is SixelImage: + return "sixel" + if TIImage is TGPImage: + return "kitty" + return None + + +def detect_support() -> bool: + """True when a graphics protocol (Kitty or Sixel) is available.""" + return _probe_graphics_protocol() is not None diff --git a/tests/tui/test_artwork.py b/tests/tui/test_artwork.py new file mode 100644 index 0000000..c5d8055 --- /dev/null +++ b/tests/tui/test_artwork.py @@ -0,0 +1,54 @@ +"""Tests for the TUI artwork render helper.""" + +from __future__ import annotations + +from unittest.mock import patch + +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) 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) + second = artwork.render_artwork(img, generation=1, height_rows=4) + 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) + second = artwork.render_artwork(img, generation=2, height_rows=4) + 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) + second = artwork.render_artwork(img, generation=1, height_rows=6) + assert first is not second + + +def test_detect_support_false_when_no_graphics_protocol() -> None: + with patch.object(artwork, "_probe_graphics_protocol", return_value=None): + assert artwork.detect_support() is False + + +def test_detect_support_true_when_kitty_or_sixel() -> None: + with patch.object(artwork, "_probe_graphics_protocol", return_value="kitty"): + assert artwork.detect_support() is True + with patch.object(artwork, "_probe_graphics_protocol", return_value="sixel"): + assert artwork.detect_support() is True From 1b044e8f72bae2ce18bbbd4002dd3128c04711e0 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 15:02:40 +0200 Subject: [PATCH 04/11] Add artwork_image and artwork_generation to UIState --- sendspin/tui/ui.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 6b4bac4..1dc8046 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 @@ -142,6 +143,13 @@ 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. id(artwork_image) is not safe because + # CPython reuses ids after garbage collection. + artwork_generation: int = 0 + # Shortcut highlight highlighted_shortcut: str | None = None highlight_time: float = 0.0 From b9f84048756cc7d08d83019f18fd77e7d9b1690a Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 15:05:45 +0200 Subject: [PATCH 05/11] Wire ArtworkHandler into TUI app lifecycle --- sendspin/tui/app.py | 76 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 7961e5d..2ddf215 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,14 @@ 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 screen. + # textual-image writes escape queries to the tty and reads responses + # back from stdin. Doing this after Live starts or after the keyboard + # loop reads stdin will hang or corrupt the response. + 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 +291,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=512, + media_height=512, + ), + ], + ) + @staticmethod def _build_visualizer_support() -> ClientHelloVisualizerSupport: """Build visualizer support payload for client/hello (visualizer@v1).""" @@ -302,6 +329,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 +350,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 +396,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 +431,15 @@ 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 + # detach() is silent (no callback), so clear UI state ourselves so the + # column collapses before the next connection's artwork arrives. + 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 +981,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: From 6c78d4383ed50373a2b6b856f313abbf298471ef Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 15:16:52 +0200 Subject: [PATCH 06/11] Render album artwork in the Now Playing panel --- sendspin/tui/ui.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 1dc8046..e530f08 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -23,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, @@ -358,13 +359,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")) @@ -373,7 +373,24 @@ 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=4, + ) + 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) @@ -1060,6 +1077,7 @@ def _build_layout(self) -> Table: self._state.title, self._state.artist, self._state.album, + self._state.artwork_generation, self._is_highlighted("prev"), self._is_highlighted("space"), self._is_highlighted("next"), From e5b4ef76342fc397ce402955982d2272f55f1f75 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 15:29:25 +0200 Subject: [PATCH 07/11] Fix artwork width and resize behavior --- sendspin/tui/artwork.py | 9 +++++---- sendspin/tui/ui.py | 2 ++ tests/tui/test_artwork.py | 22 +++++++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/sendspin/tui/artwork.py b/sendspin/tui/artwork.py index 9b31f55..728c36c 100644 --- a/sendspin/tui/artwork.py +++ b/sendspin/tui/artwork.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -_cache: tuple[tuple[int, int], "RenderableType"] | None = None +_cache: tuple[tuple[int, int, int], "RenderableType"] | None = None def clear_cache() -> None: @@ -27,18 +27,19 @@ 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). + """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) + key = (generation, height_rows, width_cells) if _cache is not None and _cache[0] == key: return _cache[1] - renderable = TIImage(image, height=height_rows) + renderable = TIImage(image, width=width_cells, height=height_rows) _cache = (key, renderable) return renderable diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index e530f08..755daff 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -381,6 +381,7 @@ def _build_now_playing_panel(self, *, expand: bool = False) -> Panel: self._state.artwork_image, self._state.artwork_generation, height_rows=4, + width_cells=8, ) narrow = self._console.width - 1 < 80 if artwork is not None and not narrow: @@ -1078,6 +1079,7 @@ def _build_layout(self) -> Table: 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/tui/test_artwork.py b/tests/tui/test_artwork.py index c5d8055..d3c4ed3 100644 --- a/tests/tui/test_artwork.py +++ b/tests/tui/test_artwork.py @@ -14,14 +14,14 @@ def _dummy_image(size: tuple[int, int] = (16, 16)) -> PILImage.Image: def test_render_artwork_returns_none_for_none_image() -> None: - assert artwork.render_artwork(None, generation=1, height_rows=4) is 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) - second = artwork.render_artwork(img, generation=1, height_rows=4) + 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 @@ -29,16 +29,24 @@ def test_render_artwork_returns_same_renderable_for_same_generation_and_height() 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) - second = artwork.render_artwork(img, generation=2, height_rows=4) + 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) - second = artwork.render_artwork(img, generation=1, height_rows=6) + 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 From 833992067be6e10cde6f4b2abbf27ba65efee5d1 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Thu, 11 Jun 2026 15:35:54 +0200 Subject: [PATCH 08/11] Request 128x128 artwork instead of 512x512 --- sendspin/tui/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 2ddf215..6f6f248 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -299,8 +299,8 @@ def _build_artwork_support() -> ClientHelloArtworkSupport: ArtworkChannel( source=ArtworkSource.ALBUM, format=PictureFormat.PNG, - media_width=512, - media_height=512, + media_width=128, + media_height=128, ), ], ) From d9b2828edbe8dcf08eee7e2bab2651081bac88f1 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 15 Jun 2026 18:36:46 +0200 Subject: [PATCH 09/11] Tighten artwork modules --- sendspin/artwork_connector.py | 3 --- sendspin/tui/app.py | 7 +------ sendspin/tui/artwork.py | 20 +++++--------------- sendspin/tui/ui.py | 3 +-- tests/tui/test_artwork.py | 14 -------------- 5 files changed, 7 insertions(+), 40 deletions(-) diff --git a/sendspin/artwork_connector.py b/sendspin/artwork_connector.py index 754b8eb..94d3710 100644 --- a/sendspin/artwork_connector.py +++ b/sendspin/artwork_connector.py @@ -28,12 +28,10 @@ def __init__( on_image: Callable[[Image.Image | None], None], ) -> None: self._on_image = on_image - self._client: SendspinClient | None = None self._unsubscribes: list[Callable[[], None]] = [] def attach_client(self, client: SendspinClient) -> None: """Register artwork, stream_end, and stream_clear listeners.""" - self._client = client self._unsubscribes = [ client.add_artwork_listener(self._on_artwork_frame), client.add_stream_end_listener(self._on_stream_end), @@ -45,7 +43,6 @@ def detach(self) -> None: for unsub in self._unsubscribes: unsub() self._unsubscribes = [] - self._client = None def _on_artwork_frame(self, channel: int, payload: bytes) -> None: if channel != 0: diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index 6f6f248..ad6eb63 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -274,10 +274,7 @@ def __init__(self, args: AppArgs) -> 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 screen. - # textual-image writes escape queries to the tty and reads responses - # back from stdin. Doing this after Live starts or after the keyboard - # loop reads stdin will hang or corrupt the response. + # 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 @@ -434,8 +431,6 @@ def _detach_client(self) -> None: if self._artwork_handler is not None: self._artwork_handler.detach() self._artwork_handler = None - # detach() is silent (no callback), so clear UI state ourselves so the - # column collapses before the next connection's artwork arrives. if self._ui is not None: self._ui.state.artwork_image = None self._ui.state.artwork_generation += 1 diff --git a/sendspin/tui/artwork.py b/sendspin/tui/artwork.py index 728c36c..b0fe611 100644 --- a/sendspin/tui/artwork.py +++ b/sendspin/tui/artwork.py @@ -44,20 +44,10 @@ def render_artwork( return renderable -def _probe_graphics_protocol() -> str | None: - """Probe for a real terminal graphics protocol. +def detect_support() -> bool: + """True when a real terminal graphics protocol (Kitty or Sixel) is available. - Returns "kitty" (TGP), "sixel", or None for halfcell/unicode fallbacks. - textual-image runs its terminal probe at module import; this function only - inspects the resolved Image class. + textual-image runs its terminal probe at module import. This function just + inspects the resolved Image class. Halfcell and Unicode fallbacks return False. """ - if TIImage is SixelImage: - return "sixel" - if TIImage is TGPImage: - return "kitty" - return None - - -def detect_support() -> bool: - """True when a graphics protocol (Kitty or Sixel) is available.""" - return _probe_graphics_protocol() is not None + return TIImage is SixelImage or TIImage is TGPImage diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 755daff..666e209 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -147,8 +147,7 @@ class UIState: # 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. id(artwork_image) is not safe because - # CPython reuses ids after garbage collection. + # the now_playing panel cache. artwork_generation: int = 0 # Shortcut highlight diff --git a/tests/tui/test_artwork.py b/tests/tui/test_artwork.py index d3c4ed3..d446e2b 100644 --- a/tests/tui/test_artwork.py +++ b/tests/tui/test_artwork.py @@ -2,8 +2,6 @@ from __future__ import annotations -from unittest.mock import patch - from PIL import Image as PILImage from sendspin.tui import artwork @@ -48,15 +46,3 @@ def test_render_artwork_rebuilds_on_new_width() -> None: 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 - - -def test_detect_support_false_when_no_graphics_protocol() -> None: - with patch.object(artwork, "_probe_graphics_protocol", return_value=None): - assert artwork.detect_support() is False - - -def test_detect_support_true_when_kitty_or_sixel() -> None: - with patch.object(artwork, "_probe_graphics_protocol", return_value="kitty"): - assert artwork.detect_support() is True - with patch.object(artwork, "_probe_graphics_protocol", return_value="sixel"): - assert artwork.detect_support() is True From 35b484b2d41de9dbe699b267a2650826d34e6375 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 15 Jun 2026 18:41:58 +0200 Subject: [PATCH 10/11] Match artwork height to the metadata column --- sendspin/tui/ui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 666e209..1ecef8e 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -379,8 +379,8 @@ def _build_now_playing_panel(self, *, expand: bool = False) -> Panel: artwork = render_artwork( self._state.artwork_image, self._state.artwork_generation, - height_rows=4, - width_cells=8, + height_rows=5, + width_cells=10, ) narrow = self._console.width - 1 < 80 if artwork is not None and not narrow: From b154f21ae9d42efd40845cace4c93761469d0648 Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Mon, 15 Jun 2026 19:04:12 +0200 Subject: [PATCH 11/11] Drop unused logger from `tui/artwork.py` --- sendspin/tui/artwork.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sendspin/tui/artwork.py b/sendspin/tui/artwork.py index b0fe611..c8721a0 100644 --- a/sendspin/tui/artwork.py +++ b/sendspin/tui/artwork.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING from textual_image.renderable import Image as TIImage @@ -12,8 +11,6 @@ from PIL.Image import Image as PILImage from rich.console import RenderableType -logger = logging.getLogger(__name__) - _cache: tuple[tuple[int, int, int], "RenderableType"] | None = None