# WebRTC Support for Group Jam & Remote Control ## Overview Add two peer-to-peer features to Spotube: 1. **Listen Together (Group Jam)**: Multi-user synced queue over WebRTC data channels (star topology, manual SDP exchange) 2. **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: 1. **Initialize webrtc-rs submodule**: ```bash cd build/webrtc-rs && git submodule update --init --recursive ``` The `rtc` crate (Sans-I/O core) is a git submodule and must be initialized before building. 2. **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` 3. **Verify Rust toolchain**: - Existing uniffi setup already works (discord-rpc, metadata modules) - Ensure `cargo` is 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**: `webrtc` v0.21.0-beta.1 (pure Rust, no external C/C++ libs) - **Architecture**: Sans-I/O core (`rtc` crate) + async API layer - **Async runtime**: tokio (default) or smol - **Crypto**: `ring` (default) or `aws-lc-rs` - **Key types**: - `PeerConnection` (trait) — created via `PeerConnectionBuilder::build()` - `DataChannel` (trait) — created via `peer.create_data_channel()` - `RTCSessionDescription` — SDP offer/answer - `RTCIceCandidateInit` — ICE candidates - `PeerConnectionEventHandler` (trait) — callback interface for events - `DataChannelEvent` (enum) — polled via `dc.poll().await` - **Event model**: PeerConnection uses callbacks; DataChannel uses polling - **Submodule**: `rtc` git submodule must be initialized before building ### Files to Modify - `composeApp/Cargo.toml` — add `webrtc` dependency - `composeApp/src/commonMain/rust/lib.rs` — register new module - `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API ### Implementation 1. **Initialize webrtc-rs submodule** (one-time setup): ```bash cd build/webrtc-rs && git submodule update --init --recursive ``` 2. **Add webrtc-rs dependency** to `composeApp/Cargo.toml`: ```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. 3. **Define uniffi API** in `webrtc_p2p.rs`: **Core objects**: ```rust #[uniffi::export] pub struct PeerConnectionWrapper { pc: Arc, runtime: Arc, } #[uniffi::export] impl PeerConnectionWrapper { pub async fn create_offer(&self) -> Result { 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 { 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**: ```rust #[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**: ```rust #[uniffi::export] pub async fn create_peer_connection( ice_servers: Vec, handler: Arc, ) -> Result { // 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 `PeerConnectionEventHandler` trait in a Rust adapter that spawns async tasks - Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks - For DataChannel polling, spawn a background task that calls `dc.poll().await` in a loop and forwards messages to the Kotlin handler 4. **Register module** in `lib.rs`: ```rust mod webrtc_p2p; pub use webrtc_p2p::*; ``` 5. **Cross-compilation considerations**: - **Good news**: webrtc-rs is pure Rust (no libwebrtc/BoringSSL C++ deps) - **Crypto**: `ring` compiles from source for all targets (requires C compiler for Android/iOS) - **JVM desktop**: Should work out of the box - **Android**: Requires NDK + `ring` cross-compilation setup (well-supported) - **iOS**: Requires `ring` cross-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 --release` in `composeApp/` - 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 to `UserSettings` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` — add toggle UI - `composeApp/src/commonMain/composeResources/values/strings.xml` — add strings - `composeApp/src/androidMain/AndroidManifest.xml` — add permissions - `iosApp/iosApp/Info.plist` — add Bonjour services #### Implementation 1. **Add to `UserSettings`**: ```kotlin val allowRemoteControl: Boolean = false, val allowedRemoteDevices: List = emptyList(), // device IDs ``` 2. **Add toggle UI** in `PlaybackSection.kt`: - Use `SwitchSettingCard` for "Allow remote control" - Add a "Manage allowed devices" item that navigates to a sub-screen (see `Routes.Blacklist` pattern) 3. **Add string resources**: ```xml Allow Remote Control Let other devices on your network control playback ``` 4. **Android permissions** (dns-sd-kt requires these): ```xml ``` **Note**: dns-sd-kt uses `androidx.startup` to auto-initialize `Context` — no manual init needed. 5. **iOS Info.plist** (add to `iosApp/iosApp/Info.plist`): ```xml NSLocalNetworkUsageDescription Spotube needs access to your local network to discover and control other devices. NSBonjourServices _spotube-ctrl._tcp ``` **Note**: dns-sd-kt's Apple backend uses `NWBrowser` (Network.framework) + custom Swift bridge. The `NSBonjourServices` key 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 via `spm4kmp` - **Two-phase resolution**: `DiscoveryEvent.Discovered` → call `resolve()` → `DiscoveryEvent.Resolved` with addresses - **Auto-init on Android**: uses `androidx.startup` to grab `Context` #### 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 dependency - `composeApp/build.gradle.kts` — add to commonMain dependencies #### Implementation 1. **Add dns-sd-kt dependency** to `gradle/libs.versions.toml`: ```toml [versions] dns-sd-kt = "1.1.0" [libraries] dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dns-sd-kt" } ``` 2. **Add to `composeApp/build.gradle.kts`** in `commonMain.dependencies`: ```kotlin implementation(libs.dns.sd.kt) ``` 3. **Create `DeviceDiscoveryService`** in `commonMain` (no expect/actual needed — dns-sd-kt handles platform differences): ```kotlin class DeviceDiscoveryService { private val serviceType = "_spotube-ctrl._tcp" fun discoverDevices(): Flow = 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 } } ``` 4. **Usage in ViewModel**: ```kotlin // 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) ``` 5. **Register in Koin** in `Modules.kt`: ```kotlin single { DeviceDiscoveryService() } ``` 6. **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:` 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` — add `ktor-server-websockets`, `ktor-client-websockets` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — add WebSocket routes, conditional bind to `0.0.0.0` - `composeApp/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 messages - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — message definitions - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — wraps AudioPlayerInterface for remote control #### Implementation 1. **Add WebSocket dependencies** to `libs.versions.toml`: ```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" } ``` 2. **Extend `LocalServer.kt`**: - Add `RemoteControlHandler` constructor parameter - In `configureRoutes()`, install `WebSockets` plugin and add `/control` WebSocket route - In `restartServer()`, check `settings.allowRemoteControl`: - If enabled: bind to `0.0.0.0` (LAN-accessible) - If disabled: bind to `127.0.0.1` (local-only, current behavior) - Add a watcher that restarts the server when `allowRemoteControl` setting changes ```kotlin 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) } } } ``` 3. **Define protocol** in `RemoteControlProtocol.kt`: ```kotlin @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) : RemoteStateUpdate() } ``` 4. **Create `RemoteControlHandler`**: - Handles incoming WebSocket connections on the controlled device - Checks `settings.allowRemoteControl` before 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 `AudioPlayerInterface` and `AudioPlayerQueue` - Broadcasts state updates (player state, queue) to the connected controller 5. **Create `RemotePlayerProxy`**: - Wraps `AudioPlayerInterface` and `AudioPlayerQueue` on 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 `RemotePlayerState` StateFlow that mirrors remote state, and the UI uses it instead of `rememberPlayerUiState()`. 6. **Register in Koin** in `Modules.kt`: ```kotlin 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.kt` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` #### Files to Modify - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Devices` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Devices" button at bottom - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Devices" icon to TopAppBar actions #### Implementation 1. **Add route** to `NavigationModule.kt`: ```kotlin @Serializable data object Devices : Routes navigation { DevicesScreen(...) } ``` 2. **Add sidebar button** in `AppSidebar.kt` (after line 177): ```kotlin SidebarItem( title = "Devices", icon = Icons.Default.Devices, onClick = { navigator.navigate(Routes.Devices) } ) ``` 3. **Add TopAppBar action** in `HomeScreen.kt`: ```kotlin ApplicationMainBar( actions = { IconButton(onClick = { navigator.navigate(Routes.Devices) }) { Icon(Icons.Default.Devices, "Devices") } } ) ``` 4. **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` 5. **RemotePlayerScreen**: - Similar to `AppExpandedPlayer` but uses `RemotePlayerProxy` instead of local `AudioPlayerInterface` - All controls (play/pause/seek/volume/queue) forward to remote device ### 1.5 Connection Request Flow #### Files to Create - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/remote/ConnectionRequestDialog.kt` #### Implementation 1. When a new device tries to connect, `RemoteControlService` shows a dialog: ```kotlin 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") } } ) ``` 2. 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 connections - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt` — message definitions - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt` — syncs queue state #### Implementation 1. **Define protocol** in `JamProtocol.kt`: ```kotlin @Serializable sealed class JamMessage { // Host → Peers @Serializable data class QueueState(val queue: List, val currentIndex: Int) : JamMessage() @Serializable data class PlaybackCommand(val command: PlaybackCommand) : JamMessage() @Serializable data class ParticipantList(val participants: List) : 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) ``` 2. **Create `JamSessionService`** using the uniffi WebRTC API from Phase 0: ```kotlin class JamSessionService( private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayer: AudioPlayerInterface, ) { private var peerConnection: PeerConnectionWrapper? = null private val _participants = MutableStateFlow>(emptyList()) val participants: StateFlow> = _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) 3. **Create `QueueSyncManager`**: - On host: wraps `AudioPlayerQueue`, intercepts all queue mutations, broadcasts them via `JamSessionService.sendMessage()` - On guest: receives queue mutations, applies them to local queue - Handles conflict resolution (host authority: host's commands always win) 4. **Register in Koin**: ```kotlin 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 session - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — active session view - `composeApp/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` — add `Routes.Jam` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Group Jam" button - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Group Jam" icon to TopAppBar #### Implementation 1. **Add route**: ```kotlin @Serializable data object Jam : Routes navigation { JamScreen(...) } ``` 2. **Add sidebar button** (above "Devices"): ```kotlin SidebarItem( title = "Group Jam", icon = Icons.Default.Group, onClick = { navigator.navigate(Routes.Jam) } ) ``` 3. **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 - **Join Session**: - Text field to paste SDP offer - QR code scanner (optional) - Generates SDP answer and shows it for host to paste back 4. **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) 5. **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) ### 2.3 Play Interception #### Files to Modify - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt` - `composeApp/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 1. **Create `PlayDestinationPicker`**: ```kotlin @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 ) } ``` 2. **Modify ViewModels**: - In `PlaylistViewModel.playPlaylist()`, check if `JamSessionService.isActive` - If active, show `PlayDestinationPicker` instead of calling `playbackHelper.playPlaylist()` directly - If user chooses "Suggest to Jam", call `JamSessionService.suggestPlaylist(playlistId)` 3. **Apply same pattern** to `AlbumViewModel`, `ArtistScreen`, `SearchScreen` --- ## Phase 3: Deep-Link Handling (Optional Enhancement) ### Goal Allow users to open `spotube://jam/` 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 filter - `iosApp/iosApp/ContentView.swift` — add `onOpenURL` handler - `composeApp/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 1. **Android**: Add intent filter to `MainActivity`: ```xml ``` 2. **iOS**: Add `onOpenURL` in `ContentView.swift`: ```swift .onOpenURL { url in // Pass to Compose via a callback } ``` 3. **Desktop**: Parse `args` in `main.kt`: ```kotlin fun main(args: Array) { val deepLink = args.firstOrNull { it.startsWith("spotube://") } // Pass to Compose } ``` 4. **DeepLinkService**: Parse URL, navigate to `Routes.Jam(sessionId)` --- ## Implementation Order 1. **Phase 0**: Rust uniffi WebRTC module (foundation for Jam) 2. **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 3. **Phase 2**: Group Jam (WebRTC, manual SDP) - 2.1 Jam Session Service - 2.2 Jam Session UI - 2.3 Play Interception 4. **Phase 3**: Deep-Link Handling (optional, can be deferred) --- ## Key Files Summary ### Rust - `composeApp/Cargo.toml` — add `webrtc` dependency - `composeApp/src/commonMain/rust/lib.rs` — register `webrtc_p2p` module - `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API ### Settings - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` - `composeApp/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-kt - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — **NEW**: handles WebSocket control connections - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — **NEW**: message definitions - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — **NEW**: remote player state proxy - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` — **NEW** - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` — **NEW** - `composeApp/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` — **NEW** - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — **NEW** - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — **NEW** ### Navigation & UI - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` - `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` ### Permissions - `composeApp/src/androidMain/AndroidManifest.xml` - `iosApp/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 1. **Unit tests**: Test protocol serialization, queue sync logic 2. **Integration tests**: Test WebSocket server/client, DNS-SD discovery 3. **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 4. **Cross-platform tests**: Verify on Android, iOS, JVM desktop (Linux/Windows/macOS)