SendSpin-Only Player: Scope and Target Architecture
SendSpin-Only Player: Scope and Target Architecture
Date: 2026-09-01 Status: Accepted Authoritative protocol spec: https://github.com/sendspin/spec
Goal
Refocus SendSpinDroid from a hybrid Music Assistant control app into a purpose-built, spec-compliant SendSpin player for TVs, tablets and phones used as music endpoints.
Music Assistant has its own, better native application. This app should not compete with it. It should be the best way to turn an Android device into a SendSpin playback endpoint.
The conformance rule
Every feature must correspond to a role or message defined in the SendSpin spec. This is the single test for whether code stays.
The spec defines exactly seven roles:
| Role | Purpose |
|---|---|
player@v1 |
outputs audio |
source@v1 |
captures audio from a local input and streams it to the server |
controller@v1 |
controls the current group |
metadata@v1 |
displays text metadata describing the currently playing audio |
artwork@v1 |
displays artwork images |
visualizer@v1 |
visualizes audio |
color@v1 |
receives colors derived from the current audio |
And this complete message set:
client/init server/init noise/handshake client/hello server/hello
server/activate client/state server/state client/time server/time
stream/start stream/clear stream/end stream/request-format
client/command server/command group/update pair/abort server/unpair
client/goodbye
The spec has no concept of a media library, browsing, search, or a viewable
playback queue. controller@v1 controls the current group – the transport
commands observed on the wire against a production server were: play, pause,
stop, next, previous, seek, seek_relative, volume, mute,
shuffle, unshuffle, repeat_off, repeat_one, repeat_all, switch.
Anything that browses, searches, or lists content is Music Assistant REST/ WebSocket API surface with no protocol counterpart, and leaves scope.
Decisions
-
Android Auto keeps server selection; the MA library branches are dropped.
Revised 2026-09-01 after implementation. The original wording claimed the browse tree was “entirely MA-backed” and could be removed wholesale. That is false.
playback/AutoBrowseTree.ktmixes two concerns in one file:- SendSpin server selection –
MEDIA_ID_DISCOVERED,MEDIA_ID_SERVER_PREFIX,MEDIA_ID_SAVED_SERVER_PREFIX,MEDIA_ID_MESSAGE_NO_SERVERS. This is a genuine SendSpin feature and the only way to choose or switch servers from a car. It stays. - MA library browse –
MEDIA_ID_MA_PLAYLISTS,MEDIA_ID_MA_ALBUMS,MEDIA_ID_MA_ARTISTS,MEDIA_ID_MA_RADIOand their item prefixes, backed bygetPlaylists/getAlbums/getArtists/getRadioStations/search. This goes.
Android Auto therefore retains server browsing plus now-playing and transport via MediaSession.
- SendSpin server selection –
-
The app is Now Playing first. Primary UI is now-playing plus transport controls. When not connected, the server picker is shown. The four-tab navigation (
HOME,SEARCH,LIBRARY,PLAYLISTS) is removed entirely. -
Remote access via WebRTC/proxy is cut for now. The player advertises itself locally by default. Remote connection by entering a specific URL is retained. A SendSpin-native remote story (possibly WebRTC) will be specified later; it will not depend on Music Assistant’s signaling server.
-
Target devices are TVs, tablets and phones used as music players. The form-factor foundation already exists (
LEANBACK_LAUNCHER,banner_tv,uses-feature leanback,ui/adaptive/FormFactor.kt,TvFocusHelpers.kt,isTvDevice,WindowSizeClass) and is retained.
Target architecture
Server picker (not connected)
|
v
Now Playing (connected) -- artwork, metadata, transport controls, volume
|
+-- Settings
+-- Diagnostics
No tab bar. No detail screens. No library.
What stays
| Area | LOC | Why |
|---|---|---|
app/sendspin/ + shared/sendspin/ |
14,513 | protocol, clock sync, crypto, transports |
app/playback/ |
6,060 | player role: audio pipeline, MediaSession |
ui/main/ |
5,170 | already contains the target shell |
ui/settings/, ui/theme/, ui/adaptive/, ui/dialogs/ |
2,810 | app chrome and form-factor support |
ui/server/, ui/wizard/ |
4,488 | server picker and setup (MA paths removed) |
app/discovery/ |
426 | local advertisement and mDNS |
app/diagnostics/, app/logging/ |
506+ | support tooling |
What goes
| Area | LOC | Kind |
|---|---|---|
ui/navigation/ (home, library, playlists, search) |
4,132 | delete |
ui/detail/ (album, artist, audiobook, playlist, podcast) |
4,246 | delete |
shared/musicassistant/ |
4,345 | delete |
app/musicassistant/ |
2,162 | delete |
ui/queue/ |
1,811 | delete |
app/remote/ |
889 | delete |
shared/remote/ (SignalingClient) |
585 | delete |
sendspin/transport/ProxyWebSocketTransport.kt |
– | delete |
io.getstream:stream-webrtc-android:1.3.4 |
– | drop dependency |
Approximately 18,200 LOC of 64,546 (28%).
Surgical decontamination
These files stay but need MA references removed:
| File | Refs | Coupling |
|---|---|---|
playback/PlaybackService.kt |
69 | Auto browse tree, queue updates, image proxy, MA DataChannel |
res/values/strings.xml |
32 | user-facing MA strings |
ui/server/AddServerWizardViewModel.kt |
20 | MA server setup path |
ui/AppShell.kt |
17 | tab navigation, MA screen wiring |
MainActivity.kt |
13 | MA state plumbing |
ui/player/PlayerViewModel.kt |
12 | MA queue/track types |
UnifiedServerRepository.kt |
8 | MA server records |
sendspin/SendSpin.kt |
4 | getMaApiDataChannel(), drainMaApiMessageBuffer() |
The protocol core containing only 4 MA references is the key structural fact: the dependency arrow points MA -> SendSpin and never the reverse, so this is a deletion job at the UI and API layers, not an untangling job at the protocol layer.
Sequencing
Construct, switch, then delete. Build and switch to the player-first shell before removing anything. This keeps the app working at every commit and makes the deletion self-verifying: if the shell still works after a package is removed, that package was genuinely orphaned.
Deleting first would leave the app without a usable main screen for the middle of the project and make regressions hard to attribute.
Dependency ordering (added 2026-09-01, after phase 1)
The stay/go table above lists packages to delete but implies they are independent. They are not. Verified after the player-first shell landed:
com.sendspindroid.ui.navigationis the ONLY genuinely orphaned package. One test imports it:ui/compose/SearchScreenResultsTest.ktusesSearchViewModel.SearchState.com.sendspindroid.ui.detailsurvives through exactly one edge:ui/queue/SaveQueueAsPlaylistDialog.ktimportsui.detail.components.BulkAddState. Removing the queue surface unblocks it.com.sendspindroid.ui.queueis the live queue surface, reached fromAppShell,NowPlayingScreen,NowPlayingHeadUnitandMainActivity.com.sendspindroid.musicassistantcannot be deleted wholesale. Around 40 files underapp/src/mainimport it, includingPlaybackService,SendSpinApp,AutoVoiceSearchand the add-server wizard. It is load-bearing for playback, artwork and server setup, not just for browsing.
The non-browse Music Assistant dependencies, and why they exist:
| Consumer | Uses | Purpose |
|---|---|---|
SendSpinApp |
MaSettings.initialize() |
app-wide settings bootstrap |
AddServerWizard |
MaEndpoint, MaSettings |
models Local / Proxy / Remote setup |
PlaybackService |
MaProxyImageFetcher |
fetches artwork over the WebRTC DataChannel |
PlaybackService |
queueUpdates |
prefetch about 1s before a track change |
PlaybackService |
getMaApiDataChannel |
MA API tunnelled over WebRTC in Remote mode |
DefaultServerPinger |
SignalingClient |
remote reachability probe |
The load-bearing consequence: MaProxyImageFetcher and the MA API DataChannel
exist because of REMOTE ACCESS, not because of browsing. In Proxy and Remote mode
artwork URLs are not directly reachable, so album art is tunnelled through MA’s
WebRTC data channel – which is why sendspin/SendSpin.kt, the protocol core,
carries four MA-aware lines. Cutting remote access therefore also retires the
image proxy, the DataChannel plumbing, the signaling client, and the wizard’s
Proxy/Remote modes.
Remaining work, as three plans
| Plan | Scope | Approx LOC | Depends on |
|---|---|---|---|
| A. Cut remote/proxy | app/remote/, shared/remote/, WebRTC dependency, SignalingClient, MaProxyImageFetcher, DataChannel plumbing, SendSpin.kt’s 4 MA lines, wizard Proxy/Remote modes |
~1,500 + native dep | nothing |
| B. Remove browse and queue | ui/navigation, the MA half of the Auto browse tree, ui/queue, then ui/detail |
~10,200 | nothing |
| C. Retire the MA client | whatever survives in musicassistant/ |
~6,500 | A and B |
Plan B is being executed first.
Queue decision
The queue UI is deleted entirely. SendSpin defines no viewable queue, so it has
no protocol counterpart, and SaveQueueAsPlaylistDialog writes to a Music
Assistant playlist – a library-write feature outside the player role. Removing
it also unblocks ui/detail.
What the WebRTC transport actually was (recorded 2026-09-01, before deletion)
Decision 3 stands – remote/WebRTC is cut. But the original justification in this spec was partly wrong, and the correction is worth keeping, because the code is recoverable from git while the analysis is not.
The transport was not Music Assistant infrastructure. remote/WebRTCTransport.kt
implemented SendSpinTransport – a peer of WebSocketTransport, not an add-on – and
opened a DataChannel named "sendspin" carrying the full SendSpin protocol, binary
audio frames included. Remote SendSpin playback genuinely worked over it. A second,
separate "ma-api" DataChannel carried Music Assistant’s control API; that half was
genuinely MA-specific.
Which parts were actually coupled to Music Assistant:
| Component | MA-coupled | Note |
|---|---|---|
WebRTCTransport |
No | carried the SendSpin protocol itself |
SignalingClient |
Default only | signalingUrl: String = DEFAULT_SIGNALING_URL was a PARAMETER, defaulting to wss://signaling.music-assistant.io/ws |
| ICE servers | No | public STUN: Google x2, Cloudflare, Home Assistant |
"ma-api" DataChannel, getMaApiDataChannel |
Yes | MA control API tunnel |
MaProxyImageFetcher |
Yes | see artwork note below |
Artwork did not need the proxy. MaProxyImageFetcher existed because “in REMOTE
mode, images hosted on the MA server (via /imageproxy) can’t be reached” – it tunnelled
MA’s HTTP artwork URLs. SendSpin carries artwork natively as binary message types 8-11
(ARTWORK_BASE = 8, channels 0-3), so a SendSpin-native remote connection would receive
artwork over the same DataChannel as audio, with no proxy at all.
The open question that was never answered. SendSpin is a synchronized player and
SendspinTimeFilter has no remote-specific handling – the same Kalman filter runs over
WAN as over LAN. Measured locally on a Relndoo T901_US: ~475 us sync error at ~16 ms RTT.
Whether that holds over internet latency and jitter was never tested. Any future
SendSpin-native remote design must answer this first: if sync cannot hold, remote
playback is a broken feature no matter how clean the transport is. Bandwidth is the
secondary constraint – PCM 48k/2ch/16-bit is roughly 1.5 Mbps sustained; Opus or FLAC
is far less.
Why it is still being cut. The signaling endpoint was a third-party dependency for a
SendSpin-only app, the WebRTC native library is a large APK cost, and the sync question
is unanswered. A future remote story should be designed against the SendSpin spec rather
than inherited from Music Assistant’s signaling infrastructure. The six existing test
files under remote/ and e2e/RemoteConnectWebRTCTest.kt are worth reading before
rebuilding.
Out of scope
- A SendSpin-native remote access design (deferred; see decision 3)
source@v1role (line-in capture)visualizer@v1andcolor@v1roles- Any replacement for library browsing
- Migration tooling for users of the MA features
Known related defect
Issue #253: stream/end is silently ignored because the client compares the
versioned player@v1 against the spec’s unversioned player. Independent of
this work but touches the same conformance theme.
Verification criteria
:app:assembleDebugand the unit test suite pass at every task boundary.- No import of
com.sendspindroid.musicassistantorcom.sendspindroid.remoteremains outside deleted files. io.getstream:stream-webrtc-androidis absent from the dependency tree.- On a device: connect to a SendSpin server, play audio, verify transport controls, artwork and metadata; verify Android Auto shows now-playing with working transport and no browse tree.
- Verified on phone, tablet and TV form factors.