32 KiB
WebRTC Support for Group Jam & Remote Control
Overview
Add two peer-to-peer features to Spotube:
- Listen Together (Group Jam): Multi-user synced queue over WebRTC data channels (star topology, manual SDP exchange)
- Remote Control: LAN-only device control via WebSocket on the existing
LocalServer(extended with control routes). No WebRTC needed for this feature.
Both features share UI patterns (adaptive dialogs for play interception) but use different transport layers based on their requirements.
Prerequisites (One-Time Setup)
Before starting implementation:
-
Initialize webrtc-rs submodule:
cd build/webrtc-rs && git submodule update --init --recursiveThe
rtccrate (Sans-I/O core) is a git submodule and must be initialized before building. -
Verify dns-sd-kt availability:
- Published to Maven Central:
com.appstractive:dns-sd-kt:1.1.0 - No setup needed; just add to
libs.versions.toml
- Published to Maven Central:
-
Verify Rust toolchain:
- Existing uniffi setup already works (discord-rpc, metadata modules)
- Ensure
cargois available and can build for all targets
Architecture Decisions (Confirmed)
| Decision | Choice | Rationale |
|---|---|---|
| WebRTC implementation | webrtc-rs via uniffi |
Single codebase, identical behavior across platforms |
| Jam topology | Star (host ↔ peers) | Simpler, scales better, matches host-authority model |
| Remote Control transport | TCP/WebSocket only | LAN-only, so WebRTC is overkill; direct connection is simpler |
| Jam signaling | Manual SDP exchange | No server infrastructure needed; users copy-paste or scan QR |
Phase 0: Rust Uniffi WebRTC Module
Goal
Add webrtc-rs to the existing Rust crate and expose a uniffi API for WebRTC peer connections and data channels.
Library Details (from build/webrtc-rs)
- Crate:
webrtcv0.21.0-beta.1 (pure Rust, no external C/C++ libs) - Architecture: Sans-I/O core (
rtccrate) + async API layer - Async runtime: tokio (default) or smol
- Crypto:
ring(default) oraws-lc-rs - Key types:
PeerConnection(trait) — created viaPeerConnectionBuilder::build()DataChannel(trait) — created viapeer.create_data_channel()RTCSessionDescription— SDP offer/answerRTCIceCandidateInit— ICE candidatesPeerConnectionEventHandler(trait) — callback interface for eventsDataChannelEvent(enum) — polled viadc.poll().await
- Event model: PeerConnection uses callbacks; DataChannel uses polling
- Submodule:
rtcgit submodule must be initialized before building
Files to Modify
composeApp/Cargo.toml— addwebrtcdependencycomposeApp/src/commonMain/rust/lib.rs— register new modulecomposeApp/src/commonMain/rust/webrtc_p2p.rs— NEW: uniffi API
Implementation
-
Initialize webrtc-rs submodule (one-time setup):
cd build/webrtc-rs && git submodule update --init --recursive -
Add webrtc-rs dependency to
composeApp/Cargo.toml:[dependencies] webrtc = { path = "../build/webrtc-rs", features = ["runtime-tokio", "crypto-ring"] } tokio = { version = "1", features = ["full"] } async-trait = "0.1"Note: Using path dependency to the local clone. For production, switch to crates.io version once stable.
-
Define uniffi API in
webrtc_p2p.rs:Core objects:
#[uniffi::export] pub struct PeerConnectionWrapper { pc: Arc<dyn PeerConnection>, runtime: Arc<dyn Runtime>, } #[uniffi::export] impl PeerConnectionWrapper { pub async fn create_offer(&self) -> Result<String, WebrtcError> { let offer = self.pc.create_offer(None).await?; Ok(offer.sdp) } pub async fn set_remote_answer(&self, answer: String) -> Result<(), WebrtcError> { let desc = RTCSessionDescription::answer(answer)?; self.pc.set_remote_description(desc).await?; Ok(()) } pub async fn create_answer(&self) -> Result<String, WebrtcError> { let answer = self.pc.create_answer(None).await?; Ok(answer.sdp) } pub async fn set_remote_offer(&self, offer: String) -> Result<(), WebrtcError> { let desc = RTCSessionDescription::offer(offer)?; self.pc.set_remote_description(desc).await?; Ok(()) } pub async fn send_data(&self, channel: String, data: String) -> Result<(), WebrtcError> { // Find or cache data channel by label // ... Ok(()) } pub async fn close(&self) -> Result<(), WebrtcError> { self.pc.close().await?; Ok(()) } }Callback interface for events:
#[uniffi::export(callback_interface)] pub trait PeerConnectionEventHandler { fn on_ice_candidate(&self, candidate: String); fn on_connection_state_change(&self, state: String); fn on_data_channel(&self, label: String); fn on_data_channel_message(&self, label: String, data: String); }Factory function:
#[uniffi::export] pub async fn create_peer_connection( ice_servers: Vec<String>, handler: Arc<dyn PeerConnectionEventHandler>, ) -> Result<PeerConnectionWrapper, WebrtcError> { // Build RTCConfiguration from ice_servers // Create MediaEngine, Registry // Build PeerConnection with handler wrapper // Spawn task to poll data channel events and forward to handler Ok(PeerConnectionWrapper { pc, runtime }) }Key challenge: webrtc-rs is fully async, but uniffi callbacks are synchronous. Solution:
- Wrap the
PeerConnectionEventHandlertrait in a Rust adapter that spawns async tasks - Use
tokio::sync::mpscchannels to bridge async events → sync callbacks - For DataChannel polling, spawn a background task that calls
dc.poll().awaitin a loop and forwards messages to the Kotlin handler
- Wrap the
-
Register module in
lib.rs:mod webrtc_p2p; pub use webrtc_p2p::*; -
Cross-compilation considerations:
- Good news: webrtc-rs is pure Rust (no libwebrtc/BoringSSL C++ deps)
- Crypto:
ringcompiles from source for all targets (requires C compiler for Android/iOS) - JVM desktop: Should work out of the box
- Android: Requires NDK +
ringcross-compilation setup (well-supported) - iOS: Requires
ringcross-compilation for aarch64-apple-ios - Gobley plugin: Already configured for multi-target Rust builds in
composeApp/build.gradle.kts
Verification
- Initialize submodule:
cd build/webrtc-rs && git submodule update --init --recursive - Build Rust crate:
cargo build --releaseincomposeApp/ - Verify Kotlin bindings are generated in
uniffi.compose_app.* - Write a simple Kotlin test that creates a peer connection and exchanges SDP
Phase 1: Remote Control (LAN-only, extend existing LocalServer)
Goal
Allow users to control playback on another device on the same LAN. Opt-in via settings. DNS-SD for discovery (via dns-sd-kt). Extend the existing LocalServer with WebSocket routes for control commands — no separate server needed.
1.1 Settings & Permissions
Files to Modify
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt— add fields toUserSettingscomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt— add toggle UIcomposeApp/src/commonMain/composeResources/values/strings.xml— add stringscomposeApp/src/androidMain/AndroidManifest.xml— add permissionsiosApp/iosApp/Info.plist— add Bonjour services
Implementation
-
Add to
UserSettings:val allowRemoteControl: Boolean = false, val allowedRemoteDevices: List<String> = emptyList(), // device IDs -
Add toggle UI in
PlaybackSection.kt:- Use
SwitchSettingCardfor "Allow remote control" - Add a "Manage allowed devices" item that navigates to a sub-screen (see
Routes.Blacklistpattern)
- Use
-
Add string resources:
<string name="settings_allow_remote_control_title">Allow Remote Control</string> <string name="settings_allow_remote_control_subtitle">Let other devices on your network control playback</string> -
Android permissions (dns-sd-kt requires these):
<!-- Already present --> <uses-permission android:name="android.permission.INTERNET" /> <!-- Required by dns-sd-kt for mDNS multicast --> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <!-- Required on Android 16+ (Baklava) --> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />Note: dns-sd-kt uses
androidx.startupto auto-initializeContext— no manual init needed. -
iOS Info.plist (add to
iosApp/iosApp/Info.plist):<key>NSLocalNetworkUsageDescription</key> <string>Spotube needs access to your local network to discover and control other devices.</string> <key>NSBonjourServices</key> <array> <string>_spotube-ctrl._tcp</string> </array>Note: dns-sd-kt's Apple backend uses
NWBrowser(Network.framework) + custom Swift bridge. TheNSBonjourServiceskey is required for Bonjour discovery to work.
1.2 DNS-SD Discovery
Library Details (from build/dns-sd-kt)
- Maven Central:
com.appstractive:dns-sd-kt:1.1.0 - KMP library: supports Android, JVM, iOS (arm64 + simulatorArm64), macOS, tvOS
- Fully coroutine/Flow-based — no callback-style API
- Platform backends:
- Android:
NsdManager(pure Kotlin) - JVM:
JmDNS 3.6.3(pure Java) - Apple:
NWBrowser+NSNetService+ custom Swift bridge viaspm4kmp
- Android:
- Two-phase resolution:
DiscoveryEvent.Discovered→ callresolve()→DiscoveryEvent.Resolvedwith addresses - Auto-init on Android: uses
androidx.startupto grabContext
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt— wraps dns-sd-kt APIs
Files to Modify
gradle/libs.versions.toml— add dns-sd-kt dependencycomposeApp/build.gradle.kts— add to commonMain dependencies
Implementation
-
Add dns-sd-kt dependency to
gradle/libs.versions.toml:[versions] dns-sd-kt = "1.1.0" [libraries] dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dns-sd-kt" } -
Add to
composeApp/build.gradle.ktsincommonMain.dependencies:implementation(libs.dns.sd.kt) -
Create
DeviceDiscoveryServiceincommonMain(no expect/actual needed — dns-sd-kt handles platform differences):class DeviceDiscoveryService { private val serviceType = "_spotube-ctrl._tcp" fun discoverDevices(): Flow<DiscoveryEvent> = discoverServices(serviceType) suspend fun registerDevice(deviceId: String, deviceName: String, port: Int): NetService { val service = createNetService( type = serviceType, name = deviceName, port = port, txt = mapOf("deviceId" to deviceId), ) service.register() return service } } -
Usage in ViewModel:
// Discover devices discoveryService.discoverDevices() .onEach { event -> when (event) { is DiscoveryEvent.Discovered -> { event.resolve() // trigger address resolution // Add to discovered devices list (addresses may be empty) } is DiscoveryEvent.Resolved -> { // Update with resolved addresses/host } is DiscoveryEvent.Removed -> { // Remove from list } } } .launchIn(viewModelScope) -
Register in Koin in
Modules.kt:single { DeviceDiscoveryService() } -
No expect/actual needed — dns-sd-kt is a KMP library that handles platform differences internally. The Apple targets use Swift interop via
spm4kmp, which is transparent to consumers.
1.3 Control Server (Extend LocalServer)
Decision: Reuse Existing LocalServer
The app already has a Ktor CIO-based LocalServer running on 127.0.0.1:<playbackProxyServerPort> for the playback proxy. We'll extend it with WebSocket routes for control commands. When remote control is enabled, the server binds to 0.0.0.0 (LAN-accessible); otherwise it stays on 127.0.0.1 (local-only).
Files to Modify
gradle/libs.versions.toml— addktor-server-websockets,ktor-client-websocketscomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt— add WebSocket routes, conditional bind to0.0.0.0composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt— update LocalServer registration
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt— handles control messagescomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt— message definitionscomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt— wraps AudioPlayerInterface for remote control
Implementation
-
Add WebSocket dependencies to
libs.versions.toml:ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } -
Extend
LocalServer.kt:- Add
RemoteControlHandlerconstructor parameter - In
configureRoutes(), installWebSocketsplugin and add/controlWebSocket route - In
restartServer(), checksettings.allowRemoteControl:- If enabled: bind to
0.0.0.0(LAN-accessible) - If disabled: bind to
127.0.0.1(local-only, current behavior)
- If enabled: bind to
- Add a watcher that restarts the server when
allowRemoteControlsetting changes
private suspend fun restartServer(port: Int) { val allowRemoteControl = settingsViewModel.settingsState.value?.allowRemoteControl ?: false val host = if (allowRemoteControl) "0.0.0.0" else "127.0.0.1" serverState.value = embeddedServer( factory = CIO, host = host, port = port, module = { configureRoutes() } ).also { engine -> engine.start(wait = false) } } private fun Application.configureRoutes() { install(WebSockets) routing { get("/health") { call.respondText("ok") } // ... existing routes ... webSocket("/control") { remoteControlHandler.handleConnection(this) } } } - Add
-
Define protocol in
RemoteControlProtocol.kt:@Serializable sealed class RemoteControlMessage { @Serializable data class Play(val trackId: String) : RemoteControlMessage() @Serializable data class Pause(val unit: Unit = Unit) : RemoteControlMessage() @Serializable data class Seek(val positionMs: Long) : RemoteControlMessage() @Serializable data class SetVolume(val volume: Float) : RemoteControlMessage() @Serializable data class AddToQueue(val trackId: String) : RemoteControlMessage() // ... etc } @Serializable sealed class RemoteStateUpdate { @Serializable data class PlayerState(val state: PlayerUiState) : RemoteStateUpdate() @Serializable data class QueueUpdate(val queue: List<QueueEntry>) : RemoteStateUpdate() } -
Create
RemoteControlHandler:- Handles incoming WebSocket connections on the controlled device
- Checks
settings.allowRemoteControlbefore accepting (rejects immediately if disabled) - Shows connection request dialog (allow/allow-always/deny) via a callback injected from the UI layer
- On acceptance: forwards commands to
AudioPlayerInterfaceandAudioPlayerQueue - Broadcasts state updates (player state, queue) to the connected controller
-
Create
RemotePlayerProxy:- Wraps
AudioPlayerInterfaceandAudioPlayerQueueon the controlling device - Sends commands over WebSocket to the controlled device
- Receives state updates and exposes them as StateFlows
- Implementation note: Full interface implementation is complex. Alternative: create a separate
RemotePlayerStateStateFlow that mirrors remote state, and the UI uses it instead ofrememberPlayerUiState().
- Wraps
-
Register in Koin in
Modules.kt:single { RemoteControlHandler(get(), get(), get()) } // LocalServer constructor updated; no other DI changes needed
Key Simplification
By reusing LocalServer, we eliminate the need for:
- A separate WebSocket server
- Separate port management
- Duplicate Ktor configuration
The server becomes a multi-purpose local server: playback proxy (always) + control endpoint (when enabled).
1.4 UI: Devices Screen
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt
Files to Modify
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt— addRoutes.DevicescomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt— add "Devices" button at bottomcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt— add "Devices" icon to TopAppBar actions
Implementation
-
Add route to
NavigationModule.kt:@Serializable data object Devices : Routes navigation<Routes.Devices> { DevicesScreen(...) } -
Add sidebar button in
AppSidebar.kt(after line 177):SidebarItem( title = "Devices", icon = Icons.Default.Devices, onClick = { navigator.navigate(Routes.Devices) } ) -
Add TopAppBar action in
HomeScreen.kt:ApplicationMainBar( actions = { IconButton(onClick = { navigator.navigate(Routes.Devices) }) { Icon(Icons.Default.Devices, "Devices") } } ) -
DevicesScreen:
- Shows list of discovered devices (from
DeviceDiscoveryService) - Each device shows name, IP, and connection status
- Clicking a device initiates connection (WebSocket)
- After connection, navigates to
RemotePlayerScreen
- Shows list of discovered devices (from
-
RemotePlayerScreen:
- Similar to
AppExpandedPlayerbut usesRemotePlayerProxyinstead of localAudioPlayerInterface - All controls (play/pause/seek/volume/queue) forward to remote device
- Similar to
1.5 Connection Request Flow
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/remote/ConnectionRequestDialog.kt
Implementation
-
When a new device tries to connect,
RemoteControlServiceshows a dialog:AdaptiveDialogBottomSheet( title = { Text("Remote Control Request") }, content = { Text("Device '${deviceName}' wants to control playback") }, actions = { Button(onClick = { deny() }) { Text("Deny") } Button(onClick = { allow(always = false) }) { Text("Allow") } Button(onClick = { allow(always = true) }) { Text("Allow Always") } } ) -
If "Allow Always", add device ID to
settings.allowedRemoteDevices
Phase 2: Group Jam (WebRTC, Manual SDP)
Goal
Multi-user synced queue over WebRTC data channels. Host creates session, shares SDP offer (via copy-paste or QR), guests join. Star topology (host ↔ peers).
2.1 Jam Session Service
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt— manages WebRTC connectionscomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt— message definitionscomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt— syncs queue state
Implementation
-
Define protocol in
JamProtocol.kt:@Serializable sealed class JamMessage { // Host → Peers @Serializable data class QueueState(val queue: List<QueueEntry>, val currentIndex: Int) : JamMessage() @Serializable data class PlaybackCommand(val command: PlaybackCommand) : JamMessage() @Serializable data class ParticipantList(val participants: List<Participant>) : JamMessage() // Peers → Host @Serializable data class SuggestTrack(val trackId: String) : JamMessage() @Serializable data class SuggestPlaylist(val playlistId: String) : JamMessage() @Serializable data class ChatMessage(val text: String) : JamMessage() } data class Participant(val id: String, val name: String, val isHost: Boolean) -
Create
JamSessionServiceusing the uniffi WebRTC API from Phase 0:class JamSessionService( private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayer: AudioPlayerInterface, ) { private var peerConnection: PeerConnectionWrapper? = null private val _participants = MutableStateFlow<List<Participant>>(emptyList()) val participants: StateFlow<List<Participant>> = _participants suspend fun createSession(): String { // Create peer connection with ICE servers peerConnection = create_peer_connection( iceServers = listOf("stun:stun.l.google.com:19302"), handler = object : PeerConnectionEventHandler { override fun on_ice_candidate(candidate: String) { // ICE candidates are bundled into SDP (non-trickle mode) } override fun on_data_channel_message(label: String, data: String) { // Parse JamMessage and handle } // ... other callbacks } ) // Create data channel for jam messages // Create SDP offer and return it for sharing val offer = peerConnection!!.create_offer() return offer } suspend fun joinSession(offer: String): String { // Create peer connection peerConnection = create_peer_connection(...) // Set remote offer and create answer peerConnection!!.set_remote_offer(offer) val answer = peerConnection!!.create_answer() return answer } suspend fun sendMessage(message: JamMessage) { val json = Json.encodeToString(message) peerConnection?.send_data("jam", json) } }Key points:
- Uses
uniffi.compose_app.create_peer_connection()from Phase 0 - Host creates multiple peer connections (one per guest) — star topology
- Data channel labeled "jam" for all jam messages
- SDP exchange is manual (copy-paste or QR code)
- Uses
-
Create
QueueSyncManager:- On host: wraps
AudioPlayerQueue, intercepts all queue mutations, broadcasts them viaJamSessionService.sendMessage() - On guest: receives queue mutations, applies them to local queue
- Handles conflict resolution (host authority: host's commands always win)
- On host: wraps
-
Register in Koin:
single { JamSessionService(get(), get()) }
2.2 Jam Session UI
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt— create/join sessioncomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt— active session viewcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/SdpExchangeDialog.kt— copy-paste SDP
Files to Modify
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt— addRoutes.JamcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt— add "Group Jam" buttoncomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt— add "Group Jam" icon to TopAppBar
Implementation
-
Add route:
@Serializable data object Jam : Routes navigation<Routes.Jam> { JamScreen(...) } -
Add sidebar button (above "Devices"):
SidebarItem( title = "Group Jam", icon = Icons.Default.Group, onClick = { navigator.navigate(Routes.Jam) } ) -
JamScreen:
- Two tabs: "Create Session" and "Join Session"
- Create Session:
- Generates SDP offer via
JamSessionService - Shows SDP as copyable text and QR code
- Waits for guests to connect
- Generates SDP offer via
- Join Session:
- Text field to paste SDP offer
- QR code scanner (optional)
- Generates SDP answer and shows it for host to paste back
-
JamSessionScreen:
- Shows list of participants (from
JamSessionService) - Shows current track and queue
- Playback controls (only work for host; guests send suggestions)
- Suggest track/playlist buttons
- Chat/messages area (optional)
- Shows list of participants (from
-
SdpExchangeDialog:
- Shows SDP string in a
TextField(read-only for offer, editable for answer) - "Copy" button
- "Paste" button (for answer)
- QR code display (using a QR generation library)
- Shows SDP string in a
2.3 Play Interception
Files to Modify
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt
Implementation
-
Create
PlayDestinationPicker:@Composable fun PlayDestinationPicker( onPlayLocally: () -> Unit, onSuggestToJam: () -> Unit, onDismiss: () -> Unit ) { AdaptiveDialogBottomSheet( title = { Text("Play Where?") }, content = { Column { Button(onClick = onPlayLocally) { Text("Play on This Device") } Button(onClick = onSuggestToJam) { Text("Suggest to Jam Session") } } }, onDismiss = onDismiss ) } -
Modify ViewModels:
- In
PlaylistViewModel.playPlaylist(), check ifJamSessionService.isActive - If active, show
PlayDestinationPickerinstead of callingplaybackHelper.playPlaylist()directly - If user chooses "Suggest to Jam", call
JamSessionService.suggestPlaylist(playlistId)
- In
-
Apply same pattern to
AlbumViewModel,ArtistScreen,SearchScreen
Phase 3: Deep-Link Handling (Optional Enhancement)
Goal
Allow users to open spotube://jam/<session-id> links to join a Jam session. With manual SDP exchange, the deep link can contain a session ID + a short-lived token, and the actual SDP exchange happens in the app.
Files to Modify
composeApp/src/androidMain/AndroidManifest.xml— add intent filteriosApp/iosApp/ContentView.swift— addonOpenURLhandlercomposeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt— parse command-line args
Files to Create
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/DeepLinkService.kt— expect interface- Platform actuals
Implementation
-
Android: Add intent filter to
MainActivity:<intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="spotube" android:host="jam" /> </intent-filter> -
iOS: Add
onOpenURLinContentView.swift:.onOpenURL { url in // Pass to Compose via a callback } -
Desktop: Parse
argsinmain.kt:fun main(args: Array<String>) { val deepLink = args.firstOrNull { it.startsWith("spotube://") } // Pass to Compose } -
DeepLinkService: Parse URL, navigate to
Routes.Jam(sessionId)
Implementation Order
- Phase 0: Rust uniffi WebRTC module (foundation for Jam)
- Phase 1: Remote Control (simpler, LAN-only, no WebRTC needed)
- 1.1 Settings & Permissions
- 1.2 DNS-SD Discovery (using dns-sd-kt)
- 1.3 Extend LocalServer with WebSocket control routes + conditional bind
- 1.4 UI: Devices Screen
- 1.5 Connection Request Flow
- Phase 2: Group Jam (WebRTC, manual SDP)
- 2.1 Jam Session Service
- 2.2 Jam Session UI
- 2.3 Play Interception
- Phase 3: Deep-Link Handling (optional, can be deferred)
Key Files Summary
Rust
composeApp/Cargo.toml— addwebrtcdependencycomposeApp/src/commonMain/rust/lib.rs— registerwebrtc_p2pmodulecomposeApp/src/commonMain/rust/webrtc_p2p.rs— NEW: uniffi API
Settings
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt
Remote Control
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt— NEW: wraps dns-sd-ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt— NEW: handles WebSocket control connectionscomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt— NEW: message definitionscomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt— NEW: remote player state proxycomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt— NEWcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt— NEWcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt— MODIFIED: add WebSocket routes, conditional bind
Group Jam
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt— NEWcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt— NEWcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt— NEW
Navigation & UI
composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.ktcomposeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt
Permissions
composeApp/src/androidMain/AndroidManifest.xmliosApp/iosApp/Info.plist
Risks & Mitigations
| Risk | Mitigation |
|---|---|
webrtc-rs rtc submodule not initialized |
Document in setup: cd build/webrtc-rs && git submodule update --init --recursive |
ring crypto cross-compilation for Android/iOS |
Well-supported; may need NDK env vars for Android. Test early. |
| webrtc-rs is pre-release (0.21.0-beta.1) | API is stabilizing; pin version. Monitor for 1.0 release. |
dns-sd-kt Apple targets use Swift interop (spm4kmp) |
Published Maven Central artifacts include cinterop bindings. Should work transparently. |
| Manual SDP exchange is poor UX | Add QR code scanning as an alternative (Phase 2.2) |
| Queue sync conflicts in Jam | Host authority model: host's commands always win |
| WebRTC data channel reliability | Use ordered, reliable data channels (default in webrtc-rs) |
| Uniffi async/sync bridge for webrtc-rs | Use tokio::sync::mpsc channels to bridge async events → sync callbacks |
Testing Strategy
- Unit tests: Test protocol serialization, queue sync logic
- Integration tests: Test WebSocket server/client, DNS-SD discovery
- Manual tests:
- Remote Control: Two devices on same LAN, control playback from one to another
- Group Jam: Three devices (1 host + 2 guests), sync queue and playback
- Cross-platform tests: Verify on Android, iOS, JVM desktop (Linux/Windows/macOS)