1313
1414if TYPE_CHECKING :
1515 from aiosendspin .models .metadata import SessionUpdateMetadata
16+
1617 from sendspin .volume_controller import VolumeController
1718
1819from aiohttp import ClientError
1920from aiosendspin .client import SendspinClient
20- from aiosendspin_mpris import MPRIS_AVAILABLE , SendspinMpris
21+ from aiosendspin . models . artwork import ArtworkChannel , ClientHelloArtworkSupport
2122from aiosendspin .models .core import (
2223 GroupUpdateServerPayload ,
2324 ServerCommandPayload ,
3031 PlayerCommandPayload ,
3132 SupportedAudioFormat ,
3233)
33- from aiosendspin .models .visualizer import (
34- BeatTiming ,
35- ClientHelloVisualizerSpectrum ,
36- ClientHelloVisualizerSupport ,
37- StreamStartVisualizer ,
38- VisualizerFrame ,
39- )
4034from 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
5055from 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
5258from sendspin .hooks import run_hook
5359from sendspin .settings import ClientSettings
60+ from sendspin .tui .artwork import detect_support as detect_artwork_support
5461from sendspin .tui .keyboard import keyboard_loop
5562from sendspin .tui .ui import ColorMode , SendspinUI
5663from 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 :
0 commit comments