Some small tweaks to the AirPlay provider
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 17 Feb 2026 12:40:24 +0000 (13:40 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Tue, 17 Feb 2026 12:40:24 +0000 (13:40 +0100)
music_assistant/providers/airplay/README.md [new file with mode: 0644]
music_assistant/providers/airplay/constants.py
music_assistant/providers/airplay/helpers.py
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/airplay/stream_session.py

diff --git a/music_assistant/providers/airplay/README.md b/music_assistant/providers/airplay/README.md
new file mode 100644 (file)
index 0000000..9405f3d
--- /dev/null
@@ -0,0 +1,485 @@
+# AirPlay Provider
+
+## Overview
+
+The AirPlay provider enables Music Assistant to stream audio to AirPlay-enabled devices on your local network. It supports both **RAOP (AirPlay 1)** and **AirPlay 2** protocols, providing compatibility with a wide range of devices including Apple HomePods, Apple TVs, Macs, and third-party AirPlay-compatible speakers.
+
+### Key Features
+
+- **Dual Protocol Support**: Automatically selects between RAOP and AirPlay 2 based on device capabilities
+- **Native Pairing**: Supports pairing with Apple devices (Apple TV, HomePod, Mac) using HAP (HomeKit Accessory Protocol) or RAOP pairing
+- **Multi-Room Audio**: Synchronizes playback across multiple AirPlay devices with NTP timestamp precision
+- **DACP Remote Control**: Receives remote control commands (play/pause/volume/next/previous) from devices while streaming
+- **Late Join Support**: Allows adding players to an existing playback session without interrupting other players
+- **Flow Mode Streaming**: Provides gapless playback and crossfade support by streaming the queue as one continuous audio stream
+
+## Architecture
+
+### Component Overview
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                      AirPlay Provider                           │
+│  ┌──────────────────────────────────────────────────────────┐  │
+│  │  MDNS Discovery (_airplay._tcp, _raop._tcp)              │  │
+│  └──────────────────────────────────────────────────────────┘  │
+│  ┌──────────────────────────────────────────────────────────┐  │
+│  │  DACP Server (_dacp._tcp) - Remote Control Callbacks     │  │
+│  └──────────────────────────────────────────────────────────┘  │
+└─────────────────────────────────────────────────────────────────┘
+                              │
+        ┌─────────────────────┼─────────────────────┐
+        │                     │                     │
+┌───────▼──────┐     ┌────────▼────────┐    ┌──────▼──────┐
+│ AirPlayPlayer│     │ AirPlayPlayer   │    │AirPlayPlayer│
+│   (Leader)   │     │  (Sync Child)   │    │(Sync Child) │
+└───────┬──────┘     └────────┬────────┘    └──────┬──────┘
+        │                     │                     │
+        └─────────────────────┼─────────────────────┘
+                              │
+                    ┌─────────▼──────────┐
+                    │ AirPlayStreamSession│
+                    │  (manages session)  │
+                    └─────────┬──────────┘
+                              │
+        ┌─────────────────────┼─────────────────────┐
+        │                     │                     │
+┌───────▼──────┐     ┌────────▼────────┐    ┌──────▼──────┐
+│  RaopStream  │     │ AirPlay2Stream  │    │ RaopStream  │
+│ ┌──────────┐ │     │ ┌────────────┐  │    │┌──────────┐ │
+│ │ cliraop  │ │     │ │  cliap2    │  │    ││ cliraop  │ │
+│ └────▲─────┘ │     │ └─────▲──────┘  │    │└────▲─────┘ │
+│      │       │     │       │         │    │     │       │
+│ ┌────┴─────┐ │     │ ┌─────┴──────┐  │    │┌────┴─────┐ │
+│ │  FFmpeg  │ │     │ │  FFmpeg    │  │    ││  FFmpeg  │ │
+│ └──────────┘ │     │ └────────────┘  │    │└──────────┘ │
+└──────────────┘     └─────────────────┘    └─────────────┘
+```
+
+### File Structure
+
+```
+airplay/
+├── provider.py           # Main provider class, MDNS discovery, DACP server
+├── player.py             # AirPlayPlayer implementation
+├── stream_session.py     # Manages streaming sessions for synchronized playback
+├── pairing.py           # HAP and RAOP pairing implementations
+├── helpers.py           # Utility functions (NTP conversion, model detection, etc.)
+├── constants.py         # Constants and enums
+├── protocols/
+│   ├── _protocol.py     # Base protocol class with shared logic
+│   ├── raop.py          # RAOP (AirPlay 1) streaming implementation
+│   └── airplay2.py      # AirPlay 2 streaming implementation
+└── bin/                 # Platform-specific CLI binaries
+    ├── cliraop-*        # RAOP streaming binaries
+    └── cliap2-*         # AirPlay 2 streaming binaries
+```
+
+## Protocol Selection: RAOP vs AirPlay 2
+
+### RAOP (AirPlay 1)
+
+- **Used for**: Older AirPlay devices, some third-party implementations
+- **Features**:
+  - Encryption support (can be disabled for problematic devices)
+  - ALAC compression option to save network bandwidth
+  - Password protection support
+  - Device-reported volume feedback via DACP
+- **Binary**: `cliraop` (based on [libraop](https://github.com/music-assistant/libraop))
+
+### AirPlay 2
+
+- **Used for**: Modern Apple devices, some third-party devices
+- **Features**:
+  - Better compatibility with newer devices
+  - More robust protocol
+  - Required for some devices that don't support RAOP
+- **Binary**: `cliap2` (based on [OwnTone](https://github.com/music-assistant/cliairplay))
+
+### Automatic Selection
+
+When protocol is set to "Automatically select" (default):
+- **Prefers AirPlay 2** for known models (e.g., Ubiquiti devices) that work better with it
+- **Falls back to RAOP** for all other devices
+- Users can manually override via player configuration if needed
+
+## Discovery and Player Setup
+
+### MDNS Service Discovery
+
+The provider discovers AirPlay devices via two MDNS service types:
+
+1. **`_airplay._tcp.local.`** - Primary AirPlay service (preferred)
+   - Contains detailed device information
+   - Announced by most modern devices
+
+2. **`_raop._tcp.local.`** - Legacy RAOP service
+   - Fallback for older devices
+   - If only RAOP service is found, provider attempts to query for AirPlay service
+
+### Player Setup Flow
+
+1. **MDNS service discovered** → `on_mdns_service_state_change()` in [provider.py](provider.py)
+2. **Extract device info** from MDNS properties:
+   - Device ID (from `deviceid` property or service name)
+   - Display name
+   - Manufacturer and model (via `get_model_info()` in [helpers.py](helpers.py))
+3. **Filter checks**:
+   - Skip if player is disabled in config
+   - Skip ShairportSync instances running on the same Music Assistant server (to avoid conflicts with AirPlay Receiver provider)
+4. **Create player** → `AirPlayPlayer` instance
+5. **Register with player controller** → `mass.players.register()`
+
+### Player ID Format
+
+Player IDs follow the format: `ap{mac_address}` (e.g., `ap1a2b3c4d5e6f`)
+
+## Pairing for Apple Devices
+
+Apple TV and Mac devices require pairing before they can be used for streaming.
+
+### Pairing Protocols
+
+1. **HAP (HomeKit Accessory Protocol)** - For AirPlay 2
+   - 6-step SRP authentication with TLV encoding
+   - Ed25519 key exchange
+   - ChaCha20-Poly1305 encryption
+   - Produces 192-character hex credentials
+
+2. **RAOP Pairing** - For AirPlay 1
+   - 3-step SRP authentication with plist encoding
+   - Ed25519 key derivation from auth secret
+   - AES-GCM encryption
+   - Produces `client_id:auth_secret` format credentials
+
+### Pairing Flow
+
+1. **Start pairing** → POST to `/pair-pin-start` (or protocol-specific endpoint)
+2. **Device displays 4-digit PIN** on screen
+3. **User enters PIN** in Music Assistant configuration
+4. **Complete pairing** → SRP authentication and key exchange
+5. **Store credentials** in player config (protocol-specific key: `raop_credentials` or `airplay_credentials`)
+
+**Important**: The DACP ID used during pairing must match the ID used during streaming. The provider uses the first 16 hex characters of `server_id` as a persistent DACP ID to ensure compatibility across restarts.
+
+## Streaming Architecture
+
+### Audio Pipeline
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                    Music Assistant Core                          │
+│  ┌──────────────────────────────────────────────────────────┐   │
+│  │  Queue Manager (assembles tracks into continuous stream) │   │
+│  └─────────────────────────┬────────────────────────────────┘   │
+└────────────────────────────┼─────────────────────────────────────┘
+                             │ PCM Audio (44.1kHz, 32-bit float)
+                    ┌────────▼─────────┐
+                    │ StreamSession    │
+                    │ _audio_streamer()│
+                    └────────┬─────────┘
+                             │ Chunks of PCM audio
+        ┌────────────────────┼────────────────────┐
+        │                    │                    │
+┌───────▼──────┐    ┌────────▼────────┐   ┌──────▼──────┐
+│   FFmpeg     │    │    FFmpeg       │   │   FFmpeg    │
+│ (resample,   │    │  (resample,     │   │ (resample,  │
+│  filter,     │    │   filter,       │   │  filter,    │
+│  convert)    │    │   convert)      │   │  convert)   │
+└───────┬──────┘    └────────┬────────┘   └──────┬──────┘
+        │ PCM 44.1kHz 16-bit │                    │
+┌───────▼──────┐    ┌────────▼────────┐   ┌──────▼──────┐
+│  cliraop     │    │    cliap2       │   │  cliraop    │
+│  (RAOP       │    │  (AirPlay 2     │   │  (RAOP      │
+│   protocol)  │    │   protocol)     │   │   protocol) │
+└───────┬──────┘    └────────┬────────┘   └──────┬──────┘
+        │                    │                    │
+        │ Network (RTP)      │ Network (RTP)      │ Network (RTP)
+        │                    │                    │
+┌───────▼──────┐    ┌────────▼────────┐   ┌──────▼──────┐
+│ AirPlay      │    │  AirPlay        │   │  AirPlay    │
+│ Device 1     │    │  Device 2       │   │  Device 3   │
+└──────────────┘    └─────────────────┘   └─────────────┘
+```
+
+### Stream Session Management
+
+The `AirPlayStreamSession` class in [stream_session.py](stream_session.py) manages streaming to one or more synchronized players:
+
+1. **Initialization** (`start()` method)
+   - Calculates start time with connection delay buffer
+   - Converts start time to NTP timestamp for precise synchronization
+
+2. **Client Setup** (per player, `_start_client()` method)
+   - Creates protocol instance (`RaopStream` or `AirPlay2Stream`)
+   - Starts CLI process with NTP start timestamp
+   - Configures FFmpeg for audio format conversion and optional DSP filters
+   - Pipes FFmpeg output to CLI process stdin
+
+3. **Audio Streaming** (`_audio_streamer()` method)
+   - Receives PCM audio chunks from Music Assistant core
+   - Distributes chunks to all players via FFmpeg
+   - Tracks elapsed time based on bytes sent
+   - Handles silence padding if audio source is slow (watchdog mechanism)
+
+4. **Connection Monitoring**
+   - Waits for all devices to connect before starting playback
+   - Monitors CLI stderr for connection status and errors
+   - Removes players that fail to keep up (write timeouts)
+
+### Flow Mode Streaming
+
+AirPlay uses **flow mode** streaming, which means:
+- The entire queue is streamed as one continuous audio stream
+- Enables true gapless playback between tracks
+- Supports crossfade between tracks
+- Once started, the stream continues until explicitly stopped
+
+
+## Multi-Room Synchronization
+
+### Synchronized Playback
+
+The provider supports synchronized multi-room audio by:
+
+1. **Using a single `AirPlayStreamSession`** for the group leader and all sync children
+2. **Coordinating start times** via NTP timestamps
+3. **Distributing identical audio** to all players simultaneously
+4. **Per-player sync adjustment** via `sync_adjust` config option (in milliseconds)
+
+### Group Management
+
+- **Leader**: The primary player that manages the stream session
+- **Members**: Child players synchronized to the leader
+- **Adding members**: Use `set_members()` method in [player.py](player.py)
+- **Removing members**: Stream continues for remaining players
+
+### Late Join Support
+
+When adding a player to an already-playing session (`add_client()` in [stream_session.py](stream_session.py)):
+
+1. **Calculate offset**: Determine how much audio has already been sent
+2. **Adjusted start time**: Start new player at `original_start_time + offset`
+3. **Receive same stream**: New player receives the same audio chunks as existing players
+4. **Automatic synchronization**: NTP timestamps keep all players in sync
+
+**Config option**: `enable_late_join` (default: `True`)
+- If disabled: Session restarts with all players when members change
+- If enabled: New players join seamlessly without interrupting others
+
+## DACP (Digital Audio Control Protocol)
+
+### Purpose
+
+DACP allows AirPlay devices to send remote control commands back to Music Assistant while streaming is active. This enables:
+- Using physical buttons on devices (e.g., Apple TV remote)
+- Volume control from the device
+- Play/pause/next/previous commands
+- Shuffle toggle
+- Source switching detection
+
+### DACP Server
+
+The provider registers a MDNS service `_dacp._tcp.local.` (in `handle_async_init()` method in [provider.py](provider.py)) and runs a TCP server to receive HTTP requests from devices.
+
+### Active-Remote ID
+
+Each streaming session generates an `active_remote_id` (via `generate_active_remote_id()` in [helpers.py](helpers.py)) from the player's MAC address. This ID is:
+- Passed to the CLI binary
+- Sent to the device during streaming
+- Used to match incoming DACP requests to the correct player
+
+### Supported DACP Commands
+
+Handled in `_handle_dacp_request()` in [provider.py](provider.py):
+
+| DACP Path | Action |
+|-----------|--------|
+| `/ctrl-int/1/nextitem` | Skip to next track |
+| `/ctrl-int/1/previtem` | Go to previous track |
+| `/ctrl-int/1/play` | Resume playback |
+| `/ctrl-int/1/pause` | Pause playback |
+| `/ctrl-int/1/playpause` | Toggle play/pause |
+| `/ctrl-int/1/stop` | Stop playback |
+| `/ctrl-int/1/volumeup` | Increase volume |
+| `/ctrl-int/1/volumedown` | Decrease volume |
+| `/ctrl-int/1/shuffle_songs` | Toggle shuffle |
+| `dmcp.device-volume=X` | Volume changed by device (RAOP only) |
+| `device-prevent-playback=1` | Device switched to another source or powered off |
+| `device-prevent-playback=0` | Device ready for playback again |
+
+### Volume Feedback
+
+Both **RAOP** and **AirPlay 2** protocols support devices reporting their volume level via DACP.
+
+**Config option**: `ignore_volume` (default: `False`, auto-enabled for Apple devices)
+- Useful when device volume reports are unreliable
+- Apple devices always ignore volume feedback (handled internally)
+
+### Device Source Switching
+
+When `device-prevent-playback=1` is received:
+- User switched the device to another input source
+- Device is powered off
+- Streaming session removes the player from the active session
+
+## External CLI Binaries
+
+### Why External Binaries?
+
+Python is not suitable for real-time audio streaming with precise timing requirements. The AirPlay protocols (especially AirPlay 2) require:
+- Accurate NTP timestamp handling
+- Real-time RTP packet transmission
+- Low-latency audio buffering
+- Precise synchronization across multiple devices
+
+Therefore, the provider uses C-based CLI binaries for the actual streaming.
+
+### Binary Selection
+
+The provider automatically selects the correct binary based on:
+- **Platform**: Linux, macOS
+- **Architecture**: x86_64, arm64, aarch64
+- **Protocol**: RAOP (`cliraop-*`) or AirPlay 2 (`cliap2-*`)
+
+Binaries are located in [bin/](bin/) directory and validated on first use.
+
+### Binary Communication
+
+**Input** (stdin):
+- PCM audio data piped from FFmpeg
+
+**Commands** (named pipe):
+- Interactive commands sent via `AsyncNamedPipeWriter`
+- Examples: `ACTION=PLAY`, `ACTION=PAUSE`, `VOLUME=50`, `TITLE=Song Name`
+
+**Output** (stderr):
+- Status messages and logs
+- Connection state
+- Playback state changes
+- Elapsed time updates
+- Error messages
+
+The provider monitors stderr in a separate task (`_stderr_reader()` in [raop.py](protocols/raop.py) and [airplay2.py](protocols/airplay2.py)) to:
+- Update player state
+- Detect connection completion
+- Handle errors and packet loss
+- Track elapsed time
+
+## NTP Timestamp Synchronization
+
+AirPlay uses **NTP (Network Time Protocol)** timestamps for synchronized playback.
+
+### NTP Format
+
+- **64-bit integer**: Upper 32 bits = seconds, lower 32 bits = fractional seconds
+- **NTP epoch**: January 1, 1900 (not Unix epoch 1970)
+- **Precision**: Nanosecond-level timing
+
+### Key Functions
+
+Available in [helpers.py](helpers.py):
+- `get_ntp_timestamp()`: Get current NTP time
+- `ntp_to_unix_time()`: Convert NTP to Unix timestamp
+- `unix_time_to_ntp()`: Convert Unix to NTP timestamp
+- `add_seconds_to_ntp()`: Add offset to NTP timestamp
+
+### Usage in Streaming
+
+1. Calculate desired start time: `current_time + connection_buffer`
+2. Convert to NTP timestamp
+3. Pass to CLI binary via `-ntpstart` argument
+4. All players start at the exact same NTP time
+5. Per-player `sync_adjust` config allows fine-tuning (+/- milliseconds)
+
+## Player Types
+
+The provider creates players with different types based on whether the device is a native Apple player or a third-party AirPlay receiver.
+
+### PlayerType.PLAYER
+- **Devices**: Apple HomePod, Apple TV, Mac
+- **Reason**: These are standalone music players with native AirPlay support
+- **Behavior**: Exposed as top-level players in Music Assistant UI
+- **Not merged**: These players are NOT combined with other protocols
+
+### PlayerType.PROTOCOL
+- **Devices**: Third-party AirPlay receivers (Sonos, receivers, smart speakers, soundbars)
+- **Reason**: AirPlay is just one output protocol among many for these devices (often supporting Chromecast, DLNA, etc.)
+- **Behavior**: Automatically merged into a **Universal Player** if other protocols are detected for the same device
+- **Example**: A Sonos speaker supporting both AirPlay and Chromecast will appear as a single "Sonos" player with selectable output protocols
+
+**Detection**: Player type is determined in [player.py](player.py) `__init__()` method based on `manufacturer == "Apple"`
+
+**For more details on output protocols and protocol linking**, see the [Player Controller README](../../controllers/players/README.md), which explains:
+- How multiple protocol players for the same physical device are automatically linked
+- The Universal Player concept for devices without native vendor support
+- Protocol selection and device identifier matching
+- Native player linking vs. Universal Player creation
+
+## Configuration Options
+
+### Protocol Selection
+- **`airplay_protocol`**: Choose RAOP, AirPlay 2, or automatic (default: automatic)
+
+### RAOP-Specific
+- **`encryption`**: Enable/disable encryption (default: enabled)
+- **`alac_encode`**: Enable ALAC compression to save bandwidth (default: enabled)
+- **`ignore_volume`**: Ignore device volume reports (default: false)
+
+### General
+- **`password`**: Device password if required
+- **`sync_adjust`**: Per-player timing adjustment in milliseconds (default: 0)
+
+### Pairing (Apple devices only)
+- **`raop_credentials`**: Stored RAOP pairing credentials (hidden)
+- **`airplay_credentials`**: Stored AirPlay 2 pairing credentials (hidden)
+
+## Known Issues
+
+### Broken AirPlay Models
+
+Some devices have known broken AirPlay implementations (see `BROKEN_AIRPLAY_MODELS` in [constants.py](constants.py)):
+- **Samsung devices**: Known issues with both RAOP and AirPlay 2
+- These players are disabled by default
+
+### Limitations
+
+1. **DACP remote control**: Only active while streaming (not when idle)
+2. **Pause while synced**: Not supported; uses stop instead
+3. **Companion protocol**: Not yet implemented for idle state monitoring
+
+## Development Notes
+
+### Testing CLI Binaries
+
+Each binary can be validated with a test command:
+- **cliraop**: `cliraop -check` (should output "cliraop check")
+- **cliap2**: `cliap2 --testrun` (should output "cliap2 check")
+
+### Adding New CLI Commands
+
+To add a new command to the CLI binaries:
+1. Update the CLI binary source code (external repositories)
+2. Update `send_cli_command()` method in [_protocol.py](protocols/_protocol.py)
+3. Send command via named pipe: `await stream.send_cli_command("YOUR_COMMAND=value")`
+
+### Debugging Streaming Issues
+
+Enable verbose logging in Music Assistant to see:
+- CLI binary arguments
+- stderr output from binaries
+- DACP requests
+- Connection state changes
+- Packet loss warnings
+
+## Credits
+
+- **libraop**: RAOP streaming implementation - https://github.com/music-assistant/libraop
+- **OwnTone**: AirPlay 2 implementation - https://github.com/OwnTone
+- **pyatv**: Reference for HAP pairing protocol - https://github.com/postlund/pyatv
+
+## Future Enhancements
+
+- **Companion protocol**: Implement idle state monitoring for Apple devices
+- **AirPlay 2 volume feedback**: Add DACP volume support for AirPlay 2
+- **Better late-join handling**: Reduce time to start a late joiner
index e4c77f6c05fb7afab1289d2020a400a826c5a0da..4d38b064d6e512210f93bdc2c611bceb26f6a18d 100644 (file)
@@ -5,7 +5,8 @@ from __future__ import annotations
 from enum import IntEnum
 from typing import Final
 
-from music_assistant_models.enums import ContentType
+from music_assistant_models.config_entries import ConfigEntry
+from music_assistant_models.enums import ConfigEntryType, ContentType, PlayerFeature
 from music_assistant_models.media_items import AudioFormat
 
 from music_assistant.constants import INTERNAL_PCM_FORMAT
@@ -80,3 +81,22 @@ AIRPLAY_2_DEFAULT_MODELS = (
     # These use the translated/friendly model names from get_model_info()
     ("Ubiquiti Inc.", "*"),
 )
+
+BROKEN_AIRPLAY_WARN = ConfigEntry(
+    key="BROKEN_AIRPLAY",
+    type=ConfigEntryType.ALERT,
+    default_value=None,
+    required=False,
+    label="This player is known to have broken AirPlay support. "
+    "Playback may fail or simply be silent. "
+    "There is no workaround for this issue at the moment. \n"
+    "If you already enforced AirPlay 2 on the player and it remains silent, "
+    "this is one of the known broken models. Only remedy is to nag the manufacturer for a fix.",
+)
+
+BASE_PLAYER_FEATURES: Final[set[PlayerFeature]] = {
+    PlayerFeature.PLAY_MEDIA,
+    PlayerFeature.SET_MEMBERS,
+    PlayerFeature.MULTI_DEVICE_DSP,
+    PlayerFeature.VOLUME_SET,
+}
index 8b1360de0f3227771a1258977b2e84f219fc2bb2..c5fef7eba12aa7759859b3ae0793824dabf927d0 100644 (file)
@@ -137,7 +137,7 @@ def is_apple_device(manufacturer: str) -> bool:
     and should be exposed as PlayerType.PLAYER. Non-Apple devices with AirPlay
     support should be exposed as PlayerType.PROTOCOL.
     """
-    return manufacturer.lower() == "apple"
+    return manufacturer.lower().startswith("apple")
 
 
 async def get_cli_binary(protocol: StreamingProtocol) -> str:
index 8b03973141a65e2c3cb0aeff792b162b7e94f26e..b9d4445d437ca6e0f84fe89fb59763c038604b4e 100644 (file)
@@ -21,6 +21,8 @@ from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
 from .constants import (
     AIRPLAY_DISCOVERY_TYPE,
     AIRPLAY_FLOW_PCM_FORMAT,
+    BASE_PLAYER_FEATURES,
+    BROKEN_AIRPLAY_WARN,
     CACHE_CATEGORY_PREV_VOLUME,
     CONF_ACTION_FINISH_PAIRING,
     CONF_ACTION_RESET_PAIRING,
@@ -56,19 +58,6 @@ if TYPE_CHECKING:
     from .provider import AirPlayProvider
 
 
-BROKEN_AIRPLAY_WARN = ConfigEntry(
-    key="BROKEN_AIRPLAY",
-    type=ConfigEntryType.ALERT,
-    default_value=None,
-    required=False,
-    label="This player is known to have broken AirPlay support. "
-    "Playback may fail or simply be silent. "
-    "There is no workaround for this issue at the moment. \n"
-    "If you already enforced AirPlay 2 on the player and it remains silent, "
-    "this is one of the known broken models. Only remedy is to nag the manufacturer for a fix.",
-)
-
-
 class AirPlayPlayer(Player):
     """AirPlay Player implementation."""
 
@@ -104,13 +93,6 @@ class AirPlayPlayer(Player):
         )
         self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
         self._attr_device_info.add_identifier(IdentifierType.IP_ADDRESS, address)
-        self._attr_supported_features = {
-            PlayerFeature.PLAY_MEDIA,
-            PlayerFeature.PAUSE,
-            PlayerFeature.SET_MEMBERS,
-            PlayerFeature.MULTI_DEVICE_DSP,
-            PlayerFeature.VOLUME_SET,
-        }
         self._attr_volume_level = initial_volume
         self._attr_can_group_with = {provider.instance_id}
         self._attr_enabled_by_default = not is_broken_airplay_model(manufacturer, model)
@@ -144,6 +126,18 @@ class AirPlayPlayer(Player):
         """Return if the player requires flow mode."""
         return True
 
+    @property
+    def supported_features(self) -> set[PlayerFeature]:
+        """Return the supported features of this player."""
+        features = set(BASE_PLAYER_FEATURES)
+        if not (self.group_members or self.synced_to):
+            # we only support pause when the player is not synced,
+            # because we don't want to deal with the complexity of pausing a group of players
+            # so in this case stop will be used to pause the stream instead of pausing it,
+            # which is a common approach for AirPlay players
+            features.add(PlayerFeature.PAUSE)
+        return features
+
     async def get_config_entries(
         self,
         action: str | None = None,
@@ -502,8 +496,7 @@ class AirPlayPlayer(Player):
         if self.stream and self.stream.running and self.stream.session:
             # Set transitioning flag to ignore stale DACP messages (like prevent-playback)
             self._transitioning = True
-            # Force stop the session (to speed up stopping)
-            await self.stream.session.stop(force=True)
+            await self.stream.session.stop()
             self.stream = None
 
         # select audio source
index 2da0767a81e3da43a3418d6cda6d47185404dfe1..8e9d5e7c2d5dc69e350203bf3db676aa219ff9c5 100644 (file)
@@ -30,12 +30,9 @@ from .helpers import convert_airplay_volume, get_model_info
 from .player import AirPlayPlayer
 
 # TODO: AirPlay provider
-# - Implement authentication for Apple TV
-# - Implement volume control for Apple devices using pyatv
-# - Implement metadata for Apple Apple devices using pyatv
-# - Use pyatv for communicating with original Apple devices (and use cliraop for actual streaming)
-# - Implement AirPlay 2 support
-# - Implement late joining to existing stream (instead of restarting it)
+# Implement Companion protocol for communicating with original Apple (TV) devices
+# This allows for getting state/metadata changes from the device,
+# even if we are not actively streaming to it.
 
 
 class AirPlayProvider(PlayerProvider):
index 0d647dd56c1cbfb48396d19a494f4c7c83feea3f..830d2448f32bd2f2a292cf631088c514eb1f9273 100644 (file)
@@ -83,21 +83,15 @@ class AirPlayStreamSession:
             await self.stop()
             raise PlayerCommandFailed("Playback failed to start")
 
-    async def stop(self, force: bool = False) -> None:
+    async def stop(self) -> None:
         """Stop playback and cleanup."""
         if self._audio_source_task and not self._audio_source_task.done():
             self._audio_source_task.cancel()
             with suppress(asyncio.CancelledError):
                 await self._audio_source_task
-        if force:
-            await asyncio.gather(
-                *[self.stop_client(x, force=True) for x in self.sync_clients],
-            )
-            self.sync_clients = []
-        else:
-            await asyncio.gather(
-                *[self.remove_client(x) for x in self.sync_clients],
-            )
+        await asyncio.gather(
+            *[self.remove_client(x) for x in self.sync_clients],
+        )
 
     async def remove_client(self, airplay_player: AirPlayPlayer) -> None:
         """Remove a sync client from the session."""
@@ -111,7 +105,7 @@ class AirPlayStreamSession:
             await self.stop()
             return
 
-    async def stop_client(self, airplay_player: AirPlayPlayer, force: bool = False) -> None:
+    async def stop_client(self, airplay_player: AirPlayPlayer) -> None:
         """
         Stop a client's stream and ffmpeg.
 
@@ -119,16 +113,12 @@ class AirPlayStreamSession:
         :param force: If True, kill CLI process immediately.
         """
         ffmpeg = self._player_ffmpeg.pop(airplay_player.player_id, None)
-        if force:
-            if ffmpeg and not ffmpeg.closed:
-                await ffmpeg.kill()
-            if airplay_player.stream and airplay_player.stream.session == self:
-                await airplay_player.stream.stop(force=True)
-        else:
-            if ffmpeg and not ffmpeg.closed:
-                await ffmpeg.close()
-            if airplay_player.stream and airplay_player.stream.session == self:
-                await airplay_player.stream.stop()
+        # note that we use kill instead of graceful close here,
+        # because otherwise it can take a very long time for the process to exit.
+        if ffmpeg and not ffmpeg.closed:
+            await ffmpeg.kill()
+        if airplay_player.stream and airplay_player.stream.session == self:
+            await airplay_player.stream.stop(force=True)
 
     async def add_client(self, airplay_player: AirPlayPlayer) -> None:
         """Add a sync client to the session as a late joiner.