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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
70 changes: 70 additions & 0 deletions sendspin/artwork_connector.py
Original file line number Diff line number Diff line change
@@ -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),
]
Comment thread
maximmaxim345 marked this conversation as resolved.

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)
Comment thread
maximmaxim345 marked this conversation as resolved.
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:
Comment thread
maximmaxim345 marked this conversation as resolved.
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:
Comment thread
maximmaxim345 marked this conversation as resolved.
return
self._on_image(None)
71 changes: 61 additions & 10 deletions sendspin/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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)."""
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions sendspin/tui/artwork.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 34 additions & 7 deletions sendspin/tui/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand All @@ -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)

Expand Down Expand Up @@ -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"),
Expand Down
Loading