Skip to content

Commit e15f174

Browse files
Display album artwork in the TUI via artwork@v1 (#266)
Renders album artwork in the Now Playing panel using the `artwork@v1` role. The role is only registered when `textual-image` detects Kitty graphics or Sixel support at startup. Terminals without graphics support keep the existing layout since the Unicode block fallback can only render a couple of pixels. Mainly wrote this to more easily test the artwork role since this doesn't require an ESP32 and most on desktop runnable implementations use the image url from the metadata role. ## Screenshot <img width="907" height="328" alt="Screenshot 2026-06-15 at 18 40 48" src="https://github.com/user-attachments/assets/d7dac2a9-8674-4b99-b1ca-3864cffaa2ef" />
1 parent 30256dc commit e15f174

8 files changed

Lines changed: 451 additions & 17 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ dependencies = [
1818
"aiosendspin-mpris~=2.1.1",
1919
"av>=15.0.0",
2020
"numpy>=1.26.0",
21+
"pillow>=10.0.0",
2122
"pulsectl-asyncio>=1.2.2; platform_system == 'Linux'",
2223
"qrcode>=8.0",
2324
"readchar>=4.0.0",
2425
"rich>=13.0.0",
2526
"sounddevice>=0.4.6",
27+
"textual-image>=0.13.0",
2628
]
2729

2830
description = "Synchronized audio player for Sendspin servers"

sendspin/artwork_connector.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Artwork connector for bridging Sendspin client to the TUI."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
import logging
7+
from collections.abc import Callable
8+
from typing import TYPE_CHECKING
9+
10+
from PIL import Image, UnidentifiedImageError
11+
12+
if TYPE_CHECKING:
13+
from aiosendspin.client import SendspinClient
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class ArtworkHandler:
19+
"""Bridges between SendspinClient artwork frames and the TUI.
20+
21+
Subscribes to artwork binary frames (album channel only), decodes them via
22+
Pillow, and routes the latest image to a callback. Empty payloads, stream
23+
end, and stream clear all collapse to ``on_image(None)``.
24+
"""
25+
26+
def __init__(
27+
self,
28+
on_image: Callable[[Image.Image | None], None],
29+
) -> None:
30+
self._on_image = on_image
31+
self._unsubscribes: list[Callable[[], None]] = []
32+
33+
def attach_client(self, client: SendspinClient) -> None:
34+
"""Register artwork, stream_end, and stream_clear listeners."""
35+
self._unsubscribes = [
36+
client.add_artwork_listener(self._on_artwork_frame),
37+
client.add_stream_end_listener(self._on_stream_end),
38+
client.add_stream_clear_listener(self._on_stream_clear),
39+
]
40+
41+
def detach(self) -> None:
42+
"""Unregister listeners. Silent: never fires the callback."""
43+
for unsub in self._unsubscribes:
44+
unsub()
45+
self._unsubscribes = []
46+
47+
def _on_artwork_frame(self, channel: int, payload: bytes) -> None:
48+
if channel != 0:
49+
return
50+
if not payload:
51+
self._on_image(None)
52+
return
53+
try:
54+
image = Image.open(io.BytesIO(payload))
55+
image.load()
56+
except (UnidentifiedImageError, OSError) as exc:
57+
logger.warning("Failed to decode artwork payload: %s", exc)
58+
self._on_image(None)
59+
return
60+
self._on_image(image)
61+
62+
def _on_stream_end(self, roles: list[str] | None) -> None:
63+
if roles is not None and "artwork" not in roles:
64+
return
65+
self._on_image(None)
66+
67+
def _on_stream_clear(self, roles: list[str] | None) -> None:
68+
if roles is not None and "artwork" not in roles:
69+
return
70+
self._on_image(None)

sendspin/tui/app.py

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313

1414
if TYPE_CHECKING:
1515
from aiosendspin.models.metadata import SessionUpdateMetadata
16+
1617
from sendspin.volume_controller import VolumeController
1718

1819
from aiohttp import ClientError
1920
from aiosendspin.client import SendspinClient
20-
from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris
21+
from aiosendspin.models.artwork import ArtworkChannel, ClientHelloArtworkSupport
2122
from aiosendspin.models.core import (
2223
GroupUpdateServerPayload,
2324
ServerCommandPayload,
@@ -30,27 +31,33 @@
3031
PlayerCommandPayload,
3132
SupportedAudioFormat,
3233
)
33-
from aiosendspin.models.visualizer import (
34-
BeatTiming,
35-
ClientHelloVisualizerSpectrum,
36-
ClientHelloVisualizerSupport,
37-
StreamStartVisualizer,
38-
VisualizerFrame,
39-
)
4034
from aiosendspin.models.types import (
35+
ArtworkSource,
4136
MediaCommand,
37+
PictureFormat,
4238
PlaybackStateType,
4339
PlayerCommand,
4440
RepeatMode,
4541
Roles,
4642
UndefinedField,
4743
)
44+
from aiosendspin.models.visualizer import (
45+
BeatTiming,
46+
ClientHelloVisualizerSpectrum,
47+
ClientHelloVisualizerSupport,
48+
StreamStartVisualizer,
49+
VisualizerFrame,
50+
)
51+
from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris
52+
from PIL.Image import Image as PILImage
4853

49-
from sendspin.audio_devices import AudioDevice, detect_supported_audio_formats
54+
from sendspin.artwork_connector import ArtworkHandler
5055
from sendspin.audio_connector import AudioStreamHandler
51-
from sendspin.discovery import ServiceDiscovery, DiscoveredServer
56+
from sendspin.audio_devices import AudioDevice, detect_supported_audio_formats
57+
from sendspin.discovery import DiscoveredServer, ServiceDiscovery
5258
from sendspin.hooks import run_hook
5359
from sendspin.settings import ClientSettings
60+
from sendspin.tui.artwork import detect_support as detect_artwork_support
5461
from sendspin.tui.keyboard import keyboard_loop
5562
from sendspin.tui.ui import ColorMode, SendspinUI
5663
from sendspin.tui.visualizer import (
@@ -264,8 +271,11 @@ def __init__(self, args: AppArgs) -> None:
264271
self._visualizer_handler: VisualizerHandler | None = None
265272
self._beat_handler: BeatHandler | None = None
266273
self._peak_handler: PeakHandler | None = None
274+
self._artwork_handler: ArtworkHandler | None = None
267275
self._settings = args.settings
268276
self._visualizer_enabled: bool = args.settings.visualizer
277+
# Probe terminal graphics support before Rich Live takes the tty.
278+
self._supports_artwork: bool = detect_artwork_support()
269279
# Currently-applied static delay in milliseconds, mirroring
270280
# `SendspinClient.static_delay_ms`. Tracked separately from settings
271281
# because CLI overrides aren't persisted to settings, so
@@ -278,6 +288,20 @@ def __init__(self, args: AppArgs) -> None:
278288
self._mpris: SendspinMpris | None = None
279289
self._listener_unsubscribes: list[Callable[[], None]] = []
280290

291+
@staticmethod
292+
def _build_artwork_support() -> ClientHelloArtworkSupport:
293+
"""Build artwork support payload for client/hello (artwork@v1)."""
294+
return ClientHelloArtworkSupport(
295+
channels=[
296+
ArtworkChannel(
297+
source=ArtworkSource.ALBUM,
298+
format=PictureFormat.PNG,
299+
media_width=128,
300+
media_height=128,
301+
),
302+
],
303+
)
304+
281305
@staticmethod
282306
def _build_visualizer_support() -> ClientHelloVisualizerSupport:
283307
"""Build visualizer support payload for client/hello (visualizer@v1)."""
@@ -302,6 +326,11 @@ def _create_client(self) -> SendspinClient:
302326
visualizer_support = self._build_visualizer_support()
303327
roles.append(Roles.VISUALIZER)
304328

329+
artwork_support: ClientHelloArtworkSupport | None = None
330+
if self._supports_artwork:
331+
artwork_support = self._build_artwork_support()
332+
roles.append(Roles.ARTWORK)
333+
305334
assert self._audio_handler is not None
306335

307336
return SendspinClient(
@@ -318,6 +347,7 @@ def _create_client(self) -> SendspinClient:
318347
supported_commands=[PlayerCommand.VOLUME, PlayerCommand.MUTE],
319348
),
320349
visualizer_support=visualizer_support,
350+
artwork_support=artwork_support,
321351
static_delay_ms=self._applied_delay_ms,
322352
state_supported_commands=[PlayerCommand.SET_STATIC_DELAY],
323353
initial_volume=self._audio_handler.volume,
@@ -363,6 +393,12 @@ def _attach_client(self) -> None:
363393
if self._ui is not None:
364394
self._ui.set_server_clock(self._server_now_us)
365395

396+
if self._supports_artwork:
397+
self._artwork_handler = ArtworkHandler(
398+
on_image=self._handle_artwork_update,
399+
)
400+
self._artwork_handler.attach_client(self._client)
401+
366402
if MPRIS_AVAILABLE and self._args.use_mpris:
367403
self._mpris = SendspinMpris(self._client)
368404
self._mpris.start()
@@ -392,6 +428,13 @@ def _detach_client(self) -> None:
392428
self._ui.set_server_clock(None)
393429
self._ui.set_visualizer_types(frozenset())
394430

431+
if self._artwork_handler is not None:
432+
self._artwork_handler.detach()
433+
self._artwork_handler = None
434+
if self._ui is not None:
435+
self._ui.state.artwork_image = None
436+
self._ui.state.artwork_generation += 1
437+
395438
if self._mpris:
396439
self._mpris.stop()
397440
self._mpris = None
@@ -933,6 +976,14 @@ def _handle_stream_start(self, message: StreamStartMessage) -> None:
933976
)
934977
self._ui.set_visualizer_types(types)
935978

979+
def _handle_artwork_update(self, image: PILImage | None) -> None:
980+
"""Receive a decoded artwork image (or None to clear) from the handler."""
981+
if self._ui is None:
982+
return
983+
self._ui.state.artwork_image = image
984+
self._ui.state.artwork_generation += 1
985+
self._ui.refresh()
986+
936987
def _handle_visualizer_frame(self, frame: VisualizerFrame) -> None:
937988
"""Handle a visualizer frame from the connector."""
938989
if self._ui is not None:

sendspin/tui/artwork.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Artwork rendering helpers for the Sendspin TUI."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from textual_image.renderable import Image as TIImage
8+
from textual_image.renderable import SixelImage, TGPImage
9+
10+
if TYPE_CHECKING:
11+
from PIL.Image import Image as PILImage
12+
from rich.console import RenderableType
13+
14+
_cache: tuple[tuple[int, int, int], "RenderableType"] | None = None
15+
16+
17+
def clear_cache() -> None:
18+
"""Drop the cached renderable."""
19+
global _cache # noqa: PLW0603
20+
_cache = None
21+
22+
23+
def render_artwork(
24+
image: "PILImage | None",
25+
generation: int,
26+
height_rows: int,
27+
width_cells: int,
28+
) -> "RenderableType | None":
29+
"""Return a Rich renderable for the given image, cached by (generation, height_rows, width_cells).
30+
31+
Returns None when image is None so the layout can collapse the image column.
32+
"""
33+
global _cache # noqa: PLW0603
34+
if image is None:
35+
return None
36+
key = (generation, height_rows, width_cells)
37+
if _cache is not None and _cache[0] == key:
38+
return _cache[1]
39+
renderable = TIImage(image, width=width_cells, height=height_rows)
40+
_cache = (key, renderable)
41+
return renderable
42+
43+
44+
def detect_support() -> bool:
45+
"""True when a real terminal graphics protocol (Kitty or Sixel) is available.
46+
47+
textual-image runs its terminal probe at module import. This function just
48+
inspects the resolved Image class. Halfcell and Unicode fallbacks return False.
49+
"""
50+
return TIImage is SixelImage or TIImage is TGPImage

sendspin/tui/ui.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from dataclasses import dataclass, field
1313
from typing import Any, Self
1414

15+
from PIL.Image import Image as PILImage
1516
from aiosendspin.models.color import SessionUpdateColor
1617
from aiosendspin.models.types import PlaybackStateType, RepeatMode, UndefinedField
1718
from aiosendspin.models.visualizer import BeatTiming
@@ -22,6 +23,7 @@
2223
from rich.text import Text
2324

2425
from sendspin.discovery import DiscoveredServer
26+
from sendspin.tui.artwork import render_artwork
2527
from sendspin.tui.visualizer import (
2628
BeatState,
2729
PeakEvent,
@@ -142,6 +144,12 @@ class UIState:
142144
palette_available: bool = False
143145
color_mode: ColorMode = ColorMode.DARK
144146

147+
# Album artwork: None when unsupported or not yet received.
148+
artwork_image: PILImage | None = None
149+
# Bumped on every artwork update, used to key the renderable cache and
150+
# the now_playing panel cache.
151+
artwork_generation: int = 0
152+
145153
# Shortcut highlight
146154
highlighted_shortcut: str | None = None
147155
highlight_time: float = 0.0
@@ -350,13 +358,12 @@ def _build_now_playing_panel(self, *, expand: bool = False) -> Panel:
350358
info.add_row("", Text("No metadata available", style=self._themed("dim")))
351359
info.add_row("")
352360

353-
# Vertical container for info + shortcuts (5 lines total)
354-
content = Table.grid()
355-
content.add_column()
356-
content.add_row(info)
357-
content.add_row("") # Line 4: spacing
361+
# Metadata + shortcuts column (what the panel showed before this change)
362+
metadata_col = Table.grid()
363+
metadata_col.add_column()
364+
metadata_col.add_row(info)
365+
metadata_col.add_row("") # spacing
358366

359-
# Line 5: playback shortcuts (always show when active)
360367
space_label = "pause" if self._state.playback_state == PlaybackStateType.PLAYING else "play"
361368
shortcuts = Text()
362369
shortcuts.append("←", style=self._shortcut_style("prev"))
@@ -365,7 +372,25 @@ def _build_now_playing_panel(self, *, expand: bool = False) -> Panel:
365372
shortcuts.append(f" {space_label} ", style=self._themed("dim"))
366373
shortcuts.append("→", style=self._shortcut_style("next"))
367374
shortcuts.append(" next", style=self._themed("dim"))
368-
content.add_row(shortcuts)
375+
metadata_col.add_row(shortcuts)
376+
377+
# Wrap with an artwork column on the left when artwork is available
378+
# and the layout is wide enough.
379+
artwork = render_artwork(
380+
self._state.artwork_image,
381+
self._state.artwork_generation,
382+
height_rows=5,
383+
width_cells=10,
384+
)
385+
narrow = self._console.width - 1 < 80
386+
if artwork is not None and not narrow:
387+
outer = Table.grid(padding=(0, 2))
388+
outer.add_column()
389+
outer.add_column()
390+
outer.add_row(artwork, metadata_col)
391+
content = outer
392+
else:
393+
content = metadata_col
369394

370395
return self._make_panel(content, title="Now Playing", default_border="blue", expand=expand)
371396

@@ -1052,6 +1077,8 @@ def _build_layout(self) -> Table:
10521077
self._state.title,
10531078
self._state.artist,
10541079
self._state.album,
1080+
self._state.artwork_generation,
1081+
narrow,
10551082
self._is_highlighted("prev"),
10561083
self._is_highlighted("space"),
10571084
self._is_highlighted("next"),

0 commit comments

Comments
 (0)