Skip to content
102 changes: 64 additions & 38 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,36 @@ Sendspin CLI is a synchronized audio player client for the [Sendspin Protocol](h
## Architecture

```
┌─────────────────────────────────────────────────────────────────┐
│ cli.py (main) │
│ - Entry point, argument parsing, async event loop │
│ - mDNS service discovery │
│ - Connection management with auto-reconnect │
│ - State management (CLIState) │
│ - Coordinates all other modules │
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ keyboard.py │ │ ui.py │ │ audio.py │
│ │ │ │ │ │
│ - Key capture │ │ - Rich TUI │ │ - Audio output │
│ - Command parse │ │ - Now Playing │ │ - Time sync │
│ - Media control │ │ - Volume panel │ │ - Buffer mgmt │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└────────────────────┼────────────────────┘
▼
┌─────────────────────┐
│ aiosendspin │
│ (external library) │
│ │
│ - WebSocket client │
│ - Protocol messages │
│ - Time sync filter │
└─────────────────────┘
cli.py (main entry)
│
┌───────────────┼───────────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ tui/ │ │ daemon/ │
└──────────┘ └──────────┘
│ │
┌───────────┼───────────┐ │
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌─────────┐ ┌─────────┐ ┌─────────────┐
│ keyboard │ │ ui │ │ app │ │ daemon │
│ │ │ │ │ │ │ │
│ - Keys │ │ - TUI │ │ - State │ │ - Headless │
│ - Commands │ │ - Panel │ │ - Tasks │ │ - Signals │
└────────────┘ └─────────┘ └─────────┘ └─────────────┘
│ │ │ │
└───────────┼───────────┴───────────────────┘
│
┌───────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ audio.py │ │ discovery.py │ │ aiosendspin │
│ │ │ │ │ (external) │
│ - Playback │ │ - mDNS │ │ │
│ - Time sync │ │ - Servers │ │ - WebSocket │
└─────────────┘ └───────────────┘ └──────────────┘
```

## File Responsibilities
Expand All @@ -44,14 +46,24 @@ Project documentation, installation instructions, usage guide (including command

### `sendspin/cli.py`
Main entry point and orchestrator. Responsibilities:
- **Argument parsing**: `--url`, `--name`, `--id`, `--audio-device`, `--static-delay-ms`, etc.
- **Service discovery**: mDNS discovery of Sendspin servers via `ServiceDiscovery` class
- **Argument parsing**: Commands (`serve`, `daemon`), flags (`--url`, `--name`, `--id`, `--audio-device`, `--static-delay-ms`)
- **Command routing**: Routes to appropriate mode (TUI app, daemon, or server)
- **Device enumeration**: Lists audio devices and servers via `--list-audio-devices` and `--list-servers`
- **Backward compatibility**: Handles deprecated `--headless` flag with warning

### `sendspin/tui/` (TUI Mode Package)
Interactive terminal UI mode with keyboard control.

#### `sendspin/tui/app.py`
TUI mode application logic. Responsibilities:
- **Service discovery**: mDNS discovery of Sendspin servers
- **Connection management**: `ConnectionManager` handles reconnection with exponential backoff
- **State management**: `CLIState` dataclass mirrors server state (playback, metadata, volume)
- **State management**: `AppState` dataclass mirrors server state (playback, metadata, volume)
- **Audio stream handling**: `AudioStreamHandler` bridges between client and `AudioPlayer`
- **Event callbacks**: Routes server messages to appropriate handlers (metadata, group updates, commands)
- **UI coordination**: Manages `SendspinUI` and keyboard input loop

### `sendspin/keyboard.py`
#### `sendspin/tui/keyboard.py`
Keyboard input handling for interactive control. Responsibilities:
- **Key capture**: Uses `readchar` library for single-keypress detection
- **Command parsing**: `CommandHandler` class parses and executes commands
Expand All @@ -60,7 +72,7 @@ Keyboard input handling for interactive control. Responsibilities:
- **Delay adjustment**: Real-time static delay adjustment via `delay` command
- **Keyboard shortcuts**: Arrow keys (prev/next/volume), space (toggle), m (mute), g (switch group), q (quit)

### `sendspin/ui.py`
#### `sendspin/tui/ui.py`
Rich-based terminal UI for visual feedback. Responsibilities:
- **Now Playing panel**: Track title, artist, album with playback shortcuts
- **Volume panel**: Group and player volume with mute indicators
Expand All @@ -69,6 +81,18 @@ Rich-based terminal UI for visual feedback. Responsibilities:
- **Shortcut highlighting**: Visual feedback when shortcuts are pressed (0.15s highlight)
- **State management**: `UIState` dataclass for all display state

### `sendspin/daemon/` (Daemon Mode Package)
Headless operation mode for background services.

#### `sendspin/daemon/daemon.py`
Daemon mode for headless operation (via `sendspin daemon` or deprecated `--headless`). Responsibilities:
- **Headless operation**: Runs without UI or keyboard input
- **Service discovery**: Same mDNS discovery as TUI mode
- **Connection management**: Reuses `ConnectionManager` from `tui/app.py`
- **Audio playback**: Same synchronized audio playback
- **Event logging**: Prints events to stdout instead of UI
- **Signal handling**: SIGINT/SIGTERM for graceful shutdown

### `sendspin/audio.py`
Time-synchronized audio playback engine. Responsibilities:
- **Audio output**: Uses `sounddevice` (PortAudio) for low-latency playback
Expand All @@ -94,8 +118,10 @@ Package entry point, exports `main` from `cli.py`.

### Running the player
```bash
uv run sendspin # Auto-discover server
uv run sendspin --url ws://host:port/sendspin # Direct connection
uv run sendspin # TUI mode with auto-discovery
uv run sendspin --url ws://host:port/sendspin # TUI mode with direct connection
uv run sendspin daemon # Daemon mode (headless) with auto-discovery
uv run sendspin daemon --url ws://host:port/sendspin # Daemon mode with direct connection
```

### Development commands
Expand All @@ -106,12 +132,12 @@ uv run mypy sendspin # Type check
```

### Adding a new keyboard shortcut
1. Add key handler in `keyboard.py` `keyboard_loop()` function
1. Add key handler in `tui/keyboard.py` `keyboard_loop()` function
2. Add UI highlight call: `ui.highlight_shortcut("shortcut_name")`
3. Execute command via `handler.execute("command")`
4. Update shortcut display in `ui.py` `_build_now_playing_panel()` or relevant panel
4. Update shortcut display in `tui/ui.py` `_build_now_playing_panel()` or relevant panel

### Adding a new media command
1. Check if `MediaCommand` enum in aiosendspin supports it
2. Add command alias in `CommandHandler.execute()` in `keyboard.py`
2. Add command alias in `CommandHandler.execute()` in `tui/keyboard.py`
3. Command is sent via `client.send_group_command(MediaCommand.XXX)`
152 changes: 122 additions & 30 deletions sendspin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import asyncio
import sys
from collections.abc import Sequence
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from sendspin.audio import AudioDevice

PORTAUDIO_NOT_FOUND_MESSAGE = """Error: PortAudio library not found.

Expand Down Expand Up @@ -81,6 +85,47 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
help="Logging level to use",
)

# Daemon subcommand
daemon_parser = subparsers.add_parser(
"daemon", help="Run Sendspin client in daemon mode (no UI)"
)
daemon_parser.add_argument(
"--url",
default=None,
help=("WebSocket URL of the Sendspin server. If omitted, discover via mDNS."),
)
daemon_parser.add_argument(
"--name",
default=None,
help="Friendly name for this client (defaults to hostname)",
)
daemon_parser.add_argument(
"--id",
default=None,
help="Unique identifier for this client (defaults to sendspin-cli-<hostname>)",
)
daemon_parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level to use",
)
daemon_parser.add_argument(
"--static-delay-ms",
type=float,
default=0.0,
help="Extra playback delay in milliseconds applied after clock sync",
)
daemon_parser.add_argument(
"--audio-device",
type=str,
default=None,
help=(
"Audio output device by index (e.g., 0, 1, 2) or name prefix (e.g., 'MacBook'). "
"Use --list-audio-devices to see available devices."
),
)

# Default behavior (client mode) - existing arguments
parser.add_argument(
"--url",
Expand Down Expand Up @@ -131,7 +176,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser.add_argument(
"--headless",
action="store_true",
help="Run without the interactive terminal UI",
help="(DEPRECATED: use 'sendspin daemon' instead) Run without the interactive terminal UI",
)
return parser.parse_args(argv)

Expand Down Expand Up @@ -159,6 +204,62 @@ async def list_servers() -> None:
sys.exit(1)


class CLIError(Exception):
"""CLI error with exit code."""

def __init__(self, message: str, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code


def _resolve_audio_device(device_arg: str | None) -> AudioDevice:
"""Resolve audio device from CLI argument.

Args:
device_arg: Device specifier (index number, name prefix, or None for default).

Returns:
The resolved AudioDevice.

Raises:
CLIError: If the device cannot be found.
"""
from sendspin.audio import query_devices

devices = query_devices()

if device_arg is None:
device = next((d for d in devices if d.is_default), None)
elif device_arg.isnumeric():
device_id = int(device_arg)
device = next((d for d in devices if d.index == device_id), None)
else:
# Find first output device whose name starts with the prefix
device = next((d for d in devices if d.name.startswith(device_arg)), None)

if device is None:
dev_type = "Default" if device_arg is None else "Specified"
raise CLIError(f"{dev_type} audio device not found.")

return device


def _run_daemon_mode(args: argparse.Namespace) -> int:
"""Run the client in daemon mode (no UI)."""
from sendspin.daemon.daemon import DaemonConfig, SendspinDaemon

daemon_config = DaemonConfig(
audio_device=_resolve_audio_device(args.audio_device),
url=args.url,
client_id=args.id,
client_name=args.name,
static_delay_ms=args.static_delay_ms,
)

daemon = SendspinDaemon(daemon_config)
return asyncio.run(daemon.run())


def main() -> int:
"""Run the CLI client."""
import logging
Expand Down Expand Up @@ -207,48 +308,39 @@ def main() -> int:
return 0

try:
from sendspin.audio import query_devices
from sendspin.app import AppConfig, SendspinApp
return _run_client_mode(args)
except CLIError as e:
print(f"Error: {e}")
return e.exit_code
except OSError as e:
if "PortAudio library not found" in str(e):
print(PORTAUDIO_NOT_FOUND_MESSAGE)
return 1
raise

# Resolve audio device if specified
audio_device = None
devices = query_devices()
if args.audio_device is None:
audio_device = next((d for d in devices if d.is_default), None)
elif args.audio_device.isnumeric():
device_id = int(args.audio_device)
for dev in devices:
if dev.index == device_id:
audio_device = dev
break
else:
# Otherwise, find first output device whose name starts with the prefix
for dev in devices:
if dev.name.startswith(args.audio_device):
audio_device = dev
break

if audio_device is None:
dev_type = "Default" if args.audio_device is None else "Specified"
print(f"Error: {dev_type} audio device not found.")
return 1

# Create config from CLI arguments

def _run_client_mode(args: argparse.Namespace) -> int:
"""Run the client in TUI or daemon mode."""
# Handle daemon subcommand
if args.command == "daemon":
return _run_daemon_mode(args)

# Handle deprecated --headless flag
if args.headless:
print("Warning: --headless is deprecated. Use 'sendspin daemon' instead.")
print("Routing to daemon mode...\n")
return _run_daemon_mode(args)

from sendspin.tui.app import AppConfig, SendspinApp

app_config = AppConfig(
audio_device=_resolve_audio_device(args.audio_device),
url=args.url,
client_id=args.id,
client_name=args.name,
static_delay_ms=args.static_delay_ms,
audio_device=audio_device,
headless=args.headless,
)

# Run the application
app = SendspinApp(app_config)
return asyncio.run(app.run())

Expand Down
Empty file added sendspin/daemon/__init__.py
Empty file.
Loading