From 6fabc876a547c24bc1f73ffadfbaf09bc150e98b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Sep 2026 00:04:10 +0600 Subject: [PATCH] chore: remove residual plans --- .opencode/plans/jovial-hopping-hare.md | 821 ------------------------- 1 file changed, 821 deletions(-) delete mode 100644 .opencode/plans/jovial-hopping-hare.md diff --git a/.opencode/plans/jovial-hopping-hare.md b/.opencode/plans/jovial-hopping-hare.md deleted file mode 100644 index bad5a743..00000000 --- a/.opencode/plans/jovial-hopping-hare.md +++ /dev/null @@ -1,821 +0,0 @@ -# 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)