From d3a8548661fe41ca9d0e647a1c09ad43e607250f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 21 Aug 2026 19:50:09 +0600 Subject: [PATCH] feat(devices): add device discovery and remote control features --- .opencode/plans/jovial-hopping-hare.md | 821 ++++++++ composeApp/Cargo.lock | 1832 ++++++++++++++++- composeApp/Cargo.toml | 9 +- composeApp/build.gradle.kts | 6 + .../src/androidMain/AndroidManifest.xml | 8 +- .../composeResources/values/strings.xml | 7 + .../dev/krtirtho/spotube/core/di/Modules.kt | 14 + .../core/discovery/DeviceDiscoveryService.kt | 100 + .../krtirtho/spotube/core/jam/JamProtocol.kt | 178 ++ .../spotube/core/jam/JamSessionService.kt | 316 +++ .../spotube/core/jam/QueueSyncManager.kt | 162 ++ .../core/navigation/NavigationModule.kt | 14 + .../core/navigation/NavigationState.kt | 2 + .../core/remote/ConnectionRequestDialog.kt | 91 + .../core/remote/RemoteControlHandler.kt | 262 +++ .../core/remote/RemoteControlProtocol.kt | 113 + .../core/remote/RemoteControlService.kt | 104 + .../spotube/core/server/LocalServer.kt | 45 +- .../spotube/modules/devices/DevicesScreen.kt | 181 ++ .../modules/devices/DevicesViewModel.kt | 79 + .../spotube/modules/home/HomeScreen.kt | 25 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 227 ++ .../spotube/modules/jam/JamViewModel.kt | 85 + .../modules/jam/PlayDestinationPicker.kt | 63 + .../modules/settings/SettingsModels.kt | 9 + .../settings/sections/PlaybackSection.kt | 41 + .../spotube/modules/shell/AppShell.kt | 3 + .../spotube/modules/shell/AppSidebar.kt | 19 + composeApp/src/commonMain/rust/lib.rs | 4 +- composeApp/src/commonMain/rust/webrtc_p2p.rs | 289 +++ gradle/libs.versions.toml | 4 + iosApp/iosApp/Info.plist | 7 + 32 files changed, 5096 insertions(+), 24 deletions(-) create mode 100644 .opencode/plans/jovial-hopping-hare.md create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt create mode 100644 composeApp/src/commonMain/rust/webrtc_p2p.rs diff --git a/.opencode/plans/jovial-hopping-hare.md b/.opencode/plans/jovial-hopping-hare.md new file mode 100644 index 00000000..bad5a743 --- /dev/null +++ b/.opencode/plans/jovial-hopping-hare.md @@ -0,0 +1,821 @@ +# 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) diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 5367c7ae..428f5064 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -8,6 +8,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -56,12 +77,133 @@ dependencies = [ "winnow", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive 0.6.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-toml" version = "0.1.10" @@ -71,12 +213,65 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -118,7 +313,29 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", ] [[package]] @@ -127,13 +344,98 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "compose-app" version = "0.1.0" dependencies = [ + "async-trait", + "bytes", "discord-rich-presence", "lofty", + "parking_lot", + "rtc", + "thiserror 2.0.18", + "tokio", "uniffi", + "webrtc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", ] [[package]] @@ -145,12 +447,83 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs 0.7.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "discord-rich-presence" version = "1.1.0" @@ -162,8 +535,19 @@ dependencies = [ "serde_derive", "serde_json", "serde_repr", - "thiserror", - "uuid", + "thiserror 2.0.18", + "uuid 0.8.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", ] [[package]] @@ -179,7 +563,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", ] [[package]] @@ -188,6 +592,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "flate2" version = "1.1.9" @@ -198,6 +608,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs-err" version = "2.11.0" @@ -207,6 +626,104 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -227,6 +744,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", ] [[package]] @@ -258,6 +776,125 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -268,12 +905,38 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -286,6 +949,21 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "lofty" version = "0.24.0" @@ -318,12 +996,31 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -340,6 +1037,50 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -350,6 +1091,40 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "ogg_pager" version = "0.7.2" @@ -359,30 +1134,108 @@ dependencies = [ "byteorder", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.2", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -392,6 +1245,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "quinn-udp" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" +dependencies = [ + "cfg_aliases", + "libc", + "log", + "socket2", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -407,25 +1293,434 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid 1.24.1", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "rtc" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "hex", + "log", + "pem", + "rand", + "rcgen", + "rtc-crypto", + "rtc-datachannel", + "rtc-dtls", + "rtc-ice", + "rtc-interceptor", + "rtc-mdns", + "rtc-media", + "rtc-rtcp", + "rtc-rtp", + "rtc-sctp", + "rtc-sdp", + "rtc-shared", + "rtc-srtp", + "rtc-stun", + "rtc-turn", + "rustls", + "sansio", + "serde", + "serde_json", + "unicase", + "url", + "x509-parser 0.16.0", +] + +[[package]] +name = "rtc-crypto" +version = "0.21.0-beta.1" +dependencies = [ + "aes", + "ccm", + "ctr", + "hmac", + "md-5", + "rand", + "ring", + "sha1", + "subtle", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "rtc-datachannel" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "log", + "rtc-sctp", + "rtc-shared", + "sansio", +] + +[[package]] +name = "rtc-dtls" +version = "0.21.0-beta.1" +dependencies = [ + "bytecheck", + "byteorder", + "bytes", + "der-parser 9.0.0", + "log", + "pem", + "rcgen", + "rkyv", + "rtc-crypto", + "rtc-shared", + "rustls", + "x509-parser 0.16.0", +] + +[[package]] +name = "rtc-ice" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "crc", + "log", + "rtc-crypto", + "rtc-mdns", + "rtc-shared", + "rtc-stun", + "sansio", + "serde", + "url", + "uuid 1.24.1", +] + +[[package]] +name = "rtc-interceptor" +version = "0.21.0-beta.1" +dependencies = [ + "log", + "rand", + "rtc-rtcp", + "rtc-rtp", + "rtc-shared", + "sansio", +] + +[[package]] +name = "rtc-mdns" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "log", + "rtc-shared", + "sansio", + "socket2", +] + +[[package]] +name = "rtc-media" +version = "0.21.0-beta.1" +dependencies = [ + "byteorder", + "bytes", + "rand", + "rtc-rtp", + "rtc-shared", + "thiserror 2.0.18", +] + +[[package]] +name = "rtc-rtcp" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "rtc-shared", +] + +[[package]] +name = "rtc-rtp" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "memchr", + "rand", + "rtc-shared", + "serde", +] + +[[package]] +name = "rtc-sctp" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "crc32c", + "log", + "rand", + "rtc-shared", + "rustc-hash", + "slab", + "thiserror 2.0.18", +] + +[[package]] +name = "rtc-sdp" +version = "0.21.0-beta.1" +dependencies = [ + "rand", + "rtc-shared", + "url", +] + +[[package]] +name = "rtc-shared" +version = "0.21.0-beta.1" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "nix", + "rand", + "serde", + "substring", + "thiserror 2.0.18", + "url", + "winapi", +] + +[[package]] +name = "rtc-srtp" +version = "0.21.0-beta.1" +dependencies = [ + "byteorder", + "bytes", + "rtc-crypto", + "rtc-rtcp", + "rtc-rtp", + "rtc-shared", +] + +[[package]] +name = "rtc-stun" +version = "0.21.0-beta.1" +dependencies = [ + "base64", + "bytes", + "crc", + "lazy_static", + "rand", + "rtc-crypto", + "rtc-shared", + "sansio", + "url", +] + +[[package]] +name = "rtc-turn" +version = "0.21.0-beta.1" +dependencies = [ + "bytes", + "log", + "rtc-crypto", + "rtc-shared", + "rtc-stun", + "sansio", +] + [[package]] name = "rustc-hash" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "sansio" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -510,30 +1805,96 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "smawk" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -556,6 +1917,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -566,7 +1938,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -578,13 +1950,33 @@ dependencies = [ "smawk", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -598,6 +1990,86 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "toml" version = "0.5.11" @@ -607,6 +2079,18 @@ dependencies = [ "serde", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -660,6 +2144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" dependencies = [ "anyhow", + "async-compat", "bytes", "once_cell", "static_assertions", @@ -732,6 +2217,30 @@ dependencies = [ "weedle2", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "0.8.2" @@ -741,12 +2250,90 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webrtc" +version = "0.21.0-beta.1" +dependencies = [ + "async-broadcast", + "async-channel", + "async-trait", + "bytes", + "event-listener", + "futures", + "log", + "quinn-udp", + "rtc", + "tokio", +] + [[package]] name = "weedle2" version = "5.0.0" @@ -756,12 +2343,43 @@ dependencies = [ "nom", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -771,6 +2389,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -780,6 +2462,140 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs 0.7.2", + "data-encoding", + "der-parser 10.0.0", + "lazy_static", + "nom", + "oid-registry 0.8.1", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 4357cc68..68dbc246 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -4,9 +4,16 @@ version = "0.1.0" edition = "2024" [dependencies] -uniffi = "0.29.4" +uniffi = { version = "0.29.4", features = ["tokio"] } lofty = "0.24.0" discord-rich-presence = "1.1.0" +thiserror = "2.0" +parking_lot = "0.12" +webrtc = { path = "../build/webrtc-rs" } +rtc = { path = "../build/webrtc-rs/rtc" } +async-trait = "0.1" +bytes = "1" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } [lib] crate-type = ["cdylib", "staticlib"] diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 34b20dd2..54122dfd 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -86,6 +86,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) + implementation(libs.dns.sd.kt) // Navigation implementation(libs.jetbrains.navigation3.ui) @@ -104,8 +105,10 @@ kotlin { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.websockets) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.websockets) // Zipline api(libs.zipline.core) implementation(libs.zipline.loader) @@ -160,6 +163,9 @@ kotlin { // Shimmer effect implementation(libs.compose.shimmer) implementation(libs.compose.placeholder.material3) + + // DLNA + implementation(libs.dns.sd.kt) } } commonTest.dependencies { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 1ee6b8ae..9e832592 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -16,13 +16,19 @@ ~ along with this program. If not, see . --> - + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 010d4259..56cd595c 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -96,6 +96,13 @@ Enable Connect Expose remote playback controls through the Spotube Connect feature. Playback proxy server port + Allow remote control + Let other devices on the same network control playback when they connect. + Remote control device name + The name other devices see when discovering this one. Current: %1$s + Choose a friendly name to show to other devices on your network when they discover this Spotube instance. + e.g. Living Room Spotube + device hostname Port used by the playback proxy server. Current: %1$d Choose a port between 1 and 65535. 14769 diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 665f351b..1a69b4e4 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -23,8 +23,12 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository import dev.krtirtho.spotube.core.db.Database +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService +import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.navigation.navigationModule +import dev.krtirtho.spotube.core.remote.RemoteControlHandler +import dev.krtirtho.spotube.core.remote.RemoteControlService import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager @@ -38,6 +42,8 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.artist.ArtistViewModel import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel +import dev.krtirtho.spotube.modules.devices.DevicesViewModel +import dev.krtirtho.spotube.modules.jam.JamViewModel import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel import dev.krtirtho.spotube.modules.home.HomeScreenRepository @@ -168,6 +174,8 @@ val sharedModules = module { // Blacklist singleOf(::BlacklistRepository) viewModelOf(::BlacklistViewModel) + viewModelOf(::DevicesViewModel) + viewModelOf(::JamViewModel) // Album singleOf(::AlbumRepository) @@ -205,6 +213,12 @@ val sharedModules = module { singleOf(::LocalServer) withOptions { createdAtStart() } + single { RemoteControlHandler(get(), get(), get()) } + singleOf(::DeviceDiscoveryService) + single { RemoteControlService(get(), get(), get()) } withOptions { + createdAtStart() + } + single { JamSessionService(get(), get()) } singleOf(::AudioPlayerQueueRepository) { bind() } single { DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt new file mode 100644 index 00000000..7c5d878b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.discovery + +import com.appstractive.dnssd.DiscoveryEvent +import com.appstractive.dnssd.NetService +import com.appstractive.dnssd.createNetService +import com.appstractive.dnssd.discoverServices +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +data class DiscoveredDevice( + val name: String, + val type: String, + val host: String, + val port: Int, + val deviceId: String, +) { + val key: String get() = "$name$type".replace(".", "") +} + +sealed interface DiscoveryState { + data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState + data class Resolved(val device: DiscoveredDevice) : DiscoveryState + data class Removed(val device: DiscoveredDevice) : DiscoveryState +} + +class DeviceDiscoveryService { + companion object { + const val SERVICE_TYPE = "_spotube-ctrl._tcp" + const val TXT_DEVICE_ID = "deviceId" + } + + fun discover(): Flow = discoverServices(SERVICE_TYPE).map { event -> + when (event) { + is DiscoveryEvent.Discovered -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + ) + DiscoveryState.Discovered(device = device, resolve = event.resolve) + } + + is DiscoveryEvent.Resolved -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + ) + DiscoveryState.Resolved(device = device) + } + + is DiscoveryEvent.Removed -> { + val device = DiscoveredDevice( + name = event.service.name, + type = event.service.type, + host = event.service.host, + port = event.service.port, + deviceId = "", + ) + DiscoveryState.Removed(device = device) + } + } + } + + suspend fun advertise( + name: String, + port: Int, + deviceId: String, + ): NetService { + val service = createNetService( + type = SERVICE_TYPE, + name = name, + port = port, + txt = mapOf(TXT_DEVICE_ID to deviceId), + ) + service.register() + return service + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt new file mode 100644 index 00000000..ee8808c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.jam + +import dev.krtirtho.spotube.core.audioplayer.LoopState +import dev.krtirtho.spotube.core.audioplayer.MediaItem +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class JamMessage { + @Serializable + @SerialName("hello") + data class Hello( + val displayName: String, + val deviceId: String, + ) : JamMessage() + + @Serializable + @SerialName("welcome") + data class Welcome( + val hostName: String, + val participantId: String, + ) : JamMessage() + + @Serializable + @SerialName("queueState") + data class QueueState( + val items: List, + val currentIndex: Int, + val isPlaying: Boolean, + val positionMs: Long, + ) : JamMessage() + + @Serializable + @SerialName("playbackCommand") + data class PlaybackCommand( + val command: PlaybackCmd, + ) : JamMessage() + + @Serializable + @SerialName("suggestTrack") + data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage() + + @Serializable + @SerialName("suggestPlaylist") + data class SuggestPlaylist(val tracks: List) : JamMessage() + + @Serializable + @SerialName("chat") + data class Chat( + val fromName: String, + val text: String, + ) : JamMessage() + + @Serializable + @SerialName("participantList") + data class ParticipantList(val participants: List) : JamMessage() + + @Serializable + @SerialName("leave") + data class Leave(val reason: String = "user_left") : JamMessage() +} + +@Serializable +sealed class PlaybackCmd { + @Serializable + @SerialName("play") + data object Play : PlaybackCmd() + + @Serializable + @SerialName("pause") + data object Pause : PlaybackCmd() + + @Serializable + @SerialName("toggle") + data object Toggle : PlaybackCmd() + + @Serializable + @SerialName("seek") + data class Seek(val positionMs: Long) : PlaybackCmd() + + @Serializable + @SerialName("skipNext") + data object SkipNext : PlaybackCmd() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : PlaybackCmd() + + @Serializable + @SerialName("setVolume") + data class SetVolume(val volume: Float) : PlaybackCmd() + + @Serializable + @SerialName("setLoop") + data class SetLoop(val loop: String) : PlaybackCmd() + + @Serializable + @SerialName("setShuffle") + data class SetShuffle(val enabled: Boolean) : PlaybackCmd() + + @Serializable + @SerialName("jumpTo") + data class JumpTo(val index: Int) : PlaybackCmd() +} + +@Serializable +data class JamMediaItem( + val url: String, + val title: String, + val artist: String, + val album: String, + val durationMs: Long, + val coverUrl: String, + val protocol: String, +) { + companion object { + fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( + url = item.url, + title = item.title, + artist = item.artist, + album = item.album, + durationMs = item.duration.inWholeMilliseconds, + coverUrl = item.coverURL, + protocol = item.protocol.name, + ) + + fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( + title = item.title, + artist = item.artist, + album = item.album, + duration = kotlin.time.Duration.parse("${item.durationMs}ms"), + coverURL = item.coverUrl, + url = item.url, + protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol + .valueOf(item.protocol), + ) + } +} + +@Serializable +data class JamParticipant( + val id: String, + val displayName: String, + val isHost: Boolean, +) + +@Serializable +enum class JamRole { + Host, + Guest, +} + +object JamLoopMapping { + fun toString(state: LoopState): String = state.name.lowercase() + fun fromString(value: String): LoopState = when (value.lowercase()) { + "none" -> LoopState.NONE + "one" -> LoopState.ONE + "all" -> LoopState.ALL + else -> LoopState.NONE + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt new file mode 100644 index 00000000..f90a91a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.jam + +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.settings.SettingsProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent +import uniffi.compose_app.IceServerConfig +import uniffi.compose_app.WebrtcEventHandler +import uniffi.compose_app.WebrtcPeerConnection +import uniffi.compose_app.createWebrtcPeerConnection + +class JamSessionService( + private val audioPlayer: AudioPlayerInterface, + private val settingsProvider: SettingsProvider, +) : KoinComponent { + val logger by injectLogger() + private val log = Logger.withTag("JamSessionService") + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val _role = MutableStateFlow(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _isActive = MutableStateFlow(false) + val isActive: StateFlow = _isActive.asStateFlow() + + private val _localParticipantId = MutableStateFlow(null) + val localParticipantId: StateFlow = _localParticipantId.asStateFlow() + + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) + val incomingMessages = _incomingMessages.asSharedFlow() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _incomingSuggestions = MutableSharedFlow(extraBufferCapacity = 32) + val incomingSuggestions = _incomingSuggestions.asSharedFlow() + + private var hostConnection: WebrtcPeerConnection? = null + private val guestConnections = mutableMapOf() + private val guestLabels = mutableMapOf() + + private val eventHandler = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) { + // No-op in non-trickle mode: candidates are bundled into SDP + } + + override fun onIceGatheringStateChange(state: String) { + log.d { "ICE gathering state: $state" } + } + + override fun onConnectionStateChange(state: String) { + log.i { "Connection state: $state" } + } + + override fun onDataChannelOpen(label: String) { + log.i { "Data channel '$label' open" } + } + + override fun onDataChannelMessage(label: String, data: String) { + handleIncomingMessage(label, data) + } + + override fun onDataChannelClose(label: String) { + log.i { "Data channel '$label' closed" } + } + } + + suspend fun createSession(): String { + log.i { "Creating jam session" } + val settings = settingsProvider.settingsState.first() + val participantName = settings?.jamParticipantName?.ifBlank { + "Host-${randomShortId()}" + } ?: "Host" + + val pc = createWebrtcPeerConnection( + iceServers = listOf( + IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + ), + handler = eventHandler, + ) + + hostConnection = pc + _role.value = JamRole.Host + _localParticipantId.value = "host" + _participants.value = listOf( + JamParticipant( + id = "host", + displayName = participantName, + isHost = true, + ) + ) + _isActive.value = true + + pc.createDataChannel("jam") + val offer = pc.createOffer() + log.i { "Generated SDP offer (length=${offer.length})" } + return offer + } + + suspend fun acceptGuestAnswer(guestId: String, answer: String) { + val pc = guestConnections[guestId] ?: run { + log.w { "acceptGuestAnswer: no connection for $guestId" } + return + } + pc.setRemoteAnswer(answer) + } + + suspend fun joinSession(offer: String): String { + log.i { "Joining jam session" } + val settings = settingsProvider.settingsState.first() + val participantName = settings?.jamParticipantName?.ifBlank { + "Guest-${randomShortId()}" + } ?: "Guest" + + val pc = createWebrtcPeerConnection( + iceServers = listOf( + IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + ), + handler = eventHandler, + ) + + hostConnection = pc + _role.value = JamRole.Guest + _localParticipantId.value = "guest" + _isActive.value = true + + pc.setRemoteOffer(offer) + pc.createDataChannel("jam") + val answer = pc.createAnswer() + log.i { "Generated SDP answer (length=${answer.length})" } + return answer + } + + suspend fun hostAdmitGuest(guestOffer: String): String { + if (_role.value != JamRole.Host) { + error("hostAdmitGuest can only be called by the host") + } + val guestId = "guest-${guestConnections.size + 1}" + log.i { "Admitting guest $guestId" } + + val handler = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) {} + override fun onIceGatheringStateChange(state: String) {} + override fun onConnectionStateChange(state: String) {} + override fun onDataChannelOpen(label: String) {} + override fun onDataChannelMessage(label: String, data: String) { + handleIncomingMessage(label, data, guestId) + } + override fun onDataChannelClose(label: String) {} + } + + val pc = createWebrtcPeerConnection( + iceServers = listOf( + IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + ), + handler = handler, + ) + guestConnections[guestId] = pc + guestLabels[guestId] = "jam-$guestId" + + pc.setRemoteOffer(guestOffer) + pc.createDataChannel("jam-${guestId}") + val answer = pc.createAnswer() + return answer + } + + suspend fun sendMessage(message: JamMessage, guestId: String? = null) { + val json = json.encodeToString(JamMessage.serializer(), message) + when (_role.value) { + JamRole.Host -> { + if (guestId != null) { + guestConnections[guestId]?.sendData("jam-$guestId", json) + } else { + guestConnections.forEach { (id, pc) -> + pc.sendData("jam-$id", json) + } + } + } + + JamRole.Guest -> { + hostConnection?.sendData("jam", json) + } + + null -> log.w { "sendMessage called while no session is active" } + } + } + + suspend fun leave() { + log.i { "Leaving jam session" } + runCatching { sendMessage(JamMessage.Leave()) } + hostConnection?.shutdown() + guestConnections.values.forEach { runCatching { it.shutdown() } } + hostConnection = null + guestConnections.clear() + guestLabels.clear() + _role.value = null + _participants.value = emptyList() + _isActive.value = false + _localParticipantId.value = null + } + + private fun handleIncomingMessage(label: String, data: String, fromGuestId: String? = null) { + try { + val message = json.decodeFromString(JamMessage.serializer(), data) + _incomingMessages.tryEmit(message) + when (message) { + is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> { + _incomingSuggestions.tryEmit(message) + } + + is JamMessage.Leave -> { + if (_role.value == JamRole.Host && fromGuestId != null) { + val leavingPc = guestConnections.remove(fromGuestId) + guestLabels.remove(fromGuestId) + scope.launch { + runCatching { leavingPc?.shutdown() } + } + _participants.update { current -> + current.filterNot { it.id == fromGuestId } + } + } + } + + else -> Unit + } + } catch (e: Exception) { + log.w(e) { "Failed to parse jam message on $label" } + } + } + + suspend fun broadcastPlaybackCommand(command: PlaybackCmd) { + if (_role.value != JamRole.Host) return + sendMessage(JamMessage.PlaybackCommand(command)) + } + + suspend fun broadcastQueueState( + items: List, + currentIndex: Int, + isPlaying: Boolean, + positionMs: Long, + ) { + if (_role.value != JamRole.Host) return + sendMessage(JamMessage.QueueState(items, currentIndex, isPlaying, positionMs)) + } + + suspend fun suggestTrack(mediaItem: JamMediaItem) { + if (_role.value != JamRole.Guest) return + sendMessage(JamMessage.SuggestTrack(mediaItem)) + } + + suspend fun suggestPlaylist(tracks: List) { + if (_role.value != JamRole.Guest) return + sendMessage(JamMessage.SuggestPlaylist(tracks)) + } +} + +private fun MutableStateFlow.update(transform: (T) -> T) { + value = transform(value) +} + +private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt new file mode 100644 index 00000000..550de713 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.jam + +import co.touchlab.kermit.Logger +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.coroutines.flow.first + +/** + * Manages queue synchronization between the host and the jam session. + * + * On the host: observes local playback state and broadcasts queue updates to guests. + * On the guest: receives queue updates and applies them to local playback. + * + * Conflict resolution: the host has authority. When a guest receives a queue state, + * it replaces the local queue. (and (The guest's local queue is essentially read-only + * during a jam session.) + */ +class QueueSyncManager( + private val audioPlayer: AudioPlayerInterface, + private val jamSession: JamSessionService, + private val scope: CoroutineScope, +) { + private val log = Logger.withTag("QueueSyncManager") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing.asStateFlow() + + private var hostBroadcastJob: Job? = null + private var guestApplyJob: Job? = null + private var guestCommandJob: Job? = null + + fun start() { + if (_isSyncing.value) return + _isSyncing.value = true + + when (jamSession.role.value) { + JamRole.Host -> startHostSync() + JamRole.Guest -> startGuestSync() + null -> { + _isSyncing.value = false + return + } + } + } + + fun stop() { + _isSyncing.value = false + hostBroadcastJob?.cancel() + guestApplyJob?.cancel() + guestCommandJob?.cancel() + hostBroadcastJob = null + guestApplyJob = null + guestCommandJob = null + } + + private fun startHostSync() { + hostBroadcastJob = scope.launch { + jamSession.role.first { it != null } + if (jamSession.role.value != JamRole.Host) return@launch + + jamSession.broadcastQueueState( + items = audioPlayer.playlistFlow.value.map(JamMediaItem::fromMediaItem), + currentIndex = audioPlayer.playlistFlow.value.indexOf( + audioPlayer.currentMediaItemFlow.value + ).coerceAtLeast(0), + isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + ) + + audioPlayer.playlistFlow.collect { playlist -> + audioPlayer.playerStateFlow.value.let { state -> + audioPlayer.positionFlow.value.let { position -> + jamSession.broadcastQueueState( + items = playlist.map(JamMediaItem::fromMediaItem), + currentIndex = playlist.indexOf(audioPlayer.currentMediaItemFlow.value) + .coerceAtLeast(0), + isPlaying = state == PlayerState.PLAYING, + positionMs = position.inWholeMilliseconds, + ) + } + } + } + } + } + + private fun startGuestSync() { + guestApplyJob = scope.launch { + jamSession.incomingMessages.collect { message -> + if (message !is JamMessage.QueueState) return@collect + applyQueueState(message) + } + } + + guestCommandJob = scope.launch { + jamSession.incomingMessages.collect { message -> + if (message !is JamMessage.PlaybackCommand) return@collect + applyPlaybackCommand(message.command) + } + } + } + + private suspend fun applyQueueState(state: JamMessage.QueueState) { + log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" } + val mediaItems = state.items.map(JamMediaItem::toMediaItem) + audioPlayer.load( + playlist = mediaItems, + autoPlay = state.isPlaying, + startPosition = state.currentIndex.coerceAtLeast(0), + ) + } + + private suspend fun applyPlaybackCommand(command: PlaybackCmd) { + log.d { "Applying playback command: $command" } + when (command) { + PlaybackCmd.Play -> audioPlayer.play() + PlaybackCmd.Pause -> audioPlayer.pause() + PlaybackCmd.Toggle -> { + if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + PlaybackCmd.SkipNext -> audioPlayer.skipToNext() + PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() + is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) + is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) + is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) + is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt index d048c1d8..79f26337 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt @@ -21,6 +21,8 @@ import androidx.navigation3.runtime.NavKey import dev.krtirtho.spotube.modules.album.AlbumScreen import dev.krtirtho.spotube.modules.artist.ArtistScreen import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen +import dev.krtirtho.spotube.modules.devices.DevicesScreen +import dev.krtirtho.spotube.modules.jam.JamScreen import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.library.LibraryScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen @@ -75,6 +77,12 @@ sealed interface Routes : NavKey { @Serializable data object Blacklist : Routes + + @Serializable + data object Devices : Routes + + @Serializable + data object Jam : Routes } @OptIn(KoinExperimentalAPI::class) @@ -148,5 +156,11 @@ val navigationModule = module { navigation { BlacklistScreen() } + navigation { + DevicesScreen(navigationCommands = get()) + } + navigation { + JamScreen(navigationCommands = get()) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt index 7ef24dec..3f0ced21 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationState.kt @@ -61,6 +61,8 @@ val serializersConfig = SavedStateConfiguration { subclass(Routes.Artist::class, Routes.Artist.serializer()) subclass(Routes.Album::class, Routes.Album.serializer()) subclass(Routes.Blacklist::class, Routes.Blacklist.serializer()) + subclass(Routes.Devices::class, Routes.Devices.serializer()) + subclass(Routes.Jam::class, Routes.Jam.serializer()) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt new file mode 100644 index 00000000..cdda53c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/ConnectionRequestDialog.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.remote + +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.style.TextAlign +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import org.koin.compose.koinInject + +@Composable +fun ConnectionRequestDialogHost() { + val handler: RemoteControlHandler = koinInject() + var pendingRequest by remember { mutableStateOf(null) } + + LaunchedEffect(handler) { + handler.incomingConnectionRequests.collect { request -> + pendingRequest = request + } + } + + pendingRequest?.let { request -> + AdaptiveDialogBottomSheet( + onDismiss = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + title = { + Text( + text = "Remote Control Request", + style = MaterialTheme.typography.titleLarge, + ) + }, + content = { + Text( + text = "\"${request.deviceName}\" wants to control playback on this device.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + ) + }, + actions = { + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) + pendingRequest = null + }, + ) { + Text("Deny") + } + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) + pendingRequest = null + }, + ) { + Text("Allow") + } + Button( + onClick = { + handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) + pendingRequest = null + }, + ) { + Text("Allow Always") + } + }, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt new file mode 100644 index 00000000..15f24636 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.remote + +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.LoopState +import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import io.ktor.server.websocket.WebSocketServerSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlin.coroutines.resume +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent + +class RemoteControlHandler( + private val settingsRepository: SettingsRepository, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, +) : KoinComponent { + val logger by injectLogger() + + val incomingConnectionRequests = MutableSharedFlow( + extraBufferCapacity = 16, + ) + + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val pendingRequestResolutions = mutableMapOf Unit>() + + suspend fun handleConnection(session: WebSocketServerSession) { + val settings = settingsRepository.userSettings.first() + if (!settings.allowRemoteControl) { + logger.w { "Rejecting remote control connection: remote control is disabled" } + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Remote control is disabled")) + return + } + + val deviceId = session.call.request.headers["X-Device-Id"] + val deviceName = session.call.request.headers["X-Device-Name"] ?: "Unknown" + + val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices + + if (!isAllowed) { + val request = ConnectionRequest( + deviceId = deviceId ?: "unknown", + deviceName = deviceName, + sessionId = session.call.request.headers["X-Request-Id"] + ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}", + ) + incomingConnectionRequests.emit(request) + val response = waitForRequestResolution(request.sessionId) + if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { + session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Connection denied")) + return + } + if (response == ConnectionRequestResponse.AllowAlways && deviceId != null) { + settingsRepository.updateSettings( + settings.copy( + allowedRemoteDevices = (settings.allowedRemoteDevices + deviceId).distinct() + ) + ) + logger.i { "Device $deviceId added to always-allowed devices" } + } + } + + logger.i { "Remote control connection established from $deviceName ($deviceId)" } + + try { + handleControlLoop(session) + } catch (e: Exception) { + logger.w(e) { "Error in remote control session" } + } finally { + session.close() + } + } + + fun resolveConnectionRequest(sessionId: String, response: ConnectionRequestResponse) { + pendingRequestResolutions.remove(sessionId)?.invoke(response) + } + + private suspend fun waitForRequestResolution(sessionId: String): ConnectionRequestResponse { + return suspendCancellableCoroutine { continuation -> + pendingRequestResolutions[sessionId] = { response -> + if (continuation.isActive) { + continuation.resume(response) + } + } + continuation.invokeOnCancellation { + pendingRequestResolutions.remove(sessionId) + } + } + } + + private suspend fun handleControlLoop(session: WebSocketServerSession) { + for (frame in session.incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + try { + val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) + handleCommand(session, envelope) + } catch (e: Exception) { + logger.w(e) { "Failed to parse remote control command" } + sendError(session, "Invalid command: ${e.message}") + } + } + } + } + + private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { + when (val command = envelope.command) { + is RemoteControlCommand.Play -> { + logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" } + } + is RemoteControlCommand.Pause -> { + audioPlayer.pause() + } + is RemoteControlCommand.TogglePlayPause -> { + if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) { + audioPlayer.pause() + } else { + audioPlayer.play() + } + } + is RemoteControlCommand.Seek -> { + audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) + } + is RemoteControlCommand.SetVolume -> { + audioPlayer.setVolume(command.volume) + } + is RemoteControlCommand.SkipNext -> { + audioPlayer.skipToNext() + } + is RemoteControlCommand.SkipPrevious -> { + audioPlayer.skipToPrevious() + } + is RemoteControlCommand.SetShuffle -> { + audioPlayer.shuffle(command.enabled) + } + is RemoteControlCommand.SetLoopMode -> { + val loopState = when (command.mode) { + "none" -> LoopState.NONE + "one" -> LoopState.ONE + "all" -> LoopState.ALL + else -> { + sendError(session, "Invalid loop mode: ${command.mode}") + return + } + } + audioPlayer.loop(loopState) + } + is RemoteControlCommand.AddToQueue -> { + logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } + } + is RemoteControlCommand.RemoveFromQueue -> { + audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl) + } + } + sendAck(session, envelope.commandId) + broadcastState(session) + } + + private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { + val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId)) + session.send(Frame.Text(text)) + } + + private suspend fun sendError(session: WebSocketServerSession, message: String) { + val text = json.encodeToString(RemoteControlEvent.Error.serializer(), RemoteControlEvent.Error(message)) + session.send(Frame.Text(text)) + } + + private suspend fun broadcastState(session: WebSocketServerSession) { + val current = audioPlayerQueue.currentQueueEntryFlow.value + val state = RemoteControlEvent.PlayerState( + isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds, + volume = audioPlayer.volumeFlow.value, + shuffleEnabled = audioPlayer.shuffleModeFlow.value, + loopMode = audioPlayer.loopStateFlow.value.name.lowercase(), + currentTrackId = current?.mediaKey(), + currentTrackTitle = current?.titleOrNull(), + currentTrackArtists = current?.artistsOrNull(), + currentTrackAlbum = current?.albumOrNull(), + currentTrackCoverUrl = current?.coverUrlOrNull(), + ) + val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.mediaKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> url + } + + private fun QueueEntry.titleOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.title + is QueueEntry.LocalTrack -> name + } + + private fun QueueEntry.artistsOrNull(): String = when (this) { + is QueueEntry.StreamingTrack -> track.artists.joinToString(", ") { artist -> artist.name } + is QueueEntry.LocalTrack -> artists.joinToString(", ") + } + + private fun QueueEntry.albumOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.album?.title + is QueueEntry.LocalTrack -> album + } + + private fun QueueEntry.coverUrlOrNull(): String? = when (this) { + is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url + is QueueEntry.LocalTrack -> null + } +} + +@Serializable +data class CommandEnvelope( + val commandId: String, + val command: RemoteControlCommand, +) + +data class ConnectionRequest( + val deviceId: String, + val deviceName: String, + val sessionId: String, +) + +enum class ConnectionRequestResponse { + Allow, + AllowAlways, + Deny, +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt new file mode 100644 index 00000000..b9cacd5c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.remote + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class RemoteControlCommand { + @Serializable + @SerialName("play") + data class Play(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("pause") + data object Pause : RemoteControlCommand() + + @Serializable + @SerialName("togglePlayPause") + data object TogglePlayPause : RemoteControlCommand() + + @Serializable + @SerialName("seek") + data class Seek(val positionMs: Long) : RemoteControlCommand() + + @Serializable + @SerialName("setVolume") + data class SetVolume(val volume: Float) : RemoteControlCommand() + + @Serializable + @SerialName("skipNext") + data object SkipNext : RemoteControlCommand() + + @Serializable + @SerialName("skipPrevious") + data object SkipPrevious : RemoteControlCommand() + + @Serializable + @SerialName("setShuffle") + data class SetShuffle(val enabled: Boolean) : RemoteControlCommand() + + @Serializable + @SerialName("setLoopMode") + data class SetLoopMode(val mode: String) : RemoteControlCommand() + + @Serializable + @SerialName("addToQueue") + data class AddToQueue(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("removeFromQueue") + data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() +} + +@Serializable +sealed class RemoteControlEvent { + @Serializable + @SerialName("playerState") + data class PlayerState( + val isPlaying: Boolean, + val positionMs: Long, + val durationMs: Long, + val volume: Float, + val shuffleEnabled: Boolean, + val loopMode: String, + val currentTrackId: String?, + val currentTrackTitle: String?, + val currentTrackArtists: String?, + val currentTrackAlbum: String?, + val currentTrackCoverUrl: String?, + ) : RemoteControlEvent() + + @Serializable + @SerialName("queueUpdated") + data class QueueUpdated( + val entries: List, + val currentIndex: Int, + ) : RemoteControlEvent() + + @Serializable + @SerialName("ack") + data class Ack(val commandId: String) : RemoteControlEvent() + + @Serializable + @SerialName("error") + data class Error(val message: String) : RemoteControlEvent() +} + +@Serializable +data class RemoteQueueEntry( + val mediaUrl: String, + val trackId: String, + val title: String, + val artists: String, + val album: String?, + val coverUrl: String?, + val durationMs: Long, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt new file mode 100644 index 00000000..0e342da4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.remote + +import co.touchlab.kermit.Logger +import com.appstractive.dnssd.NetService +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService +import dev.krtirtho.spotube.core.server.LocalServer +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * Advertises this device on the local network via DNS-SD so that other Spotube + * instances can discover and control it. Advertises only while the + * "Allow remote control" setting is enabled and the local playback server is + * listening on the LAN (0.0.0.0). + */ +class RemoteControlService( + private val settingsRepository: SettingsRepository, + private val discoveryService: DeviceDiscoveryService, + private val localServer: LocalServer, +) { + private val log = Logger.withTag("RemoteControlService") + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var advertisedService: NetService? = null + + init { + scope.launch { + combine( + settingsRepository.userSettings, + localServer.port, + ) { settings, port -> settings to port } + .distinctUntilChanged() + .collect { (settings, port) -> + if (settings.allowRemoteControl && port != null) { + ensureAdvertised(settings.remoteControlDeviceName, port) + } else { + stopAdvertising() + } + } + } + } + + private suspend fun ensureAdvertised(name: String, port: Int) { + val deviceId = resolveDeviceId() + val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } + if (advertisedService == null) { + try { + advertisedService = discoveryService.advertise( + name = serviceName, + port = port, + deviceId = deviceId, + ) + log.i { "Advertising remote control service '$serviceName' on port $port" } + } catch (e: Exception) { + log.w(e) { "Failed to advertise remote control service" } + } + } + } + + private suspend fun stopAdvertising() { + if (advertisedService != null) { + runCatching { advertisedService?.unregister() } + advertisedService = null + log.i { "Stopped advertising remote control service" } + } + } + + private suspend fun resolveDeviceId(): String { + val settings = settingsRepository.userSettings.first() + if (settings.remoteControlDeviceId.isNotBlank()) { + return settings.remoteControlDeviceId + } + val generated = buildString(16) { + val chars = "0123456789abcdef" + repeat(16) { append(chars[Random.nextInt(chars.length)]) } + } + settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) + return generated + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt index 6725f215..c61f45c5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt @@ -19,10 +19,12 @@ package dev.krtirtho.spotube.core.server import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.modules.settings.SettingsViewModel import io.ktor.client.HttpClient import io.ktor.http.HttpMethod import io.ktor.server.application.Application +import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer @@ -30,6 +32,8 @@ import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.head import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -55,6 +59,7 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, + private val remoteControlHandler: RemoteControlHandler, ) : KoinComponent { val logger by injectLogger() @@ -70,8 +75,10 @@ class LocalServer( private val activePort = MutableStateFlow(null) val port = activePort.asStateFlow() + private val activeHost = MutableStateFlow(null) + val baseUrl = activePort.map { port -> - port?.let { "http://$HOST:$it" } + port?.let { "http://$HOST_LOCAL:$it" } }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) private val cachedCacheEnabled = MutableStateFlow(false) @@ -89,18 +96,19 @@ class LocalServer( } companion object { - private const val HOST = "127.0.0.1" + private const val HOST_LOCAL = "127.0.0.1" + private const val HOST_LAN = "0.0.0.0" } init { logger.d { "Starting playback proxy port watcher" } portWatcher = scope.launch { settingsViewModel.settingsState - .mapNotNull { it?.playbackProxyServerPort } + .mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } } .distinctUntilChanged() - .collectLatest { port -> - logger.d { "Observed playback proxy port change to $port" } - restartServer(port) + .collectLatest { (port, allowRemoteControl) -> + logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" } + restartServer(port, allowRemoteControl) } } scope.launch { @@ -129,26 +137,28 @@ class LocalServer( logger.d { "Playback proxy server stopped" } } - private suspend fun restartServer(port: Int) { + private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) { + val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL serverMutex.withLock { - if (serverState.value != null && activePort.value == port) { - logger.v { "Playback proxy server already running on port $port; skipping restart" } + if (serverState.value != null && activePort.value == port && activeHost.value == host) { + logger.v { "Playback proxy server already running on $host:$port; skipping restart" } return } - logger.d { "Restarting playback proxy server on port $port" } + logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" } stopServerLocked() serverState.value = embeddedServer( factory = CIO, - host = HOST, + host = host, port = port, module = { configureRoutes() } ).also { engine -> engine.start(wait = false) } activePort.value = port - logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$HOST:$port"}" } + activeHost.value = host + logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:$port"}" } } } @@ -165,9 +175,16 @@ class LocalServer( } serverState.value = null activePort.value = null + activeHost.value = null } private fun Application.configureRoutes() { + install(WebSockets) { + pingPeriodMillis = 30_000L + timeoutMillis = 60_000L + maxFrameSize = 10L * 1024 * 1024 + masking = false + } routing { get("/health") { call.respondText("ok") @@ -188,6 +205,10 @@ class LocalServer( get("/segment/{trackId}") { streamProxy.handleSegmentRequest(call) } + + webSocket("/control") { + remoteControlHandler.handleConnection(this) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt new file mode 100644 index 00000000..d9b3bf28 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun DevicesScreen( + navigationCommands: NavigationCommands, +) { + val viewModel = koinViewModel() + val devices by viewModel.devices.collectAsStateWithLifecycle() + val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() + + DisposableEffect(Unit) { + viewModel.startDiscovery() + onDispose { viewModel.stopDiscovery() } + } + + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Devices") }, + actions = { + if (isDiscovering) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Iconsax.IconsaxRefreshRight, + contentDescription = "Refresh", + modifier = Modifier + .size(24.dp) + .clickable { viewModel.startDiscovery() }, + ) + } + }, + ) + }, + ) { innerPadding -> + val shellBottomInset = LocalAppShellBottomInset.current + + if (devices.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (isDiscovering) { + "Searching for devices on the network..." + } else { + "No devices found" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!isDiscovering) { + Text( + text = "Make sure the other device has \"Allow remote control\" enabled in settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp), + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), + ) { + items(devices.values.toList(), key = { it.key }) { device -> + DeviceRow( + device = device, + onClick = { viewModel.connectToDevice(device) }, + ) + } + item { + Box(modifier = Modifier.padding(bottom = shellBottomInset)) + } + } + } + } +} + +@Composable +private fun DeviceRow( + device: DiscoveredDevice, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${device.host}:${device.port}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt new file mode 100644 index 00000000..5c8809f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.appstractive.dnssd.NetService +import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService +import dev.krtirtho.spotube.core.discovery.DiscoveredDevice +import dev.krtirtho.spotube.core.discovery.DiscoveryState +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class DevicesViewModel : ViewModel(), KoinComponent { + private val logger = Logger.withTag("DevicesViewModel") + private val discoveryService: DeviceDiscoveryService by inject() + + private val _devices = MutableStateFlow>(emptyMap()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _isDiscovering = MutableStateFlow(false) + val isDiscovering: StateFlow = _isDiscovering.asStateFlow() + + private var discoveryJob: Job? = null + private var advertisedService: NetService? = null + + fun startDiscovery() { + if (discoveryJob?.isActive == true) return + _isDiscovering.value = true + discoveryJob = viewModelScope.launch { + discoveryService.discover().collect { event -> + when (event) { + is DiscoveryState.Discovered -> { + event.resolve() + _devices.update { it + (event.device.key to event.device.copy()) } + } + is DiscoveryState.Resolved -> { + _devices.update { it + (event.device.key to event.device) } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } + } + } + } + } + + fun stopDiscovery() { + discoveryJob?.cancel() + discoveryJob = null + _isDiscovering.value = false + } + + fun connectToDevice(device: DiscoveredDevice) { + logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index c34d53ee..012e6947 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -66,8 +68,14 @@ import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import dev.krtirtho.spotube.core.ui.component.dragScrollable import dev.krtirtho.spotube.core.ui.misc.SkeletonTree +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.User +import org.koin.compose.koinInject import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -75,6 +83,7 @@ import kotlinx.coroutines.flow.map @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(viewModel: HomeScreenViewModel) { + val navigationCommands = koinInject() val platform = getPlatform() val isDesktop = platform.type == PlatformType.Windows || platform.type == PlatformType.Linux || @@ -117,7 +126,21 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { backButton = false, title = { Text("Browse") - } + }, + actions = { + IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = "Devices", + ) + } + IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) { + Icon( + imageVector = Iconsax.User, + contentDescription = "Group Jam", + ) + } + }, ) }, ) { innerPadding -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt new file mode 100644 index 00000000..93b3ca37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.jam + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun JamScreen( + navigationCommands: NavigationCommands, +) { + val viewModel = koinViewModel() + val isActive by viewModel.isActive.collectAsStateWithLifecycle() + val pendingOffer by viewModel.pendingHostOffer.collectAsStateWithLifecycle() + val pendingAnswer by viewModel.pendingGuestAnswer.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + + LaunchedEffect(isActive) { + if (isActive && navigationCommands != null) { + // navigationCommands doesn't navigate here automatically; + // the session screen is the same screen so we just stay. + } + } + + Scaffold( + topBar = { + ApplicationMainBar( + backButton = true, + title = { Text("Group Jam") }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (error != null) { + Text( + text = error ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + + if (pendingOffer == null && pendingAnswer == null) { + CreateOrJoinView( + onCreate = { viewModel.createSession() }, + onJoin = { offer -> viewModel.joinSession(offer) }, + ) + } else if (pendingOffer != null) { + HostOfferView( + offer = pendingOffer!!, + onLeave = { viewModel.leave() }, + ) + } else if (pendingAnswer != null) { + GuestAnswerView( + answer = pendingAnswer!!, + onLeave = { viewModel.leave() }, + ) + } + } + } +} + +@Composable +private fun CreateOrJoinView( + onCreate: () -> Unit, + onJoin: (String) -> Unit, +) { + var tab by remember { mutableIntStateOf(0) } + var offer by remember { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = "Listen Together with friends", + style = MaterialTheme.typography.titleLarge, + ) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = tab == 0, + onClick = { tab = 0 }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text("Create") } + SegmentedButton( + selected = tab == 1, + onClick = { tab = 1 }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text("Join") } + } + + if (tab == 0) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Create a new jam session. You'll be the host and can control playback. Share the SDP offer with your friends so they can join.", + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onCreate) { + Text("Create Session") + } + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Paste the SDP offer from the host below. You'll get an SDP answer to send back.", + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = offer, + onValueChange = { offer = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Host's SDP offer") }, + minLines = 3, + maxLines = 6, + ) + Button( + onClick = { onJoin(offer.trim()) }, + enabled = offer.isNotBlank(), + ) { + Text("Generate Answer") + } + } + } + } +} + +@Composable +private fun HostOfferView( + offer: String, + onLeave: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Session created. Send this SDP offer to your friends:", + style = MaterialTheme.typography.bodyMedium, + ) + SelectionContainer { + OutlinedTextField( + value = offer, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + label = { Text("SDP Offer (copy and send to guests)") }, + minLines = 4, + maxLines = 10, + ) + } + Text( + text = "When a guest responds with an SDP answer, use the JamSessionScreen to add them.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onLeave) { + Text("Leave Session") + } + } +} + +@Composable +private fun GuestAnswerView( + answer: String, + onLeave: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "You've joined the session. Send this SDP answer back to the host:", + style = MaterialTheme.typography.bodyMedium, + ) + SelectionContainer { + OutlinedTextField( + value = answer, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + label = { Text("SDP Answer (copy and send to host)") }, + minLines = 4, + maxLines = 10, + ) + } + Button(onClick = onLeave) { + Text("Leave Session") + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt new file mode 100644 index 00000000..10500045 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.jam + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.jam.JamMessage +import dev.krtirtho.spotube.core.jam.JamParticipant +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamSessionService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class JamViewModel : ViewModel(), KoinComponent { + private val jamSession: JamSessionService by inject() + private val audioPlayer: AudioPlayerInterface by inject() + + val role: StateFlow = jamSession.role + val participants: StateFlow> = jamSession.participants + val isActive: StateFlow = jamSession.isActive + + private val _pendingHostOffer = MutableStateFlow(null) + val pendingHostOffer: StateFlow = _pendingHostOffer.asStateFlow() + + private val _pendingGuestAnswer = MutableStateFlow(null) + val pendingGuestAnswer: StateFlow = _pendingGuestAnswer.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + fun createSession() { + viewModelScope.launch { + try { + val offer = jamSession.createSession() + _pendingHostOffer.value = offer + } catch (e: Exception) { + _error.value = "Failed to create session: ${e.message}" + } + } + } + + fun joinSession(offer: String) { + viewModelScope.launch { + try { + val answer = jamSession.joinSession(offer) + _pendingGuestAnswer.value = answer + } catch (e: Exception) { + _error.value = "Failed to join session: ${e.message}" + } + } + } + + fun leave() { + viewModelScope.launch { + jamSession.leave() + _pendingHostOffer.value = null + _pendingGuestAnswer.value = null + } + } + + fun clearError() { + _error.value = null + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt new file mode 100644 index 00000000..9c1f30c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.modules.jam + +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import dev.krtirtho.spotube.core.ui.base.ThemedDialog + +/** + * Dialog shown when the user tries to play a collection while a jam session is active. + * The user picks between playing locally on their device or suggesting it to the jam session. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onSuggestToJam: () -> Unit, +) { + if (!visible) return + + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text("Play Where?", style = MaterialTheme.typography.titleLarge) + }, + content = { + Text( + text = "You have an active jam session. Choose where to play this collection.", + style = MaterialTheme.typography.bodyMedium, + ) + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + TextButton(onClick = onPlayLocally) { + Text("Play here") + } + Button(onClick = onSuggestToJam) { + Text("Suggest to Jam") + } + }, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index cfd8ed3e..1878ae96 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -55,6 +55,15 @@ data class UserSettings( val enableConnect: Boolean = false, val playbackProxyServerPort: Int = 14769, + // Remote Control (LAN) + val allowRemoteControl: Boolean = false, + val allowedRemoteDevices: List = emptyList(), + val remoteControlDeviceName: String = "", + val remoteControlDeviceId: String = "", + + // Group Jam (P2P) + val jamParticipantName: String = "", + // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder val localMediaFolders: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt index 4dee2d85..658e6119 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt @@ -38,6 +38,7 @@ import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard import dev.krtirtho.spotube.resources.iconsax.CustomServer import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay @@ -135,6 +136,46 @@ internal fun LazyListScope.playbackSection( } ) }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_allow_remote_control_title), + subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle), + icon = { + SettingsItemIcon( + Iconsax.IconsaxMirroringScreen, + stringResource(Res.string.settings_allow_remote_control_title) + ) + }, + checked = settings.allowRemoteControl, + onCheckedChange = { enabled -> + settingsViewModel.updateSettings { + copy(allowRemoteControl = enabled) + } + } + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_remote_device_name_title), + subtitle = stringResource( + Res.string.settings_remote_device_name_subtitle, + settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) } + ), + icon = { + SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title)) + }, + value = settings.remoteControlDeviceName, + dialogDescription = stringResource(Res.string.settings_remote_device_name_description), + placeholder = stringResource(Res.string.settings_remote_device_name_placeholder), + normalize = { it.trim() }, + validate = { _ -> null }, + onValueSaved = { value -> + settingsViewModel.updateSettings { + copy(remoteControlDeviceName = value) + } + } + ) + }, { val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_port_range = stringResource(Res.string.settings_error_port_range) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index a3f7e1c3..e41b2ef5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -69,6 +69,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel @@ -116,6 +117,8 @@ fun AppShell( } } + ConnectionRequestDialogHost() + Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt index b6e82e55..2cbdfe0d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt @@ -68,8 +68,10 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator import dev.krtirtho.spotube.modules.library.LibraryState import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken +import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.tabs import org.jetbrains.compose.resources.Font import org.koin.compose.koinInject @@ -103,6 +105,7 @@ fun AppSidebar( }, horizontalAlignment = Alignment.CenterHorizontally ) { + Column(modifier = Modifier.weight(1f)) { Row( modifier = Modifier .fillMaxWidth() @@ -175,6 +178,22 @@ fun AppSidebar( ) } } + } + + SidebarItem( + label = "Devices", + activeIcon = Iconsax.IconsaxMirroringScreen, + onClick = { navigator.navigate(Routes.Devices) }, + selected = false, + expanded = expanded, + ) + SidebarItem( + label = "Group Jam", + activeIcon = Iconsax.User, + onClick = { navigator.navigate(Routes.Jam) }, + selected = false, + expanded = expanded, + ) } } diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index b5ab652c..dff859ba 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -1,7 +1,9 @@ mod metadata; mod discord_rpc; +mod webrtc_p2p; pub use metadata::*; pub use discord_rpc::*; +pub use webrtc_p2p::*; -uniffi::setup_scaffolding!(); +uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs new file mode 100644 index 00000000..ba3ed9d9 --- /dev/null +++ b/composeApp/src/commonMain/rust/webrtc_p2p.rs @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +use std::sync::Arc; + +use parking_lot::Mutex; +use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors; +use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit}; +use webrtc::peer_connection::{ + MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, + RTCConfigurationBuilder, RTCIceGatheringState, RTCIceServer, RTCPeerConnectionIceEvent, + RTCPeerConnectionState, RTCSessionDescription, Registry, +}; +use webrtc::runtime::channel; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum WebrtcError { + #[error("SDP error: {reason}")] + SdpError { reason: String }, + #[error("Connection error: {reason}")] + ConnectionError { reason: String }, + #[error("Data channel error: {reason}")] + DataChannelError { reason: String }, + #[error("Invalid state: {reason}")] + InvalidState { reason: String }, + #[error("Internal error: {reason}")] + Internal { reason: String }, +} + +impl From for WebrtcError { + fn from(e: webrtc::error::Error) -> Self { + WebrtcError::Internal { + reason: format!("{e:?}"), + } + } +} + +#[derive(uniffi::Record)] +pub struct IceServerConfig { + pub urls: Vec, + pub username: String, + pub credential: String, +} + +#[uniffi::export(callback_interface)] +pub trait WebrtcEventHandler: Send + Sync + 'static { + fn on_ice_candidate(&self, candidate: String); + fn on_ice_gathering_state_change(&self, state: String); + fn on_connection_state_change(&self, state: String); + fn on_data_channel_open(&self, label: String); + fn on_data_channel_message(&self, label: String, data: String); + fn on_data_channel_close(&self, label: String); +} + +struct DataChannelEntry { + dc: Arc, + label: String, +} + +#[derive(uniffi::Object)] +pub struct WebrtcPeerConnection { + pc: Arc, + handler: Arc, + channels: Mutex>, + gather_rx: Mutex>, +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn create_webrtc_peer_connection( + ice_servers: Vec, + handler: Box, +) -> Result, WebrtcError> { + let handler: Arc = Arc::from(handler); + + let mut media_engine = MediaEngine::default(); + media_engine + .register_default_codecs() + .map_err(|e| WebrtcError::Internal { + reason: format!("media_engine: {e:?}"), + })?; + + let registry = register_default_interceptors(Registry::new(), &mut media_engine) + .map_err(|e| WebrtcError::Internal { + reason: format!("interceptor_registry: {e:?}"), + })?; + + let config = RTCConfigurationBuilder::new() + .with_ice_servers( + ice_servers + .into_iter() + .map(|s| RTCIceServer { + urls: s.urls, + username: s.username, + credential: s.credential, + }) + .collect(), + ) + .build(); + + let (gather_tx, gather_rx) = channel::<()>(1); + let pc_handler = Arc::new(PeerHandlerBridge { + handler: Arc::clone(&handler), + gather_tx, + }); + + let pc = PeerConnectionBuilder::new() + .with_configuration(config) + .with_media_engine(media_engine) + .with_interceptor_registry(registry) + .with_handler(pc_handler) + .with_udp_addrs(vec!["0.0.0.0:0"]) + .build() + .await?; + + Ok(Arc::new(WebrtcPeerConnection { + pc: Arc::new(pc) as Arc, + handler, + channels: Mutex::new(Vec::new()), + gather_rx: Mutex::new(gather_rx), + })) +} + +impl WebrtcPeerConnection { + /// Waits for ICE gathering to reach `Complete` so the local SDP includes all + /// candidates (non-trickle exchange). Must be called after `set_local_description`, + /// which is what starts gathering. + async fn wait_for_ice_gathering(&self) { + let mut gather_rx = self.gather_rx.lock().clone(); + let _ = gather_rx.recv().await; + } +} + +#[uniffi::export] +impl WebrtcPeerConnection { + #[uniffi::method(async_runtime = "tokio")] + pub async fn create_offer(&self) -> Result { + let offer = self.pc.create_offer(None).await?; + self.pc.set_local_description(offer.clone()).await?; + self.wait_for_ice_gathering().await; + Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(offer.sdp)) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn create_answer(&self) -> Result { + let answer = self.pc.create_answer(None).await?; + self.pc.set_local_description(answer.clone()).await?; + self.wait_for_ice_gathering().await; + Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(answer.sdp)) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn set_remote_offer(&self, sdp: String) -> Result<(), WebrtcError> { + let desc = RTCSessionDescription::offer(sdp) + .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; + self.pc.set_remote_description(desc).await?; + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn set_remote_answer(&self, sdp: String) -> Result<(), WebrtcError> { + let desc = RTCSessionDescription::answer(sdp) + .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; + self.pc.set_remote_description(desc).await?; + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn local_description(&self) -> Option { + self.pc.local_description().await.map(|d| d.sdp) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn create_data_channel(&self, label: String) -> Result<(), WebrtcError> { + let dc = self + .pc + .create_data_channel(&label, None::) + .await?; + + spawn_data_channel_poll_loop(Arc::clone(&dc), Arc::clone(&self.handler)); + + self.channels.lock().push(DataChannelEntry { dc, label }); + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn send_data(&self, label: String, data: String) -> Result<(), WebrtcError> { + let dc = { + let channels = self.channels.lock(); + channels + .iter() + .find(|c| c.label == label) + .map(|c| Arc::clone(&c.dc)) + }; + let dc = dc.ok_or_else(|| WebrtcError::InvalidState { + reason: format!("No data channel with label '{label}'"), + })?; + dc.send_text(&data).await?; + Ok(()) + } + + #[uniffi::method(async_runtime = "tokio")] + pub async fn shutdown(&self) -> Result<(), WebrtcError> { + let channels: Vec> = { + let channels = self.channels.lock(); + channels.iter().map(|c| Arc::clone(&c.dc)).collect() + }; + for dc in channels.iter() { + let _ = dc.close().await; + } + self.pc.close().await?; + Ok(()) + } +} + +struct PeerHandlerBridge { + handler: Arc, + gather_tx: webrtc::runtime::Sender<()>, +} + +#[async_trait::async_trait] +impl PeerConnectionEventHandler for PeerHandlerBridge { + async fn on_ice_candidate(&self, event: RTCPeerConnectionIceEvent) { + self.handler.on_ice_candidate(event.candidate.to_string()); + } + + async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) { + let s = state.to_string(); + if matches!(state, RTCIceGatheringState::Complete) { + let _ = self.gather_tx.try_send(()); + } + self.handler.on_ice_gathering_state_change(s); + } + + async fn on_connection_state_change(&self, state: RTCPeerConnectionState) { + self.handler.on_connection_state_change(state.to_string()); + } + + async fn on_data_channel(&self, dc: Arc) { + spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler)); + } +} + +fn spawn_data_channel_poll_loop( + dc: Arc, + handler: Arc, +) { + ::tokio::spawn(async move { + let label = match dc.label().await { + Ok(l) => l, + Err(_) => return, + }; + while let Some(event) = dc.poll().await { + match event { + DataChannelEvent::OnOpen => { + handler.on_data_channel_open(label.clone()); + } + DataChannelEvent::OnMessage(msg) => { + let text = if msg.is_string { + String::from_utf8_lossy(&msg.data).into_owned() + } else { + format!("[binary:{}bytes]", msg.data.len()) + }; + handler.on_data_channel_message(label.clone(), text); + } + DataChannelEvent::OnClose | DataChannelEvent::OnClosing => { + handler.on_data_channel_close(label.clone()); + if matches!(event, DataChannelEvent::OnClose) { + break; + } + } + _ => {} + } + } + }); +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04084c5a..4c59d3c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ vlcj = "4.12.1" vlcjNative = "4.12.0" ziplineVersion = "1.27.0" ktor = "3.5.1" +dnssdkt = "1.1.0" kotlinStdlib = "2.4.10" runner = "1.7.0" core = "1.7.0" @@ -85,6 +86,7 @@ androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" } compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" } compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } +dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" } haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" } @@ -117,6 +119,8 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 11845e1d..870b2c71 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,5 +4,12 @@ CADisableMinimumFrameDurationOnPhone + NSLocalNetworkUsageDescription + Required to discover local network devices + NSBonjourServices + + _http._tcp + _spotube-ctrl._tcp +