From d3a8548661fe41ca9d0e647a1c09ad43e607250f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 21 Aug 2026 19:50:09 +0600 Subject: [PATCH 01/16] 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 + From f21442b1c27b5cad96e5206f02a18dd401125280 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 28 Aug 2026 09:33:39 +0600 Subject: [PATCH 02/16] feat(deeplinks): implement deep link handling for jam sessions --- .../src/androidMain/AndroidManifest.xml | 11 + .../dev/krtirtho/spotube/MainActivity.kt | 8 + .../kotlin/dev/krtirtho/spotube/App.kt | 9 + .../core/deeplink/ExternalUriHandler.kt | 50 +++ .../core/deeplink/JamDeepLinkService.kt | 50 +++ .../dev/krtirtho/spotube/core/di/Modules.kt | 2 + .../core/discovery/DeviceDiscoveryService.kt | 5 +- .../spotube/core/jam/JamInviteCodec.kt | 105 +++++ .../spotube/core/jam/JamSessionService.kt | 360 ++++++++++-------- .../krtirtho/spotube/modules/jam/JamScreen.kt | 315 +++++++++++---- .../spotube/modules/jam/JamViewModel.kt | 202 ++++++++-- .../kotlin/dev/krtirtho/spotube/main.kt | 22 +- iosApp/iosApp/Info.plist | 11 + iosApp/iosApp/iOSApp.swift | 3 + 14 files changed, 890 insertions(+), 263 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 9e832592..3245b681 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -55,6 +55,17 @@ + + + + + + + + + () val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) + val jamDeepLinks: JamDeepLinkService = koinInject() + DisposableEffect(Unit) { + ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) } + onDispose { ExternalUriHandler.listener = null } + } + val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt new file mode 100644 index 00000000..00deb04d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/ExternalUriHandler.kt @@ -0,0 +1,50 @@ +/* + * 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.deeplink + +/** + * Cross-platform receiver for URIs handed to the app by the operating system + * (deep links). Platform entry points (Android activity intents, desktop command + * line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable + * installs a [listener] once composition starts. + * + * Follows the Compose Multiplatform deep linking docs pattern: URIs arriving + * before a listener is installed are cached and delivered as soon as one is set. + */ +object ExternalUriHandler { + private var cached: String? = null + + var listener: ((uri: String) -> Unit)? = null + set(value) { + field = value + if (value != null) { + cached?.let(value::invoke) + cached = null + } + } + + fun onNewUri(uri: String) { + if (uri.isBlank()) return + val currentListener = listener + if (currentListener != null) { + currentListener(uri) + } else { + cached = uri + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt new file mode 100644 index 00000000..32e529db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt @@ -0,0 +1,50 @@ +/* + * 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.deeplink + +import dev.krtirtho.spotube.core.jam.JamInviteCodec +import dev.krtirtho.spotube.core.jam.JamInviteLink +import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI, + * and navigates to [Routes.Jam] so the user lands where the link is handled. + */ +class JamDeepLinkService( + private val navigationCommands: NavigationCommands, +) { + private val _pendingLink = MutableStateFlow(null) + val pendingLink: StateFlow = _pendingLink.asStateFlow() + + fun handleUri(uri: String) { + val link = JamInviteCodec.parse(uri) ?: return + _pendingLink.value = link + navigationCommands.navigateTo(Routes.Jam) + } + + /** Consumes the currently pending link (if any). */ + fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null } + + fun clear() { + _pendingLink.value = null + } +} \ No newline at end of file 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 1a69b4e4..e898e202 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,6 +23,7 @@ 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.deeplink.JamDeepLinkService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService import dev.krtirtho.spotube.core.jam.JamSessionService @@ -219,6 +220,7 @@ val sharedModules = module { createdAtStart() } single { JamSessionService(get(), get()) } + singleOf(::JamDeepLinkService) 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 index 7c5d878b..1c6b0a22 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -23,6 +23,7 @@ import com.appstractive.dnssd.createNetService import com.appstractive.dnssd.discoverServices import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlin.text.decodeToString data class DiscoveredDevice( val name: String, @@ -54,7 +55,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), ) DiscoveryState.Discovered(device = device, resolve = event.resolve) } @@ -65,7 +66,7 @@ class DeviceDiscoveryService { type = event.service.type, host = event.service.host, port = event.service.port, - deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(), + deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), ) DiscoveryState.Resolved(device = device) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt new file mode 100644 index 00000000..7ecb8ff9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt @@ -0,0 +1,105 @@ +/* + * 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 io.ktor.http.decodeURLQueryComponent +import io.ktor.http.encodeURLParameter + +/** + * SDP payloads exchanged between jam peers are wrapped into `spotube://` deep links + * so they can be shared through any messaging medium. The SDP blob is percent-encoded + * as a query parameter. + * + * Host invite : `spotube://jam/invite?name=&sdp=` + * Guest answer : `spotube://jam/answer?name=&sdp=` + */ +sealed interface JamInviteLink { + val peerName: String + val sdp: String + + data class HostInvite( + override val peerName: String, + override val sdp: String, + ) : JamInviteLink + + data class GuestAnswer( + override val peerName: String, + override val sdp: String, + ) : JamInviteLink +} + +object JamInviteCodec { + const val SCHEME = "spotube" + const val INVITE_PATH = "jam/invite" + const val ANSWER_PATH = "jam/answer" + + fun buildHostInvite(hostName: String, offerSdp: String): String = + buildLink(INVITE_PATH, hostName, offerSdp) + + fun buildGuestAnswer(guestName: String, answerSdp: String): String = + buildLink(ANSWER_PATH, guestName, answerSdp) + + private fun buildLink(path: String, peerName: String, sdp: String): String = + "$SCHEME://$path?name=${peerName.encodeURLParameter()}" + + "&sdp=${sdp.encodeURLParameter()}" + + /** + * Parses a `spotude://jam/...` link. Returns null for foreign or malformed URIs. + * Parsing is done manually — generic URI parsers normalize unknown schemes in + * ways that mangle percent-encoded multi-line payloads. + */ + fun parse(rawUri: String): JamInviteLink? { + val uri = rawUri.trim() + if (!uri.startsWith("$SCHEME://", ignoreCase = true)) return null + + val withoutScheme = uri.substring(SCHEME.length + 3) + val queryStart = withoutScheme.indexOf('?') + if (queryStart < 0) return null + + val path = withoutScheme.take(queryStart).trim('/').lowercase() + val params = withoutScheme.substring(queryStart + 1) + .split('&') + .mapNotNull { pair -> + val separator = pair.indexOf('=') + if (separator <= 0) return@mapNotNull null + pair.take(separator) to pair.substring(separator + 1) + } + .toMap() + + val sdp = params["sdp"]?.decodeURLQueryComponent()?.takeIf { it.isNotBlank() } + ?: return null + val peerName = params["name"]?.decodeURLQueryComponent().orEmpty() + + return when (path) { + INVITE_PATH -> JamInviteLink.HostInvite(peerName, sdp) + ANSWER_PATH -> JamInviteLink.GuestAnswer(peerName, sdp) + else -> null + } + } + + /** + * Extracts an SDP payload from user input which may either be a full + * `spotube://` deep link or a raw SDP body pasted by hand. + */ + fun extractSdp(rawInput: String): String? { + val input = rawInput.trim() + parse(input)?.let { return it.sdp } + // Heuristic for raw SDP: first line is the session description header + return if (input.startsWith("v=", ignoreCase = false)) input else null + } +} \ 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 index f90a91a1..c85db931 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -23,6 +23,7 @@ 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.IO import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -38,6 +39,16 @@ import uniffi.compose_app.WebrtcEventHandler import uniffi.compose_app.WebrtcPeerConnection import uniffi.compose_app.createWebrtcPeerConnection +/** + * An invite generated by the host for one guest slot. The [sdp] offer is shared + * via a deep link; once the guest's answer comes back, [JamSessionService.acceptAnswer] + * completes the handshake for that slot. + */ +data class JamInvite( + val id: String, + val sdp: String, +) + class JamSessionService( private val audioPlayer: AudioPlayerInterface, private val settingsProvider: SettingsProvider, @@ -66,100 +77,109 @@ class JamSessionService( 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 val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** Host side: invites whose answers have not arrived yet. */ + private val pendingInvites = mutableMapOf() + + /** Host side: guests whose handshake completed. Keyed by invite id. */ + private val connectedGuests = mutableMapOf() + + /** Guest side: the single connection to the host. */ 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 hostName = resolveParticipantName(defaultPrefix = "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, + displayName = hostName, isHost = true, ) ) _isActive.value = true - pc.createDataChannel("jam") - val offer = pc.createOffer() - log.i { "Generated SDP offer (length=${offer.length})" } - return offer + return generateInvite().sdp } - suspend fun acceptGuestAnswer(guestId: String, answer: String) { - val pc = guestConnections[guestId] ?: run { - log.w { "acceptGuestAnswer: no connection for $guestId" } - return + /** + * Generates a new invite (peer connection + SDP offer with bundled ICE candidates). + * Each invite admits exactly one guest. + */ + suspend fun generateInvite(): JamInvite { + if (_role.value != JamRole.Host) { + error("generateInvite can only be called by the host") } - 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 inviteId = "guest-${randomShortId()}" + log.i { "Generating invite $inviteId" } val pc = createWebrtcPeerConnection( - iceServers = listOf( - IceServerConfig( - urls = listOf("stun:stun.l.google.com:19302"), - username = "", - credential = "", - ) - ), + iceServers = listOf(defaultIceServer()), + handler = guestEventHandler(inviteId), + ) + + pc.createDataChannel(CHANNEL_LABEL) + val offer = pc.createOffer() + pendingInvites[inviteId] = pc + return JamInvite(id = inviteId, sdp = offer) + } + + /** + * Completes a guest's handshake: applies their SDP answer to the peer connection + * created for [inviteId] and adds them to the participant list. + * + * When [inviteId] is null, the oldest still-pending invite is used — convenient + * when an answer deep link arrives out of band. + * + * @param answerSdp raw SDP answer body (not a deep link). + * @param peerName display name of the guest, taken from their answer link if available. + */ + suspend fun acceptAnswer(inviteId: String?, answerSdp: String, peerName: String): Boolean { + if (_role.value != JamRole.Host) { + log.w { "acceptAnswer ignored: not hosting" } + return false + } + val resolvedId = inviteId ?: pendingInvites.keys.firstOrNull() + if (resolvedId == null) { + log.w { "acceptAnswer: no pending invite" } + return false + } + val pc = pendingInvites.remove(resolvedId) + if (pc == null) { + log.w { "acceptAnswer: no pending invite '$resolvedId'" } + return false + } + runCatching { pc.setRemoteAnswer(answerSdp) } + .onFailure { e -> + log.w(e) { "Failed to apply answer for $inviteId" } + scope.launch { runCatching { pc.shutdown() } } + return false + } + connectedGuests[resolvedId] = pc + _participants.update { current -> + current + JamParticipant( + id = resolvedId, + displayName = peerName.ifBlank { "Guest-${resolvedId.takeLast(4)}" }, + isHost = false, + ) + } + log.i { "Guest $resolvedId ($peerName) joined" } + return true + } + + suspend fun joinSession(offerSdp: String): String { + log.i { "Joining jam session" } + val participantName = resolveParticipantName(defaultPrefix = "Guest") + + val pc = createWebrtcPeerConnection( + iceServers = listOf(defaultIceServer()), handler = eventHandler, ) @@ -168,65 +188,31 @@ class JamSessionService( _localParticipantId.value = "guest" _isActive.value = true - pc.setRemoteOffer(offer) - pc.createDataChannel("jam") + // The data channel arrives in-band from the host's offer via on_data_channel; + // we only answer here. + pc.setRemoteOffer(offerSdp) 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) + val payload = json.encodeToString(JamMessage.serializer(), message) when (_role.value) { JamRole.Host -> { - if (guestId != null) { - guestConnections[guestId]?.sendData("jam-$guestId", json) + val targets = if (guestId != null) { + listOfNotNull(connectedGuests[guestId]) } else { - guestConnections.forEach { (id, pc) -> - pc.sendData("jam-$id", json) - } + connectedGuests.values.toList() + } + targets.forEach { pc -> + runCatching { pc.sendData(CHANNEL_LABEL, payload) } + .onFailure { e -> log.w(e) { "Failed to send to guest" } } } } JamRole.Guest -> { - hostConnection?.sendData("jam", json) + hostConnection?.sendData(CHANNEL_LABEL, payload) } null -> log.w { "sendMessage called while no session is active" } @@ -236,46 +222,13 @@ class JamSessionService( 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() + shutdownAll() _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)) @@ -300,8 +253,113 @@ class JamSessionService( if (_role.value != JamRole.Guest) return sendMessage(JamMessage.SuggestPlaylist(tracks)) } + + private fun defaultIceServer() = IceServerConfig( + urls = listOf("stun:stun.l.google.com:19302"), + username = "", + credential = "", + ) + + private suspend fun resolveParticipantName(defaultPrefix: String): String { + val settings = settingsProvider.settingsState.first() + return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" } + ?: "$defaultPrefix-${randomShortId()}" + } + + /** + * Per-guest handler so messages received on a guest's connection can be + * attributed back to that guest (needed for kick-on-leave and targeted sends). + */ + private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) {} + + override fun onIceGatheringStateChange(state: String) { + log.d { "[$guestId] ICE gathering state: $state" } + } + + override fun onConnectionStateChange(state: String) { + log.i { "[$guestId] Connection state: $state" } + } + + override fun onDataChannelOpen(label: String) { + log.i { "[$guestId] Data channel '$label' open" } + } + + override fun onDataChannelMessage(label: String, data: String) { + handleIncomingMessage(data, fromGuestId = guestId) + } + + override fun onDataChannelClose(label: String) { + log.i { "[$guestId] Data channel closed" } + } + } + + private val eventHandler = object : WebrtcEventHandler { + override fun onIceCandidate(candidate: String) {} + + 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(data, fromGuestId = null) + } + + override fun onDataChannelClose(label: String) { + log.i { "Data channel closed" } + } + } + + private fun handleIncomingMessage(data: String, fromGuestId: String?) { + 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 = connectedGuests.remove(fromGuestId) + scope.launch { + runCatching { leavingPc?.shutdown() } + } + _participants.update { current -> + current.filterNot { it.id == fromGuestId } + } + } else if (_role.value == JamRole.Guest) { + scope.launch { leave() } + } + } + + else -> Unit + } + } catch (e: Exception) { + log.w(e) { "Failed to parse jam message" } + } + } + + private suspend fun shutdownAll() { + pendingInvites.values.forEach { runCatching { it.shutdown() } } + connectedGuests.values.forEach { runCatching { it.shutdown() } } + runCatching { hostConnection?.shutdown() } + pendingInvites.clear() + connectedGuests.clear() + hostConnection = null + } } +private const val CHANNEL_LABEL = "jam" + private fun MutableStateFlow.update(transform: (T) -> T) { value = transform(value) } 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 index 93b3ca37..26280783 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -19,12 +19,17 @@ package dev.krtirtho.spotube.modules.jam import androidx.compose.foundation.layout.Arrangement 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.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton @@ -32,15 +37,20 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import org.koin.compose.viewmodel.koinViewModel @@ -50,17 +60,7 @@ 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. - } - } + val state by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -74,49 +74,70 @@ fun JamScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp), + .padding(16.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (error != null) { - Text( - text = error ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) + state.error?.let { error -> + ErrorBanner(text = error, onDismiss = viewModel::clearError) } - if (pendingOffer == null && pendingAnswer == null) { - CreateOrJoinView( - onCreate = { viewModel.createSession() }, - onJoin = { offer -> viewModel.joinSession(offer) }, + when { + !state.isActive && state.incomingOfferSdp != null -> IncomingInviteView( + hostName = state.incomingHostName.orEmpty(), + onJoin = viewModel::joinWithIncomingInvite, + onDismiss = viewModel::dismissIncomingInvite, ) - } else if (pendingOffer != null) { - HostOfferView( - offer = pendingOffer!!, - onLeave = { viewModel.leave() }, + + !state.isActive -> CreateOrJoinView( + onCreate = viewModel::createSession, + onJoin = viewModel::joinWithPasted, ) - } else if (pendingAnswer != null) { - GuestAnswerView( - answer = pendingAnswer!!, - onLeave = { viewModel.leave() }, + + state.role == JamRole.Host -> HostSessionView( + state = state, + onNewInvite = viewModel::generateNewInvite, + onSubmitAnswer = viewModel::submitAnswerPasted, + onShare = viewModel::share, + onLeave = viewModel::leave, + ) + + else -> GuestSessionView( + state = state, + onShare = viewModel::share, + onLeave = viewModel::leave, ) } } } } +@Composable +private fun ErrorBanner(text: String, onDismiss: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = text, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton(onClick = onDismiss) { + Text("Dismiss") + } + } +} + @Composable private fun CreateOrJoinView( onCreate: () -> Unit, onJoin: (String) -> Unit, ) { var tab by remember { mutableIntStateOf(0) } - var offer by remember { mutableStateOf("") } + var pasted by rememberSaveable { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = "Listen Together with friends", - style = MaterialTheme.typography.titleLarge, + text = "Listen together with friends over a peer-to-peer connection.", + style = MaterialTheme.typography.titleMedium, ) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { @@ -135,8 +156,10 @@ private fun CreateOrJoinView( 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.", + text = "Start a session as the host. You'll get a shareable invite link " + + "to send to friends; when they accept, they appear here.", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button(onClick = onCreate) { Text("Create Session") @@ -145,22 +168,24 @@ private fun CreateOrJoinView( } 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.", + text = "Paste the invite link the host shared with you.", style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( - value = offer, - onValueChange = { offer = it }, + value = pasted, + onValueChange = { pasted = it }, modifier = Modifier.fillMaxWidth(), - label = { Text("Host's SDP offer") }, - minLines = 3, + label = { Text("Invite link") }, + placeholder = { Text("spotube://jam/invite?...") }, + minLines = 2, maxLines = 6, ) Button( - onClick = { onJoin(offer.trim()) }, - enabled = offer.isNotBlank(), + onClick = { onJoin(pasted) }, + enabled = pasted.isNotBlank(), ) { - Text("Generate Answer") + Text("Join Session") } } } @@ -168,60 +193,196 @@ private fun CreateOrJoinView( } @Composable -private fun HostOfferView( - offer: String, - onLeave: () -> Unit, +private fun IncomingInviteView( + hostName: String, + onJoin: () -> Unit, + onDismiss: () -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Session created. Send this SDP offer to your friends:", - style = MaterialTheme.typography.bodyMedium, + text = "$hostName invited you to a jam session", + style = MaterialTheme.typography.titleMedium, ) - 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") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onJoin) { + Text("Join") + } + OutlinedButton(onClick = onDismiss) { + Text("Ignore") + } } } } @Composable -private fun GuestAnswerView( - answer: String, +private fun HostSessionView( + state: JamUiState, + onNewInvite: () -> Unit, + onSubmitAnswer: (String) -> Unit, + onShare: (String) -> Unit, onLeave: () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + val clipboard = LocalClipboardManager.current + var pastedAnswer by rememberSaveable { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ParticipantsSection(state.participants) + + HorizontalDivider() + Text( - text = "You've joined the session. Send this SDP answer back to the host:", - style = MaterialTheme.typography.bodyMedium, + text = "Invite someone", + style = MaterialTheme.typography.titleSmall, ) + val inviteLink = state.inviteLink + if (inviteLink != null) { + ShareableLinkBox( + label = "Invite link", + link = inviteLink, + onCopy = { clipboard.setText(AnnotatedString(inviteLink)) }, + onShare = { onShare(inviteLink) }, + ) + } + OutlinedButton(onClick = onNewInvite) { + Text("Generate new invite") + } + + HorizontalDivider() + + Text( + text = "Accept a guest's answer", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = "When your guest sends back their answer link, paste it below.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = pastedAnswer, + onValueChange = { pastedAnswer = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Answer link or SDP") }, + minLines = 2, + maxLines = 6, + ) + Button( + onClick = { + onSubmitAnswer(pastedAnswer) + pastedAnswer = "" + }, + enabled = pastedAnswer.isNotBlank(), + ) { + Text("Accept Answer") + } + + LeaveButton(onLeave) + } +} + +@Composable +private fun GuestSessionView( + state: JamUiState, + onShare: (String) -> Unit, + onLeave: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ParticipantsSection(state.participants) + + val answerLink = state.answerLink + if (answerLink == null) { + Text( + text = "Connecting to the session...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "Almost there! Send your answer back to the host:", + style = MaterialTheme.typography.titleSmall, + ) + ShareableLinkBox( + label = "Answer link", + link = answerLink, + onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, + onShare = { onShare(answerLink) }, + ) + } + + LeaveButton(onLeave) + } +} + +@Composable +private fun ParticipantsSection(participants: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Participants (${participants.size})", + style = MaterialTheme.typography.titleSmall, + ) + participants.forEach { participant -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = participant.displayName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (participant.isHost) { + Text( + text = "Host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +@Composable +private fun ShareableLinkBox( + label: String, + link: String, + onCopy: () -> Unit, + onShare: () -> Unit, +) { + val viewModel: JamViewModel = koinViewModel() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SelectionContainer { OutlinedTextField( - value = answer, + value = link, onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), - label = { Text("SDP Answer (copy and send to host)") }, - minLines = 4, - maxLines = 10, + label = { Text(label) }, + minLines = 2, + maxLines = 6, ) } - Button(onClick = onLeave) { - Text("Leave Session") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onCopy) { + Text("Copy") + } + if (viewModel.supportsNativeShare) { + OutlinedButton(onClick = onShare) { + Text("Share") + } + } } } +} + +@Composable +private fun LeaveButton(onLeave: () -> Unit) { + OutlinedButton(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 index 10500045..2033c322 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -19,67 +19,205 @@ 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.PlatformType +import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService +import dev.krtirtho.spotube.core.jam.JamInviteCodec +import dev.krtirtho.spotube.core.jam.JamInviteLink import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.share.ShareService +import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.modules.settings.SettingsProvider 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() +data class JamUiState( + val isActive: Boolean = false, + val role: JamRole? = null, + val participants: List = emptyList(), + /** Host: deep link containing this session's SDP offer, ready to share. */ + val inviteLink: String? = null, + /** Guest: deep link containing our SDP answer, to send back to the host. */ + val answerLink: String? = null, + /** Guest: offer received via deep link (or paste), waiting for confirmation. */ + val incomingHostName: String? = null, + val incomingOfferSdp: String? = null, + val error: String? = null, +) - val role: StateFlow = jamSession.role - val participants: StateFlow> = jamSession.participants - val isActive: StateFlow = jamSession.isActive +class JamViewModel( + private val jamSession: JamSessionService, + private val deepLinks: JamDeepLinkService, + private val shareService: ShareService, + private val settingsProvider: SettingsProvider, +) : ViewModel() { - private val _pendingHostOffer = MutableStateFlow(null) - val pendingHostOffer: StateFlow = _pendingHostOffer.asStateFlow() + private val _uiState = MutableStateFlow(JamUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _pendingGuestAnswer = MutableStateFlow(null) - val pendingGuestAnswer: StateFlow = _pendingGuestAnswer.asStateFlow() + val supportsNativeShare: Boolean = + getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() - - fun createSession() { + init { viewModelScope.launch { - try { - val offer = jamSession.createSession() - _pendingHostOffer.value = offer - } catch (e: Exception) { - _error.value = "Failed to create session: ${e.message}" + // Mirror live session state into the UI state. + jamSession.isActive.collect { active -> + _uiState.update { + it.copy( + isActive = active, + role = jamSession.role.value, + participants = jamSession.participants.value, + inviteLink = if (!active) null else it.inviteLink, + answerLink = if (!active) null else it.answerLink, + incomingOfferSdp = if (!active) it.incomingOfferSdp else null, + incomingHostName = if (!active) it.incomingHostName else null, + ) + } + } + } + viewModelScope.launch { + jamSession.participants.collect { participants -> + _uiState.update { it.copy(participants = participants) } + } + } + viewModelScope.launch { + deepLinks.pendingLink.collect { link -> + handleDeepLink(link) } } } - fun joinSession(offer: String) { + fun createSession() { viewModelScope.launch { - try { - val answer = jamSession.joinSession(offer) - _pendingGuestAnswer.value = answer - } catch (e: Exception) { - _error.value = "Failed to join session: ${e.message}" + runCatching { + val offer = jamSession.createSession() + JamInviteCodec.buildHostInvite(localName(), offer) + }.onSuccess { link -> + _uiState.update { it.copy(inviteLink = link, error = null) } + }.onFailure { e -> + _uiState.update { it.copy(error = "Failed to create session: ${e.message}") } } } } + fun generateNewInvite() { + viewModelScope.launch { + runCatching { + val invite = jamSession.generateInvite() + JamInviteCodec.buildHostInvite(localName(), invite.sdp) + }.onSuccess { link -> + _uiState.update { it.copy(inviteLink = link, error = null) } + }.onFailure { e -> + _uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") } + } + } + } + + fun joinWithIncomingInvite() { + val sdp = _uiState.value.incomingOfferSdp ?: return + join(sdp) + } + + fun joinWithPasted(input: String) { + val sdp = JamInviteCodec.extractSdp(input) + if (sdp == null) { + _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") } + return + } + join(sdp) + } + + /** + * Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link. + */ + fun submitAnswerPasted(input: String) { + when (val parsed = JamInviteCodec.parse(input.trim())) { + is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName) + else -> { + val sdp = JamInviteCodec.extractSdp(input) + if (sdp == null) { + _uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") } + } else { + acceptAnswerInternal(sdp, "") + } + } + } + } + + fun share(text: String) { + shareService.share(text, "Spotube Group Jam") + } + fun leave() { viewModelScope.launch { jamSession.leave() - _pendingHostOffer.value = null - _pendingGuestAnswer.value = null + deepLinks.clear() + _uiState.update { + JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName) + } } } fun clearError() { - _error.value = null + _uiState.update { it.copy(error = null) } } + + fun dismissIncomingInvite() { + deepLinks.clear() + _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) } + } + + private fun join(offerSdp: String) { + viewModelScope.launch { + runCatching { + val answer = jamSession.joinSession(offerSdp) + JamInviteCodec.buildGuestAnswer(localName(), answer) + }.onSuccess { link -> + _uiState.update { + it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null) + } + }.onFailure { e -> + _uiState.update { it.copy(error = "Failed to join session: ${e.message}") } + } + } + } + + private fun acceptAnswerInternal(answerSdp: String, peerName: String) { + viewModelScope.launch { + val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) } + .getOrDefault(false) + if (!accepted) { + _uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") } + } else { + _uiState.update { it.copy(error = null) } + } + } + } + + private suspend fun handleDeepLink(link: JamInviteLink?) { + when (link) { + is JamInviteLink.HostInvite -> { + if (!jamSession.isActive.value) { + _uiState.update { + it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp) + } + } + } + + is JamInviteLink.GuestAnswer -> { + if (jamSession.role.value == JamRole.Host) { + acceptAnswerInternal(link.sdp, link.peerName) + } + } + + null -> Unit + } + } + + private fun localName(): String = + settingsProvider.settingsState.value?.jamParticipantName.orEmpty() } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index 3e597a48..171f215b 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState import com.sun.jna.Library import com.sun.jna.Native import dev.krtirtho.spotube.core.di.initKoin +import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.paths.Paths import dev.krtirtho.spotube.core.systemtray.SystemTray @@ -81,9 +82,28 @@ private fun disableWebKitGpuCompositing() { LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1) } +/** + * Routes `spotube://` deep links into [ExternalUriHandler]. + * macOS delivers them through the open-URI handler; on Linux/Windows they arrive + * as command line arguments (scheme registration is handled by the distribution + * packaging, e.g. the `.desktop` file's `Exec %u`). + */ +private fun handleStartupDeepLinks(args: Array) { + runCatching { + if (java.awt.Desktop.isDesktopSupported()) { + java.awt.Desktop.getDesktop().setOpenURIHandler { event -> + ExternalUriHandler.onNewUri(event.uri.toString()) + } + } + } + args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) } + ?.let(ExternalUriHandler::onNewUri) +} + @OptIn(ExperimentalComposeUiApi::class) -fun main() { +fun main(args: Array) { disableWebKitGpuCompositing() + handleStartupDeepLinks(args) FileKit.init(appId = "dev.krtirtho.spotube") initKoin() NewPipeDownloader.init(KoinPathsProvider.paths) diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 870b2c71..a07e2e68 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,17 @@ CADisableMinimumFrameDurationOnPhone + CFBundleURLTypes + + + CFBundleURLName + dev.krtirtho.spotube + CFBundleURLSchemes + + spotube + + + NSLocalNetworkUsageDescription Required to discover local network devices NSBonjourServices diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 08b58d47..f0058387 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -20,6 +20,9 @@ struct iOSApp: App { var body: some Scene { WindowGroup { ContentView() + .onOpenURL { url in + ExternalUriHandler.shared.onNewUri(uri: url.absoluteString) + } } } } \ No newline at end of file From 1aeee1db79d77120525fc33b57f3006e121ef54a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 28 Aug 2026 13:18:04 +0600 Subject: [PATCH 03/16] fix(sidebar): add spacer to AppSidebar for improved layout --- .../kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt | 2 ++ gradle/libs.versions.toml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) 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 2cbdfe0d..0c4a3c78 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 @@ -194,6 +194,8 @@ fun AppSidebar( selected = false, expanded = expanded, ) + + Spacer(modifier = Modifier.height(120.dp)) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c59d3c8..b515031d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ androidx-activity = "1.13.0" androidx-appcompat = "1.7.1" androidx-core = "1.19.0" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.11.0" +androidx-lifecycle = "2.10.0" androidx-testExt = "1.3.0" appdirs = "1.5.0" cache4k = "0.14.0" @@ -62,8 +62,8 @@ compose-webview = "1.0.1" composeNativeTray = "2.0.3" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" -compose-multiplatform-adaptive = "1.3.0-beta02" -compose-multiplatform-lifecycle = "2.11.0" +compose-multiplatform-adaptive = "1.3.0-alpha05" +compose-multiplatform-lifecycle = "2.10.0" feather-icons = "1.1.1" material3-window-size = "1.9.0" kotlinx-datetime = "0.8.0" From df3a6dcbd275299888ba1e591f7f5d92fe5ea461 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 28 Aug 2026 18:28:24 +0600 Subject: [PATCH 04/16] feat(remote-control): implement remote playback functionality and UI components --- .../dev/krtirtho/spotube/MainActivity.kt | 13 +- .../dev/krtirtho/spotube/MyApplication.kt | 11 +- .../spotube/core/di/Modules.android.kt | 2 +- .../LocalNetworkPermission.android.kt | 50 +++ .../spotube/core/paths/Paths.android.kt | 16 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 9 +- .../core/discovery/DeviceDiscoveryService.kt | 3 +- .../core/discovery/LocalNetworkPermission.kt | 28 ++ .../core/navigation/NavigationModule.kt | 9 + .../core/remote/RemoteControlClient.kt | 188 +++++++++ .../core/remote/RemoteControlHandler.kt | 8 + .../core/remote/RemoteControlProtocol.kt | 8 + .../core/remote/RemoteControlService.kt | 83 +++- .../core/remote/RemotePlaybackController.kt | 117 ++++++ .../spotube/core/server/LocalServer.kt | 10 +- .../spotube/modules/devices/DevicesScreen.kt | 218 +++++++--- .../modules/devices/DevicesViewModel.kt | 162 +++++++- .../modules/devices/PlayDestinationPicker.kt | 88 ++++ .../modules/devices/RemoteControlScreen.kt | 375 ++++++++++++++++++ .../modules/devices/RemoteControlViewModel.kt | 170 ++++++++ .../modules/playlist/PlaylistScreen.kt | 9 + .../modules/playlist/PlaylistViewModel.kt | 30 +- .../modules/settings/SettingsScreen.kt | 3 + .../settings/sections/PlaybackSection.kt | 6 + .../discovery/LocalNetworkPermission.ios.kt | 24 ++ .../discovery/LocalNetworkPermission.jvm.kt | 24 ++ 26 files changed, 1573 insertions(+), 91 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt create mode 100644 composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt index 4734a6f8..55ab3202 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MainActivity.kt @@ -18,6 +18,7 @@ package dev.krtirtho.spotube import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -25,6 +26,7 @@ import androidx.activity.enableEdgeToEdge import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader import dev.krtirtho.spotube.core.paths.Paths +import dev.krtirtho.spotube.media.PlaybackService import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.dialogs.init @@ -33,7 +35,16 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) FileKit.init(this) - NewPipeDownloader.init(Paths(this)) + NewPipeDownloader.init(Paths()) + + // Start PlaybackService from Activity context (allowed on Android 12+) + val serviceIntent = Intent(this, PlaybackService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + intent?.dataString?.let(ExternalUriHandler::onNewUri) setContent { App() diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt index 306e4433..d93242aa 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/MyApplication.kt @@ -18,10 +18,8 @@ package dev.krtirtho.spotube import android.app.Application -import android.content.Intent -import android.os.Build import dev.krtirtho.spotube.core.di.initKoin -import dev.krtirtho.spotube.media.PlaybackService +import dev.krtirtho.spotube.core.paths.Paths import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent { override fun onCreate() { super.onCreate() + Paths.init(this) initKoin { androidContext(this@MyApplication) } - val intent = Intent(this, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(intent) - } else { - startService(intent) - } } override fun onTerminate() { diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt index 1ee43348..be29dba7 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/di/Modules.android.kt @@ -29,7 +29,7 @@ import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscove import org.koin.dsl.module actual val platformModules = module { - single { Paths(get()) } + single { Paths() } single { AudioPlayer(get()) } single { AndroidLocalMediaDiscoveryService(get()) } single { AndroidShareService(get()) } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt new file mode 100644 index 00000000..961a6b7c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.android.kt @@ -0,0 +1,50 @@ +/* + * 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 android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat + +/** + * On Android 13+ (and especially 16+ where it became a runtime permission), + * mDNS/NSD discovery requires `NEARBY_WIFI_DEVICES`. Requests it when the + * returned lambda is invoked; the caller decides when (e.g. first visit to the + * Devices screen). + */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { /* result is picked up by discovery/advertising retry loops */ } + + return { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) + } + } +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt index e8f172ce..20c187a0 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/paths/Paths.android.kt @@ -20,9 +20,10 @@ package dev.krtirtho.spotube.core.paths import android.content.Context import android.os.Environment -actual class Paths( - val context: Context -) { +actual class Paths { + private val context: Context + get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" } + actual fun getApplicationCacheDirPath(): String { return context.cacheDir.absolutePath } @@ -38,4 +39,13 @@ actual class Paths( actual fun getMusicCacheDirPath(): String { return context.cacheDir.absolutePath + "/music_cache" } + + companion object { + @Volatile + private var appContext: Context? = null + + fun init(context: Context) { + appContext = context.applicationContext + } + } } \ No newline at end of file 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 e898e202..a2b6fe98 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 @@ -28,8 +28,10 @@ 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.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.core.remote.RemoteControlService +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.server.AlternativeTracksRepository import dev.krtirtho.spotube.core.server.CacheManager @@ -44,6 +46,7 @@ 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.devices.RemoteControlViewModel import dev.krtirtho.spotube.modules.jam.JamViewModel import dev.krtirtho.spotube.modules.downloads.DownloadManager import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel @@ -140,6 +143,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -175,7 +179,8 @@ val sharedModules = module { // Blacklist singleOf(::BlacklistRepository) viewModelOf(::BlacklistViewModel) - viewModelOf(::DevicesViewModel) + viewModel { DevicesViewModel(get()) } + viewModelOf(::RemoteControlViewModel) viewModelOf(::JamViewModel) // Album @@ -215,10 +220,12 @@ val sharedModules = module { createdAtStart() } single { RemoteControlHandler(get(), get(), get()) } + single { RemoteControlClient() } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } + single { RemotePlaybackController() } single { JamSessionService(get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } 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 index 1c6b0a22..04221d6a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -88,6 +88,7 @@ class DeviceDiscoveryService { name: String, port: Int, deviceId: String, + registerTimeoutMs: Long = 5_000, ): NetService { val service = createNetService( type = SERVICE_TYPE, @@ -95,7 +96,7 @@ class DeviceDiscoveryService { port = port, txt = mapOf(TXT_DEVICE_ID to deviceId), ) - service.register() + service.register(timeoutInMs = registerTimeoutMs) return service } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt new file mode 100644 index 00000000..404c2b0d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.kt @@ -0,0 +1,28 @@ +/* + * 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 androidx.compose.runtime.Composable + +/** + * Returns a lambda that requests the OS permission needed for local network + * discovery (mDNS/NSD). No-op on platforms where such a permission doesn't + * exist or is granted implicitly. + */ +@Composable +expect fun rememberLocalNetworkPermissionRequester(): () -> Unit \ 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 79f26337..bfea9743 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 @@ -22,6 +22,7 @@ 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.devices.RemoteControlScreen import dev.krtirtho.spotube.modules.jam.JamScreen import dev.krtirtho.spotube.modules.home.HomeScreen import dev.krtirtho.spotube.modules.library.LibraryScreen @@ -78,6 +79,9 @@ sealed interface Routes : NavKey { @Serializable data object Blacklist : Routes + @Serializable + data object RemoteControl : Routes + @Serializable data object Devices : Routes @@ -159,6 +163,11 @@ val navigationModule = module { navigation { DevicesScreen(navigationCommands = get()) } + navigation { + RemoteControlScreen( + onDisconnect = { get().pop() } + ) + } navigation { JamScreen(navigationCommands = get()) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt new file mode 100644 index 00000000..e6d268f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -0,0 +1,188 @@ +/* + * 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 io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.websocket.CloseReason +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** + * WebSocket client for controlling a remote Spotube instance. + * Connects to the remote device's `/control` endpoint and sends commands. + */ +class RemoteControlClient { + private val logger = Logger.withTag("RemoteControlClient") + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val httpClient = HttpClient { + install(WebSockets) + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + requestTimeoutMillis = 30_000 + } + } + + private var session: WebSocketSession? = null + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _stateUpdates = MutableSharedFlow(extraBufferCapacity = 32) + val stateUpdates: SharedFlow = _stateUpdates.asSharedFlow() + + suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { + if (_connectionState.value is ConnectionState.Connected) { + logger.w { "Already connected" } + return + } + + _connectionState.value = ConnectionState.Connecting + try { + session = httpClient.webSocketSession { + url("ws://$host:$port/control") + header("X-Device-Id", deviceId) + header("X-Device-Name", deviceName) + } + + logger.i { "WebSocket connected to $host:$port, waiting for authorization..." } + + // Start receiving messages in a separate coroutine + scope.launch { + receiveLoop(host, port) + } + } catch (e: Exception) { + logger.e(e) { "Failed to connect to $host:$port" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection failed") + disconnect() + } + } + + private suspend fun receiveLoop(host: String, port: Int) { + val currentSession = session ?: return + try { + for (frame in currentSession.incoming) { + when (frame) { + is Frame.Text -> { + val text = frame.readText() + try { + val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + when (event) { + is RemoteControlEvent.Connected -> { + logger.i { "Connection authorized by server" } + _connectionState.value = ConnectionState.Connected(host, port) + } + is RemoteControlEvent.WaitingForPermission -> { + logger.i { "Waiting for permission: ${event.message}" } + // Keep showing connecting state + } + else -> { + // Only emit state updates after connection is established + if (_connectionState.value is ConnectionState.Connected) { + _stateUpdates.emit(event) + } + } + } + } catch (e: Exception) { + logger.w(e) { "Failed to parse message: $text" } + } + } + is Frame.Close -> { + logger.i { "WebSocket closed by server" } + _connectionState.value = ConnectionState.Disconnected + break + } + else -> {} + } + } + } catch (e: Exception) { + logger.e(e) { "Error in receive loop" } + _connectionState.value = ConnectionState.Error(e.message ?: "Connection lost") + } + } + + suspend fun sendCommand(command: RemoteControlCommand) { + val currentSession = session ?: run { + logger.w { "Not connected" } + return + } + + val envelope = CommandEnvelope( + commandId = randomShortId(), + command = command, + ) + + try { + val text = json.encodeToString(CommandEnvelope.serializer(), envelope) + currentSession.send(Frame.Text(text)) + logger.d { "Sent command: $command" } + } catch (e: Exception) { + logger.e(e) { "Failed to send command" } + _connectionState.value = ConnectionState.Error(e.message ?: "Send failed") + } + } + + suspend fun disconnect() { + session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) + session = null + _connectionState.value = ConnectionState.Disconnected + logger.i { "Disconnected" } + } + + private fun randomShortId(): String { + val chars = "0123456789abcdef" + return buildString(8) { + repeat(8) { + append(chars[kotlin.random.Random.nextInt(chars.length)]) + } + } + } +} + +sealed interface ConnectionState { + data object Disconnected : ConnectionState + data object Connecting : ConnectionState + data class Connected(val host: String, val port: Int) : ConnectionState + data class Error(val message: String) : ConnectionState +} \ 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 index 15f24636..f192855a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -70,6 +70,12 @@ class RemoteControlHandler( val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices if (!isAllowed) { + // Send waiting for permission message + val waitingMessage = RemoteControlEvent.WaitingForPermission( + "Waiting for permission from $deviceName..." + ) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage))) + val request = ConnectionRequest( deviceId = deviceId ?: "unknown", deviceName = deviceName, @@ -92,6 +98,8 @@ class RemoteControlHandler( } } + // Send connected message + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected))) logger.i { "Remote control connection established from $deviceName ($deviceId)" } try { 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 index b9cacd5c..5e63d305 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -69,6 +69,14 @@ sealed class RemoteControlCommand { @Serializable sealed class RemoteControlEvent { + @Serializable + @SerialName("connected") + data object Connected : RemoteControlEvent() + + @Serializable + @SerialName("waitingForPermission") + data class WaitingForPermission(val message: String) : RemoteControlEvent() + @Serializable @SerialName("playerState") data class PlayerState( 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 index 0e342da4..207e2f8b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -24,10 +24,18 @@ 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.IO +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlin.coroutines.coroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.random.Random @@ -36,6 +44,10 @@ import kotlin.random.Random * 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). + * + * Registration is retried with backoff: NsdManager is flaky right after a cold + * start, and a single registration attempt is bounded by a short timeout so a + * stalled platform callback can't wedge a dispatcher thread for long. */ class RemoteControlService( private val settingsRepository: SettingsRepository, @@ -45,9 +57,18 @@ class RemoteControlService( private val log = Logger.withTag("RemoteControlService") private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val _localDeviceId = MutableStateFlow("") + val localDeviceId: StateFlow = _localDeviceId.asStateFlow() + private var advertisedService: NetService? = null + private var registerJob: Job? = null init { + // Ensure a stable device id exists and is persisted up front, so discovery + // can reliably filter out this device's own advertisement. + scope.launch { + _localDeviceId.value = resolveDeviceId() + } scope.launch { combine( settingsRepository.userSettings, @@ -56,32 +77,74 @@ class RemoteControlService( .distinctUntilChanged() .collect { (settings, port) -> if (settings.allowRemoteControl && port != null) { - ensureAdvertised(settings.remoteControlDeviceName, port) + if (registerJob?.isActive != true) { + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } } else { + registerJob?.cancel() + registerJob = null stopAdvertising() } } } } - private suspend fun ensureAdvertised(name: String, port: Int) { + /** + * The service name this device advertises under, derived deterministically + * from settings so discovery can match it against the local advertisement. + */ + fun advertisedName(): String { + val deviceId = _localDeviceId.value.ifBlank { + settingsRepository.userSettings.value.remoteControlDeviceId + } + val configured = settingsRepository.userSettings.value.remoteControlDeviceName + return configured.ifBlank { "Spotube-${deviceId.take(6)}" } + } + + /** + * Kicks off (or restarts) the advertising loop. Used when the local-network + * permission is granted at runtime after earlier attempts failed. + */ + fun retryAdvertising() { + val settings = settingsRepository.userSettings.value + val port = localServer.port.value + if (!settings.allowRemoteControl || port == null) return + registerJob?.cancel() + registerJob = scope.launch { + registerLoop(settings.remoteControlDeviceName, port) + } + } + + private suspend fun registerLoop(name: String, port: Int) { val deviceId = resolveDeviceId() + _localDeviceId.value = deviceId val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" } - if (advertisedService == null) { + + var attempt = 0 + while (advertisedService == null && coroutineContext.isActive) { + attempt++ + // The user may have toggled the setting off during backoff. + if (!settingsRepository.userSettings.value.allowRemoteControl) return try { advertisedService = discoveryService.advertise( name = serviceName, port = port, deviceId = deviceId, + registerTimeoutMs = REGISTER_TIMEOUT_MS, ) - log.i { "Advertising remote control service '$serviceName' on port $port" } + log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } } catch (e: Exception) { - log.w(e) { "Failed to advertise remote control service" } + log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + delay(retryDelayMs(attempt)) } } } private suspend fun stopAdvertising() { + registerJob?.cancel() + registerJob = null if (advertisedService != null) { runCatching { advertisedService?.unregister() } advertisedService = null @@ -101,4 +164,14 @@ class RemoteControlService( settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated)) return generated } + + private fun retryDelayMs(attempt: Int): Long = when { + attempt >= 6 -> 5 * 60_000L + attempt >= 3 -> 30_000L + else -> 5_000L + } + + companion object { + private const val REGISTER_TIMEOUT_MS = 4_000L + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt new file mode 100644 index 00000000..39b4fdae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -0,0 +1,117 @@ +/* + * 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +/** + * Manages the play destination picker state and remote playback commands. + * Injected into ViewModels to handle playback actions when a remote device is connected. + */ +class RemotePlaybackController : KoinComponent { + private val logger = Logger.withTag("RemotePlaybackController") + private val remoteControlClient: RemoteControlClient by inject() + + private val _showPicker = MutableStateFlow(false) + val showPicker: StateFlow = _showPicker.asStateFlow() + + private var pendingAction: (() -> Unit)? = null + + /** + * Checks if a remote device is connected. + */ + fun isRemoteConnected(): Boolean { + return remoteControlClient.connectionState.value is ConnectionState.Connected + } + + /** + * Wraps a playback action. If a remote device is connected, shows the picker. + * Otherwise, executes the action immediately. + * + * @param action The action to execute if playing locally + */ + fun wrapPlaybackAction(action: () -> Unit) { + if (isRemoteConnected()) { + pendingAction = action + _showPicker.value = true + } else { + action() + } + } + + /** + * Called when the user chooses to play locally. + */ + fun playLocally() { + _showPicker.value = false + pendingAction?.invoke() + pendingAction = null + } + + /** + * Called when the user chooses to play on the remote device. + * Sends a play command to the remote device. + * + * @param source The source identifier (e.g., playlist ID, album ID, track ID) + */ + fun playOnRemote(source: String) { + _showPicker.value = false + pendingAction = null + + CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + remoteControlClient.sendCommand(RemoteControlCommand.Play(source)) + logger.i { "Sent play command for source: $source" } + } catch (e: Exception) { + logger.e(e) { "Failed to send play command" } + } + } + } + + /** + * Called when the user dismisses the picker. + */ + fun dismissPicker() { + _showPicker.value = false + pendingAction = null + } + + /** + * Sends an add-to-queue command to the remote device. + * + * @param source The source identifier (e.g., playlist ID, album ID, track ID) + */ + fun addToQueueOnRemote(source: String) { + CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source)) + logger.i { "Sent add-to-queue command for source: $source" } + } catch (e: Exception) { + logger.e(e) { "Failed to send add-to-queue command" } + } + } + } +} 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 c61f45c5..e23f9b02 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 @@ -22,6 +22,7 @@ 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.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application import io.ktor.server.application.install @@ -63,7 +64,14 @@ class LocalServer( ) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient() + private val httpClient = HttpClient { + // A stalled upstream connection must not wedge the CIO dispatcher thread + // forever. Only the connect phase is bounded — the proxy streams long + // audio bodies, so request/socket timeouts would cut playback short. + install(HttpTimeout) { + connectTimeoutMillis = 10_000 + } + } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serverMutex = Mutex() 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 index d9b3bf28..b4b4ce9c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -28,9 +28,11 @@ 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.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,7 +44,9 @@ 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.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax @@ -57,10 +61,19 @@ fun DevicesScreen( val viewModel = koinViewModel() val devices by viewModel.devices.collectAsStateWithLifecycle() val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() + val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() DisposableEffect(Unit) { + // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. + requestLocalNetworkPermission() viewModel.startDiscovery() - onDispose { viewModel.stopDiscovery() } + onDispose { + viewModel.stopDiscovery() + viewModel.disconnect() + } } Scaffold( @@ -69,7 +82,7 @@ fun DevicesScreen( backButton = true, title = { Text("Devices") }, actions = { - if (isDiscovering) { + if (isDiscovering && connectingToDevice == null) { CircularProgressIndicator( modifier = Modifier .size(24.dp) @@ -82,7 +95,9 @@ fun DevicesScreen( contentDescription = "Refresh", modifier = Modifier .size(24.dp) - .clickable { viewModel.startDiscovery() }, + .clickable(enabled = connectingToDevice == null) { + viewModel.startDiscovery() + }, ) } }, @@ -91,53 +106,131 @@ fun DevicesScreen( ) { 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) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Error banner + error?.let { errorMessage -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { 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), + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, ) + OutlinedButton( + onClick = { viewModel.clearError() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Dismiss") + } } } } - } 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) }, - ) + + // Connection status + when (val state = connectionState) { + is ConnectionState.Connected -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column { + Text( + text = "Connected to ${state.host}:${state.port}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + OutlinedButton( + onClick = { viewModel.disconnect() }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text("Disconnect") + } + } + } } - item { - Box(modifier = Modifier.padding(bottom = shellBottomInset)) + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Connecting...", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + else -> {} + } + + // Device list + if (devices.isEmpty() && connectingToDevice == null) { + Box( + modifier = Modifier + .fillMaxSize() + .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() + .weight(1f), + 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, + isConnecting = connectingToDevice?.key == device.key, + onClick = { viewModel.connectToDevice(device) }, + ) + } + item { + Box(modifier = Modifier.padding(bottom = shellBottomInset)) + } } } } @@ -147,35 +240,56 @@ fun DevicesScreen( @Composable private fun DeviceRow( device: DiscoveredDevice, + isConnecting: Boolean, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clickable(onClick = onClick, enabled = !isConnecting) .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, - ) + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } Column(modifier = Modifier.weight(1f)) { Text( - text = device.name, + text = device.name.ifBlank { "Unknown Device" }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - text = "${device.host}:${device.port}", + text = if (device.host.isNotBlank() && device.port > 0) { + "${device.host}:${device.port}" + } else { + "Resolving..." + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (device.deviceId.isNotBlank()) { + Text( + text = "ID: ${device.deviceId.take(8)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + 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 index 5c8809f6..435feaaf 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt @@ -20,10 +20,15 @@ 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 dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlService +import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,9 +38,14 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject -class DevicesViewModel : ViewModel(), KoinComponent { +class DevicesViewModel( + private val navigationCommands: NavigationCommands, +) : ViewModel(), KoinComponent { private val logger = Logger.withTag("DevicesViewModel") private val discoveryService: DeviceDiscoveryService by inject() + private val remoteControlClient: RemoteControlClient by inject() + private val remoteControlService: RemoteControlService by inject() + private val settingsProvider: SettingsProvider by inject() private val _devices = MutableStateFlow>(emptyMap()) val devices: StateFlow> = _devices.asStateFlow() @@ -43,30 +53,109 @@ class DevicesViewModel : ViewModel(), KoinComponent { private val _isDiscovering = MutableStateFlow(false) val isDiscovering: StateFlow = _isDiscovering.asStateFlow() + private val _connectingToDevice = MutableStateFlow(null) + val connectingToDevice: StateFlow = _connectingToDevice.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + private var discoveryJob: Job? = null - private var advertisedService: NetService? = null + + init { + // Observe connection state from the client + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + if (state is ConnectionState.Error) { + _error.value = state.message + _connectingToDevice.value = null + } else if (state is ConnectionState.Disconnected) { + _connectingToDevice.value = null + } else if (state is ConnectionState.Connected) { + // Navigate to remote control screen after successful connection + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + } + // Whenever the local device id is resolved, drop any of our own + // advertisements that may have been picked up before we knew our id. + viewModelScope.launch { + remoteControlService.localDeviceId.collect { id -> + if (id.isNotBlank()) { + removeSelf() + } + } + } + } fun startDiscovery() { if (discoveryJob?.isActive == true) return _isDiscovering.value = true + _error.value = null + logger.i { "Starting device discovery" } + // Advertising may have failed before the local-network permission was + // granted; give it another chance now that discovery is being used. + remoteControlService.retryAdvertising() 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 } + try { + discoveryService.discover().collect { event -> + logger.d { "Discovery event: $event" } + when (event) { + is DiscoveryState.Discovered -> { + event.resolve() + if (!isSelf(event.device)) { + _devices.update { it + (event.device.key to event.device.copy()) } + } + } + is DiscoveryState.Resolved -> { + if (isSelf(event.device)) { + // Resolved now carries our deviceId in TXT; drop self. + _devices.update { it - event.device.key } + } else { + _devices.update { it + (event.device.key to event.device) } + } + } + is DiscoveryState.Removed -> { + _devices.update { it - event.device.key } + } } } + } catch (e: Exception) { + logger.e(e) { "Discovery failed" } + _error.value = "Discovery failed: ${e.message}" + _isDiscovering.value = false } } } + /** + * True when [device] is this device's own advertisement. On the initial + * `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is + * empty), so we match by the name we advertise; once resolved we also have + * the authoritative deviceId. + */ + private fun isSelf(device: DiscoveredDevice): Boolean { + val localId = remoteControlService.localDeviceId.value.ifBlank { + settingsProvider.settingsState.value?.remoteControlDeviceId ?: "" + } + if (localId.isNotBlank() && device.deviceId == localId) return true + // Match by the deterministic advertised name as a fallback for the + // pre-resolution event where deviceId isn't available yet. + return device.name.isNotBlank() && device.name == remoteControlService.advertisedName() + } + + private fun removeSelf() { + val localId = remoteControlService.localDeviceId.value + if (localId.isBlank()) return + _devices.update { map -> + map.filterNot { (_, device) -> device.deviceId == localId } + } + } + fun stopDiscovery() { discoveryJob?.cancel() discoveryJob = null @@ -74,6 +163,51 @@ class DevicesViewModel : ViewModel(), KoinComponent { } fun connectToDevice(device: DiscoveredDevice) { + if (_connectingToDevice.value != null) { + logger.w { "Already connecting to a device" } + return + } + if (isSelf(device)) { + logger.w { "Refusing to connect to self: ${device.name}" } + return + } + + _connectingToDevice.value = device + _error.value = null logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" } + + viewModelScope.launch { + try { + val settings = settingsProvider.settingsState.value + val deviceId = settings?.remoteControlDeviceId ?: "" + val deviceName = settings?.remoteControlDeviceName?.ifBlank { "Spotube Controller" } + ?: "Spotube Controller" + + remoteControlClient.connect( + host = device.host, + port = device.port, + deviceId = deviceId, + deviceName = deviceName, + ) + + // Clear connecting state after connection attempt + // The connectionState flow will show the actual connection status + _connectingToDevice.value = null + } catch (e: Exception) { + logger.e(e) { "Failed to connect to device" } + _error.value = "Failed to connect: ${e.message}" + _connectingToDevice.value = null + } + } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } + + fun clearError() { + _error.value = null } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt new file mode 100644 index 00000000..3c412260 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -0,0 +1,88 @@ +/* + * 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.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import org.koin.compose.koinInject + +/** + * Dialog shown when a remote device is connected and the user tries to play/add to queue. + * Allows the user to choose between playing on the local device or the remote device. + */ +@Composable +fun PlayDestinationPicker( + visible: Boolean, + onDismiss: () -> Unit, + onPlayLocally: () -> Unit, + onPlayOnRemote: () -> Unit, +) { + val remoteControlClient = koinInject() + val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + + if (!visible) return + + val remoteDeviceName = when (val state = connectionState) { + is ConnectionState.Connected -> "Remote Device (${state.host})" + else -> "Remote Device" + } + + ThemedDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Play Where?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + content = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Choose where to play this content:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + actions = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + TextButton(onClick = onPlayLocally) { + Text("This Device") + } + TextButton(onClick = onPlayOnRemote) { + Text(remoteDeviceName) + } + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt new file mode 100644 index 00000000..e5f1e7f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -0,0 +1,375 @@ +/* + * 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.background +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.ui.base.GhostIconButton +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.Slider +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious +import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle +import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh +import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RemoteControlScreen( + onDisconnect: () -> Unit, +) { + val viewModel = koinViewModel() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + GhostIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + } + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect", + ) + } + } + ) + } + ) { padding -> + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(padding) + ) + } + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } + } + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } + } + } + } +} + +@Composable +private fun RemoteControlContent( + playerState: RemotePlayerState, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onSetVolume: (Float) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoopMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(32.dp)) + + // Album art + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + ) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = playerState.currentTrackTitle ?: "Unknown Track", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = playerState.currentTrackArtists ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (playerState.currentTrackAlbum != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playerState.currentTrackAlbum!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Seek bar + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Slider( + value = playerState.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(playerState.positionMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(playerState.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // Shuffle + IconButton( + onClick = onToggleShuffle, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Skip previous + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = "Previous", + modifier = Modifier.size(32.dp), + ) + } + + // Play/Pause + IconButton( + onClick = onTogglePlayPause, + modifier = Modifier + .size(72.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(40.dp), + ) + } + + // Skip next + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = "Next", + modifier = Modifier.size(32.dp), + ) + } + + // Loop mode + IconButton( + onClick = onCycleLoopMode, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Volume control + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxVolumeHigh, + contentDescription = "Volume", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + + Slider( + value = playerState.volume, + onValueChange = onSetVolume, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +private fun formatDuration(ms: Long): String { + val duration = ms.milliseconds + val minutes = duration.inWholeMinutes + val seconds = duration.inWholeSeconds % 60 + return "$minutes:${seconds.toString().padStart(2, '0')}" +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt new file mode 100644 index 00000000..65983a86 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -0,0 +1,170 @@ +/* + * 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 dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemoteControlCommand +import dev.krtirtho.spotube.core.remote.RemoteControlEvent +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 + +data class RemotePlayerState( + val isPlaying: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val volume: Float = 1.0f, + val shuffleEnabled: Boolean = false, + val loopMode: String = "none", + val currentTrackId: String? = null, + val currentTrackTitle: String? = null, + val currentTrackArtists: String? = null, + val currentTrackAlbum: String? = null, + val currentTrackCoverUrl: String? = null, +) + +class RemoteControlViewModel : ViewModel(), KoinComponent { + private val logger = Logger.withTag("RemoteControlViewModel") + private val remoteControlClient: RemoteControlClient by inject() + + private val _playerState = MutableStateFlow(RemotePlayerState()) + val playerState: StateFlow = _playerState.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private var stateUpdateJob: Job? = null + + init { + viewModelScope.launch { + remoteControlClient.connectionState.collect { state -> + _connectionState.value = state + } + } + + viewModelScope.launch { + remoteControlClient.stateUpdates.collect { event -> + handleStateUpdate(event) + } + } + } + + private fun handleStateUpdate(event: RemoteControlEvent) { + when (event) { + is RemoteControlEvent.Connected -> { + // Connection already handled in RemoteControlClient + logger.d { "Connection confirmed" } + } + is RemoteControlEvent.WaitingForPermission -> { + // Waiting for permission - no action needed + logger.d { "Waiting for permission: ${event.message}" } + } + is RemoteControlEvent.PlayerState -> { + _playerState.update { + it.copy( + isPlaying = event.isPlaying, + positionMs = event.positionMs, + durationMs = event.durationMs, + volume = event.volume, + shuffleEnabled = event.shuffleEnabled, + loopMode = event.loopMode, + currentTrackId = event.currentTrackId, + currentTrackTitle = event.currentTrackTitle, + currentTrackArtists = event.currentTrackArtists, + currentTrackAlbum = event.currentTrackAlbum, + currentTrackCoverUrl = event.currentTrackCoverUrl, + ) + } + } + is RemoteControlEvent.QueueUpdated -> { + // TODO: Handle queue updates if needed + logger.d { "Queue updated: ${event.entries.size} entries" } + } + is RemoteControlEvent.Ack -> { + logger.d { "Command acknowledged: ${event.commandId}" } + } + is RemoteControlEvent.Error -> { + logger.e { "Remote error: ${event.message}" } + } + } + } + + fun togglePlayPause() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) + } + } + + fun skipNext() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipNext) + } + } + + fun skipPrevious() { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious) + } + } + + fun seek(positionMs: Long) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs)) + } + } + + fun setVolume(volume: Float) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + } + } + + fun toggleShuffle() { + viewModelScope.launch { + val newState = !_playerState.value.shuffleEnabled + remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState)) + } + } + + fun cycleLoopMode() { + viewModelScope.launch { + val currentMode = _playerState.value.loopMode + val newMode = when (currentMode) { + "none" -> "one" + "one" -> "all" + else -> "none" + } + remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode)) + } + } + + fun disconnect() { + viewModelScope.launch { + remoteControlClient.disconnect() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..dfbc15e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -38,6 +38,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView +import dev.krtirtho.spotube.modules.devices.PlayDestinationPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet @@ -59,6 +60,7 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() + val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle() var showEditPlaylist by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) } @@ -169,6 +171,13 @@ fun PlaylistScreen( viewModel.refresh() }, ) + + PlayDestinationPicker( + visible = showPlayDestinationPicker, + onDismiss = viewModel::dismissPlayPicker, + onPlayLocally = viewModel::playLocally, + onPlayOnRemote = viewModel::playOnRemote, + ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 7cd51cbb..e0e3a054 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -98,6 +99,7 @@ class PlaylistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -115,6 +117,8 @@ class PlaylistViewModel( private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() + val showPlayDestinationPicker = remotePlaybackController.showPicker + private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) private val _showAddToPlaylistPicker = MutableStateFlow(false) val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() @@ -223,15 +227,35 @@ class PlaylistViewModel( } fun playPlaylist() { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + remotePlaybackController.wrapPlaybackAction { + viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } + } } fun addPlaylistToQueue() { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + if (remotePlaybackController.isRemoteConnected()) { + remotePlaybackController.addToQueueOnRemote(playlistId) + } else { + viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } + } } fun playPlaylistFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + remotePlaybackController.wrapPlaybackAction { + viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } + } + } + + fun playLocally() { + remotePlaybackController.playLocally() + } + + fun playOnRemote() { + remotePlaybackController.playOnRemote(playlistId) + } + + fun dismissPlayPicker() { + remotePlaybackController.dismissPicker() } fun refresh() { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index e420009a..7601f014 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import spotube.composeapp.generated.resources.* @@ -62,6 +63,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { platformType == PlatformType.MacOS val shellBottomInset = LocalAppShellBottomInset.current + val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val contentPadding = remember(shellBottomInset) { PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) } @@ -106,6 +108,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { settings = settingsState!!, settingsViewModel = settingsViewModel, navigatorCommands = navigatorCommands, + requestLocalNetworkPermission = requestLocalNetworkPermission, ) if (settingsState != null) cacheSection( 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 658e6119..52d0a190 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 @@ -50,6 +50,7 @@ internal fun LazyListScope.playbackSection( settings: UserSettings, settingsViewModel: SettingsViewModel, navigatorCommands: NavigationCommands, + requestLocalNetworkPermission: () -> Unit, ) { val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets) val streamingQualities = availableAudioQualities( @@ -151,6 +152,11 @@ internal fun LazyListScope.playbackSection( settingsViewModel.updateSettings { copy(allowRemoteControl = enabled) } + // Request the local network permission when enabling remote control + // so that DNS-SD registration can succeed on Android 16+ + if (enabled) { + requestLocalNetworkPermission() + } } ) }, diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt new file mode 100644 index 00000000..4b02635f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.ios.kt @@ -0,0 +1,24 @@ +/* + * 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 androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on iOS. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt new file mode 100644 index 00000000..68522b82 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/discovery/LocalNetworkPermission.jvm.kt @@ -0,0 +1,24 @@ +/* + * 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 androidx.compose.runtime.Composable + +/** No runtime local-network permission needed on the JVM. */ +@Composable +actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {} \ No newline at end of file From c5d35bfc935822eceb08cb82f55716380b49141c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 09:07:08 +0600 Subject: [PATCH 05/16] fix(remote-control): update connection handling and improve logging for remote control events --- .../spotube/core/remote/RemoteControlClient.kt | 4 ++++ .../spotube/core/remote/RemoteControlHandler.kt | 17 ++++++++++++----- .../spotube/modules/devices/DevicesScreen.kt | 3 ++- .../modules/devices/RemoteControlScreen.kt | 13 +++++++++---- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index e6d268f9..b88fca90 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -101,13 +101,16 @@ class RemoteControlClient { private suspend fun receiveLoop(host: String, port: Int) { val currentSession = session ?: return + logger.d { "Starting receive loop for $host:$port" } try { for (frame in currentSession.incoming) { when (frame) { is Frame.Text -> { val text = frame.readText() + logger.d { "Received frame: $text" } try { val event = json.decodeFromString(RemoteControlEvent.serializer(), text) + logger.d { "Parsed event: $event" } when (event) { is RemoteControlEvent.Connected -> { logger.i { "Connection authorized by server" } @@ -136,6 +139,7 @@ class RemoteControlClient { else -> {} } } + logger.d { "Receive loop exited normally" } } catch (e: Exception) { logger.e(e) { "Error in receive loop" } _connectionState.value = ConnectionState.Error(e.message ?: "Connection lost") 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 index f192855a..3c709a95 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -74,7 +74,7 @@ class RemoteControlHandler( val waitingMessage = RemoteControlEvent.WaitingForPermission( "Waiting for permission from $deviceName..." ) - session.send(Frame.Text(json.encodeToString(RemoteControlEvent.WaitingForPermission.serializer(), waitingMessage))) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage))) val request = ConnectionRequest( deviceId = deviceId ?: "unknown", @@ -99,14 +99,18 @@ class RemoteControlHandler( } // Send connected message - session.send(Frame.Text(json.encodeToString(RemoteControlEvent.Connected.serializer(), RemoteControlEvent.Connected))) + session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected))) logger.i { "Remote control connection established from $deviceName ($deviceId)" } + // Broadcast initial player state so the controller shows current track info + broadcastState(session) + try { handleControlLoop(session) } catch (e: Exception) { logger.w(e) { "Error in remote control session" } } finally { + logger.d { "Closing session in finally block" } session.close() } } @@ -129,9 +133,11 @@ class RemoteControlHandler( } private suspend fun handleControlLoop(session: WebSocketServerSession) { + logger.d { "Starting control loop for session" } for (frame in session.incoming) { if (frame is Frame.Text) { val text = frame.readText() + logger.d { "Received command: $text" } try { val envelope = json.decodeFromString(CommandEnvelope.serializer(), text) handleCommand(session, envelope) @@ -141,6 +147,7 @@ class RemoteControlHandler( } } } + logger.d { "Control loop exited normally" } } private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) { @@ -197,12 +204,12 @@ class RemoteControlHandler( } private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { - val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId)) + val text = json.encodeToString(RemoteControlEvent.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)) + val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message)) session.send(Frame.Text(text)) } @@ -221,7 +228,7 @@ class RemoteControlHandler( currentTrackAlbum = current?.albumOrNull(), currentTrackCoverUrl = current?.coverUrlOrNull(), ) - val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state) + val text = json.encodeToString(RemoteControlEvent.serializer(), state) session.send(Frame.Text(text)) } 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 index b4b4ce9c..95366a4a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt @@ -72,7 +72,8 @@ fun DevicesScreen( viewModel.startDiscovery() onDispose { viewModel.stopDiscovery() - viewModel.disconnect() + // Don't disconnect here - the connection should persist when navigating to RemoteControlScreen + // The RemoteControlViewModel will manage the connection lifecycle } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index e5f1e7f8..5880ee1f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -57,6 +57,7 @@ import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider 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.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxNext @@ -77,6 +78,7 @@ fun RemoteControlScreen( val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current Scaffold( topBar = { @@ -110,14 +112,15 @@ fun RemoteControlScreen( onSetVolume = viewModel::setVolume, onToggleShuffle = viewModel::toggleShuffle, onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(padding) + modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) ) } is ConnectionState.Connecting -> { Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { Text("Connecting...") @@ -127,7 +130,8 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { Text("Disconnected") @@ -137,7 +141,8 @@ fun RemoteControlScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .padding(bottom = shellBottomInset), contentAlignment = Alignment.Center ) { Text("Connection error: ${(connectionState as ConnectionState.Error).message}") From 1c169d007a081b527e71a1d5a13b1833df6c779c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 09:24:15 +0600 Subject: [PATCH 06/16] feat(remote-control): enhance queue management and player state synchronization --- .../core/remote/RemoteControlClient.kt | 22 +- .../core/remote/RemoteControlHandler.kt | 110 +++++++- .../core/remote/RemoteControlProtocol.kt | 4 + .../modules/devices/RemoteControlScreen.kt | 236 ++++++++++++++++-- .../modules/devices/RemoteControlViewModel.kt | 103 +++++--- 5 files changed, 406 insertions(+), 69 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt index b88fca90..8f2adeea 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlClient.kt @@ -33,11 +33,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -69,8 +66,11 @@ class RemoteControlClient { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private val _stateUpdates = MutableSharedFlow(extraBufferCapacity = 32) - val stateUpdates: SharedFlow = _stateUpdates.asSharedFlow() + private val _latestPlayerState = MutableStateFlow(null) + val latestPlayerState: StateFlow = _latestPlayerState.asStateFlow() + + private val _latestQueue = MutableStateFlow(null) + val latestQueue: StateFlow = _latestQueue.asStateFlow() suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { if (_connectionState.value is ConnectionState.Connected) { @@ -121,9 +121,17 @@ class RemoteControlClient { // Keep showing connecting state } else -> { - // Only emit state updates after connection is established + // Only store state updates after connection is established if (_connectionState.value is ConnectionState.Connected) { - _stateUpdates.emit(event) + when (event) { + is RemoteControlEvent.PlayerState -> { + _latestPlayerState.value = event + } + is RemoteControlEvent.QueueUpdated -> { + _latestQueue.value = event + } + else -> {} + } } } } 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 index 3c709a95..89be1235 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -30,8 +30,14 @@ import io.ktor.websocket.Frame import io.ktor.websocket.close import io.ktor.websocket.readText import kotlin.coroutines.resume +import kotlin.time.TimeSource +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -104,9 +110,20 @@ class RemoteControlHandler( // Broadcast initial player state so the controller shows current track info broadcastState(session) + broadcastQueue(session) try { - handleControlLoop(session) + // Periodically push player state so the controller's progress bar + // stays in sync even when no commands are being sent. + coroutineScope { + launch { + while (isActive) { + delay(1_000) + broadcastState(session) + } + } + handleControlLoop(session) + } } catch (e: Exception) { logger.w(e) { "Error in remote control session" } } finally { @@ -159,10 +176,13 @@ class RemoteControlHandler( audioPlayer.pause() } is RemoteControlCommand.TogglePlayPause -> { - if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) { + val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING + if (isPlaying) { audioPlayer.pause() + waitForPlaybackState(expectPlaying = false) } else { audioPlayer.play() + waitForPlaybackState(expectPlaying = true) } } is RemoteControlCommand.Seek -> { @@ -195,12 +215,27 @@ class RemoteControlHandler( is RemoteControlCommand.AddToQueue -> { logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } } + is RemoteControlCommand.PlayIndex -> { + audioPlayerQueue.jumpTo(command.index) + } is RemoteControlCommand.RemoveFromQueue -> { - audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl) + val queue = audioPlayerQueue.queueFlow.value + val entry = queue.firstOrNull { candidate -> + when (candidate) { + is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl + is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl + } + } + if (entry != null) { + audioPlayerQueue.removeFromQueue(entry) + } else { + logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" } + } } } sendAck(session, envelope.commandId) broadcastState(session) + broadcastQueue(session) } private suspend fun sendAck(session: WebSocketServerSession, commandId: String) { @@ -213,6 +248,20 @@ class RemoteControlHandler( session.send(Frame.Text(text)) } + /** + * Player state changes are applied asynchronously (e.g. ExoPlayer listener + * callbacks posted to the main looper), so after play/pause we poll until + * [playerStateFlow] reflects the expected state before broadcasting it back + * to the controller. Otherwise the client would see a stale (inverted) icon. + */ + private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) { + val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds + while (timeoutAt.hasNotPassedNow()) { + if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return + delay(25) + } + } + private suspend fun broadcastState(session: WebSocketServerSession) { val current = audioPlayerQueue.currentQueueEntryFlow.value val state = RemoteControlEvent.PlayerState( @@ -232,6 +281,58 @@ class RemoteControlHandler( session.send(Frame.Text(text)) } + private suspend fun broadcastQueue(session: WebSocketServerSession) { + val queue = audioPlayerQueue.queueFlow.value + val current = audioPlayerQueue.currentQueueEntryFlow.value + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesCurrent(current) } + } else { + -1 + } + val event = RemoteControlEvent.QueueUpdated( + entries = queue.map { it.toRemoteQueueEntry() }, + currentIndex = currentIndex, + ) + val text = json.encodeToString(RemoteControlEvent.serializer(), event) + session.send(Frame.Text(text)) + } + + private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> { + this.track.id == current.track.id + } + + this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> { + this.url == current.url && this.name == current.name + } + + else -> false + } + } + + private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) { + is QueueEntry.StreamingTrack -> RemoteQueueEntry( + mediaUrl = track.id, + trackId = track.id, + title = track.title, + artists = track.artists.joinToString(", ") { artist -> artist.name }, + album = track.album?.title, + coverUrl = coverUrlOrNull(), + durationMs = track.durationMs, + ) + + is QueueEntry.LocalTrack -> RemoteQueueEntry( + mediaUrl = url, + trackId = url, + title = name, + artists = artists.joinToString(", "), + album = album, + coverUrl = null, + durationMs = duration, + ) + } + private fun QueueEntry.mediaKey(): String = when (this) { is QueueEntry.StreamingTrack -> track.id is QueueEntry.LocalTrack -> url @@ -253,7 +354,8 @@ class RemoteControlHandler( } private fun QueueEntry.coverUrlOrNull(): String? = when (this) { - is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url + is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url is QueueEntry.LocalTrack -> null } } 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 index 5e63d305..f2739792 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -62,6 +62,10 @@ sealed class RemoteControlCommand { @SerialName("addToQueue") data class AddToQueue(val source: String) : RemoteControlCommand() + @Serializable + @SerialName("playIndex") + data class PlayIndex(val index: Int) : RemoteControlCommand() + @Serializable @SerialName("removeFromQueue") data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index 5880ee1f..ed03f6f3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -29,10 +30,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -40,11 +45,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -53,13 +59,22 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry +import dev.krtirtho.spotube.core.ui.base.BaseUITheme import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.ListRowTile +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay @@ -78,6 +93,8 @@ fun RemoteControlScreen( val viewModel = koinViewModel() val playerState by viewModel.playerState.collectAsStateWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() + val queueState by viewModel.queueState.collectAsStateWithLifecycle() + val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current Scaffold( @@ -86,6 +103,19 @@ fun RemoteControlScreen( title = { Text("Remote Control") }, backButton = true, actions = { + GhostIconButton( + onClick = viewModel::toggleQueueVisibility, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } GhostIconButton( onClick = { viewModel.disconnect() @@ -150,6 +180,18 @@ fun RemoteControlScreen( } } } + + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { viewModel.toggleQueueVisibility() }, + modifier = Modifier.fillMaxSize(), + ) { + RemoteQueueSection( + queueState = queueState, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem, + ) + } } @Composable @@ -179,13 +221,28 @@ private fun RemoteControlContent( .fillMaxWidth() .aspectRatio(1f) .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = "Album cover", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) + if (playerState.currentTrackCoverUrl != null) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } + } } Spacer(modifier = Modifier.height(32.dp)) @@ -295,14 +352,16 @@ private fun RemoteControlContent( } // Play/Pause - IconButton( + val baseTheme = LocalBaseUITheme.current + val circlePrimaryIconTheme = remember(baseTheme) { + baseTheme.iconButtons.primary.copy( + shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) + ) + } + PrimaryIconButton( onClick = onTogglePlayPause, - modifier = Modifier - .size(72.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), + modifier = Modifier.size(72.dp), + theme = circlePrimaryIconTheme, ) { Icon( imageVector = if (playerState.isPlaying) { @@ -311,7 +370,6 @@ private fun RemoteControlContent( Iconsax.IconsaxPlay }, contentDescription = if (playerState.isPlaying) "Pause" else "Play", - tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(40.dp), ) } @@ -372,9 +430,153 @@ private fun RemoteControlContent( } } +@Composable +private fun RemoteQueueSection( + queueState: RemoteQueueState, + onPlayQueueItem: (Int) -> Unit, + onRemoveQueueItem: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + ) + + if (queueState.entries.isEmpty()) { + Text( + text = "No queue entries", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed( + items = queueState.entries, + key = { index, entry -> "${entry.mediaUrl}@$index" }, + ) { index, entry -> + RemoteQueueItemRow( + entry = entry, + index = index, + isCurrent = index == queueState.currentIndex, + onPlayClick = { onPlayQueueItem(index) }, + onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }, + ) + } + } + } + } +} + +@Composable +private fun RemoteQueueItemRow( + entry: RemoteQueueEntry, + index: Int, + isCurrent: Boolean, + onPlayClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + ListRowTile( + onClick = onPlayClick, + selected = isCurrent, + modifier = Modifier, + leading = { + Box( + modifier = Modifier + .size(48.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (entry.coverUrl != null) { + AsyncImage( + model = entry.coverUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + title = { + Text( + text = entry.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isCurrent) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + }, + subtitle = { + Text( + text = entry.artists, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailing = { + Text( + text = formatDuration(entry.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box { + GhostIconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Remove from queue") }, + onClick = { + onRemoveClick() + showMenu = false + }, + leadingIcon = { + Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) + }, + ) + } + } + } + ) +} + private fun formatDuration(ms: Long): String { val duration = ms.milliseconds val minutes = duration.inWholeMinutes val seconds = duration.inWholeSeconds % 60 return "$minutes:${seconds.toString().padStart(2, '0')}" -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt index 65983a86..9e329f26 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlViewModel.kt @@ -24,7 +24,7 @@ import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlCommand import dev.krtirtho.spotube.core.remote.RemoteControlEvent -import kotlinx.coroutines.Job +import dev.krtirtho.spotube.core.remote.RemoteQueueEntry import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,6 +47,11 @@ data class RemotePlayerState( val currentTrackCoverUrl: String? = null, ) +data class RemoteQueueState( + val entries: List = emptyList(), + val currentIndex: Int = -1, +) + class RemoteControlViewModel : ViewModel(), KoinComponent { private val logger = Logger.withTag("RemoteControlViewModel") private val remoteControlClient: RemoteControlClient by inject() @@ -57,7 +62,11 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() - private var stateUpdateJob: Job? = null + private val _queueState = MutableStateFlow(RemoteQueueState()) + val queueState: StateFlow = _queueState.asStateFlow() + + private val _isQueueVisible = MutableStateFlow(false) + val isQueueVisible: StateFlow = _isQueueVisible.asStateFlow() init { viewModelScope.launch { @@ -67,52 +76,47 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { } viewModelScope.launch { - remoteControlClient.stateUpdates.collect { event -> - handleStateUpdate(event) + remoteControlClient.latestPlayerState.collect { event -> + if (event != null) { + handlePlayerState(event) + } + } + } + + viewModelScope.launch { + remoteControlClient.latestQueue.collect { event -> + if (event != null) { + handleQueueUpdated(event) + } } } } - private fun handleStateUpdate(event: RemoteControlEvent) { - when (event) { - is RemoteControlEvent.Connected -> { - // Connection already handled in RemoteControlClient - logger.d { "Connection confirmed" } - } - is RemoteControlEvent.WaitingForPermission -> { - // Waiting for permission - no action needed - logger.d { "Waiting for permission: ${event.message}" } - } - is RemoteControlEvent.PlayerState -> { - _playerState.update { - it.copy( - isPlaying = event.isPlaying, - positionMs = event.positionMs, - durationMs = event.durationMs, - volume = event.volume, - shuffleEnabled = event.shuffleEnabled, - loopMode = event.loopMode, - currentTrackId = event.currentTrackId, - currentTrackTitle = event.currentTrackTitle, - currentTrackArtists = event.currentTrackArtists, - currentTrackAlbum = event.currentTrackAlbum, - currentTrackCoverUrl = event.currentTrackCoverUrl, - ) - } - } - is RemoteControlEvent.QueueUpdated -> { - // TODO: Handle queue updates if needed - logger.d { "Queue updated: ${event.entries.size} entries" } - } - is RemoteControlEvent.Ack -> { - logger.d { "Command acknowledged: ${event.commandId}" } - } - is RemoteControlEvent.Error -> { - logger.e { "Remote error: ${event.message}" } - } + private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { + _playerState.update { + it.copy( + isPlaying = event.isPlaying, + positionMs = event.positionMs, + durationMs = event.durationMs, + volume = event.volume, + shuffleEnabled = event.shuffleEnabled, + loopMode = event.loopMode, + currentTrackId = event.currentTrackId, + currentTrackTitle = event.currentTrackTitle, + currentTrackArtists = event.currentTrackArtists, + currentTrackAlbum = event.currentTrackAlbum, + currentTrackCoverUrl = event.currentTrackCoverUrl, + ) } } + private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { + _queueState.value = RemoteQueueState( + entries = event.entries, + currentIndex = event.currentIndex, + ) + } + fun togglePlayPause() { viewModelScope.launch { remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) @@ -162,6 +166,23 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { } } + fun playQueueItem(index: Int) { + if (index < 0) return + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index)) + } + } + + fun removeQueueItem(mediaUrl: String) { + viewModelScope.launch { + remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl)) + } + } + + fun toggleQueueVisibility() { + _isQueueVisible.update { !it } + } + fun disconnect() { viewModelScope.launch { remoteControlClient.disconnect() From 7bf17afc427fdadc83bc8edbed0f6672002b47f0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 12:02:32 +0600 Subject: [PATCH 07/16] feat(remote-control): enhance remote playback functionality with new commands and UI integration --- .../dev/krtirtho/spotube/core/di/Modules.kt | 8 +- .../core/playback/CollectionPlaybackHelper.kt | 51 ++ .../core/remote/RemoteControlHandler.kt | 95 ++- .../core/remote/RemoteControlProtocol.kt | 25 + .../core/remote/RemotePlaybackController.kt | 343 ++++++++-- .../spotube/core/ui/component/AlbumCard.kt | 20 +- .../spotube/core/ui/component/PlaylistCard.kt | 17 +- .../spotube/modules/album/AlbumViewModel.kt | 64 +- .../spotube/modules/artist/ArtistViewModel.kt | 103 ++- .../modules/devices/PlayDestinationPicker.kt | 98 ++- .../modules/devices/RemoteControlScreen.kt | 597 ++++++++++-------- .../modules/playlist/PlaylistScreen.kt | 9 - .../modules/playlist/PlaylistViewModel.kt | 84 +-- .../saved_tracks/SavedTracksViewModel.kt | 54 +- .../spotube/modules/search/SearchScreen.kt | 42 +- .../spotube/modules/shell/AppShell.kt | 2 + 16 files changed, 1012 insertions(+), 600 deletions(-) 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 a2b6fe98..1b3d4f3c 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 @@ -158,6 +158,7 @@ val sharedModules = module { libraryRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -173,6 +174,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -196,6 +198,7 @@ val sharedModules = module { blacklistRepository = get(), shareService = get(), downloadManager = get(), + remotePlaybackController = get(), ) } @@ -208,6 +211,7 @@ val sharedModules = module { albumRepository = get(), playlistRepository = get(), savedTracksRepository = get(), + artistRepository = get(), audioPlayerQueue = get(), blacklistRepository = get(), ) @@ -219,13 +223,13 @@ val sharedModules = module { singleOf(::LocalServer) withOptions { createdAtStart() } - single { RemoteControlHandler(get(), get(), get()) } + single { RemoteControlHandler(get(), get(), get(), get()) } single { RemoteControlClient() } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } - single { RemotePlaybackController() } + single { RemotePlaybackController(get(), get(), get(), get()) } single { JamSessionService(get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 81594eda..7441309c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -22,6 +22,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.modules.album.AlbumRepository +import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository @@ -30,6 +31,7 @@ class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, private val playlistRepository: PlaylistRepository, private val savedTracksRepository: SavedTracksRepository, + private val artistRepository: ArtistRepository, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, ) { @@ -56,6 +58,13 @@ class CollectionPlaybackHelper( } } + suspend fun playAlbumNext(albumId: String) { + val entries = fetchAllAlbumTracks(albumId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) { val entries = fetchAllAlbumTracks(albumId) if (entries.isEmpty()) return @@ -104,6 +113,13 @@ class CollectionPlaybackHelper( } } + suspend fun playPlaylistNext(playlistId: String) { + val entries = fetchAllPlaylistTracks(playlistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) { val entries = fetchAllPlaylistTracks(playlistId) if (entries.isEmpty()) return @@ -142,6 +158,32 @@ class CollectionPlaybackHelper( } } + suspend fun playArtistTopTracks(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + } + + suspend fun addArtistTopTracksToQueue(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllToQueue(entries) + } + } + + suspend fun playArtistTopTracksNext(artistId: String) { + val entries = fetchArtistTopTracks(artistId) + if (entries.isNotEmpty()) { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + suspend fun addSavedTracksToQueue() { val entries = fetchAllSavedTracks() if (entries.isNotEmpty()) { @@ -243,6 +285,15 @@ class CollectionPlaybackHelper( } } + private suspend fun fetchArtistTopTracks(artistId: String): List { + val tracks = artistRepository.topTracks(artistId).orEmpty() + val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return tracks + .filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + } + private fun isTrackBlacklisted( track: MetadataTrack, blacklistedTrackIds: Set, 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 index 89be1235..602fc9f1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt @@ -23,6 +23,7 @@ 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.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.settings.SettingsRepository import io.ktor.server.websocket.WebSocketServerSession import io.ktor.websocket.CloseReason @@ -47,6 +48,7 @@ class RemoteControlHandler( private val settingsRepository: SettingsRepository, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, + private val collectionPlaybackHelper: CollectionPlaybackHelper, ) : KoinComponent { val logger by injectLogger() @@ -170,7 +172,7 @@ class RemoteControlHandler( 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)" } + handleCollectionSource(command.source, RemoteCollectionAction.Play) } is RemoteControlCommand.Pause -> { audioPlayer.pause() @@ -213,7 +215,36 @@ class RemoteControlHandler( audioPlayer.loop(loopState) } is RemoteControlCommand.AddToQueue -> { - logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" } + handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue) + } + is RemoteControlCommand.PlayNext -> { + handleCollectionSource(command.source, RemoteCollectionAction.PlayNext) + } + is RemoteControlCommand.PlayTrack -> { + audioPlayerQueue.load( + entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + is RemoteControlCommand.AddTrackToQueue -> { + audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) + } + is RemoteControlCommand.PlayTrackNext -> { + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = ""))) + } + is RemoteControlCommand.AddTracksToQueue -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllToQueue(entries) + } + is RemoteControlCommand.PlayTracksNext -> { + val entries = command.tracks.map { track -> + QueueEntry.StreamingTrack(track = track, url = "") + } + audioPlayerQueue.addAllAfterCurrent(entries) } is RemoteControlCommand.PlayIndex -> { audioPlayerQueue.jumpTo(command.index) @@ -262,6 +293,55 @@ class RemoteControlHandler( } } + /** + * Resolves a `spotube://` collection source URI (playlist/album/artist top + * tracks/saved tracks) and applies the requested action on the remote queue. + */ + private suspend fun handleCollectionSource(source: String, action: RemoteCollectionAction) { + logger.d { "Remote collection $action for source: $source" } + when { + source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { + val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id) + } + } + + source.startsWith(COLLECTION_ALBUM_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ALBUM_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id) + } + } + + source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> { + val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX) + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id) + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id) + RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id) + } + } + + source == COLLECTION_SAVED_TRACKS -> { + when (action) { + RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks() + RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + RemoteCollectionAction.PlayNext -> { + // Saved tracks "play next" is not supported; add to queue instead + collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + else -> logger.w { "Unknown remote collection source: $source" } + } + } + private suspend fun broadcastState(session: WebSocketServerSession) { val current = audioPlayerQueue.currentQueueEntryFlow.value val state = RemoteControlEvent.PlayerState( @@ -366,6 +446,17 @@ data class CommandEnvelope( val command: RemoteControlCommand, ) +private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" +private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" +private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/" +private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks" + +enum class RemoteCollectionAction { + Play, + AddToQueue, + PlayNext, +} + data class ConnectionRequest( val deviceId: String, val deviceName: String, 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 index f2739792..fc51834e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt @@ -17,6 +17,7 @@ package dev.krtirtho.spotube.core.remote +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -62,6 +63,30 @@ sealed class RemoteControlCommand { @SerialName("addToQueue") data class AddToQueue(val source: String) : RemoteControlCommand() + @Serializable + @SerialName("playNext") + data class PlayNext(val source: String) : RemoteControlCommand() + + @Serializable + @SerialName("playTrack") + data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTrackToQueue") + data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("playTrackNext") + data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand() + + @Serializable + @SerialName("addTracksToQueue") + data class AddTracksToQueue(val tracks: List) : RemoteControlCommand() + + @Serializable + @SerialName("playTracksNext") + data class PlayTracksNext(val tracks: List) : RemoteControlCommand() + @Serializable @SerialName("playIndex") data class PlayIndex(val index: Int) : RemoteControlCommand() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 39b4fdae..a0ba5f5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -18,100 +18,307 @@ package dev.krtirtho.spotube.core.remote import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.IO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent -import org.koin.core.component.inject + +enum class PlaybackDestinationAction { + Play, + AddToQueue, + PlayNext, +} + +enum class RemoteCollectionType { + Playlist, + Album, + ArtistTopTracks, + SavedTracks, +} /** - * Manages the play destination picker state and remote playback commands. - * Injected into ViewModels to handle playback actions when a remote device is connected. + * A playback request awaiting a destination choice (local device vs a connected + * remote device). [title] is the content label shown in the picker dialog. */ -class RemotePlaybackController : KoinComponent { +sealed interface PlaybackDestinationRequest { + val title: String + val action: PlaybackDestinationAction + + data class Collection( + override val title: String, + override val action: PlaybackDestinationAction, + val type: RemoteCollectionType, + val id: String, + val startTrack: MetadataTrack? = null, + ) : PlaybackDestinationRequest + + data class Track( + override val title: String, + override val action: PlaybackDestinationAction, + val track: MetadataTrack, + ) : PlaybackDestinationRequest + + data class Tracks( + override val title: String, + override val action: PlaybackDestinationAction, + val tracks: List, + ) : PlaybackDestinationRequest +} + +/** + * Routes playback actions (play / add to queue / play next) to either the local + * device or a connected remote device. When a remote device is connected the + * user is shown a destination picker; otherwise the action runs locally. + */ +class RemotePlaybackController( + private val remoteControlClient: RemoteControlClient, + private val collectionPlaybackHelper: CollectionPlaybackHelper, + private val audioPlayerQueue: AudioPlayerQueue, + private val blacklistRepository: BlacklistRepository, +) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") - private val remoteControlClient: RemoteControlClient by inject() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val _showPicker = MutableStateFlow(false) - val showPicker: StateFlow = _showPicker.asStateFlow() + private val _pendingRequest = MutableStateFlow(null) + val pendingRequest: StateFlow = _pendingRequest.asStateFlow() - private var pendingAction: (() -> Unit)? = null - - /** - * Checks if a remote device is connected. - */ fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } - /** - * Wraps a playback action. If a remote device is connected, shows the picker. - * Otherwise, executes the action immediately. - * - * @param action The action to execute if playing locally - */ - fun wrapPlaybackAction(action: () -> Unit) { - if (isRemoteConnected()) { - pendingAction = action - _showPicker.value = true - } else { - action() - } + // ---------- Collection actions ---------- + + fun requestCollectionPlay( + type: RemoteCollectionType, + id: String, + title: String, + startTrack: MetadataTrack? = null, + ) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack)) } - /** - * Called when the user chooses to play locally. - */ + fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id)) + } + + fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) { + request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id)) + } + + // ---------- Single track actions ---------- + + fun requestTrackAddToQueue(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track)) + } + + fun requestTrackPlayNext(track: MetadataTrack) { + request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track)) + } + + // ---------- Bulk track actions ---------- + + fun requestTracksAddToQueue(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks)) + } + + fun requestTracksPlayNext(tracks: List, title: String) { + if (tracks.isEmpty()) return + request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks)) + } + + // ---------- Picker resolution ---------- + fun playLocally() { - _showPicker.value = false - pendingAction?.invoke() - pendingAction = null + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeLocally(request) } - /** - * Called when the user chooses to play on the remote device. - * Sends a play command to the remote device. - * - * @param source The source identifier (e.g., playlist ID, album ID, track ID) - */ - fun playOnRemote(source: String) { - _showPicker.value = false - pendingAction = null - - CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - remoteControlClient.sendCommand(RemoteControlCommand.Play(source)) - logger.i { "Sent play command for source: $source" } - } catch (e: Exception) { - logger.e(e) { "Failed to send play command" } - } - } + fun playOnRemote() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + executeOnRemote(request) } - /** - * Called when the user dismisses the picker. - */ fun dismissPicker() { - _showPicker.value = false - pendingAction = null + _pendingRequest.value = null } - /** - * Sends an add-to-queue command to the remote device. - * - * @param source The source identifier (e.g., playlist ID, album ID, track ID) - */ - fun addToQueueOnRemote(source: String) { - CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { - try { - remoteControlClient.sendCommand(RemoteControlCommand.AddToQueue(source)) - logger.i { "Sent add-to-queue command for source: $source" } - } catch (e: Exception) { - logger.e(e) { "Failed to send add-to-queue command" } + // ---------- Internals ---------- + + private fun request(request: PlaybackDestinationRequest) { + if (isRemoteConnected()) { + _pendingRequest.value = request + } else { + executeLocally(request) + } + } + + private fun executeLocally(request: PlaybackDestinationRequest) { + scope.launch { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val startTrack = request.startTrack + when (request.type) { + RemoteCollectionType.Playlist -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playPlaylist(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id) + } + + RemoteCollectionType.Album -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack) + } else { + collectionPlaybackHelper.playAlbum(request.id) + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id) + } + + RemoteCollectionType.ArtistTopTracks -> when (request.action) { + PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id) + } + + RemoteCollectionType.SavedTracks -> when (request.action) { + PlaybackDestinationAction.Play -> { + if (startTrack != null) { + collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) + } else { + collectionPlaybackHelper.playSavedTracks() + } + } + + PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue() + PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue() + } + } + } + + is PlaybackDestinationRequest.Track -> { + val entry = QueueEntry.StreamingTrack(track = request.track, url = "") + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = listOf(entry), + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addToQueue(entry) + } + + PlaybackDestinationAction.PlayNext -> { + val queue = audioPlayerQueue.getQueue() + queue.find { candidate -> + (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true + }?.let { audioPlayerQueue.removeFromQueue(it) } + audioPlayerQueue.addAllAfterCurrent(listOf(entry)) + } + } + } + + is PlaybackDestinationRequest.Tracks -> { + val entries = request.tracks + .filter { track -> !isTrackBlacklisted(track) } + .map { track -> QueueEntry.StreamingTrack(track = track, url = "") } + when (request.action) { + PlaybackDestinationAction.Play -> { + audioPlayerQueue.load( + entries = entries, + autoPlay = true, + startPosition = 0, + collectionEntry = null, + ) + } + + PlaybackDestinationAction.AddToQueue -> { + audioPlayerQueue.addAllToQueue(entries) + } + + PlaybackDestinationAction.PlayNext -> { + audioPlayerQueue.addAllAfterCurrent(entries) + } + } + } } } } -} + + private fun executeOnRemote(request: PlaybackDestinationRequest) { + scope.launch { + try { + val command = when (request) { + is PlaybackDestinationRequest.Collection -> { + val source = when (request.type) { + RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}" + RemoteCollectionType.Album -> "spotube://album/${request.id}" + RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}" + RemoteCollectionType.SavedTracks -> "spotube://saved_tracks" + } + when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source) + } + } + + is PlaybackDestinationRequest.Track -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track) + } + + is PlaybackDestinationRequest.Tracks -> when (request.action) { + PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks) + PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks) + PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks) + } + } + remoteControlClient.sendCommand(command) + logger.i { "Sent remote ${request.action} for ${request.title}" } + } catch (e: Exception) { + logger.e(e) { "Failed to send remote playback command" } + } + } + } + + private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean { + val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() + val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() + return track.id in trackIds || track.artists.any { it.id in artistIds } + } + + private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { + if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id + return title == other.title && + durationMs == other.durationMs && + album?.id == other.album?.id && + artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt index 2e021980..769b8c43 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AlbumCard.kt @@ -20,7 +20,6 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -29,8 +28,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard -import kotlinx.coroutines.launch import org.koin.compose.koinInject @Composable @@ -39,9 +39,9 @@ fun AlbumCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { - val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() PlayableCard( @@ -54,10 +54,18 @@ fun AlbumCard( }, onPlay = { if (currentCollectionEntry?.id == album.id) return@PlayableCard - scope.launch { playbackHelper.playAlbum(album.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addAlbumToQueue(album.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Album, + album.id, + album.title, + ) }, modifier = modifier.width(160.dp), sharedElementKey = "album_art_${album.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt index 1bca2308..cf83ec60 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/PlaylistCard.kt @@ -27,6 +27,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -37,7 +39,8 @@ fun PlaylistCard( modifier: Modifier = Modifier, audioPlayerQueue: AudioPlayerQueue = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(), - navigationCommands: NavigationCommands = koinInject() + navigationCommands: NavigationCommands = koinInject(), + remotePlaybackController: RemotePlaybackController = koinInject(), ) { val scope = rememberCoroutineScope() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() @@ -52,10 +55,18 @@ fun PlaylistCard( }, onPlay = { if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard - scope.launch { playbackHelper.playPlaylist(playlist.id) } + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, onAddToQueue = { - scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) } + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.Playlist, + playlist.id, + playlist.title, + ) }, modifier = modifier, sharedElementKey = "playlist_art_${playlist.id}", diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index f705968c..eb2b2ee0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class AlbumViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -217,15 +220,28 @@ class AlbumViewModel( } fun playAlbum() { - viewModelScope.launch { playbackHelper.playAlbum(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title) } fun addAlbumToQueue() { - viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title) + } + + fun playAlbumNext() { + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title) } fun playAlbumFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Album, + id = albumId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -241,14 +257,20 @@ class AlbumViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -312,33 +334,13 @@ class AlbumViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" + remotePlaybackController.requestTracksPlayNext(tracks, title) } fun isTrackBlacklisted(track: MetadataTrack): Boolean { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index e6af958c..2baa9e9d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -27,6 +27,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -73,6 +75,7 @@ class ArtistViewModel( private val blacklistRepository: BlacklistRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -247,80 +250,40 @@ class ArtistViewModel( } fun addTopTracksToQueue() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionAddToQueue( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracks() { - viewModelScope.launch { - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = 0, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + RemoteCollectionType.ArtistTopTracks, + artistId, + artistName, + ) } fun playTopTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { - val queue = audioPlayerQueue.getQueue() - val queueIndex = queue.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - } - if (queueIndex >= 0) { - audioPlayerQueue.jumpTo(queueIndex) - return@launch - } - - val entries = resolveTopTrackEntries() - if (entries.isEmpty()) return@launch - - val startPosition = entries.indexOfFirst { entry -> - (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }.coerceAtLeast(0) - - audioPlayerQueue.load( - entries = entries, - autoPlay = true, - startPosition = startPosition, - collectionEntry = null, - ) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.ArtistTopTracks, + id = artistId, + title = artistName, + startTrack = track, + ) } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksAddToQueue(tracks, artistName) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" + remotePlaybackController.requestTracksPlayNext(tracks, artistName) } fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { @@ -329,13 +292,19 @@ class ArtistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 3c412260..a5b9f4b9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -17,8 +17,10 @@ package dev.krtirtho.spotube.modules.devices +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -26,38 +28,49 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.remote.ConnectionState +import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient +import dev.krtirtho.spotube.core.remote.RemotePlaybackController +import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxCd +import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import org.koin.compose.koinInject /** - * Dialog shown when a remote device is connected and the user tries to play/add to queue. - * Allows the user to choose between playing on the local device or the remote device. + * Globally hosted dialog shown when a remote device is connected and the user + * tries to play / add to queue / play next. Lets the user choose between the + * local device and the connected remote device(s). */ @Composable -fun PlayDestinationPicker( - visible: Boolean, - onDismiss: () -> Unit, - onPlayLocally: () -> Unit, - onPlayOnRemote: () -> Unit, -) { +fun PlayDestinationPickerHost() { + val controller = koinInject() val remoteControlClient = koinInject() + val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - if (!visible) return + val pendingRequest = request ?: return val remoteDeviceName = when (val state = connectionState) { is ConnectionState.Connected -> "Remote Device (${state.host})" else -> "Remote Device" } + val actionLabel = when (pendingRequest.action) { + PlaybackDestinationAction.Play -> "Play" + PlaybackDestinationAction.AddToQueue -> "Add to queue" + PlaybackDestinationAction.PlayNext -> "Play next" + } + ThemedDialog( - onDismissRequest = onDismiss, + onDismissRequest = controller::dismissPicker, title = { Text( - text = "Play Where?", + text = "Where to $actionLabel?", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) @@ -65,24 +78,69 @@ fun PlayDestinationPicker( content = { Column( modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Choose where to play this content:", + text = "$actionLabel \"${pendingRequest.title}\" on:", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + + ListRowTile( + onClick = controller::playLocally, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxCd, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "This Device", + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + + ListRowTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = remoteDeviceName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel on the connected device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) } }, actions = { - TextButton(onClick = onDismiss) { + TextButton(onClick = controller::dismissPicker) { Text("Cancel") } - TextButton(onClick = onPlayLocally) { - Text("This Device") - } - TextButton(onClick = onPlayOnRemote) { - Text(remoteDeviceName) - } }, ) -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt index ed03f6f3..31949017 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemoteControlScreen.kt @@ -18,6 +18,8 @@ package dev.krtirtho.spotube.modules.devices import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,11 +27,13 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -97,101 +101,131 @@ fun RemoteControlScreen( val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current - Scaffold( - topBar = { - ApplicationMainBar( - title = { Text("Remote Control") }, - backButton = true, - actions = { - GhostIconButton( - onClick = viewModel::toggleQueueVisibility, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = "Queue", - tint = if (isQueueVisible) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - GhostIconButton( - onClick = { - viewModel.disconnect() - onDisconnect() + // Center the mobile-inspired layout and limit its width so it doesn't + // stretch awkwardly on large screens. + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Scaffold( + modifier = Modifier + .fillMaxHeight(), + topBar = { + ApplicationMainBar( + title = { Text("Remote Control") }, + backButton = true, + actions = { + GhostIconButton( + onClick = viewModel::toggleQueueVisibility, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = "Queue", + tint = if (isQueueVisible) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + GhostIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + } + ) { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = "Disconnect", + ) } - ) { - Icon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = "Disconnect", - ) } - } - ) - } - ) { padding -> - when (connectionState) { - is ConnectionState.Connected -> { - RemoteControlContent( - playerState = playerState, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onSetVolume = viewModel::setVolume, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) ) } - is ConnectionState.Connecting -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connecting...") + ) { padding -> + when (connectionState) { + is ConnectionState.Connected -> { + RemoteControlContent( + playerState = playerState, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onSetVolume = viewModel::setVolume, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier.padding(padding).padding(bottom = shellBottomInset) + ) } - } - is ConnectionState.Disconnected -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Disconnected") + is ConnectionState.Connecting -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connecting...") + } } - } - is ConnectionState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + is ConnectionState.Disconnected -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Disconnected") + } + } + is ConnectionState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(bottom = shellBottomInset), + contentAlignment = Alignment.Center + ) { + Text("Connection error: ${(connectionState as ConnectionState.Error).message}") + } } } } } - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize(), - ) { - RemoteQueueSection( - queueState = queueState, - onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem, + // Click-outside scrim for the sliding queue sheet on large screens. + // (The ModalBottomSheet variant has its own built-in scrim.) + if (isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { viewModel.toggleQueueVisibility() }, + ) ) } + + // Keep the sliding sheet above the AppLargePlayer on large screens. + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset), + ) { + QueueSheet( + isVisible = isQueueVisible, + onDismiss = { viewModel.toggleQueueVisibility() }, + modifier = Modifier.fillMaxSize(), + ) { + RemoteQueueSection( + queueState = queueState, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem, + ) + } + } } @Composable @@ -206,227 +240,230 @@ private fun RemoteControlContent( onCycleLoopMode: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(32.dp)) - - // Album art - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), - ) { - if (playerState.currentTrackCoverUrl != null) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = "Album cover", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(48.dp), - ) - } - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Track info + Box (modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .widthIn(max = 480.dp) + .align(Alignment.TopCenter), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = playerState.currentTrackTitle ?: "Unknown Track", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Spacer(modifier = Modifier.height(32.dp)) - Spacer(modifier = Modifier.height(8.dp)) + // Album art + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + if (playerState.currentTrackCoverUrl != null) { + AsyncImage( + model = playerState.currentTrackCoverUrl, + contentDescription = "Album cover", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } + } + } - Text( - text = playerState.currentTrackArtists ?: "Unknown Artist", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Spacer(modifier = Modifier.height(32.dp)) - if (playerState.currentTrackAlbum != null) { - Spacer(modifier = Modifier.height(4.dp)) + // Track info + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text( - text = playerState.currentTrackAlbum!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + text = playerState.currentTrackTitle ?: "Unknown Track", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = playerState.currentTrackArtists ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + + if (playerState.currentTrackAlbum != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playerState.currentTrackAlbum!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } - } - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(32.dp)) - // Seek bar - Column( - modifier = Modifier.fillMaxWidth(), - ) { - Slider( - value = playerState.positionMs.toFloat(), - onValueChange = { onSeek(it.toLong()) }, - valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + // Seek bar + Column( modifier = Modifier.fillMaxWidth(), - ) + ) { + Slider( + value = playerState.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = formatDuration(playerState.positionMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDuration(playerState.durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Playback controls Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = formatDuration(playerState.positionMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Shuffle + IconButton( + onClick = onToggleShuffle, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + // Skip previous + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = "Previous", + modifier = Modifier.size(32.dp), + ) + } + + // Play/Pause + val baseTheme = LocalBaseUITheme.current + val circlePrimaryIconTheme = remember(baseTheme) { + baseTheme.iconButtons.primary.copy( + shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) + ) + } + PrimaryIconButton( + onClick = onTogglePlayPause, + modifier = Modifier.size(72.dp), + theme = circlePrimaryIconTheme, + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(40.dp), + ) + } + + // Skip next + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxNext, + contentDescription = "Next", + modifier = Modifier.size(32.dp), + ) + } + + // Loop mode + IconButton( + onClick = onCycleLoopMode, + modifier = Modifier.size(48.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Volume control + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Iconsax.IconsaxVolumeHigh, + contentDescription = "Volume", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) - Text( - text = formatDuration(playerState.durationMs), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + + Slider( + value = playerState.volume, + onValueChange = onSetVolume, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), ) } + + Spacer(modifier = Modifier.height(32.dp)) } - - Spacer(modifier = Modifier.height(24.dp)) - - // Playback controls - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - // Shuffle - IconButton( - onClick = onToggleShuffle, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = "Shuffle", - tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - - // Skip previous - IconButton( - onClick = onSkipPrevious, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxPrevious, - contentDescription = "Previous", - modifier = Modifier.size(32.dp), - ) - } - - // Play/Pause - val baseTheme = LocalBaseUITheme.current - val circlePrimaryIconTheme = remember(baseTheme) { - baseTheme.iconButtons.primary.copy( - shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape) - ) - } - PrimaryIconButton( - onClick = onTogglePlayPause, - modifier = Modifier.size(72.dp), - theme = circlePrimaryIconTheme, - ) { - Icon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp), - ) - } - - // Skip next - IconButton( - onClick = onSkipNext, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxNext, - contentDescription = "Next", - modifier = Modifier.size(32.dp), - ) - } - - // Loop mode - IconButton( - onClick = onCycleLoopMode, - modifier = Modifier.size(48.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = "Loop mode", - tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Volume control - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Iconsax.IconsaxVolumeHigh, - contentDescription = "Volume", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) - - Slider( - value = playerState.volume, - onValueChange = onSetVolume, - valueRange = 0f..1f, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = Modifier.height(32.dp)) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index dfbc15e1..7a5c0d58 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -38,7 +38,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView -import dev.krtirtho.spotube.modules.devices.PlayDestinationPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormData import dev.krtirtho.spotube.modules.library.playlist.PlaylistFormSheet @@ -60,7 +59,6 @@ fun PlaylistScreen( val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() - val showPlayDestinationPicker by viewModel.showPlayDestinationPicker.collectAsStateWithLifecycle() var showEditPlaylist by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) } @@ -171,13 +169,6 @@ fun PlaylistScreen( viewModel.refresh() }, ) - - PlayDestinationPicker( - visible = showPlayDestinationPicker, - onDismiss = viewModel::dismissPlayPicker, - onPlayLocally = viewModel::playLocally, - onPlayOnRemote = viewModel::playOnRemote, - ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index e0e3a054..8380e8af 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction @@ -117,8 +118,6 @@ class PlaylistViewModel( private val _blacklistedArtistIds = MutableStateFlow>(emptySet()) val blacklistedArtistIds: StateFlow> = _blacklistedArtistIds.asStateFlow() - val showPlayDestinationPicker = remotePlaybackController.showPicker - private val _tracksToAddToPlaylist = MutableStateFlow>(emptyList()) private val _showAddToPlaylistPicker = MutableStateFlow(false) val showAddToPlaylistPicker: StateFlow = _showAddToPlaylistPicker.asStateFlow() @@ -227,35 +226,28 @@ class PlaylistViewModel( } fun playPlaylist() { - remotePlaybackController.wrapPlaybackAction { - viewModelScope.launch { playbackHelper.playPlaylist(playlistId) } - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title) } fun addPlaylistToQueue() { - if (remotePlaybackController.isRemoteConnected()) { - remotePlaybackController.addToQueueOnRemote(playlistId) - } else { - viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) } - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title) + } + + fun playPlaylistNext() { + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title) } fun playPlaylistFromTrack(track: MetadataTrack) { - remotePlaybackController.wrapPlaybackAction { - viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) } - } - } - - fun playLocally() { - remotePlaybackController.playLocally() - } - - fun playOnRemote() { - remotePlaybackController.playOnRemote(playlistId) - } - - fun dismissPlayPicker() { - remotePlaybackController.dismissPicker() + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.Playlist, + id = playlistId, + title = title, + startTrack = track, + ) } fun refresh() { @@ -298,14 +290,20 @@ class PlaylistViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -381,33 +379,13 @@ class PlaylistViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksAddToQueue(tracks, title) } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" + remotePlaybackController.requestTracksPlayNext(tracks, title) } val savedTrackIds diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 41fa1bc7..72d52113 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -34,6 +34,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper +import dev.krtirtho.spotube.core.remote.RemoteCollectionType +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext @@ -97,6 +99,7 @@ class SavedTracksViewModel( private val libraryRepository: LibraryRepository, private val shareService: ShareService, private val downloadManager: DownloadManager, + private val remotePlaybackController: RemotePlaybackController, ) : ViewModel(), KoinComponent { private val logger by injectLogger() @@ -200,15 +203,20 @@ class SavedTracksViewModel( } fun playSavedTracks() { - viewModelScope.launch { playbackHelper.playSavedTracks() } + remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun addSavedTracksToQueue() { - viewModelScope.launch { playbackHelper.addSavedTracksToQueue() } + remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks") } fun playSavedTracksFromTrack(track: MetadataTrack) { - viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) } + remotePlaybackController.requestCollectionPlay( + type = RemoteCollectionType.SavedTracks, + id = SAVED_TRACKS_COLLECTION_ID, + title = "Saved Tracks", + startTrack = track, + ) } fun refresh() { @@ -224,14 +232,20 @@ class SavedTracksViewModel( is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -299,33 +313,11 @@ class SavedTracksViewModel( } fun addTracksToQueue(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } fun playTracksNext(tracks: List) { - viewModelScope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks") } suspend fun isSavedTracks(trackIds: List): List { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 0428244f..b8f699d8 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.ChipTab @@ -121,6 +122,7 @@ private val GridMinCellSize = 180.dp fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val audioPlayerQueue: AudioPlayerQueue = koinInject() val shareService: ShareService = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() val downloadsViewModel: DownloadsViewModel = koinViewModel() val libraryRepository: LibraryRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject() @@ -171,14 +173,20 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.StartRadio -> {} is TrackOptionsAction.PlayNext -> { val queue = audioPlayerQueue.getQueue() - queue.find { entry -> + val existing = queue.find { entry -> (entry as? QueueEntry.StreamingTrack)?.track?.id == track.id - }?.let { audioPlayerQueue.removeFromQueue(it) } - audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } + if (existing != null) { + // Already in the local queue: move it to the next position + audioPlayerQueue.removeFromQueue(existing) + audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = ""))) + } else { + remotePlaybackController.requestTrackPlayNext(track) + } } is TrackOptionsAction.AddToQueue -> { - audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = "")) + remotePlaybackController.requestTrackAddToQueue(track) } is TrackOptionsAction.RemoveFromQueue -> { @@ -237,33 +245,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } fun bulkAddToQueue(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllToQueue(entries) - } + remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") } fun bulkPlayNext(tracks: List) { - scope.launch { - val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet() - val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet() - - val filteredTracks = tracks.filter { track -> - track.id !in blacklistedTrackIds && - track.artists.none { it.id in blacklistedArtistIds } - } - - val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") } - audioPlayerQueue.addAllAfterCurrent(entries) - } + remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } Scaffold( 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 e41b2ef5..14fe94f9 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 @@ -70,6 +70,7 @@ 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.devices.PlayDestinationPickerHost 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 @@ -118,6 +119,7 @@ fun AppShell( } ConnectionRequestDialogHost() + PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { val useSidebar = viewModel.useSidebar() From 0eb07ef9b6486b166f89d638535a3cb7413f02a1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 14:17:37 +0600 Subject: [PATCH 08/16] feat(webrtc): update ICE server configuration and enhance logging for peer connection events --- composeApp/Cargo.lock | 517 ++++++++++++++++-- composeApp/Cargo.toml | 5 +- .../spotube/core/jam/JamSessionService.kt | 42 +- .../spotube/modules/home/HomeScreen.kt | 26 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 3 + composeApp/src/commonMain/rust/webrtc_p2p.rs | 24 +- 6 files changed, 549 insertions(+), 68 deletions(-) diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 428f5064..1897105f 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -29,6 +29,20 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -198,12 +212,24 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -243,6 +269,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -316,6 +351,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.3" @@ -350,6 +394,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -361,6 +416,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "cipher" version = "0.4.4" @@ -369,6 +437,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -379,6 +448,7 @@ dependencies = [ "bytes", "discord-rich-presence", "lofty", + "log", "parking_lot", "rtc", "thiserror 2.0.18", @@ -396,6 +466,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -453,6 +529,18 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -473,12 +561,49 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -520,6 +645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -550,6 +676,41 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -592,6 +753,22 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -722,6 +899,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -747,6 +925,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" @@ -764,6 +952,17 @@ dependencies = [ "scroll", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -782,6 +981,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -911,6 +1119,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -1158,6 +1367,36 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -1203,6 +1442,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1215,12 +1463,45 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1236,6 +1517,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1308,7 +1598,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1360,6 +1650,16 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1406,15 +1706,16 @@ dependencies = [ [[package]] name = "rtc" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9005c36795ad076abd36db3ea9ae0275a60395944647d58c1f2bc3e118dddba" dependencies = [ "bytes", "hex", "log", - "pem", "rand", "rcgen", - "rtc-crypto", + "ring", "rtc-datachannel", "rtc-dtls", "rtc-ice", @@ -1433,31 +1734,16 @@ dependencies = [ "sansio", "serde", "serde_json", + "sha2", "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" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b61c7b8e9892094cba8a6c7fb8120ae863d32d432b00e560177b3d7ae6d2c6" dependencies = [ "bytes", "log", @@ -1468,30 +1754,47 @@ dependencies = [ [[package]] name = "rtc-dtls" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c737b1dd17a0ff63884a2f466b9588f0cffadfce5a724c0c368764d7c27f9c74" dependencies = [ + "aes", "bytecheck", "byteorder", "bytes", + "cbc", + "ccm", + "chacha20poly1305", "der-parser 9.0.0", + "hmac", "log", - "pem", + "p256", + "p384", + "rand", + "rand_core 0.6.4", "rcgen", + "ring", "rkyv", - "rtc-crypto", "rtc-shared", "rustls", + "sec1", + "sha1", + "sha2", + "subtle", + "x25519-dalek", "x509-parser 0.16.0", ] [[package]] name = "rtc-ice" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c06eeabd250a7693e1e8b28222b78c4a81a7c6ca7fe3cb99bbdff2f6c0ff0ab" dependencies = [ "bytes", "crc", "log", - "rtc-crypto", + "rand", "rtc-mdns", "rtc-shared", "rtc-stun", @@ -1503,19 +1806,35 @@ dependencies = [ [[package]] name = "rtc-interceptor" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec2776ab86c0c03c3de8ec742aa13ccc3b2c82317590d0624f251bd37eae3b9" dependencies = [ "log", "rand", + "rtc-interceptor-derive", "rtc-rtcp", "rtc-rtp", "rtc-shared", "sansio", ] +[[package]] +name = "rtc-interceptor-derive" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc31da3875839bc18e00997354a582e6decacdb23d39f8c004fc4fe2fd3fcd8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "rtc-mdns" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cd53120b83321c8b8310cce9512c685d2d6356acef34647a99ea420e6f2249" dependencies = [ "bytes", "log", @@ -1526,7 +1845,9 @@ dependencies = [ [[package]] name = "rtc-media" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0411e44fcdc1363487ae0c6a4612ec908b9b09175d05499c5de0b3c81f0b5e5" dependencies = [ "byteorder", "bytes", @@ -1538,7 +1859,9 @@ dependencies = [ [[package]] name = "rtc-rtcp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fceaadac17b114140368dd68478d186ccfc293970f79cb8dd28f94dbbc0300be" dependencies = [ "bytes", "rtc-shared", @@ -1546,7 +1869,9 @@ dependencies = [ [[package]] name = "rtc-rtp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "babb45ac2263340e2bb412d1921aa07ca441ae9b5589477221d1a50d98750ba3" dependencies = [ "bytes", "memchr", @@ -1557,7 +1882,9 @@ dependencies = [ [[package]] name = "rtc-sctp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2513391b524b88b041277faba617e0531ad726729861ea8933015393fd68ab05" dependencies = [ "bytes", "crc32c", @@ -1571,7 +1898,9 @@ dependencies = [ [[package]] name = "rtc-sdp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d136a422965289e3d32d8e110789d8fa2ac669a4474a4084fa1eeac32758ee" dependencies = [ "rand", "rtc-shared", @@ -1580,12 +1909,19 @@ dependencies = [ [[package]] name = "rtc-shared" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce81c72adbce3a2f2ad8e2b97674d2482be83cb3bfd9a153341b04b1a87ca24" dependencies = [ + "aes", + "aes-gcm", "bitflags 1.3.2", "bytes", "nix", + "p256", "rand", + "rcgen", + "sec1", "serde", "substring", "thiserror 2.0.18", @@ -1595,38 +1931,50 @@ dependencies = [ [[package]] name = "rtc-srtp" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b880d5f6d436b3215c7bcfd3fb5ffe2a752fea0236440ccd98b6bf1c3561df8" dependencies = [ + "aes", "byteorder", "bytes", - "rtc-crypto", + "ctr", + "hmac", + "ring", "rtc-rtcp", "rtc-rtp", "rtc-shared", + "sha1", + "subtle", ] [[package]] name = "rtc-stun" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06b19c36a200df6071b92a25583a19ab0f9c6d5ad036d8c3ff5d392802cc926e" dependencies = [ "base64", "bytes", "crc", "lazy_static", + "md-5", "rand", - "rtc-crypto", + "ring", "rtc-shared", "sansio", + "subtle", "url", ] [[package]] name = "rtc-turn" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf0b5fbb94085c86be7277c38ae8f1c21ec75ab47ba975be0984409732020dd" dependencies = [ "bytes", "log", - "rtc-crypto", "rtc-shared", "rtc-stun", "sansio", @@ -1741,6 +2089,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1816,12 +2178,33 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -1868,6 +2251,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2217,6 +2610,16 @@ dependencies = [ "weedle2", ] +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2320,7 +2723,9 @@ dependencies = [ [[package]] name = "webrtc" -version = "0.21.0-beta.1" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3daa8f2f6366331ae3275a6c02a855c6fb3faa1d16960498d7daaf61c96e76bd" dependencies = [ "async-broadcast", "async-channel", @@ -2468,6 +2873,18 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -2562,6 +2979,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 68dbc246..900cd117 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -9,8 +9,9 @@ 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" } +log = "0.4" +webrtc = "0.20.4" +rtc = "0.20.4" async-trait = "0.1" bytes = "1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } 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 index c85db931..bfcde591 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -121,7 +121,7 @@ class JamSessionService( log.i { "Generating invite $inviteId" } val pc = createWebrtcPeerConnection( - iceServers = listOf(defaultIceServer()), + iceServers = defaultIceServers(), handler = guestEventHandler(inviteId), ) @@ -179,7 +179,7 @@ class JamSessionService( val participantName = resolveParticipantName(defaultPrefix = "Guest") val pc = createWebrtcPeerConnection( - iceServers = listOf(defaultIceServer()), + iceServers = defaultIceServers(), handler = eventHandler, ) @@ -254,10 +254,28 @@ class JamSessionService( sendMessage(JamMessage.SuggestPlaylist(tracks)) } - private fun defaultIceServer() = IceServerConfig( - urls = listOf("stun:stun.l.google.com:19302"), - username = "", - credential = "", + /** + * ICE servers for global peer-to-peer jam sessions: multiple STUN servers for + * NAT traversal plus a TURN relay for symmetric NATs and strict firewalls. + * Unreachable servers no longer stall offer/answer creation — the webrtc + * driver completes gathering once every STUN client has answered or timed out, + * and [WebrtcPeerConnection] bounds the wait anyway. + */ + private fun defaultIceServers(): List = listOf( + IceServerConfig( + urls = listOf( + "stun:stun.cloudflare.com:3478", + "stun:stun1.l.google.com:19302", + "stun:stun.l.google.com:19302", + ), + username = "", + credential = "", + ), + IceServerConfig( + urls = listOf("turn:openrelay.metered.ca:80"), + username = "openrelayproject", + credential = "openrelayproject", + ), ) private suspend fun resolveParticipantName(defaultPrefix: String): String { @@ -271,10 +289,12 @@ class JamSessionService( * attributed back to that guest (needed for kick-on-leave and targeted sends). */ private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) {} + override fun onIceCandidate(candidate: String) { + log.i { "[$guestId] ICE candidate: $candidate" } + } override fun onIceGatheringStateChange(state: String) { - log.d { "[$guestId] ICE gathering state: $state" } + log.i { "[$guestId] ICE gathering state: $state" } } override fun onConnectionStateChange(state: String) { @@ -295,10 +315,12 @@ class JamSessionService( } private val eventHandler = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) {} + override fun onIceCandidate(candidate: String) { + log.i { "ICE candidate: $candidate" } + } override fun onIceGatheringStateChange(state: String) { - log.d { "ICE gathering state: $state" } + log.i { "ICE gathering state: $state" } } override fun onConnectionStateChange(state: String) { 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 012e6947..67924aaf 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 @@ -128,17 +128,19 @@ fun HomeScreen(viewModel: HomeScreenViewModel) { 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", - ) + if (!isDesktop) { + 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", + ) + } } }, ) @@ -355,7 +357,7 @@ private fun HomeSection( if (!subtitle.isNullOrEmpty()) Text( text = subtitle, style = MaterialTheme.typography.labelMedium.copy( - color = MaterialTheme.colorScheme.secondary, + color = MaterialTheme.colorScheme.secondary, fontWeight = FontWeight.Medium, ), modifier = Modifier.padding(horizontal = 16.dp), 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 index 26280783..bb3cc055 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import org.koin.compose.viewmodel.koinViewModel @Composable @@ -61,6 +62,7 @@ fun JamScreen( ) { val viewModel = koinViewModel() val state by viewModel.uiState.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current Scaffold( topBar = { @@ -75,6 +77,7 @@ fun JamScreen( .fillMaxSize() .padding(innerPadding) .padding(16.dp) + .padding(bottom = shellBottomInset) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs index ba3ed9d9..3b60d756 100644 --- a/composeApp/src/commonMain/rust/webrtc_p2p.rs +++ b/composeApp/src/commonMain/rust/webrtc_p2p.rs @@ -16,9 +16,12 @@ */ use std::sync::Arc; +use std::time::Duration; use parking_lot::Mutex; +use rtc::ice::mdns::MulticastDnsMode; use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors; +use rtc::peer_connection::configuration::setting_engine::SettingEngine; use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit}; use webrtc::peer_connection::{ MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, @@ -111,6 +114,13 @@ pub async fn create_webrtc_peer_connection( ) .build(); + // mDNS adds a multicast UDP socket per peer connection. On some platforms + // (notably Android) that socket can stall and ICE gathering then never + // completes. Real-IP host candidates (no mDNS) work fine alongside STUN/TURN, + // so mDNS is disabled. + let mut setting_engine = SettingEngine::default(); + setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled); + let (gather_tx, gather_rx) = channel::<()>(1); let pc_handler = Arc::new(PeerHandlerBridge { handler: Arc::clone(&handler), @@ -119,6 +129,7 @@ pub async fn create_webrtc_peer_connection( let pc = PeerConnectionBuilder::new() .with_configuration(config) + .with_setting_engine(setting_engine) .with_media_engine(media_engine) .with_interceptor_registry(registry) .with_handler(pc_handler) @@ -138,9 +149,20 @@ 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. + /// + /// Bounded by a timeout so a stalled gatherer (e.g. a platform that never reports + /// completion) can never hang `create_offer`/`create_answer` forever — the SDP + /// with the candidates gathered so far is returned instead. async fn wait_for_ice_gathering(&self) { let mut gather_rx = self.gather_rx.lock().clone(); - let _ = gather_rx.recv().await; + match tokio::time::timeout(Duration::from_secs(5), gather_rx.recv()).await { + Ok(_) => {} + Err(_) => { + log::warn!( + "ICE gathering did not complete within 5s; returning SDP with the candidates gathered so far" + ); + } + } } } From 60e38f48688f798940f09fd8203615099ac6056d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 4 Sep 2026 15:46:56 +0600 Subject: [PATCH 09/16] feat(jam-session): add connection state management and update UI feedback for session status --- .../spotube/core/jam/JamSessionService.kt | 15 +++++- .../krtirtho/spotube/modules/jam/JamScreen.kt | 46 ++++++++++++------- .../spotube/modules/jam/JamViewModel.kt | 18 ++++++-- 3 files changed, 56 insertions(+), 23 deletions(-) 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 index bfcde591..b23b25f6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -74,6 +74,9 @@ class JamSessionService( private val _localParticipantId = MutableStateFlow(null) val localParticipantId: StateFlow = _localParticipantId.asStateFlow() + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) val incomingMessages = _incomingMessages.asSharedFlow() @@ -174,7 +177,7 @@ class JamSessionService( return true } - suspend fun joinSession(offerSdp: String): String { + suspend fun joinSession(offerSdp: String, hostName: String? = null): String { log.i { "Joining jam session" } val participantName = resolveParticipantName(defaultPrefix = "Guest") @@ -186,6 +189,13 @@ class JamSessionService( hostConnection = pc _role.value = JamRole.Guest _localParticipantId.value = "guest" + _participants.value = listOf( + JamParticipant( + id = "host", + displayName = hostName?.ifBlank { null } ?: "Host", + isHost = true, + ) + ) _isActive.value = true // The data channel arrives in-band from the host's offer via on_data_channel; @@ -226,6 +236,7 @@ class JamSessionService( _role.value = null _participants.value = emptyList() _isActive.value = false + _isConnected.value = false _localParticipantId.value = null } @@ -303,6 +314,7 @@ class JamSessionService( override fun onDataChannelOpen(label: String) { log.i { "[$guestId] Data channel '$label' open" } + _isConnected.value = true } override fun onDataChannelMessage(label: String, data: String) { @@ -329,6 +341,7 @@ class JamSessionService( override fun onDataChannelOpen(label: String) { log.i { "Data channel '$label' open" } + _isConnected.value = true } override fun onDataChannelMessage(label: String, data: String) { 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 index bb3cc055..84b0e737 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -295,23 +295,35 @@ private fun GuestSessionView( ParticipantsSection(state.participants) val answerLink = state.answerLink - if (answerLink == null) { - Text( - text = "Connecting to the session...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text( - text = "Almost there! Send your answer back to the host:", - style = MaterialTheme.typography.titleSmall, - ) - ShareableLinkBox( - label = "Answer link", - link = answerLink, - onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, - onShare = { onShare(answerLink) }, - ) + when { + state.isConnected -> { + Text( + text = "Connected to the session", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + answerLink == null -> { + Text( + text = "Connecting to the session...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + Text( + text = "Almost there! Send your answer back to the host:", + style = MaterialTheme.typography.titleSmall, + ) + ShareableLinkBox( + label = "Answer link", + link = answerLink, + onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, + onShare = { onShare(answerLink) }, + ) + } } LeaveButton(onLeave) 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 index 2033c322..0b831839 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.launch data class JamUiState( val isActive: Boolean = false, + val isConnected: Boolean = false, val role: JamRole? = null, val participants: List = emptyList(), /** Host: deep link containing this session's SDP offer, ready to share. */ @@ -69,6 +70,7 @@ class JamViewModel( _uiState.update { it.copy( isActive = active, + isConnected = jamSession.isConnected.value, role = jamSession.role.value, participants = jamSession.participants.value, inviteLink = if (!active) null else it.inviteLink, @@ -84,6 +86,11 @@ class JamViewModel( _uiState.update { it.copy(participants = participants) } } } + viewModelScope.launch { + jamSession.isConnected.collect { connected -> + _uiState.update { it.copy(isConnected = connected) } + } + } viewModelScope.launch { deepLinks.pendingLink.collect { link -> handleDeepLink(link) @@ -119,16 +126,17 @@ class JamViewModel( fun joinWithIncomingInvite() { val sdp = _uiState.value.incomingOfferSdp ?: return - join(sdp) + join(sdp, _uiState.value.incomingHostName) } fun joinWithPasted(input: String) { - val sdp = JamInviteCodec.extractSdp(input) + val parsed = JamInviteCodec.parse(input) + val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input) if (sdp == null) { _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") } return } - join(sdp) + join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName) } /** @@ -171,10 +179,10 @@ class JamViewModel( _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) } } - private fun join(offerSdp: String) { + private fun join(offerSdp: String, hostName: String? = null) { viewModelScope.launch { runCatching { - val answer = jamSession.joinSession(offerSdp) + val answer = jamSession.joinSession(offerSdp, hostName) JamInviteCodec.buildGuestAnswer(localName(), answer) }.onSuccess { link -> _uiState.update { From 9ec7704d7baf4b01cedc02f2e52ed1858e9429c6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 5 Sep 2026 09:38:44 +0600 Subject: [PATCH 10/16] feat(jam-session): enhance jam session functionality with improved playback synchronization and error handling --- .../core/audioplayer/AudioPlayer.android.kt | 19 +- .../krtirtho/spotube/media/PlaybackService.kt | 11 +- .../dev/krtirtho/spotube/core/di/Modules.kt | 15 +- .../krtirtho/spotube/core/jam/JamProtocol.kt | 51 ++- .../spotube/core/jam/JamSessionService.kt | 223 ++++++++++-- .../spotube/core/jam/QueueSyncManager.kt | 294 ++++++++++++--- .../core/playback/CollectionPlaybackHelper.kt | 16 + .../core/remote/RemotePlaybackController.kt | 51 ++- .../modules/devices/PlayDestinationPicker.kt | 87 +++-- .../krtirtho/spotube/modules/jam/JamScreen.kt | 343 +++++++++++++++++- .../spotube/modules/jam/JamViewModel.kt | 198 ++++++++++ composeApp/src/commonMain/rust/webrtc_p2p.rs | 47 ++- 12 files changed, 1226 insertions(+), 129 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt index 899cd58f..a002f398 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt @@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.audioplayer import android.content.Context import android.content.Intent import android.os.Build +import android.util.Log import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaMetadata @@ -51,11 +52,19 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface private val appContext: Context = (context as Context).applicationContext private fun ensureServiceStarted() { - val intent = Intent(appContext, PlaybackService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - appContext.startForegroundService(intent) - } else { - appContext.startService(intent) + // On Android 12+ starting a foreground service from the background throws + // (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or + // remote control applies playback while the app is backgrounded. Never let + // that crash the app; playback itself runs in-process without the service. + try { + val intent = Intent(appContext, PlaybackService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + } catch (e: Exception) { + Log.w("AudioPlayer", "Failed to start playback service", e) } } diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt index 6db62be6..3c49b84c 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/media/PlaybackService.kt @@ -86,7 +86,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent { .setOngoing(true) .build() - startForeground(NOTIFICATION_ID, notification) + // On Android 15+ a service created from the background (e.g. by a jam + // sync or queue restoration) hits this in startForeground instead of at + // the startForegroundService call site. Don't crash: playback keeps + // running in-process, just without the notification until the app is + // foregrounded and the service can start properly. + try { + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Log.w(TAG, "startForeground not allowed; continuing without notification", e) + } librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) 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 1b3d4f3c..8bf06e4f 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 @@ -183,7 +183,16 @@ val sharedModules = module { viewModelOf(::BlacklistViewModel) viewModel { DevicesViewModel(get()) } viewModelOf(::RemoteControlViewModel) - viewModelOf(::JamViewModel) + viewModel { + JamViewModel( + jamSession = get(), + deepLinks = get(), + shareService = get(), + settingsProvider = get(), + audioPlayer = get(), + audioPlayerQueue = get(), + ) + } // Album singleOf(::AlbumRepository) @@ -229,8 +238,8 @@ val sharedModules = module { single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() } - single { RemotePlaybackController(get(), get(), get(), get()) } - single { JamSessionService(get(), get()) } + single { RemotePlaybackController(get(), get(), get(), get(), get()) } + single { JamSessionService(get(), get(), get()) } singleOf(::JamDeepLinkService) singleOf(::AudioPlayerQueueRepository) { bind() } single { 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 index ee8808c8..01fafa94 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -17,8 +17,10 @@ package dev.krtirtho.spotube.core.jam +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.MediaItem +import dev.krtirtho.spotube.core.audioplayer.QueueEntry import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -72,6 +74,13 @@ sealed class JamMessage { @SerialName("participantList") data class ParticipantList(val participants: List) : JamMessage() + @Serializable + @SerialName("kick") + data class Kick( + val participantId: String, + val reason: String = "kicked", + ) : JamMessage() + @Serializable @SerialName("leave") data class Leave(val reason: String = "user_left") : JamMessage() @@ -123,6 +132,7 @@ sealed class PlaybackCmd { @Serializable data class JamMediaItem( val url: String, + val trackId: String = "", val title: String, val artist: String, val album: String, @@ -131,6 +141,45 @@ data class JamMediaItem( val protocol: String, ) { companion object { + fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { + is QueueEntry.StreamingTrack -> JamMediaItem( + url = "", + trackId = entry.track.id, + title = entry.track.title, + artist = entry.track.artists.joinToString(", ") { it.name }, + album = entry.track.album?.title.orEmpty(), + durationMs = entry.track.durationMs, + coverUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = entry.protocol.name, + ) + + is QueueEntry.LocalTrack -> JamMediaItem( + url = entry.url, + trackId = "", + title = entry.name, + artist = entry.artists.joinToString(", "), + album = entry.album.orEmpty(), + durationMs = entry.duration, + coverUrl = "", + protocol = "PROGRESSIVE", + ) + } + + fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( + url = "", + trackId = track.id, + title = track.title, + artist = track.artists.joinToString(", ") { it.name }, + album = track.album?.title.orEmpty(), + durationMs = track.durationMs, + coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url + ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url + .orEmpty(), + protocol = "PROGRESSIVE", + ) + fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( url = item.url, title = item.title, @@ -149,7 +198,7 @@ data class JamMediaItem( coverURL = item.coverUrl, url = item.url, protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol - .valueOf(item.protocol), + .valueOf(item.protocol.ifBlank { "PROGRESSIVE" }), ) } } 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 index b23b25f6..d14cbca6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -19,6 +19,7 @@ package dev.krtirtho.spotube.core.jam import co.touchlab.kermit.Logger import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope @@ -49,13 +50,32 @@ data class JamInvite( val sdp: String, ) +/** + * Owns the peer connections of a jam session (star topology: host relays state + * to all guests) and the hello/welcome handshake, participant bookkeeping and + * kick/ban. Playback & queue synchronization itself is delegated to + * [QueueSyncManager], which runs while a session is active. + */ class JamSessionService( private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, private val settingsProvider: SettingsProvider, ) : KoinComponent { val logger by injectLogger() private val log = Logger.withTag("JamSessionService") + /** + * Playback/queue synchronization. Owned by this service (not a Koin bean) so + * the two don't form a circular dependency; it's started/stopped with the + * session lifecycle. + */ + private val queueSyncManager = QueueSyncManager( + audioPlayer = audioPlayer, + audioPlayerQueue = audioPlayerQueue, + jamSession = this, + settingsProvider = settingsProvider, + ) + private val json = Json { ignoreUnknownKeys = true classDiscriminator = "type" @@ -91,23 +111,36 @@ class JamSessionService( /** Host side: guests whose handshake completed. Keyed by invite id. */ private val connectedGuests = mutableMapOf() + /** Host side: guest device ids, used for bans. */ + private val guestDeviceIds = mutableMapOf() + + /** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */ + private val guestConnectionStates = mutableMapOf() + + /** Host side: device ids banned for this session. */ + private val bannedDeviceIds = mutableSetOf() + /** Guest side: the single connection to the host. */ private var hostConnection: WebrtcPeerConnection? = null + private var hostDisplayName: String = "Host" + private var guestDisplayName: String = "Guest" + suspend fun createSession(): String { log.i { "Creating jam session" } - val hostName = resolveParticipantName(defaultPrefix = "Host") + hostDisplayName = resolveParticipantName(defaultPrefix = "Host") _role.value = JamRole.Host _localParticipantId.value = "host" _participants.value = listOf( JamParticipant( id = "host", - displayName = hostName, + displayName = hostDisplayName, isHost = true, ) ) _isActive.value = true + queueSyncManager.start() return generateInvite().sdp } @@ -174,12 +207,13 @@ class JamSessionService( ) } log.i { "Guest $resolvedId ($peerName) joined" } + broadcastParticipantList() return true } suspend fun joinSession(offerSdp: String, hostName: String? = null): String { log.i { "Joining jam session" } - val participantName = resolveParticipantName(defaultPrefix = "Guest") + guestDisplayName = resolveParticipantName(defaultPrefix = "Guest") val pc = createWebrtcPeerConnection( iceServers = defaultIceServers(), @@ -188,7 +222,7 @@ class JamSessionService( hostConnection = pc _role.value = JamRole.Guest - _localParticipantId.value = "guest" + _localParticipantId.value = null _participants.value = listOf( JamParticipant( id = "host", @@ -197,6 +231,7 @@ class JamSessionService( ) ) _isActive.value = true + queueSyncManager.start() // The data channel arrives in-band from the host's offer via on_data_channel; // we only answer here. @@ -210,27 +245,52 @@ class JamSessionService( val payload = json.encodeToString(JamMessage.serializer(), message) when (_role.value) { JamRole.Host -> { - val targets = if (guestId != null) { - listOfNotNull(connectedGuests[guestId]) - } else { - connectedGuests.values.toList() - } - targets.forEach { pc -> + if (guestId != null) { + val pc = connectedGuests[guestId] ?: return runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> log.w(e) { "Failed to send to guest" } } + .onFailure { e -> + log.w(e) { "Failed to send to guest $guestId" } + onSendFailure(guestId) + } + } else { + val dead = mutableListOf() + connectedGuests.forEach { (id, pc) -> + runCatching { pc.sendData(CHANNEL_LABEL, payload) } + .onFailure { e -> + log.w(e) { "Failed to send to guest $id" } + dead += id + } + } + dead.forEach { id -> onSendFailure(id) } } } JamRole.Guest -> { - hostConnection?.sendData(CHANNEL_LABEL, payload) + runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) } + .onFailure { e -> + log.w(e) { "Failed to send to host" } + } } null -> log.w { "sendMessage called while no session is active" } } } + /** + * A send to a guest failed. If that guest's connection has already given up + * (failed/closed), drop them from the session; while the connection is merely + * "connecting" the channel may simply not be open yet, so keep them. + */ + private fun onSendFailure(guestId: String) { + val state = guestConnectionStates[guestId] + if (state == "failed" || state == "closed" || state == "disconnected") { + scope.launch { removeGuest(guestId) } + } + } + suspend fun leave() { log.i { "Leaving jam session" } + queueSyncManager.stop() runCatching { sendMessage(JamMessage.Leave()) } shutdownAll() _role.value = null @@ -238,6 +298,9 @@ class JamSessionService( _isActive.value = false _isConnected.value = false _localParticipantId.value = null + guestDeviceIds.clear() + guestConnectionStates.clear() + bannedDeviceIds.clear() } suspend fun broadcastPlaybackCommand(command: PlaybackCmd) { @@ -265,12 +328,44 @@ class JamSessionService( sendMessage(JamMessage.SuggestPlaylist(tracks)) } + // ---------- Host moderation ---------- + + suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { + if (_role.value != JamRole.Host) return + log.i { "Kicking participant $participantId" } + sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId) + removeGuest(participantId) + } + + suspend fun banParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + val deviceId = guestDeviceIds[participantId] + if (deviceId != null) { + bannedDeviceIds += deviceId + log.i { "Banning device $deviceId (participant $participantId)" } + } + kickParticipant(participantId, "banned by host") + } + + private suspend fun removeGuest(guestId: String) { + val pc = connectedGuests.remove(guestId) + runCatching { pc?.shutdown() } + guestDeviceIds.remove(guestId) + guestConnectionStates.remove(guestId) + _participants.update { current -> + current.filterNot { it.id == guestId } + } + broadcastParticipantList() + } + + private suspend fun broadcastParticipantList() { + if (_role.value != JamRole.Host) return + sendMessage(JamMessage.ParticipantList(_participants.value)) + } + /** * ICE servers for global peer-to-peer jam sessions: multiple STUN servers for * NAT traversal plus a TURN relay for symmetric NATs and strict firewalls. - * Unreachable servers no longer stall offer/answer creation — the webrtc - * driver completes gathering once every STUN client has answered or timed out, - * and [WebrtcPeerConnection] bounds the wait anyway. */ private fun defaultIceServers(): List = listOf( IceServerConfig( @@ -295,6 +390,11 @@ class JamSessionService( ?: "$defaultPrefix-${randomShortId()}" } + private fun localDeviceId(): String { + return settingsProvider.settingsState.value?.remoteControlDeviceId + ?: "device-${randomShortId()}" + } + /** * Per-guest handler so messages received on a guest's connection can be * attributed back to that guest (needed for kick-on-leave and targeted sends). @@ -310,6 +410,10 @@ class JamSessionService( override fun onConnectionStateChange(state: String) { log.i { "[$guestId] Connection state: $state" } + guestConnectionStates[guestId] = state + if (state == "failed" || state == "closed") { + scope.launch { removeGuest(guestId) } + } } override fun onDataChannelOpen(label: String) { @@ -323,6 +427,9 @@ class JamSessionService( override fun onDataChannelClose(label: String) { log.i { "[$guestId] Data channel closed" } + if (_role.value == JamRole.Host) { + scope.launch { removeGuest(guestId) } + } } } @@ -337,11 +444,19 @@ class JamSessionService( override fun onConnectionStateChange(state: String) { log.i { "Connection state: $state" } + if (state == "failed" || state == "closed") { + scope.launch { leave() } + } } override fun onDataChannelOpen(label: String) { log.i { "Data channel '$label' open" } _isConnected.value = true + // Introduce ourselves so the host can fill in our name and hand us + // our participant id. + scope.launch { + sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId())) + } } override fun onDataChannelMessage(label: String, data: String) { @@ -350,6 +465,7 @@ class JamSessionService( override fun onDataChannelClose(label: String) { log.i { "Data channel closed" } + scope.launch { leave() } } } @@ -362,15 +478,44 @@ class JamSessionService( _incomingSuggestions.tryEmit(message) } + is JamMessage.Hello -> { + if (_role.value == JamRole.Host && fromGuestId != null) { + handleHello(fromGuestId, message) + } + } + + is JamMessage.Welcome -> { + if (_role.value == JamRole.Guest) { + _localParticipantId.value = message.participantId + _participants.update { current -> + current.map { participant -> + if (participant.isHost) { + participant.copy(displayName = message.hostName.ifBlank { participant.displayName }) + } else { + participant + } + } + } + log.i { "Welcome: joined as ${message.participantId}" } + } + } + + is JamMessage.ParticipantList -> { + if (_role.value == JamRole.Guest) { + _participants.value = message.participants + } + } + + is JamMessage.Kick -> { + if (_role.value == JamRole.Guest) { + log.i { "Kicked by host: ${message.reason}" } + scope.launch { leave() } + } + } + is JamMessage.Leave -> { if (_role.value == JamRole.Host && fromGuestId != null) { - val leavingPc = connectedGuests.remove(fromGuestId) - scope.launch { - runCatching { leavingPc?.shutdown() } - } - _participants.update { current -> - current.filterNot { it.id == fromGuestId } - } + scope.launch { removeGuest(fromGuestId) } } else if (_role.value == JamRole.Guest) { scope.launch { leave() } } @@ -383,6 +528,40 @@ class JamSessionService( } } + private fun handleHello(guestId: String, hello: JamMessage.Hello) { + val deviceId = hello.deviceId + if (deviceId in bannedDeviceIds) { + log.w { "Rejecting banned device $deviceId" } + scope.launch { + sendMessage( + JamMessage.Kick(guestId, "banned by host"), + guestId = guestId, + ) + removeGuest(guestId) + } + return + } + guestDeviceIds[guestId] = deviceId + _participants.update { current -> + current.map { participant -> + if (participant.id == guestId) { + participant.copy(displayName = hello.displayName.ifBlank { participant.displayName }) + } else { + participant + } + } + } + scope.launch { + sendMessage( + JamMessage.Welcome(hostDisplayName, guestId), + guestId = guestId, + ) + broadcastParticipantList() + // Give the newly joined guest the current queue + playback state. + queueSyncManager.broadcastNow() + } + } + private suspend fun shutdownAll() { pendingInvites.values.forEach { runCatching { it.shutdown() } } connectedGuests.values.forEach { runCatching { it.shutdown() } } 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 index 550de713..104cf223 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt @@ -18,46 +18,61 @@ package dev.krtirtho.spotube.core.jam import co.touchlab.kermit.Logger +import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import kotlinx.coroutines.flow.first /** - * Manages queue synchronization between the host and the jam session. + * Keeps playback in sync across a jam session (star topology). * - * 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. + * On the **host**: applies incoming playback commands and guest suggestions to the + * host's player, and broadcasts the current queue + playback state to all guests + * (on queue changes and periodically, so play/pause/seek/position propagate). * - * 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.) + * On the **guest**: mirrors the host's queue into the local player and applies + * playback commands. The guest's queue is read-only — the host has authority. */ class QueueSyncManager( private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, private val jamSession: JamSessionService, - private val scope: CoroutineScope, + private val settingsProvider: SettingsProvider, ) { private val log = Logger.withTag("QueueSyncManager") - private val json = Json { - ignoreUnknownKeys = true - classDiscriminator = "type" - encodeDefaults = true - } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _isSyncing = MutableStateFlow(false) val isSyncing: StateFlow = _isSyncing.asStateFlow() private var hostBroadcastJob: Job? = null + private var hostCommandJob: Job? = null private var guestApplyJob: Job? = null private var guestCommandJob: Job? = null + /** Guest side: the last applied queue snapshot, used to detect real queue changes. */ + private var lastAppliedItems: List = emptyList() + + /** Guest side: tracks the player was told to start playing from. */ + private var lastAppliedCurrentIndex = -1 + fun start() { if (_isSyncing.value) return _isSyncing.value = true @@ -75,43 +90,87 @@ class QueueSyncManager( fun stop() { _isSyncing.value = false hostBroadcastJob?.cancel() + hostCommandJob?.cancel() guestApplyJob?.cancel() guestCommandJob?.cancel() hostBroadcastJob = null + hostCommandJob = null guestApplyJob = null guestCommandJob = null + lastAppliedItems = emptyList() + lastAppliedCurrentIndex = -1 } + // ---------- Host side ---------- + private fun startHostSync() { + // Apply commands/suggestions coming from guests. + hostCommandJob = scope.launch { + jamSession.role.first { it != null } + if (jamSession.role.value != JamRole.Host) return@launch + + jamSession.incomingMessages.collect { message -> + when (message) { + is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command) + is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem)) + is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks) + else -> {} + } + } + } + + // Broadcast state on queue changes and periodically. 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, - ) - } + // Queue changes (separate coroutine — collect() never returns). + launch { + audioPlayerQueue.queueFlow.collect { + broadcastCurrentState() } } + + // Periodic tick so play/pause/seek/position propagate to guests. + while (isActive) { + delay(2_000) + broadcastCurrentState() + } } } + /** Immediately pushes the current queue + playback state to all guests. */ + suspend fun broadcastNow() { + if (jamSession.role.value == JamRole.Host) { + broadcastCurrentState() + } + } + + private suspend fun broadcastCurrentState() { + val queue = audioPlayerQueue.getQueue() + val current = audioPlayerQueue.getCurrentQueueEntry() + val currentIndex = if (current != null) { + queue.indexOfFirst { it.matchesEntry(current) } + } else { + -1 + } + jamSession.broadcastQueueState( + items = queue.map(JamMediaItem::fromQueueEntry), + currentIndex = currentIndex.coerceAtLeast(0), + isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, + positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, + ) + } + + private suspend fun acceptSuggestion(items: List) { + if (items.isEmpty()) return + val entries = items.map { it.toQueueEntry() } + log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" } + audioPlayerQueue.addAllToQueue(entries) + } + + // ---------- Guest side ---------- + private fun startGuestSync() { guestApplyJob = scope.launch { jamSession.incomingMessages.collect { message -> @@ -130,33 +189,162 @@ class QueueSyncManager( 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), + + // Items that carry neither a track id nor a usable URL can't be played + // on this device — skip them instead of crashing the player. + val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + + val queueChanged = playableItems != lastAppliedItems + if (queueChanged) { + lastAppliedItems = playableItems + lastAppliedCurrentIndex = state.currentIndex + val mediaItems = playableItems.map { it.toPlayableMediaItem() } + runCatching { + audioPlayer.load( + playlist = mediaItems, + autoPlay = state.isPlaying, + startPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex.coerceAtLeast(0)), + ) + }.onFailure { e -> + log.e(e) { "Failed to apply jam queue to local player" } + } + return + } + + // Same queue: just sync playback state. Avoid seeking on every tick unless + // the drift is meaningful. + if (state.currentIndex != lastAppliedCurrentIndex) { + lastAppliedCurrentIndex = state.currentIndex + runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) } + .onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } } + } + val currentState = audioPlayer.playerStateFlow.value + if (state.isPlaying && currentState != PlayerState.PLAYING) { + audioPlayer.play() + } else if (!state.isPlaying && currentState == PlayerState.PLAYING) { + audioPlayer.pause() + } + val driftMs = kotlin.math.abs( + audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs ) + if (driftMs > POSITION_SYNC_THRESHOLD_MS) { + runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) } + .onFailure { e -> log.w(e) { "Failed to sync position" } } + } } 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() + runCatching { + 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) } - 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) + }.onFailure { e -> + log.w(e) { "Failed to apply playback command: $command" } } } + + // ---------- Conversions ---------- + + /** Host side: turn a suggested item into a playable queue entry. */ + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { + trackId.isNotBlank() -> QueueEntry.StreamingTrack( + track = MetadataTrack( + id = trackId, + title = title, + durationMs = durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) + ), + album = null, + thumbnails = null, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = null, + ), + url = "", + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + ) + + else -> QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = durationMs, + album = album.ifBlank { null }, + coverBytes = null, + url = url, + ) + } + + /** + * Guest side: build a playable MediaItem. Streaming tracks have their stream + * URL resolved through this device's own playback proxy (the host never sends + * usable URLs — each guest must fetch from its own plugins). + */ + private suspend fun JamMediaItem.toPlayableMediaItem(): MediaItem { + if (trackId.isNotBlank()) { + val proxyUrl = buildStreamingUrl(trackId, protocol) + return MediaItem( + title = title, + artist = artist, + album = album, + duration = kotlin.time.Duration.parse("${durationMs}ms"), + coverURL = coverUrl, + url = proxyUrl, + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + ) + } + return JamMediaItem.toMediaItem(this) + } + + private suspend fun buildStreamingUrl(trackId: String, protocol: String): String { + val port = settingsProvider.settingsState + .first() + ?.playbackProxyServerPort ?: return "" + val baseUrl = "http://127.0.0.1:$port" + val streamProtocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE) + return when (streamProtocol) { + StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" + StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" + } + } + + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } + } + + companion object { + /** Seek the guest only when its position drifts more than this from the host. */ + private const val POSITION_SYNC_THRESHOLD_MS = 3_000L + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt index 7441309c..da55fb38 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/playback/CollectionPlaybackHelper.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.playlist.PlaylistRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository +import dev.krtirtho.spotube.core.remote.RemoteCollectionType class CollectionPlaybackHelper( private val albumRepository: AlbumRepository, @@ -264,6 +265,21 @@ class CollectionPlaybackHelper( } } + /** + * Resolves the tracks of a collection without loading them into the local + * queue — used to suggest a collection into a jam session from a guest. + */ + suspend fun resolveCollectionTracks(type: RemoteCollectionType, id: String): List = + when (type) { + RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks() + RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks() + RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks() + RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks() + } + + private fun List.asTracks(): List = + mapNotNull { (it as? QueueEntry.StreamingTrack)?.track } + private suspend fun fetchAllSavedTracks(): List { val allTracks = mutableListOf() var pagination = savedTracksRepository.getSavedTracks() diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index a0ba5f5b..01a77c4b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -21,6 +21,9 @@ import co.touchlab.kermit.Logger import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamMediaItem +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope @@ -84,6 +87,7 @@ class RemotePlaybackController( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, + private val jamSession: JamSessionService, ) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -154,6 +158,49 @@ class RemotePlaybackController( _pendingRequest.value = null } + /** + * Routes the pending request into the active jam session. On the host the jam + * queue IS the local queue, so the action runs locally; on a guest the content + * is suggested to the host, which accepts it into the shared queue. + */ + fun playOnJam() { + val request = _pendingRequest.value ?: return + _pendingRequest.value = null + scope.launch { + try { + when (jamSession.role.value) { + JamRole.Host -> executeLocally(request) + JamRole.Guest -> suggestToJam(request) + null -> {} + } + } catch (e: Exception) { + logger.e(e) { "Failed to send content to jam session" } + } + } + } + + private suspend fun suggestToJam(request: PlaybackDestinationRequest) { + when (request) { + is PlaybackDestinationRequest.Collection -> { + val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id) + if (tracks.isNotEmpty()) { + jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() }) + logger.i { "Suggested ${tracks.size} track(s) to the jam session" } + } + } + + is PlaybackDestinationRequest.Track -> { + jamSession.suggestTrack(request.track.toJamMediaItem()) + } + + is PlaybackDestinationRequest.Tracks -> { + if (request.tracks.isNotEmpty()) { + jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() }) + } + } + } + } + // ---------- Internals ---------- private fun request(request: PlaybackDestinationRequest) { @@ -321,4 +368,6 @@ class RemotePlaybackController( album?.id == other.album?.id && artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } } -} \ No newline at end of file +} + +private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index a5b9f4b9..6fd70573 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.krtirtho.spotube.core.jam.JamSessionService import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient @@ -39,19 +40,22 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist import org.koin.compose.koinInject /** - * Globally hosted dialog shown when a remote device is connected and the user - * tries to play / add to queue / play next. Lets the user choose between the - * local device and the connected remote device(s). + * Globally hosted dialog shown when the user tries to play / add to queue / + * play next and there is more than one place it could go (a connected remote + * device and/or an active jam session). Lets the user choose the destination. */ @Composable fun PlayDestinationPickerHost() { val controller = koinInject() val remoteControlClient = koinInject() + val jamSession = koinInject() val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() + val jamActive by jamSession.isActive.collectAsStateWithLifecycle() val pendingRequest = request ?: return @@ -111,30 +115,59 @@ fun PlayDestinationPickerHost() { }, ) - ListRowTile( - onClick = controller::playOnRemote, - modifier = Modifier.fillMaxWidth(), - leading = { - Icon( - imageVector = Iconsax.IconsaxMirroringScreen, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - title = { - Text( - text = remoteDeviceName, - style = MaterialTheme.typography.bodyLarge, - ) - }, - subtitle = { - Text( - text = "$actionLabel on the connected device", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) + if (connectionState is ConnectionState.Connected) { + ListRowTile( + onClick = controller::playOnRemote, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMirroringScreen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = remoteDeviceName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel on the connected device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } + + if (jamActive) { + ListRowTile( + onClick = controller::playOnJam, + modifier = Modifier.fillMaxWidth(), + leading = { + Icon( + imageVector = Iconsax.IconsaxMusicPlaylist, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + title = { + Text( + text = "Jam Session", + style = MaterialTheme.typography.bodyLarge, + ) + }, + subtitle = { + Text( + text = "$actionLabel in the shared jam queue", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } } }, actions = { 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 index 84b0e737..adc4d246 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,17 +17,25 @@ package dev.krtirtho.spotube.modules.jam +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -45,15 +53,31 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.ui.base.IconButton +import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme +import dev.krtirtho.spotube.core.ui.base.copyShape 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.IconsaxArrowDown4 +import dev.krtirtho.spotube.resources.iconsax.IconsaxNext +import dev.krtirtho.spotube.resources.iconsax.IconsaxPause +import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay +import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious +import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import org.koin.compose.viewmodel.koinViewModel @Composable @@ -99,16 +123,34 @@ fun JamScreen( state.role == JamRole.Host -> HostSessionView( state = state, + playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, onNewInvite = viewModel::generateNewInvite, onSubmitAnswer = viewModel::submitAnswerPasted, onShare = viewModel::share, onLeave = viewModel::leave, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onJumpTo = viewModel::jumpTo, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoop = viewModel::cycleLoopMode, + onKick = viewModel::kickParticipant, + onBan = viewModel::banParticipant, ) else -> GuestSessionView( state = state, + playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, onShare = viewModel::share, onLeave = viewModel::leave, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onSkipPrevious = viewModel::skipPrevious, + onSeek = viewModel::seek, + onJumpTo = viewModel::jumpTo, + onToggleShuffle = viewModel::toggleShuffle, + onCycleLoop = viewModel::cycleLoopMode, ) } } @@ -220,16 +262,35 @@ private fun IncomingInviteView( @Composable private fun HostSessionView( state: JamUiState, + playerState: JamPlayerUiState, onNewInvite: () -> Unit, onSubmitAnswer: (String) -> Unit, onShare: (String) -> Unit, onLeave: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onJumpTo: (Int) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoop: () -> Unit, + onKick: (String) -> Unit, + onBan: (String) -> Unit, ) { val clipboard = LocalClipboardManager.current var pastedAnswer by rememberSaveable { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants) + ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan) + + JamNowPlayingView( + playerState = playerState, + onTogglePlayPause = onTogglePlayPause, + onSkipNext = onSkipNext, + onSkipPrevious = onSkipPrevious, + onToggleShuffle = onToggleShuffle, + onCycleLoop = onCycleLoop, + ) HorizontalDivider() @@ -279,6 +340,13 @@ private fun HostSessionView( Text("Accept Answer") } + HorizontalDivider() + + JamQueueView( + queue = playerState.queue, + onJumpTo = onJumpTo, + ) + LeaveButton(onLeave) } } @@ -286,21 +354,37 @@ private fun HostSessionView( @Composable private fun GuestSessionView( state: JamUiState, + playerState: JamPlayerUiState, onShare: (String) -> Unit, onLeave: () -> Unit, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onSeek: (Long) -> Unit, + onJumpTo: (Int) -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoop: () -> Unit, ) { val clipboard = LocalClipboardManager.current Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants) + ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {}) val answerLink = state.answerLink when { state.isConnected -> { - Text( - text = "Connected to the session", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + JamNowPlayingView( + playerState = playerState, + onTogglePlayPause = onTogglePlayPause, + onSkipNext = onSkipNext, + onSkipPrevious = onSkipPrevious, + onToggleShuffle = onToggleShuffle, + onCycleLoop = onCycleLoop, + ) + + JamQueueView( + queue = playerState.queue, + onJumpTo = onJumpTo, ) } @@ -331,7 +415,231 @@ private fun GuestSessionView( } @Composable -private fun ParticipantsSection(participants: List) { +private fun JamNowPlayingView( + playerState: JamPlayerUiState, + onTogglePlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + onToggleShuffle: () -> Unit, + onCycleLoop: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AsyncImage( + model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + .clip(MaterialTheme.shapes.medium), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = playerState.currentTitle ?: "Nothing playing", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = playerState.currentArtist ?: "—", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatJamDuration(playerState.positionMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatJamDuration(playerState.durationMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onToggleShuffle, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = "Shuffle", + tint = if (playerState.shuffleEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + IconButton( + onClick = onSkipPrevious, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") + } + IconButton( + onClick = onTogglePlayPause, + theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape), + modifier = Modifier.size(64.dp), + ) { + Icon( + imageVector = if (playerState.isPlaying) { + Iconsax.IconsaxPause + } else { + Iconsax.IconsaxPlay + }, + contentDescription = if (playerState.isPlaying) "Pause" else "Play", + modifier = Modifier.size(32.dp), + ) + } + IconButton( + onClick = onSkipNext, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon(Iconsax.IconsaxNext, contentDescription = "Next") + } + IconButton( + onClick = onCycleLoop, + theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), + ) { + Icon( + imageVector = Iconsax.IconsaxRepeateMusic, + contentDescription = "Loop mode", + tint = if (playerState.loopMode != "none") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } +} + +@Composable +private fun JamQueueView( + queue: List, + onJumpTo: (Int) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Queue (${queue.size})", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Iconsax.IconsaxArrowDown4, + contentDescription = if (expanded) "Collapse queue" else "Expand queue", + modifier = Modifier + .size(20.dp) + .graphicsLayer { rotationZ = if (expanded) 180f else 0f }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (queue.isEmpty()) { + Text( + text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (expanded) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed(queue) { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onJumpTo(index) } + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + AsyncImage( + model = item.coverUrl.takeIf { it.isNotBlank() }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(40.dp) + .clip(MaterialTheme.shapes.small), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (item.isCurrent) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = item.artist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = formatJamDuration(item.durationMs), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } else { + Text( + text = "Tap to view the shared queue.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ParticipantsSection( + participants: List, + isHost: Boolean, + onKick: (String) -> Unit, + onBan: (String) -> Unit, +) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Participants (${participants.size})", @@ -357,11 +665,32 @@ private fun ParticipantsSection(participants: List = emptyList(), + val currentIndex: Int = -1, + val currentTitle: String? = null, + val currentArtist: String? = null, + val currentCoverUrl: String? = null, + val isPlaying: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val shuffleEnabled: Boolean = false, + val loopMode: String = "none", +) + class JamViewModel( private val jamSession: JamSessionService, private val deepLinks: JamDeepLinkService, private val shareService: ShareService, private val settingsProvider: SettingsProvider, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, ) : ViewModel() { private val _uiState = MutableStateFlow(JamUiState()) @@ -98,6 +136,144 @@ class JamViewModel( } } + /** + * The jam player state: the shared queue + current playback, built from the + * local player (the host's queue IS the jam queue; on guests the synced + * mirror lives in the local player). + */ + val jamPlayerState: StateFlow = combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.playlistFlow, + audioPlayer.currentMediaItemFlow, + audioPlayer.playerStateFlow, + audioPlayer.positionFlow, + audioPlayer.durationFlow, + audioPlayer.loopStateFlow, + audioPlayer.shuffleModeFlow, + ) { values -> + val queue: List = values[0] as List + val currentEntry: QueueEntry? = values[1] as QueueEntry? + val playlist: List = values[2] as List + val currentItem: MediaItem? = values[3] as MediaItem? + val playerState: PlayerState = values[4] as PlayerState + val position: kotlin.time.Duration = values[5] as kotlin.time.Duration + val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration + val loop: LoopState = values[7] as LoopState + val shuffle: Boolean = values[8] as Boolean + + val isHost = jamSession.role.value == JamRole.Host + + val items: List + val currentIndex: Int + val currentTitle: String? + val currentArtist: String? + val currentCoverUrl: String? + + if (isHost) { + val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) } + val index = if (currentEntry != null) { + queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) } + } else { + -1 + } + items = queueItems.mapIndexed { i, item -> + item.toUiItem(i == index) + } + currentIndex = index + currentTitle = queueItems.getOrNull(index)?.title + currentArtist = queueItems.getOrNull(index)?.artist + currentCoverUrl = queueItems.getOrNull(index)?.coverUrl + } else { + val index = playlist.indexOf(currentItem) + items = playlist.mapIndexed { i, item -> + JamMediaItem.fromMediaItem(item).toUiItem(i == index) + } + currentIndex = index + currentTitle = currentItem?.title + currentArtist = currentItem?.artist + currentCoverUrl = currentItem?.coverURL + } + + JamPlayerUiState( + queue = items, + currentIndex = currentIndex, + currentTitle = currentTitle, + currentArtist = currentArtist, + currentCoverUrl = currentCoverUrl, + isPlaying = playerState == PlayerState.PLAYING, + positionMs = position.inWholeMilliseconds, + durationMs = duration.inWholeMilliseconds, + shuffleEnabled = shuffle, + loopMode = loop.name.lowercase(), + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState()) + + // ---------- Playback controls ---------- + + fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle) + + fun skipNext() = sendOrApply(PlaybackCmd.SkipNext) + + fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious) + + fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs)) + + fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index)) + + fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled)) + + fun cycleLoopMode() { + val next = when (jamPlayerState.value.loopMode) { + "none" -> "one" + "one" -> "all" + else -> "none" + } + sendOrApply(PlaybackCmd.SetLoop(next)) + } + + private fun sendOrApply(command: PlaybackCmd) { + viewModelScope.launch { + if (jamSession.role.value == JamRole.Host) { + applyCommandLocally(command) + } else { + jamSession.sendMessage(JamMessage.PlaybackCommand(command)) + } + } + } + + private suspend fun applyCommandLocally(command: PlaybackCmd) { + 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) + } + } + + // ---------- Host moderation ---------- + + fun kickParticipant(participantId: String) { + viewModelScope.launch { jamSession.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + viewModelScope.launch { jamSession.banParticipant(participantId) } + } + fun createSession() { viewModelScope.launch { runCatching { @@ -228,4 +404,26 @@ class JamViewModel( private fun localName(): String = settingsProvider.settingsState.value?.jamParticipantName.orEmpty() +} + +private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem( + id = if (trackId.isNotBlank()) trackId else url, + title = title, + artist = artist, + album = album, + durationMs = durationMs, + coverUrl = coverUrl, + isCurrent = isCurrent, +) + +private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean { + return when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs index 3b60d756..c40e9a69 100644 --- a/composeApp/src/commonMain/rust/webrtc_p2p.rs +++ b/composeApp/src/commonMain/rust/webrtc_p2p.rs @@ -78,7 +78,7 @@ struct DataChannelEntry { pub struct WebrtcPeerConnection { pc: Arc, handler: Arc, - channels: Mutex>, + channels: Arc>>, gather_rx: Mutex>, } @@ -122,9 +122,11 @@ pub async fn create_webrtc_peer_connection( setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled); let (gather_tx, gather_rx) = channel::<()>(1); + let channels = Arc::new(Mutex::new(Vec::new())); let pc_handler = Arc::new(PeerHandlerBridge { handler: Arc::clone(&handler), gather_tx, + channels: Arc::clone(&channels), }); let pc = PeerConnectionBuilder::new() @@ -140,7 +142,7 @@ pub async fn create_webrtc_peer_connection( Ok(Arc::new(WebrtcPeerConnection { pc: Arc::new(pc) as Arc, handler, - channels: Mutex::new(Vec::new()), + channels, gather_rx: Mutex::new(gather_rx), })) } @@ -150,17 +152,34 @@ impl WebrtcPeerConnection { /// candidates (non-trickle exchange). Must be called after `set_local_description`, /// which is what starts gathering. /// - /// Bounded by a timeout so a stalled gatherer (e.g. a platform that never reports - /// completion) can never hang `create_offer`/`create_answer` forever — the SDP - /// with the candidates gathered so far is returned instead. + /// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at + /// least one candidate has landed in the local description, a short grace period + /// is enough — the SDP must never leave candidate-less. Hard cap at 5s. async fn wait_for_ice_gathering(&self) { let mut gather_rx = self.gather_rx.lock().clone(); - match tokio::time::timeout(Duration::from_secs(5), gather_rx.recv()).await { - Ok(_) => {} - Err(_) => { + let started = std::time::Instant::now(); + + loop { + let elapsed = started.elapsed(); + if elapsed >= Duration::from_secs(5) { log::warn!( - "ICE gathering did not complete within 5s; returning SDP with the candidates gathered so far" + "ICE gathering did not complete within 5s; using the candidates gathered so far" ); + return; + } + + match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await { + Ok(Some(())) => return, // gathering complete + Ok(None) => return, // handler dropped + Err(_) => {} // timed out, keep waiting + } + + // Grace period once candidates are present, so the SDP always carries them. + if elapsed >= Duration::from_secs(1) { + let sdp = self.pc.local_description().await.map(|d| d.sdp); + if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) { + return; + } } } } @@ -251,6 +270,7 @@ impl WebrtcPeerConnection { struct PeerHandlerBridge { handler: Arc, gather_tx: webrtc::runtime::Sender<()>, + channels: Arc>>, } #[async_trait::async_trait] @@ -272,6 +292,15 @@ impl PeerConnectionEventHandler for PeerHandlerBridge { } async fn on_data_channel(&self, dc: Arc) { + // Register in-band (remote-initiated) channels so send_data() can find + // them — without this, the answering peer can never send anything. + let label = match dc.label().await { + Ok(l) => l, + Err(_) => return, + }; + self.channels + .lock() + .push(DataChannelEntry { dc: Arc::clone(&dc), label }); spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler)); } } From 571aea8d38077e1b9320b23c1f01b7d32cdfc21c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 5 Sep 2026 09:52:06 +0600 Subject: [PATCH 11/16] feat(remote-control): refactor service registration and cleanup logic for improved reliability --- .../core/audioplayer/AudioPlayer.android.kt | 7 ++- .../core/discovery/DeviceDiscoveryService.kt | 18 ++++-- .../spotube/core/jam/JamSessionService.kt | 1 - .../spotube/core/jam/QueueSyncManager.kt | 56 ++++-------------- .../core/remote/RemoteControlService.kt | 58 ++++++++++++++++--- 5 files changed, 80 insertions(+), 60 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt index a002f398..63f7f49b 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayer.android.kt @@ -235,7 +235,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface actual override suspend fun seekTo(position: Duration) { withContext(Dispatchers.Main) { - val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration) + val duration = exoPlayer.duration + val targetMs = if (duration > 0) { + position.inWholeMilliseconds.coerceIn(0, duration) + } else { + position.inWholeMilliseconds.coerceAtLeast(0) + } exoPlayer.seekTo(targetMs) _position.tryEmit(exoPlayer.currentPosition.milliseconds) } 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 index 04221d6a..25537c3f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt @@ -90,13 +90,19 @@ class DeviceDiscoveryService { deviceId: String, registerTimeoutMs: Long = 5_000, ): NetService { - val service = createNetService( - type = SERVICE_TYPE, - name = name, - port = port, - txt = mapOf(TXT_DEVICE_ID to deviceId), - ) + val service = createService(name, port, deviceId) service.register(timeoutInMs = registerTimeoutMs) return service } + + fun createService( + name: String, + port: Int, + deviceId: String, + ): NetService = createNetService( + type = SERVICE_TYPE, + name = name, + port = port, + txt = mapOf(TXT_DEVICE_ID to deviceId), + ) } \ 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 index d14cbca6..30cf046d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt @@ -73,7 +73,6 @@ class JamSessionService( audioPlayer = audioPlayer, audioPlayerQueue = audioPlayerQueue, jamSession = this, - settingsProvider = settingsProvider, ) private val json = Json { 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 index 104cf223..23e9d26e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt @@ -23,10 +23,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.modules.settings.SettingsProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -54,7 +52,6 @@ class QueueSyncManager( private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, private val jamSession: JamSessionService, - private val settingsProvider: SettingsProvider, ) { private val log = Logger.withTag("QueueSyncManager") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -198,12 +195,15 @@ class QueueSyncManager( if (queueChanged) { lastAppliedItems = playableItems lastAppliedCurrentIndex = state.currentIndex - val mediaItems = playableItems.map { it.toPlayableMediaItem() } + val entries = playableItems.map { it.toQueueEntry() } runCatching { - audioPlayer.load( - playlist = mediaItems, + // Load through the queue repository (like the host does) so the + // stream proxy can resolve the tracks — it only knows tracks in + // queueFlow. + audioPlayerQueue.load( + entries = entries, autoPlay = state.isPlaying, - startPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex.coerceAtLeast(0)), + startPosition = state.currentIndex.coerceIn(0, entries.lastIndex.coerceAtLeast(0)), ) }.onFailure { e -> log.e(e) { "Failed to apply jam queue to local player" } @@ -260,9 +260,10 @@ class QueueSyncManager( } } - // ---------- Conversions ---------- - - /** Host side: turn a suggested item into a playable queue entry. */ + /** + * Build a queue entry from a jam media item. Streaming tracks carry their id + * so the device's own queue/stream proxy can resolve a playable URL later. + */ private fun JamMediaItem.toQueueEntry(): QueueEntry = when { trackId.isNotBlank() -> QueueEntry.StreamingTrack( track = MetadataTrack( @@ -296,41 +297,6 @@ class QueueSyncManager( ) } - /** - * Guest side: build a playable MediaItem. Streaming tracks have their stream - * URL resolved through this device's own playback proxy (the host never sends - * usable URLs — each guest must fetch from its own plugins). - */ - private suspend fun JamMediaItem.toPlayableMediaItem(): MediaItem { - if (trackId.isNotBlank()) { - val proxyUrl = buildStreamingUrl(trackId, protocol) - return MediaItem( - title = title, - artist = artist, - album = album, - duration = kotlin.time.Duration.parse("${durationMs}ms"), - coverURL = coverUrl, - url = proxyUrl, - protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } - .getOrDefault(StreamProtocol.PROGRESSIVE), - ) - } - return JamMediaItem.toMediaItem(this) - } - - private suspend fun buildStreamingUrl(trackId: String, protocol: String): String { - val port = settingsProvider.settingsState - .first() - ?.playbackProxyServerPort ?: return "" - val baseUrl = "http://127.0.0.1:$port" - val streamProtocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } - .getOrDefault(StreamProtocol.PROGRESSIVE) - return when (streamProtocol) { - StreamProtocol.HLS, StreamProtocol.DASH -> "${baseUrl.trimEnd('/')}/manifest/$trackId" - StreamProtocol.PROGRESSIVE -> "${baseUrl.trimEnd('/')}/stream/$trackId" - } - } - private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean { return when { this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> 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 index 207e2f8b..86b920d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlService.kt @@ -22,6 +22,7 @@ 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.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -61,7 +62,12 @@ class RemoteControlService( val localDeviceId: StateFlow = _localDeviceId.asStateFlow() private var advertisedService: NetService? = null + + /** A registration attempt that may have been left pending by the platform. */ + private var pendingService: NetService? = null + private var registerJob: Job? = null + private var cleanupJob: Job? = null init { // Ensure a stable device id exists and is persisted up front, so discovery @@ -112,6 +118,9 @@ class RemoteControlService( val port = localServer.port.value if (!settings.allowRemoteControl || port == null) return registerJob?.cancel() + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null registerJob = scope.launch { registerLoop(settings.remoteControlDeviceName, port) } @@ -127,24 +136,53 @@ class RemoteControlService( attempt++ // The user may have toggled the setting off during backoff. if (!settingsRepository.userSettings.value.allowRemoteControl) return + val service = discoveryService.createService( + name = serviceName, + port = port, + deviceId = deviceId, + ) + pendingService = service try { - advertisedService = discoveryService.advertise( - name = serviceName, - port = port, - deviceId = deviceId, - registerTimeoutMs = REGISTER_TIMEOUT_MS, - ) + service.register(timeoutInMs = REGISTER_TIMEOUT_MS) + pendingService = null + advertisedService = service log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { + pendingService = null log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" } + // The library leaks the platform registration on timeout. Once the + // platform eventually completes it (success), isRegistered flips + // and unregister() will actually remove it — keep trying until then. + scheduleCleanup(service) delay(retryDelayMs(attempt)) } } } + /** + * Repeatedly tries to unregister a service whose registration attempt failed. + * The library's `unregister()` is a no-op while the platform hasn't completed + * the registration, so poll until it has (or give up after a while). + */ + private fun scheduleCleanup(service: NetService?) { + if (service == null) return + cleanupJob?.cancel() + cleanupJob = scope.launch { + repeat(REGISTER_CLEANUP_TRIES) { + delay(1_000) + runCatching { service.unregister() } + } + } + } + private suspend fun stopAdvertising() { registerJob?.cancel() registerJob = null + // Clean up any in-flight registration the cancelled job may have leaked. + scheduleCleanup(pendingService) + pendingService = null if (advertisedService != null) { runCatching { advertisedService?.unregister() } advertisedService = null @@ -172,6 +210,12 @@ class RemoteControlService( } companion object { - private const val REGISTER_TIMEOUT_MS = 4_000L + // Generous enough that the library's timeout (which leaks the platform + // registration) rarely fires on a working system — registration callbacks + // normally arrive within a second. + private const val REGISTER_TIMEOUT_MS = 10_000L + + // How long to keep polling unregister() on a failed service, in seconds. + private const val REGISTER_CLEANUP_TRIES = 15 } } \ No newline at end of file From a2b9f4178c93fc4c2afd2944a11fe4f97d910f5c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 11 Sep 2026 19:35:54 +0600 Subject: [PATCH 12/16] feat(jam-session): integrate MQTT broker configuration and enhance jam session settings --- composeApp/Cargo.lock | 2157 +---------------- composeApp/Cargo.toml | 4 - composeApp/build.gradle.kts | 6 + .../src/androidMain/AndroidManifest.xml | 11 - .../composeResources/values/strings.xml | 14 + .../kotlin/dev/krtirtho/spotube/App.kt | 8 - .../core/audioplayer/AudioPlayerModels.kt | 7 +- .../core/deeplink/JamDeepLinkService.kt | 50 - .../dev/krtirtho/spotube/core/di/Modules.kt | 13 +- .../spotube/core/jam/JamInviteCodec.kt | 105 - .../krtirtho/spotube/core/jam/JamProtocol.kt | 105 +- .../spotube/core/jam/JamRoomClient.kt | 323 +++ .../krtirtho/spotube/core/jam/JamRoomCode.kt | 47 + .../spotube/core/jam/JamRoomService.kt | 475 ++++ .../spotube/core/jam/JamSessionService.kt | 587 ----- .../spotube/core/jam/QueueSyncManager.kt | 316 --- .../core/remote/RemotePlaybackController.kt | 37 +- .../modules/devices/PlayDestinationPicker.kt | 8 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 537 +--- .../spotube/modules/jam/JamViewModel.kt | 428 +--- .../modules/settings/SettingsModels.kt | 21 +- .../modules/settings/SettingsScreen.kt | 6 + .../modules/settings/sections/JamSection.kt | 208 ++ .../spotube/modules/shell/AppShell.kt | 18 + .../PlayerQueueContentViewModel.kt | 7 +- composeApp/src/commonMain/rust/lib.rs | 2 - composeApp/src/commonMain/rust/webrtc_p2p.rs | 340 --- gradle/libs.versions.toml | 6 + 28 files changed, 1361 insertions(+), 4485 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt delete mode 100644 composeApp/src/commonMain/rust/webrtc_p2p.rs diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 1897105f..d8199d57 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -8,41 +8,6 @@ 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 = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "anyhow" version = "1.0.103" @@ -91,97 +56,6 @@ 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" @@ -195,41 +69,12 @@ dependencies = [ "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 = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "basic-toml" version = "0.1.10" @@ -239,74 +84,12 @@ 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 = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -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" @@ -348,38 +131,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[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", + "thiserror", ] [[package]] @@ -388,130 +140,17 @@ 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.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[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 = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] - [[package]] name = "compose-app" version = "0.1.0" dependencies = [ - "async-trait", - "bytes", "discord-rich-presence", "lofty", "log", "parking_lot", - "rtc", - "thiserror 2.0.18", + "thiserror", "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 = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[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]] @@ -523,133 +162,12 @@ 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-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[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 = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[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", - "const-oid", - "crypto-common", - "subtle", -] - [[package]] name = "discord-rich-presence" version = "1.1.0" @@ -661,54 +179,8 @@ dependencies = [ "serde_derive", "serde_json", "serde_repr", - "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]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", + "thiserror", + "uuid", ] [[package]] @@ -724,27 +196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "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", + "windows-sys", ] [[package]] @@ -753,28 +205,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[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" @@ -785,15 +215,6 @@ 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" @@ -803,105 +224,18 @@ 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", - "zeroize", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -922,17 +256,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", ] [[package]] @@ -952,17 +275,6 @@ dependencies = [ "scroll", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -975,134 +287,6 @@ 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 = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[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" @@ -1113,39 +297,12 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "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" @@ -1158,12 +315,6 @@ 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" @@ -1205,31 +356,12 @@ 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" @@ -1246,50 +378,6 @@ 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" @@ -1300,40 +388,6 @@ 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" @@ -1343,66 +397,12 @@ 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 = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[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" @@ -1432,25 +432,6 @@ 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 = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1463,69 +444,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[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 = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1535,39 +459,6 @@ 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" @@ -1583,401 +474,13 @@ 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 0.10.1", - "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 = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[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.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9005c36795ad076abd36db3ea9ae0275a60395944647d58c1f2bc3e118dddba" -dependencies = [ - "bytes", - "hex", - "log", - "rand", - "rcgen", - "ring", - "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", - "sha2", - "unicase", - "url", -] - -[[package]] -name = "rtc-datachannel" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b61c7b8e9892094cba8a6c7fb8120ae863d32d432b00e560177b3d7ae6d2c6" -dependencies = [ - "bytes", - "log", - "rtc-sctp", - "rtc-shared", - "sansio", -] - -[[package]] -name = "rtc-dtls" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c737b1dd17a0ff63884a2f466b9588f0cffadfce5a724c0c368764d7c27f9c74" -dependencies = [ - "aes", - "bytecheck", - "byteorder", - "bytes", - "cbc", - "ccm", - "chacha20poly1305", - "der-parser 9.0.0", - "hmac", - "log", - "p256", - "p384", - "rand", - "rand_core 0.6.4", - "rcgen", - "ring", - "rkyv", - "rtc-shared", - "rustls", - "sec1", - "sha1", - "sha2", - "subtle", - "x25519-dalek", - "x509-parser 0.16.0", -] - -[[package]] -name = "rtc-ice" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c06eeabd250a7693e1e8b28222b78c4a81a7c6ca7fe3cb99bbdff2f6c0ff0ab" -dependencies = [ - "bytes", - "crc", - "log", - "rand", - "rtc-mdns", - "rtc-shared", - "rtc-stun", - "sansio", - "serde", - "url", - "uuid 1.24.1", -] - -[[package]] -name = "rtc-interceptor" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ec2776ab86c0c03c3de8ec742aa13ccc3b2c82317590d0624f251bd37eae3b9" -dependencies = [ - "log", - "rand", - "rtc-interceptor-derive", - "rtc-rtcp", - "rtc-rtp", - "rtc-shared", - "sansio", -] - -[[package]] -name = "rtc-interceptor-derive" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc31da3875839bc18e00997354a582e6decacdb23d39f8c004fc4fe2fd3fcd8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "rtc-mdns" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cd53120b83321c8b8310cce9512c685d2d6356acef34647a99ea420e6f2249" -dependencies = [ - "bytes", - "log", - "rtc-shared", - "sansio", - "socket2", -] - -[[package]] -name = "rtc-media" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0411e44fcdc1363487ae0c6a4612ec908b9b09175d05499c5de0b3c81f0b5e5" -dependencies = [ - "byteorder", - "bytes", - "rand", - "rtc-rtp", - "rtc-shared", - "thiserror 2.0.18", -] - -[[package]] -name = "rtc-rtcp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fceaadac17b114140368dd68478d186ccfc293970f79cb8dd28f94dbbc0300be" -dependencies = [ - "bytes", - "rtc-shared", -] - -[[package]] -name = "rtc-rtp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "babb45ac2263340e2bb412d1921aa07ca441ae9b5589477221d1a50d98750ba3" -dependencies = [ - "bytes", - "memchr", - "rand", - "rtc-shared", - "serde", -] - -[[package]] -name = "rtc-sctp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513391b524b88b041277faba617e0531ad726729861ea8933015393fd68ab05" -dependencies = [ - "bytes", - "crc32c", - "log", - "rand", - "rtc-shared", - "rustc-hash", - "slab", - "thiserror 2.0.18", -] - -[[package]] -name = "rtc-sdp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d136a422965289e3d32d8e110789d8fa2ac669a4474a4084fa1eeac32758ee" -dependencies = [ - "rand", - "rtc-shared", - "url", -] - -[[package]] -name = "rtc-shared" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce81c72adbce3a2f2ad8e2b97674d2482be83cb3bfd9a153341b04b1a87ca24" -dependencies = [ - "aes", - "aes-gcm", - "bitflags 1.3.2", - "bytes", - "nix", - "p256", - "rand", - "rcgen", - "sec1", - "serde", - "substring", - "thiserror 2.0.18", - "url", - "winapi", -] - -[[package]] -name = "rtc-srtp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b880d5f6d436b3215c7bcfd3fb5ffe2a752fea0236440ccd98b6bf1c3561df8" -dependencies = [ - "aes", - "byteorder", - "bytes", - "ctr", - "hmac", - "ring", - "rtc-rtcp", - "rtc-rtp", - "rtc-shared", - "sha1", - "subtle", -] - -[[package]] -name = "rtc-stun" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06b19c36a200df6071b92a25583a19ab0f9c6d5ad036d8c3ff5d392802cc926e" -dependencies = [ - "base64", - "bytes", - "crc", - "lazy_static", - "md-5", - "rand", - "ring", - "rtc-shared", - "sansio", - "subtle", - "url", -] - -[[package]] -name = "rtc-turn" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf0b5fbb94085c86be7277c38ae8f1c21ec75ab47ba975be0984409732020dd" -dependencies = [ - "bytes", - "log", - "rtc-shared", - "rtc-stun", - "sansio", + "bitflags", ] [[package]] @@ -1986,83 +489,19 @@ 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 2.13.1", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] -[[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" @@ -2089,20 +528,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "semver" version = "1.0.28" @@ -2167,68 +592,18 @@ 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 = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -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 = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[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" @@ -2241,53 +616,12 @@ 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 = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[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" @@ -2310,17 +644,6 @@ 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" @@ -2331,7 +654,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2343,33 +666,13 @@ 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 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", + "thiserror-impl", ] [[package]] @@ -2383,73 +686,14 @@ 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]] @@ -2472,18 +716,6 @@ 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" @@ -2610,40 +842,6 @@ dependencies = [ "weedle2", ] -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[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" @@ -2653,92 +851,12 @@ 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.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3daa8f2f6366331ae3275a6c02a855c6fb3faa1d16960498d7daaf61c96e76bd" -dependencies = [ - "async-broadcast", - "async-channel", - "async-trait", - "bytes", - "event-listener", - "futures", - "log", - "quinn-udp", - "rtc", - "tokio", -] - [[package]] name = "weedle2" version = "5.0.0" @@ -2748,43 +866,12 @@ 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" @@ -2794,70 +881,6 @@ 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" @@ -2867,166 +890,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", - "zeroize", -] - -[[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" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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 900cd117..8a06d501 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -10,10 +10,6 @@ discord-rich-presence = "1.1.0" thiserror = "2.0" parking_lot = "0.12" log = "0.4" -webrtc = "0.20.4" -rtc = "0.20.4" -async-trait = "0.1" -bytes = "1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } [lib] diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 54122dfd..cdca27ed 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -166,6 +166,12 @@ kotlin { // DLNA implementation(libs.dns.sd.kt) + + // mqtt client for jam-session + implementation(libs.mqtt.client) + implementation(libs.mqtt.x.models) + implementation(libs.mqtt.buffer) + implementation(libs.mqtt.buffer.codec) } } commonTest.dependencies { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 3245b681..9e832592 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -55,17 +55,6 @@ - - - - - - - - - Send and receive data over the internet WebView Display web content inside the app + Group Jam + MQTT Broker + Broker host + %1$s:%2$d + Broker port + Use TLS + Username (optional) + Password (optional) + Client ID prefix + Test connection + Testing… + Connected in %1$d ms + Failed: %1$s + Placeholder broker — configure your own to self-host diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 92aa8241..ee165e9b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -33,8 +33,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay -import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES @@ -106,12 +104,6 @@ fun App( val settingsRepository: SettingsRepository = koinInject() val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) - val jamDeepLinks: JamDeepLinkService = koinInject() - DisposableEffect(Unit) { - ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) } - onDispose { ExternalUriHandler.listener = null } - } - val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt index b2864360..1562a2e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.Serializable @Serializable sealed interface QueueEntry { val url: String + val addedBy: String @Serializable @SerialName("streaming") @@ -32,6 +33,7 @@ sealed interface QueueEntry { val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + override val addedBy: String = "", ) : QueueEntry @Serializable @@ -42,7 +44,8 @@ sealed interface QueueEntry { val duration: Long, val album: String?, val coverBytes: ByteArray?, - override val url: String + override val url: String, + override val addedBy: String = "", ) : QueueEntry { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -56,6 +59,7 @@ sealed interface QueueEntry { if (album != other.album) return false if (!coverBytes.contentEquals(other.coverBytes)) return false if (url != other.url) return false + if (addedBy != other.addedBy) return false return true } @@ -67,6 +71,7 @@ sealed interface QueueEntry { result = 31 * result + (album?.hashCode() ?: 0) result = 31 * result + (coverBytes?.contentHashCode() ?: 0) result = 31 * result + url.hashCode() + result = 31 * result + addedBy.hashCode() return result } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt deleted file mode 100644 index 32e529db..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.deeplink - -import dev.krtirtho.spotube.core.jam.JamInviteCodec -import dev.krtirtho.spotube.core.jam.JamInviteLink -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI, - * and navigates to [Routes.Jam] so the user lands where the link is handled. - */ -class JamDeepLinkService( - private val navigationCommands: NavigationCommands, -) { - private val _pendingLink = MutableStateFlow(null) - val pendingLink: StateFlow = _pendingLink.asStateFlow() - - fun handleUri(uri: String) { - val link = JamInviteCodec.parse(uri) ?: return - _pendingLink.value = link - navigationCommands.navigateTo(Routes.Jam) - } - - /** Consumes the currently pending link (if any). */ - fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null } - - fun clear() { - _pendingLink.value = null - } -} \ No newline at end of file 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 8bf06e4f..a130729f 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,10 +23,10 @@ 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.deeplink.JamDeepLinkService 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.jam.JamRoomClient +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.navigationModule import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler @@ -185,12 +185,9 @@ val sharedModules = module { viewModelOf(::RemoteControlViewModel) viewModel { JamViewModel( - jamSession = get(), - deepLinks = get(), + jamRoomService = get(), shareService = get(), settingsProvider = get(), - audioPlayer = get(), - audioPlayerQueue = get(), ) } @@ -239,8 +236,8 @@ val sharedModules = module { createdAtStart() } single { RemotePlaybackController(get(), get(), get(), get(), get()) } - single { JamSessionService(get(), get(), get()) } - singleOf(::JamDeepLinkService) + singleOf(::JamRoomClient) + single { JamRoomService(get(), get(), get(), get()) } singleOf(::AudioPlayerQueueRepository) { bind() } single { DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt deleted file mode 100644 index 7ecb8ff9..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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 io.ktor.http.decodeURLQueryComponent -import io.ktor.http.encodeURLParameter - -/** - * SDP payloads exchanged between jam peers are wrapped into `spotube://` deep links - * so they can be shared through any messaging medium. The SDP blob is percent-encoded - * as a query parameter. - * - * Host invite : `spotube://jam/invite?name=&sdp=` - * Guest answer : `spotube://jam/answer?name=&sdp=` - */ -sealed interface JamInviteLink { - val peerName: String - val sdp: String - - data class HostInvite( - override val peerName: String, - override val sdp: String, - ) : JamInviteLink - - data class GuestAnswer( - override val peerName: String, - override val sdp: String, - ) : JamInviteLink -} - -object JamInviteCodec { - const val SCHEME = "spotube" - const val INVITE_PATH = "jam/invite" - const val ANSWER_PATH = "jam/answer" - - fun buildHostInvite(hostName: String, offerSdp: String): String = - buildLink(INVITE_PATH, hostName, offerSdp) - - fun buildGuestAnswer(guestName: String, answerSdp: String): String = - buildLink(ANSWER_PATH, guestName, answerSdp) - - private fun buildLink(path: String, peerName: String, sdp: String): String = - "$SCHEME://$path?name=${peerName.encodeURLParameter()}" + - "&sdp=${sdp.encodeURLParameter()}" - - /** - * Parses a `spotude://jam/...` link. Returns null for foreign or malformed URIs. - * Parsing is done manually — generic URI parsers normalize unknown schemes in - * ways that mangle percent-encoded multi-line payloads. - */ - fun parse(rawUri: String): JamInviteLink? { - val uri = rawUri.trim() - if (!uri.startsWith("$SCHEME://", ignoreCase = true)) return null - - val withoutScheme = uri.substring(SCHEME.length + 3) - val queryStart = withoutScheme.indexOf('?') - if (queryStart < 0) return null - - val path = withoutScheme.take(queryStart).trim('/').lowercase() - val params = withoutScheme.substring(queryStart + 1) - .split('&') - .mapNotNull { pair -> - val separator = pair.indexOf('=') - if (separator <= 0) return@mapNotNull null - pair.take(separator) to pair.substring(separator + 1) - } - .toMap() - - val sdp = params["sdp"]?.decodeURLQueryComponent()?.takeIf { it.isNotBlank() } - ?: return null - val peerName = params["name"]?.decodeURLQueryComponent().orEmpty() - - return when (path) { - INVITE_PATH -> JamInviteLink.HostInvite(peerName, sdp) - ANSWER_PATH -> JamInviteLink.GuestAnswer(peerName, sdp) - else -> null - } - } - - /** - * Extracts an SDP payload from user input which may either be a full - * `spotube://` deep link or a raw SDP body pasted by hand. - */ - fun extractSdp(rawInput: String): String? { - val input = rawInput.trim() - parse(input)?.let { return it.sdp } - // Heuristic for raw SDP: first line is the session description header - return if (input.startsWith("v=", ignoreCase = false)) input else null - } -} \ 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 index 01fafa94..ac9e35d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -18,61 +18,46 @@ package dev.krtirtho.spotube.core.jam import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.QueueEntry import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * Jam messages exchanged over MQTT. + * + * - `state` topic: [QueueState] (retained, host -> everyone) + * - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist] + * (anyone -> host, except Kick which is host -> guest) + * - `presence/{clientId}` topic: [JamPresence] (retained, one per participant) + */ @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, + val shuffleEnabled: Boolean = false, ) : JamMessage() @Serializable @SerialName("playbackCommand") - data class PlaybackCommand( - val command: PlaybackCmd, - ) : JamMessage() + 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, + data class SuggestTrack( + val mediaItem: JamMediaItem, + val addedBy: String = "", ) : JamMessage() @Serializable - @SerialName("participantList") - data class ParticipantList(val participants: List) : JamMessage() + @SerialName("suggestPlaylist") + data class SuggestPlaylist( + val tracks: List, + val addedBy: String = "", + ) : JamMessage() @Serializable @SerialName("kick") @@ -86,24 +71,12 @@ sealed class JamMessage { data class Leave(val reason: String = "user_left") : JamMessage() } +/** + * Playback commands. Only queue navigation is global — play/pause, seek, + * volume, shuffle and loop are local to each participant. + */ @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() @@ -112,23 +85,20 @@ sealed class PlaybackCmd { @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() } +/** Retained per-participant presence entry (with an MQTT Last Will for leave). */ +@Serializable +data class JamPresence( + val clientId: String, + val displayName: String, + val isHost: Boolean, + val left: Boolean = false, +) + @Serializable data class JamMediaItem( val url: String, @@ -139,6 +109,7 @@ data class JamMediaItem( val durationMs: Long, val coverUrl: String, val protocol: String, + val addedBy: String = "", ) { companion object { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { @@ -153,6 +124,7 @@ data class JamMediaItem( ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url .orEmpty(), protocol = entry.protocol.name, + addedBy = entry.addedBy, ) is QueueEntry.LocalTrack -> JamMediaItem( @@ -164,6 +136,7 @@ data class JamMediaItem( durationMs = entry.duration, coverUrl = "", protocol = "PROGRESSIVE", + addedBy = entry.addedBy, ) } @@ -214,14 +187,4 @@ data class JamParticipant( 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/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt new file mode 100644 index 00000000..83531f19 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt @@ -0,0 +1,323 @@ +/* + * 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 com.ditchoom.buffer.Charset +import com.ditchoom.buffer.codec.asReadBuffer +import com.ditchoom.buffer.toReadBuffer +import com.ditchoom.mqtt.client.ConnectionState +import com.ditchoom.mqtt.client.MqttClient +import com.ditchoom.mqtt.connection.MqttConnectionOptions +import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec +import com.ditchoom.mqtt.controlpacket.QualityOfService +import com.ditchoom.mqtt.controlpacket.TopicName +import com.ditchoom.mqtt.controlpacket.WillConfig +import com.ditchoom.mqtt5.controlpacket.ConnectionRequest +import dev.krtirtho.spotube.modules.settings.JamBroker +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import org.koin.core.component.KoinComponent + +/** + * Thin facade over the Ditchoom MQTT 5 client for one jam room. + * + * Topics (room code `C`): + * - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone) + * - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest) + * - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will + * (`left = true`) so a dropped client disappears from the room automatically. + * + * The library keeps the connection alive (auto-reconnect + backoff); this class only + * re-establishes the room subscription and re-publishes presence after a reconnect. + */ +class JamRoomClient : KoinComponent { + private val log = Logger.withTag("JamRoomClient") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val json = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + encodeDefaults = true + } + + private var client: MqttClient? = null + + private var roomCode: String? = null + private var localPresence: JamPresence? = null + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _state = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + val state: SharedFlow = _state.asSharedFlow() + + private val _commands = MutableSharedFlow(extraBufferCapacity = 32) + val commands: SharedFlow = _commands.asSharedFlow() + + private val _presence = MutableStateFlow>(emptyMap()) + val presence: StateFlow> = _presence.asStateFlow() + + // ---------- Public API ---------- + + /** One-off connection check used by the settings screen. Returns latency description. */ + suspend fun testConnection(broker: JamBroker): Result { + if (broker.host.isBlank()) return Result.failure(IllegalArgumentException("Broker host is empty")) + val started = kotlin.time.TimeSource.Monotonic.markNow() + return runCatching { + val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test") + try { + withTimeout(broker.connectionTimeoutSeconds.seconds) { + client.awaitConnectivity() + } + "Connected in ${started.elapsedNow().inWholeMilliseconds} ms" + } finally { + runCatching { client.shutdown(sendDisconnect = true, drain = false) } + } + } + } + + /** Connects to [code] on [broker] and starts routing room messages. */ + suspend fun connect( + broker: JamBroker, + code: String, + clientId: String, + displayName: String, + isHost: Boolean, + ): Result { + disconnect() + if (broker.host.isBlank()) { + return Result.failure(IllegalArgumentException("No jam broker configured")) + } + return runCatching { + roomCode = code + localPresence = JamPresence( + clientId = clientId, + displayName = displayName, + isHost = isHost, + left = false, + ) + + val mqtt = startClient(broker, clientId) + client = mqtt + withTimeout(broker.connectionTimeoutSeconds.seconds) { + mqtt.awaitConnectivity() + } + _connectionError.value = null + log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" } + + mqtt.connectionState + .onEach { onConnectionStateChanged(it) } + .launchIn(scope) + Unit + }.onFailure { e -> + log.w(e) { "Failed to connect to jam broker" } + _connectionError.value = e.message ?: "Connection failed" + _isConnected.value = false + runCatching { client?.shutdown(sendDisconnect = true, drain = false) } + client = null + } + } + + suspend fun publishState(state: JamMessage.QueueState) { + val code = roomCode ?: return + publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true) + } + + suspend fun publishCommand(message: JamMessage) { + val code = roomCode ?: return + publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false) + } + + /** Publishes our own (retained) presence. Re-published after every reconnect. */ + suspend fun publishPresence() { + val code = roomCode ?: return + val presence = localPresence ?: return + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence), + retain = true, + ) + } + + /** Marks us as the host in presence (host takeover). */ + suspend fun claimHost() { + val presence = localPresence ?: return + localPresence = presence.copy(isHost = true) + publishPresence() + } + + /** Graceful leave: publish `left = true` before disconnecting. */ + suspend fun leavePresence() { + val presence = localPresence ?: return + val code = roomCode ?: return + runCatching { + publishJson( + presenceTopic(code, presence.clientId), + json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), + retain = true, + ) + } + } + + suspend fun disconnect() { + roomCode = null + localPresence = null + _isConnected.value = false + _presence.value = emptyMap() + val current = client + client = null + runCatching { current?.shutdown(sendDisconnect = true, drain = false) } + } + + // ---------- Internals ---------- + + private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient { + val connection = MqttConnectionOptions.SocketConnection( + host = broker.host, + port = broker.port, + tlsEnabled = broker.useTls, + connectionTimeout = broker.connectionTimeoutSeconds.seconds, + ) + val code = roomCode ?: "unset" + val will = WillConfig.Enabled( + topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), + payload = json.encodeToString( + JamPresence.serializer(), + JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true), + ).toReadBuffer(Charset.UTF8), + qos = QualityOfService.AT_LEAST_ONCE, + retain = true, + ) + val request = ConnectionRequest( + clientId = clientId, + keepAliveSeconds = broker.keepAliveSeconds, + cleanStart = true, + userName = broker.username, + password = broker.password, + will = will, + ) + val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) + val brokerRef = persistence.addBroker(connection, request) + return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) + } + + private fun onConnectionStateChanged(state: ConnectionState) { + when (state) { + is ConnectionState.Connected -> { + _isConnected.value = true + _connectionError.value = null + scope.launch { + // Every connection (initial + reconnects) must re-establish the + // broker-side subscription (clean session) and re-publish our + // retained presence to clear any Last Will. Re-subscribing with + // the same filter replaces the previous dispatcher handler. + subscribeRoom() + publishPresence() + } + } + + ConnectionState.Disconnected, ConnectionState.Handshaking -> { + _isConnected.value = false + } + + else -> { + _isConnected.value = false + _connectionError.value = "Connection lost" + } + } + } + + private suspend fun subscribeRoom() { + val mqtt = client ?: return + val code = roomCode ?: return + val operation = mqtt.subscribe( + roomFilter(code), + OpaquePublishPayloadCodec, + QualityOfService.AT_LEAST_ONCE, + ) { publish, payload -> + route(publish.topic.toString(), payload) + } + runCatching { operation.subAck.await() } + .onFailure { log.w(it) { "Subscribe ack failed for room $code" } } + } + + private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) { + val text = runCatching { + val buffer = payload.handle.asReadBuffer() + buffer.readString(buffer.remaining(), Charset.UTF8) + }.getOrElse { e -> + log.w(e) { "Failed to read jam payload on $topic" } + return + } + + runCatching { + when { + topic.endsWith("/state") -> { + _state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text)) + } + + topic.endsWith("/cmd") -> { + _commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text)) + } + + topic.contains("/presence/") -> { + val presence = json.decodeFromString(JamPresence.serializer(), text) + _presence.value = _presence.value + (presence.clientId to presence) + } + } + }.onFailure { e -> + log.w(e) { "Failed to decode jam message on $topic: $text" } + } + } + + private suspend fun publishJson(topic: String, payload: String, retain: Boolean) { + val mqtt = client ?: return + runCatching { + mqtt.publish( + topicName = topic, + qos = QualityOfService.AT_LEAST_ONCE, + payload = payload.toReadBuffer(Charset.UTF8), + retain = retain, + ) + }.onFailure { e -> + log.w(e) { "Failed to publish to $topic" } + } + } + + private fun stateTopic(code: String) = "spotube/jam/$code/state" + private fun cmdTopic(code: String) = "spotube/jam/$code/cmd" + private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId" + private fun roomFilter(code: String) = "spotube/jam/$code/#" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt new file mode 100644 index 00000000..0a1b891f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt @@ -0,0 +1,47 @@ +/* + * 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 kotlin.random.Random + +/** + * Six-character room codes shared verbally / by text. Codes are opaque keys used + * to namespace the MQTT topics of a jam room — they carry no connection details. + * + * The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to + * read aloud and retype. + */ +object JamRoomCode { + const val LENGTH = 6 + private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + fun generate(): String = buildString(LENGTH) { + repeat(LENGTH) { + append(ALPHABET[Random.nextInt(ALPHABET.length)]) + } + } + + /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ + fun normalize(input: String): String = input + .uppercase() + .filter { it.isLetterOrDigit() } + .take(LENGTH) + + fun isValid(code: String): Boolean = + code.length == LENGTH && code.all { it in ALPHABET } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt new file mode 100644 index 00000000..8107e1cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -0,0 +1,475 @@ +/* + * 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.plugin_interfaces.plugin_apis.audio.StreamProtocol +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail +import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface +import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue +import dev.krtirtho.spotube.core.audioplayer.PlayerState +import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.modules.settings.SettingsRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * A jam session over MQTT (star topology, host-authoritative queue). + * + * Sync rules: + * - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo] + * from anyone are applied by the host, then broadcast via the retained state topic. + * - Play/pause, seek, volume and loop are local to each device — never broadcast. + * When the queue moves on, a paused participant stays paused; a playing one + * keeps playing the new current item. + * - Shuffle is host-only; guests mirror the host's shuffle setting. + * - Guests can only add to the queue (suggest); the host applies suggestions. + * - If the host leaves, the participant with the lowest client id takes over. + */ +class JamRoomService( + private val jamClient: JamRoomClient, + private val audioPlayer: AudioPlayerInterface, + private val audioPlayerQueue: AudioPlayerQueue, + private val settingsRepository: SettingsRepository, +) { + private val log = Logger.withTag("JamRoomService") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _role = MutableStateFlow(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _roomCode = MutableStateFlow(null) + val roomCode: StateFlow = _roomCode.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _shuffleEnabled = MutableStateFlow(false) + val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() + + private var localClientId: String = "" + private var localDisplayName: String = "" + private var hostBroadcastJob: Job? = null + + /** Guest side: last queue snapshot applied to the local player. */ + private var lastAppliedItems: List = emptyList() + private var lastAppliedIndex = -1 + + /** Host side: client ids banned for this session. */ + private val bannedClientIds = mutableSetOf() + + private var leaving = false + + init { + jamClient.isConnected + .onEach { _isConnected.value = it } + .launchIn(scope) + jamClient.connectionError + .onEach { _connectionError.value = it } + .launchIn(scope) + jamClient.state + .onEach { onRemoteState(it) } + .launchIn(scope) + jamClient.commands + .onEach { onCommand(it) } + .launchIn(scope) + jamClient.presence + .onEach { onPresence(it) } + .launchIn(scope) + } + + // ---------- Session lifecycle ---------- + + suspend fun createRoom(): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException("No jam broker configured")) + } + val code = JamRoomCode.generate() + localClientId = newClientId(broker) + val name = participantName("Host") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = code, + clientId = localClientId, + displayName = name, + isHost = true, + ).map { + _role.value = JamRole.Host + _roomCode.value = code + _participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + leaving = false + startHostBroadcast() + persistLastCode(code) + code + } + } + + suspend fun joinRoom(code: String): Result { + val broker = settingsRepository.userSettings.value.jamBroker + if (broker.host.isBlank()) { + return Result.failure(IllegalStateException("No jam broker configured")) + } + val normalized = JamRoomCode.normalize(code) + if (!JamRoomCode.isValid(normalized)) { + return Result.failure(IllegalArgumentException("Invalid room code")) + } + localClientId = newClientId(broker) + val name = participantName("Guest") + localDisplayName = name + + return jamClient.connect( + broker = broker, + code = normalized, + clientId = localClientId, + displayName = name, + isHost = false, + ).map { + _role.value = JamRole.Guest + _roomCode.value = normalized + _participants.value = emptyList() + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + leaving = false + persistLastCode(normalized) + } + } + + suspend fun leaveRoom() { + leaving = true + stopHostBroadcast() + runCatching { jamClient.leavePresence() } + jamClient.disconnect() + _role.value = null + _roomCode.value = null + _participants.value = emptyList() + _shuffleEnabled.value = false + _isConnected.value = false + lastAppliedItems = emptyList() + lastAppliedIndex = -1 + bannedClientIds.clear() + } + + // ---------- Controls (called from the UI) ---------- + + fun skipNext() { + publishCommand(PlaybackCmd.SkipNext) + } + + fun skipPrevious() { + publishCommand(PlaybackCmd.SkipPrevious) + } + + fun jumpTo(index: Int) { + publishCommand(PlaybackCmd.JumpTo(index)) + } + + /** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */ + fun toggleShuffle() { + if (_role.value != JamRole.Host) return + scope.launch { + runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) } + } + } + + suspend fun suggestTrack(track: MetadataTrack) { + jamClient.publishCommand( + JamMessage.SuggestTrack( + mediaItem = JamMediaItem.fromTrack(track), + addedBy = localDisplayName, + ) + ) + } + + suspend fun suggestPlaylist(tracks: List) { + if (tracks.isEmpty()) return + jamClient.publishCommand( + JamMessage.SuggestPlaylist( + tracks = tracks.map(JamMediaItem::fromTrack), + addedBy = localDisplayName, + ) + ) + } + + suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { + if (_role.value != JamRole.Host) return + jamClient.publishCommand(JamMessage.Kick(participantId, reason)) + } + + suspend fun banParticipant(participantId: String) { + if (_role.value != JamRole.Host) return + bannedClientIds += participantId + kickParticipant(participantId, "banned by host") + } + + // ---------- Host: broadcast ---------- + + private fun startHostBroadcast() { + if (hostBroadcastJob?.isActive == true) return + hostBroadcastJob = scope.launch { + combine( + audioPlayerQueue.queueFlow, + audioPlayerQueue.currentQueueEntryFlow, + audioPlayer.shuffleModeFlow, + ) { queue, current, shuffle -> Triple(queue, current, shuffle) } + .onEach { (queue, current, shuffle) -> + if (_role.value != JamRole.Host) return@onEach + val index = if (current != null) { + queue.indexOfFirst { it.matchesEntry(current) } + } else { + -1 + } + _shuffleEnabled.value = shuffle + jamClient.publishState( + JamMessage.QueueState( + items = queue.map(JamMediaItem::fromQueueEntry), + currentIndex = index.coerceAtLeast(0), + shuffleEnabled = shuffle, + ) + ) + } + .launchIn(this) + } + } + + private fun stopHostBroadcast() { + hostBroadcastJob?.cancel() + hostBroadcastJob = null + } + + // ---------- Guest: apply remote state ---------- + + private suspend fun onRemoteState(state: JamMessage.QueueState) { + if (_role.value != JamRole.Guest) return + if (leaving) return + + _shuffleEnabled.value = state.shuffleEnabled + runCatching { audioPlayer.shuffle(state.shuffleEnabled) } + + val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING + + if (items != lastAppliedItems) { + lastAppliedItems = items + lastAppliedIndex = state.currentIndex + runCatching { + audioPlayerQueue.load( + entries = items.map { it.toQueueEntry() }, + autoPlay = wasPlaying, + startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), + ) + }.onFailure { log.w(it) { "Failed to apply jam queue" } } + return + } + + if (state.currentIndex != lastAppliedIndex) { + lastAppliedIndex = state.currentIndex + // Queue moved on: follow it, but keep this device's play/pause state. + runCatching { + audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) + }.onFailure { log.w(it) { "Failed to follow jam queue index" } } + } + } + + // ---------- Incoming commands ---------- + + private suspend fun onCommand(message: JamMessage) { + when (message) { + is JamMessage.PlaybackCommand -> { + if (_role.value != JamRole.Host) return + applyCommand(message.command) + } + + is JamMessage.SuggestTrack -> { + if (_role.value == JamRole.Host) acceptSuggestion(listOf(message.mediaItem)) + } + + is JamMessage.SuggestPlaylist -> { + if (_role.value == JamRole.Host) acceptSuggestion(message.tracks) + } + + is JamMessage.Kick -> { + if (_role.value == JamRole.Guest && message.participantId == localClientId) { + log.i { "Kicked from jam room: ${message.reason}" } + leaveRoom() + } + } + + else -> Unit + } + } + + private suspend fun applyCommand(command: PlaybackCmd) { + when (command) { + PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() } + PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() } + is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) } + } + } + + private suspend fun acceptSuggestion(items: List) { + if (items.isEmpty()) return + log.i { "Accepting ${items.size} suggested item(s) into the jam queue" } + runCatching { + audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() }) + } + } + + // ---------- Presence & host takeover ---------- + + private fun onPresence(all: Map) { + if (_role.value == null) return + val live = all.values.filter { !it.left } + _participants.value = live + .sortedBy { it.clientId } + .map { JamParticipant(it.clientId, it.displayName, it.isHost) } + + // Host-side: auto-kick banned participants that rejoin. + if (_role.value == JamRole.Host) { + live.filter { it.clientId in bannedClientIds }.forEach { banned -> + scope.launch { kickParticipant(banned.clientId, "banned by host") } + } + return + } + + val host = live.firstOrNull { it.isHost } + if (host != null) return + + // Host left: the lowest client id takes over (deterministic, clock-free). + val candidate = live.minByOrNull { it.clientId } ?: return + if (candidate.clientId != localClientId) return + + scope.launch { + delay(HOST_TAKEOVER_DELAY_MS) + val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost } + if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch + log.i { "Taking over as jam host (previous host left)" } + _role.value = JamRole.Host + jamClient.claimHost() + startHostBroadcast() + } + } + + // ---------- Helpers ---------- + + private fun publishCommand(command: PlaybackCmd) { + scope.launch { + jamClient.publishCommand(JamMessage.PlaybackCommand(command)) + } + } + + private fun participantName(fallbackPrefix: String): String { + val configured = settingsRepository.userSettings.value.jamParticipantName + return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" } + } + + private fun newClientId(broker: dev.krtirtho.spotube.modules.settings.JamBroker): String { + val suffix = buildString(6) { + val chars = "0123456789abcdef" + repeat(6) { append(chars[Random.nextInt(chars.length)]) } + } + return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix" + } + + private fun persistLastCode(code: String) { + scope.launch { + runCatching { + val settings = settingsRepository.userSettings.value + if (settings.lastJamCode != code) { + settingsRepository.updateSettings(settings.copy(lastJamCode = code)) + } + } + } + } + + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { + trackId.isNotBlank() -> QueueEntry.StreamingTrack( + track = MetadataTrack( + id = trackId, + title = title, + durationMs = durationMs, + trackNumber = null, + discNumber = null, + artists = listOf( + MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) + ), + album = null, + // Keep the cover art flowing to participants — the wire carries it + // as coverUrl, so mirror it back into the reconstructed thumbnails. + thumbnails = coverUrl.takeIf { it.isNotBlank() } + ?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) }, + explicit = null, + popularity = null, + isrcCode = null, + externalUri = null, + ), + url = "", + protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } + .getOrDefault(StreamProtocol.PROGRESSIVE), + addedBy = addedBy, + ) + + else -> QueueEntry.LocalTrack( + name = title, + artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, + duration = durationMs, + album = album.ifBlank { null }, + coverBytes = null, + url = url, + addedBy = addedBy, + ) + } + + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when { + this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> + this.track.id == other.track.id + + this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> + this.url == other.url && this.name == other.name + + else -> false + } + + companion object { + private const val HOST_TAKEOVER_DELAY_MS = 1_500L + } +} \ 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 deleted file mode 100644 index 30cf046d..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ /dev/null @@ -1,587 +0,0 @@ -/* - * 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.AudioPlayerQueue -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.IO -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 - -/** - * An invite generated by the host for one guest slot. The [sdp] offer is shared - * via a deep link; once the guest's answer comes back, [JamSessionService.acceptAnswer] - * completes the handshake for that slot. - */ -data class JamInvite( - val id: String, - val sdp: String, -) - -/** - * Owns the peer connections of a jam session (star topology: host relays state - * to all guests) and the hello/welcome handshake, participant bookkeeping and - * kick/ban. Playback & queue synchronization itself is delegated to - * [QueueSyncManager], which runs while a session is active. - */ -class JamSessionService( - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, - private val settingsProvider: SettingsProvider, -) : KoinComponent { - val logger by injectLogger() - private val log = Logger.withTag("JamSessionService") - - /** - * Playback/queue synchronization. Owned by this service (not a Koin bean) so - * the two don't form a circular dependency; it's started/stopped with the - * session lifecycle. - */ - private val queueSyncManager = QueueSyncManager( - audioPlayer = audioPlayer, - audioPlayerQueue = audioPlayerQueue, - jamSession = this, - ) - - 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 _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected.asStateFlow() - - private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) - val incomingMessages = _incomingMessages.asSharedFlow() - - private val _incomingSuggestions = MutableSharedFlow(extraBufferCapacity = 32) - val incomingSuggestions = _incomingSuggestions.asSharedFlow() - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /** Host side: invites whose answers have not arrived yet. */ - private val pendingInvites = mutableMapOf() - - /** Host side: guests whose handshake completed. Keyed by invite id. */ - private val connectedGuests = mutableMapOf() - - /** Host side: guest device ids, used for bans. */ - private val guestDeviceIds = mutableMapOf() - - /** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */ - private val guestConnectionStates = mutableMapOf() - - /** Host side: device ids banned for this session. */ - private val bannedDeviceIds = mutableSetOf() - - /** Guest side: the single connection to the host. */ - private var hostConnection: WebrtcPeerConnection? = null - - private var hostDisplayName: String = "Host" - private var guestDisplayName: String = "Guest" - - suspend fun createSession(): String { - log.i { "Creating jam session" } - hostDisplayName = resolveParticipantName(defaultPrefix = "Host") - - _role.value = JamRole.Host - _localParticipantId.value = "host" - _participants.value = listOf( - JamParticipant( - id = "host", - displayName = hostDisplayName, - isHost = true, - ) - ) - _isActive.value = true - queueSyncManager.start() - - return generateInvite().sdp - } - - /** - * Generates a new invite (peer connection + SDP offer with bundled ICE candidates). - * Each invite admits exactly one guest. - */ - suspend fun generateInvite(): JamInvite { - if (_role.value != JamRole.Host) { - error("generateInvite can only be called by the host") - } - val inviteId = "guest-${randomShortId()}" - log.i { "Generating invite $inviteId" } - - val pc = createWebrtcPeerConnection( - iceServers = defaultIceServers(), - handler = guestEventHandler(inviteId), - ) - - pc.createDataChannel(CHANNEL_LABEL) - val offer = pc.createOffer() - pendingInvites[inviteId] = pc - return JamInvite(id = inviteId, sdp = offer) - } - - /** - * Completes a guest's handshake: applies their SDP answer to the peer connection - * created for [inviteId] and adds them to the participant list. - * - * When [inviteId] is null, the oldest still-pending invite is used — convenient - * when an answer deep link arrives out of band. - * - * @param answerSdp raw SDP answer body (not a deep link). - * @param peerName display name of the guest, taken from their answer link if available. - */ - suspend fun acceptAnswer(inviteId: String?, answerSdp: String, peerName: String): Boolean { - if (_role.value != JamRole.Host) { - log.w { "acceptAnswer ignored: not hosting" } - return false - } - val resolvedId = inviteId ?: pendingInvites.keys.firstOrNull() - if (resolvedId == null) { - log.w { "acceptAnswer: no pending invite" } - return false - } - val pc = pendingInvites.remove(resolvedId) - if (pc == null) { - log.w { "acceptAnswer: no pending invite '$resolvedId'" } - return false - } - runCatching { pc.setRemoteAnswer(answerSdp) } - .onFailure { e -> - log.w(e) { "Failed to apply answer for $inviteId" } - scope.launch { runCatching { pc.shutdown() } } - return false - } - connectedGuests[resolvedId] = pc - _participants.update { current -> - current + JamParticipant( - id = resolvedId, - displayName = peerName.ifBlank { "Guest-${resolvedId.takeLast(4)}" }, - isHost = false, - ) - } - log.i { "Guest $resolvedId ($peerName) joined" } - broadcastParticipantList() - return true - } - - suspend fun joinSession(offerSdp: String, hostName: String? = null): String { - log.i { "Joining jam session" } - guestDisplayName = resolveParticipantName(defaultPrefix = "Guest") - - val pc = createWebrtcPeerConnection( - iceServers = defaultIceServers(), - handler = eventHandler, - ) - - hostConnection = pc - _role.value = JamRole.Guest - _localParticipantId.value = null - _participants.value = listOf( - JamParticipant( - id = "host", - displayName = hostName?.ifBlank { null } ?: "Host", - isHost = true, - ) - ) - _isActive.value = true - queueSyncManager.start() - - // The data channel arrives in-band from the host's offer via on_data_channel; - // we only answer here. - pc.setRemoteOffer(offerSdp) - val answer = pc.createAnswer() - log.i { "Generated SDP answer (length=${answer.length})" } - return answer - } - - suspend fun sendMessage(message: JamMessage, guestId: String? = null) { - val payload = json.encodeToString(JamMessage.serializer(), message) - when (_role.value) { - JamRole.Host -> { - if (guestId != null) { - val pc = connectedGuests[guestId] ?: return - runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to guest $guestId" } - onSendFailure(guestId) - } - } else { - val dead = mutableListOf() - connectedGuests.forEach { (id, pc) -> - runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to guest $id" } - dead += id - } - } - dead.forEach { id -> onSendFailure(id) } - } - } - - JamRole.Guest -> { - runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to host" } - } - } - - null -> log.w { "sendMessage called while no session is active" } - } - } - - /** - * A send to a guest failed. If that guest's connection has already given up - * (failed/closed), drop them from the session; while the connection is merely - * "connecting" the channel may simply not be open yet, so keep them. - */ - private fun onSendFailure(guestId: String) { - val state = guestConnectionStates[guestId] - if (state == "failed" || state == "closed" || state == "disconnected") { - scope.launch { removeGuest(guestId) } - } - } - - suspend fun leave() { - log.i { "Leaving jam session" } - queueSyncManager.stop() - runCatching { sendMessage(JamMessage.Leave()) } - shutdownAll() - _role.value = null - _participants.value = emptyList() - _isActive.value = false - _isConnected.value = false - _localParticipantId.value = null - guestDeviceIds.clear() - guestConnectionStates.clear() - bannedDeviceIds.clear() - } - - 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)) - } - - // ---------- Host moderation ---------- - - suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { - if (_role.value != JamRole.Host) return - log.i { "Kicking participant $participantId" } - sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId) - removeGuest(participantId) - } - - suspend fun banParticipant(participantId: String) { - if (_role.value != JamRole.Host) return - val deviceId = guestDeviceIds[participantId] - if (deviceId != null) { - bannedDeviceIds += deviceId - log.i { "Banning device $deviceId (participant $participantId)" } - } - kickParticipant(participantId, "banned by host") - } - - private suspend fun removeGuest(guestId: String) { - val pc = connectedGuests.remove(guestId) - runCatching { pc?.shutdown() } - guestDeviceIds.remove(guestId) - guestConnectionStates.remove(guestId) - _participants.update { current -> - current.filterNot { it.id == guestId } - } - broadcastParticipantList() - } - - private suspend fun broadcastParticipantList() { - if (_role.value != JamRole.Host) return - sendMessage(JamMessage.ParticipantList(_participants.value)) - } - - /** - * ICE servers for global peer-to-peer jam sessions: multiple STUN servers for - * NAT traversal plus a TURN relay for symmetric NATs and strict firewalls. - */ - private fun defaultIceServers(): List = listOf( - IceServerConfig( - urls = listOf( - "stun:stun.cloudflare.com:3478", - "stun:stun1.l.google.com:19302", - "stun:stun.l.google.com:19302", - ), - username = "", - credential = "", - ), - IceServerConfig( - urls = listOf("turn:openrelay.metered.ca:80"), - username = "openrelayproject", - credential = "openrelayproject", - ), - ) - - private suspend fun resolveParticipantName(defaultPrefix: String): String { - val settings = settingsProvider.settingsState.first() - return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" } - ?: "$defaultPrefix-${randomShortId()}" - } - - private fun localDeviceId(): String { - return settingsProvider.settingsState.value?.remoteControlDeviceId - ?: "device-${randomShortId()}" - } - - /** - * Per-guest handler so messages received on a guest's connection can be - * attributed back to that guest (needed for kick-on-leave and targeted sends). - */ - private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) { - log.i { "[$guestId] ICE candidate: $candidate" } - } - - override fun onIceGatheringStateChange(state: String) { - log.i { "[$guestId] ICE gathering state: $state" } - } - - override fun onConnectionStateChange(state: String) { - log.i { "[$guestId] Connection state: $state" } - guestConnectionStates[guestId] = state - if (state == "failed" || state == "closed") { - scope.launch { removeGuest(guestId) } - } - } - - override fun onDataChannelOpen(label: String) { - log.i { "[$guestId] Data channel '$label' open" } - _isConnected.value = true - } - - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(data, fromGuestId = guestId) - } - - override fun onDataChannelClose(label: String) { - log.i { "[$guestId] Data channel closed" } - if (_role.value == JamRole.Host) { - scope.launch { removeGuest(guestId) } - } - } - } - - private val eventHandler = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) { - log.i { "ICE candidate: $candidate" } - } - - override fun onIceGatheringStateChange(state: String) { - log.i { "ICE gathering state: $state" } - } - - override fun onConnectionStateChange(state: String) { - log.i { "Connection state: $state" } - if (state == "failed" || state == "closed") { - scope.launch { leave() } - } - } - - override fun onDataChannelOpen(label: String) { - log.i { "Data channel '$label' open" } - _isConnected.value = true - // Introduce ourselves so the host can fill in our name and hand us - // our participant id. - scope.launch { - sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId())) - } - } - - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(data, fromGuestId = null) - } - - override fun onDataChannelClose(label: String) { - log.i { "Data channel closed" } - scope.launch { leave() } - } - } - - private fun handleIncomingMessage(data: String, fromGuestId: String?) { - try { - val message = json.decodeFromString(JamMessage.serializer(), data) - _incomingMessages.tryEmit(message) - when (message) { - is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> { - _incomingSuggestions.tryEmit(message) - } - - is JamMessage.Hello -> { - if (_role.value == JamRole.Host && fromGuestId != null) { - handleHello(fromGuestId, message) - } - } - - is JamMessage.Welcome -> { - if (_role.value == JamRole.Guest) { - _localParticipantId.value = message.participantId - _participants.update { current -> - current.map { participant -> - if (participant.isHost) { - participant.copy(displayName = message.hostName.ifBlank { participant.displayName }) - } else { - participant - } - } - } - log.i { "Welcome: joined as ${message.participantId}" } - } - } - - is JamMessage.ParticipantList -> { - if (_role.value == JamRole.Guest) { - _participants.value = message.participants - } - } - - is JamMessage.Kick -> { - if (_role.value == JamRole.Guest) { - log.i { "Kicked by host: ${message.reason}" } - scope.launch { leave() } - } - } - - is JamMessage.Leave -> { - if (_role.value == JamRole.Host && fromGuestId != null) { - scope.launch { removeGuest(fromGuestId) } - } else if (_role.value == JamRole.Guest) { - scope.launch { leave() } - } - } - - else -> Unit - } - } catch (e: Exception) { - log.w(e) { "Failed to parse jam message" } - } - } - - private fun handleHello(guestId: String, hello: JamMessage.Hello) { - val deviceId = hello.deviceId - if (deviceId in bannedDeviceIds) { - log.w { "Rejecting banned device $deviceId" } - scope.launch { - sendMessage( - JamMessage.Kick(guestId, "banned by host"), - guestId = guestId, - ) - removeGuest(guestId) - } - return - } - guestDeviceIds[guestId] = deviceId - _participants.update { current -> - current.map { participant -> - if (participant.id == guestId) { - participant.copy(displayName = hello.displayName.ifBlank { participant.displayName }) - } else { - participant - } - } - } - scope.launch { - sendMessage( - JamMessage.Welcome(hostDisplayName, guestId), - guestId = guestId, - ) - broadcastParticipantList() - // Give the newly joined guest the current queue + playback state. - queueSyncManager.broadcastNow() - } - } - - private suspend fun shutdownAll() { - pendingInvites.values.forEach { runCatching { it.shutdown() } } - connectedGuests.values.forEach { runCatching { it.shutdown() } } - runCatching { hostConnection?.shutdown() } - pendingInvites.clear() - connectedGuests.clear() - hostConnection = null - } -} - -private const val CHANNEL_LABEL = "jam" - -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 deleted file mode 100644 index 23e9d26e..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt +++ /dev/null @@ -1,316 +0,0 @@ -/* - * 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.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json - -/** - * Keeps playback in sync across a jam session (star topology). - * - * On the **host**: applies incoming playback commands and guest suggestions to the - * host's player, and broadcasts the current queue + playback state to all guests - * (on queue changes and periodically, so play/pause/seek/position propagate). - * - * On the **guest**: mirrors the host's queue into the local player and applies - * playback commands. The guest's queue is read-only — the host has authority. - */ -class QueueSyncManager( - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, - private val jamSession: JamSessionService, -) { - private val log = Logger.withTag("QueueSyncManager") - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _isSyncing = MutableStateFlow(false) - val isSyncing: StateFlow = _isSyncing.asStateFlow() - - private var hostBroadcastJob: Job? = null - private var hostCommandJob: Job? = null - private var guestApplyJob: Job? = null - private var guestCommandJob: Job? = null - - /** Guest side: the last applied queue snapshot, used to detect real queue changes. */ - private var lastAppliedItems: List = emptyList() - - /** Guest side: tracks the player was told to start playing from. */ - private var lastAppliedCurrentIndex = -1 - - 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() - hostCommandJob?.cancel() - guestApplyJob?.cancel() - guestCommandJob?.cancel() - hostBroadcastJob = null - hostCommandJob = null - guestApplyJob = null - guestCommandJob = null - lastAppliedItems = emptyList() - lastAppliedCurrentIndex = -1 - } - - // ---------- Host side ---------- - - private fun startHostSync() { - // Apply commands/suggestions coming from guests. - hostCommandJob = scope.launch { - jamSession.role.first { it != null } - if (jamSession.role.value != JamRole.Host) return@launch - - jamSession.incomingMessages.collect { message -> - when (message) { - is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command) - is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem)) - is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks) - else -> {} - } - } - } - - // Broadcast state on queue changes and periodically. - hostBroadcastJob = scope.launch { - jamSession.role.first { it != null } - if (jamSession.role.value != JamRole.Host) return@launch - - // Queue changes (separate coroutine — collect() never returns). - launch { - audioPlayerQueue.queueFlow.collect { - broadcastCurrentState() - } - } - - // Periodic tick so play/pause/seek/position propagate to guests. - while (isActive) { - delay(2_000) - broadcastCurrentState() - } - } - } - - /** Immediately pushes the current queue + playback state to all guests. */ - suspend fun broadcastNow() { - if (jamSession.role.value == JamRole.Host) { - broadcastCurrentState() - } - } - - private suspend fun broadcastCurrentState() { - val queue = audioPlayerQueue.getQueue() - val current = audioPlayerQueue.getCurrentQueueEntry() - val currentIndex = if (current != null) { - queue.indexOfFirst { it.matchesEntry(current) } - } else { - -1 - } - jamSession.broadcastQueueState( - items = queue.map(JamMediaItem::fromQueueEntry), - currentIndex = currentIndex.coerceAtLeast(0), - isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, - positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, - ) - } - - private suspend fun acceptSuggestion(items: List) { - if (items.isEmpty()) return - val entries = items.map { it.toQueueEntry() } - log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" } - audioPlayerQueue.addAllToQueue(entries) - } - - // ---------- Guest side ---------- - - 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}" } - - // Items that carry neither a track id nor a usable URL can't be played - // on this device — skip them instead of crashing the player. - val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } - - val queueChanged = playableItems != lastAppliedItems - if (queueChanged) { - lastAppliedItems = playableItems - lastAppliedCurrentIndex = state.currentIndex - val entries = playableItems.map { it.toQueueEntry() } - runCatching { - // Load through the queue repository (like the host does) so the - // stream proxy can resolve the tracks — it only knows tracks in - // queueFlow. - audioPlayerQueue.load( - entries = entries, - autoPlay = state.isPlaying, - startPosition = state.currentIndex.coerceIn(0, entries.lastIndex.coerceAtLeast(0)), - ) - }.onFailure { e -> - log.e(e) { "Failed to apply jam queue to local player" } - } - return - } - - // Same queue: just sync playback state. Avoid seeking on every tick unless - // the drift is meaningful. - if (state.currentIndex != lastAppliedCurrentIndex) { - lastAppliedCurrentIndex = state.currentIndex - runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) } - .onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } } - } - val currentState = audioPlayer.playerStateFlow.value - if (state.isPlaying && currentState != PlayerState.PLAYING) { - audioPlayer.play() - } else if (!state.isPlaying && currentState == PlayerState.PLAYING) { - audioPlayer.pause() - } - val driftMs = kotlin.math.abs( - audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs - ) - if (driftMs > POSITION_SYNC_THRESHOLD_MS) { - runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) } - .onFailure { e -> log.w(e) { "Failed to sync position" } } - } - } - - private suspend fun applyPlaybackCommand(command: PlaybackCmd) { - log.d { "Applying playback command: $command" } - runCatching { - 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) - } - }.onFailure { e -> - log.w(e) { "Failed to apply playback command: $command" } - } - } - - /** - * Build a queue entry from a jam media item. Streaming tracks carry their id - * so the device's own queue/stream proxy can resolve a playable URL later. - */ - private fun JamMediaItem.toQueueEntry(): QueueEntry = when { - trackId.isNotBlank() -> QueueEntry.StreamingTrack( - track = MetadataTrack( - id = trackId, - title = title, - durationMs = durationMs, - trackNumber = null, - discNumber = null, - artists = listOf( - MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) - ), - album = null, - thumbnails = null, - explicit = null, - popularity = null, - isrcCode = null, - externalUri = null, - ), - url = "", - protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } - .getOrDefault(StreamProtocol.PROGRESSIVE), - ) - - else -> QueueEntry.LocalTrack( - name = title, - artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, - duration = durationMs, - album = album.ifBlank { null }, - coverBytes = null, - url = url, - ) - } - - private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean { - return when { - this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> - this.track.id == other.track.id - - this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> - this.url == other.url && this.name == other.name - - else -> false - } - } - - companion object { - /** Seek the guest only when its position drifts more than this from the host. */ - private const val POSITION_SYNC_THRESHOLD_MS = 3_000L - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 01a77c4b..7857b1f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -21,16 +21,18 @@ import co.touchlab.kermit.Logger import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamMediaItem import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository 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.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -87,7 +89,7 @@ class RemotePlaybackController( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, - private val jamSession: JamSessionService, + private val jamRoomService: JamRoomService, ) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -95,6 +97,10 @@ class RemotePlaybackController( private val _pendingRequest = MutableStateFlow(null) val pendingRequest: StateFlow = _pendingRequest.asStateFlow() + /** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */ + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } @@ -168,34 +174,41 @@ class RemotePlaybackController( _pendingRequest.value = null scope.launch { try { - when (jamSession.role.value) { + when (jamRoomService.role.value) { JamRole.Host -> executeLocally(request) JamRole.Guest -> suggestToJam(request) - null -> {} + null -> return@launch } + _events.emit(confirmationMessage(request)) } catch (e: Exception) { logger.e(e) { "Failed to send content to jam session" } } } } + private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) { + PlaybackDestinationAction.Play -> "Playing on the jam queue" + PlaybackDestinationAction.AddToQueue -> "Added to the jam queue" + PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue" + } + private suspend fun suggestToJam(request: PlaybackDestinationRequest) { when (request) { is PlaybackDestinationRequest.Collection -> { val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id) if (tracks.isNotEmpty()) { - jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() }) + jamRoomService.suggestPlaylist(tracks) logger.i { "Suggested ${tracks.size} track(s) to the jam session" } } } is PlaybackDestinationRequest.Track -> { - jamSession.suggestTrack(request.track.toJamMediaItem()) + jamRoomService.suggestTrack(request.track) } is PlaybackDestinationRequest.Tracks -> { if (request.tracks.isNotEmpty()) { - jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() }) + jamRoomService.suggestPlaylist(request.tracks) } } } @@ -204,7 +217,9 @@ class RemotePlaybackController( // ---------- Internals ---------- private fun request(request: PlaybackDestinationRequest) { - if (isRemoteConnected()) { + // The picker offers "This Device", a connected remote device, and an + // active jam session — show it whenever more than one destination exists. + if (isRemoteConnected() || jamRoomService.role.value != null) { _pendingRequest.value = request } else { executeLocally(request) @@ -368,6 +383,4 @@ class RemotePlaybackController( album?.id == other.album?.id && artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } } -} - -private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this) \ No newline at end of file +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 6fd70573..906c1492 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient @@ -41,6 +41,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist +import kotlinx.coroutines.flow.map import org.koin.compose.koinInject /** @@ -52,10 +53,11 @@ import org.koin.compose.koinInject fun PlayDestinationPickerHost() { val controller = koinInject() val remoteControlClient = koinInject() - val jamSession = koinInject() + val jamRoomService = koinInject() val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - val jamActive by jamSession.isActive.collectAsStateWithLifecycle() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val pendingRequest = request ?: return 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 index adc4d246..b7f76b00 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,25 +17,18 @@ package dev.krtirtho.spotube.modules.jam -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -48,38 +41,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomCode import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.copyShape 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.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import org.koin.compose.viewmodel.koinViewModel +/** + * Group Jam session screen. Playback controls and the queue live in the app's + * regular player / queue sheet (the shared jam queue is the local queue), so + * this screen only covers participation and session management. + */ @Composable fun JamScreen( navigationCommands: NavigationCommands, @@ -110,48 +93,19 @@ fun JamScreen( } when { - !state.isActive && state.incomingOfferSdp != null -> IncomingInviteView( - hostName = state.incomingHostName.orEmpty(), - onJoin = viewModel::joinWithIncomingInvite, - onDismiss = viewModel::dismissIncomingInvite, - ) - !state.isActive -> CreateOrJoinView( + state = state, onCreate = viewModel::createSession, - onJoin = viewModel::joinWithPasted, + onJoin = viewModel::joinWithCode, ) - state.role == JamRole.Host -> HostSessionView( + else -> SessionView( state = state, - playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, - onNewInvite = viewModel::generateNewInvite, - onSubmitAnswer = viewModel::submitAnswerPasted, - onShare = viewModel::share, + onShareCode = viewModel::shareRoomCode, onLeave = viewModel::leave, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onJumpTo = viewModel::jumpTo, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoop = viewModel::cycleLoopMode, onKick = viewModel::kickParticipant, onBan = viewModel::banParticipant, ) - - else -> GuestSessionView( - state = state, - playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, - onShare = viewModel::share, - onLeave = viewModel::leave, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onJumpTo = viewModel::jumpTo, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoop = viewModel::cycleLoopMode, - ) } } } @@ -173,18 +127,33 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) { @Composable private fun CreateOrJoinView( + state: JamUiState, onCreate: () -> Unit, onJoin: (String) -> Unit, ) { - var tab by remember { mutableIntStateOf(0) } + var tab by rememberSaveable { mutableIntStateOf(0) } var pasted by rememberSaveable { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = "Listen together with friends over a peer-to-peer connection.", + text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", style = MaterialTheme.typography.titleMedium, ) + if (!state.brokerConfigured) { + Text( + text = "No jam broker configured — set one up in Settings to host or join a session.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + text = "Broker: ${state.brokerHost}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( selected = tab == 0, @@ -201,34 +170,36 @@ private fun CreateOrJoinView( if (tab == 0) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Start a session as the host. You'll get a shareable invite link " + - "to send to friends; when they accept, they appear here.", + text = "Start a session as the host. You'll get a 6-character room code to " + + "share with friends; you control the queue.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button(onClick = onCreate) { + Button( + onClick = onCreate, + enabled = state.brokerConfigured, + ) { Text("Create Session") } } } else { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Paste the invite link the host shared with you.", + text = "Enter the 6-character room code the host shared with you.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( value = pasted, - onValueChange = { pasted = it }, + onValueChange = { pasted = JamRoomCode.normalize(it) }, modifier = Modifier.fillMaxWidth(), - label = { Text("Invite link") }, - placeholder = { Text("spotube://jam/invite?...") }, - minLines = 2, - maxLines = 6, + label = { Text("Room code") }, + placeholder = { Text("ABC123") }, + singleLine = true, ) Button( onClick = { onJoin(pasted) }, - enabled = pasted.isNotBlank(), + enabled = state.brokerConfigured && JamRoomCode.isValid(pasted), ) { Text("Join Session") } @@ -238,401 +209,54 @@ private fun CreateOrJoinView( } @Composable -private fun IncomingInviteView( - hostName: String, - onJoin: () -> Unit, - onDismiss: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "$hostName invited you to a jam session", - style = MaterialTheme.typography.titleMedium, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onJoin) { - Text("Join") - } - OutlinedButton(onClick = onDismiss) { - Text("Ignore") - } - } - } -} - -@Composable -private fun HostSessionView( +private fun SessionView( state: JamUiState, - playerState: JamPlayerUiState, - onNewInvite: () -> Unit, - onSubmitAnswer: (String) -> Unit, - onShare: (String) -> Unit, + onShareCode: () -> Unit, onLeave: () -> Unit, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onJumpTo: (Int) -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, onKick: (String) -> Unit, onBan: (String) -> Unit, ) { - val clipboard = LocalClipboardManager.current - var pastedAnswer by rememberSaveable { mutableStateOf("") } + val isHost = state.role == JamRole.Host Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan) - - JamNowPlayingView( - playerState = playerState, - onTogglePlayPause = onTogglePlayPause, - onSkipNext = onSkipNext, - onSkipPrevious = onSkipPrevious, - onToggleShuffle = onToggleShuffle, - onCycleLoop = onCycleLoop, - ) - - HorizontalDivider() - - Text( - text = "Invite someone", - style = MaterialTheme.typography.titleSmall, - ) - val inviteLink = state.inviteLink - if (inviteLink != null) { - ShareableLinkBox( - label = "Invite link", - link = inviteLink, - onCopy = { clipboard.setText(AnnotatedString(inviteLink)) }, - onShare = { onShare(inviteLink) }, + if (!state.isConnected) { + Text( + text = "Connecting to the session…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - OutlinedButton(onClick = onNewInvite) { - Text("Generate new invite") + + ParticipantsSection( + participants = state.participants, + isHost = isHost, + onKick = onKick, + onBan = onBan, + ) + + if (isHost) { + HorizontalDivider() + + Text( + text = "Invite someone", + style = MaterialTheme.typography.titleSmall, + ) + RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) } HorizontalDivider() Text( - text = "Accept a guest's answer", - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = "When your guest sends back their answer link, paste it below.", + text = "The queue and playback controls are in the player at the bottom of the app — " + + "the jam queue is shared with every participant.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( - value = pastedAnswer, - onValueChange = { pastedAnswer = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Answer link or SDP") }, - minLines = 2, - maxLines = 6, - ) - Button( - onClick = { - onSubmitAnswer(pastedAnswer) - pastedAnswer = "" - }, - enabled = pastedAnswer.isNotBlank(), - ) { - Text("Accept Answer") - } - - HorizontalDivider() - - JamQueueView( - queue = playerState.queue, - onJumpTo = onJumpTo, - ) LeaveButton(onLeave) } } -@Composable -private fun GuestSessionView( - state: JamUiState, - playerState: JamPlayerUiState, - onShare: (String) -> Unit, - onLeave: () -> Unit, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onJumpTo: (Int) -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, -) { - val clipboard = LocalClipboardManager.current - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {}) - - val answerLink = state.answerLink - when { - state.isConnected -> { - JamNowPlayingView( - playerState = playerState, - onTogglePlayPause = onTogglePlayPause, - onSkipNext = onSkipNext, - onSkipPrevious = onSkipPrevious, - onToggleShuffle = onToggleShuffle, - onCycleLoop = onCycleLoop, - ) - - JamQueueView( - queue = playerState.queue, - onJumpTo = onJumpTo, - ) - } - - answerLink == null -> { - Text( - text = "Connecting to the session...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - else -> { - Text( - text = "Almost there! Send your answer back to the host:", - style = MaterialTheme.typography.titleSmall, - ) - ShareableLinkBox( - label = "Answer link", - link = answerLink, - onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, - onShare = { onShare(answerLink) }, - ) - } - } - - LeaveButton(onLeave) - } -} - -@Composable -private fun JamNowPlayingView( - playerState: JamPlayerUiState, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - AsyncImage( - model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() }, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(64.dp) - .clip(MaterialTheme.shapes.medium), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = playerState.currentTitle ?: "Nothing playing", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = playerState.currentArtist ?: "—", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatJamDuration(playerState.positionMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = formatJamDuration(playerState.durationMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = onToggleShuffle, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = "Shuffle", - tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - IconButton( - onClick = onSkipPrevious, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = onTogglePlayPause, - theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape), - modifier = Modifier.size(64.dp), - ) { - Icon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(32.dp), - ) - } - IconButton( - onClick = onSkipNext, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") - } - IconButton( - onClick = onCycleLoop, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = "Loop mode", - tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } -} - -@Composable -private fun JamQueueView( - queue: List, - onJumpTo: (Int) -> Unit, -) { - var expanded by rememberSaveable { mutableStateOf(false) } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Queue (${queue.size})", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = if (expanded) "Collapse queue" else "Expand queue", - modifier = Modifier - .size(20.dp) - .graphicsLayer { rotationZ = if (expanded) 180f else 0f }, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (queue.isEmpty()) { - Text( - text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else if (expanded) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 280.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - itemsIndexed(queue) { index, item -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onJumpTo(index) } - .padding(vertical = 6.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - AsyncImage( - model = item.coverUrl.takeIf { it.isNotBlank() }, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(40.dp) - .clip(MaterialTheme.shapes.small), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.title, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (item.isCurrent) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - Text( - text = item.artist, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Text( - text = formatJamDuration(item.durationMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } else { - Text( - text = "Tap to view the shared queue.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - @Composable private fun ParticipantsSection( participants: List, @@ -684,35 +308,26 @@ private fun ParticipantsSection( } } -private fun formatJamDuration(ms: Long): String { - val totalSeconds = (ms / 1000).coerceAtLeast(0) - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} - @Composable -private fun ShareableLinkBox( - label: String, - link: String, - onCopy: () -> Unit, +private fun RoomCodeBox( + code: String, onShare: () -> Unit, ) { + val clipboard = LocalClipboardManager.current val viewModel: JamViewModel = koinViewModel() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SelectionContainer { - OutlinedTextField( - value = link, - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - label = { Text(label) }, - minLines = 2, - maxLines = 6, + Text( + text = code, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp), ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onCopy) { + Button(onClick = { clipboard.setText(AnnotatedString(code)) }) { Text("Copy") } if (viewModel.supportsNativeShare) { 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 index b83e55fa..92dcc936 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -20,22 +20,10 @@ package dev.krtirtho.spotube.modules.jam import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.PlatformType -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.MediaItem -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService -import dev.krtirtho.spotube.core.jam.JamInviteCodec -import dev.krtirtho.spotube.core.jam.JamInviteLink -import dev.krtirtho.spotube.core.jam.JamLoopMapping -import dev.krtirtho.spotube.core.jam.JamMediaItem -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 dev.krtirtho.spotube.core.jam.PlaybackCmd +import dev.krtirtho.spotube.core.jam.JamRoomCode +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.settings.SettingsProvider @@ -45,7 +33,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class JamUiState( @@ -53,377 +40,104 @@ data class JamUiState( val isConnected: Boolean = false, val role: JamRole? = null, val participants: List = emptyList(), - /** Host: deep link containing this session's SDP offer, ready to share. */ - val inviteLink: String? = null, - /** Guest: deep link containing our SDP answer, to send back to the host. */ - val answerLink: String? = null, - /** Guest: offer received via deep link (or paste), waiting for confirmation. */ - val incomingHostName: String? = null, - val incomingOfferSdp: String? = null, + val roomCode: String? = null, + val brokerHost: String = "", + val brokerConfigured: Boolean = false, val error: String? = null, ) -data class JamQueueUiItem( - val id: String, - val title: String, - val artist: String, - val album: String, - val durationMs: Long, - val coverUrl: String, - val isCurrent: Boolean, -) - -data class JamPlayerUiState( - val queue: List = emptyList(), - val currentIndex: Int = -1, - val currentTitle: String? = null, - val currentArtist: String? = null, - val currentCoverUrl: String? = null, - val isPlaying: Boolean = false, - val positionMs: Long = 0, - val durationMs: Long = 0, - val shuffleEnabled: Boolean = false, - val loopMode: String = "none", -) - class JamViewModel( - private val jamSession: JamSessionService, - private val deepLinks: JamDeepLinkService, + private val jamRoomService: JamRoomService, private val shareService: ShareService, private val settingsProvider: SettingsProvider, - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, ) : ViewModel() { - private val _uiState = MutableStateFlow(JamUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _localError = MutableStateFlow(null) val supportsNativeShare: Boolean = getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS - init { - viewModelScope.launch { - // Mirror live session state into the UI state. - jamSession.isActive.collect { active -> - _uiState.update { - it.copy( - isActive = active, - isConnected = jamSession.isConnected.value, - role = jamSession.role.value, - participants = jamSession.participants.value, - inviteLink = if (!active) null else it.inviteLink, - answerLink = if (!active) null else it.answerLink, - incomingOfferSdp = if (!active) it.incomingOfferSdp else null, - incomingHostName = if (!active) it.incomingHostName else null, - ) - } - } - } - viewModelScope.launch { - jamSession.participants.collect { participants -> - _uiState.update { it.copy(participants = participants) } - } - } - viewModelScope.launch { - jamSession.isConnected.collect { connected -> - _uiState.update { it.copy(isConnected = connected) } - } - } - viewModelScope.launch { - deepLinks.pendingLink.collect { link -> - handleDeepLink(link) - } - } - } - - /** - * The jam player state: the shared queue + current playback, built from the - * local player (the host's queue IS the jam queue; on guests the synced - * mirror lives in the local player). - */ - val jamPlayerState: StateFlow = combine( - audioPlayerQueue.queueFlow, - audioPlayerQueue.currentQueueEntryFlow, - audioPlayer.playlistFlow, - audioPlayer.currentMediaItemFlow, - audioPlayer.playerStateFlow, - audioPlayer.positionFlow, - audioPlayer.durationFlow, - audioPlayer.loopStateFlow, - audioPlayer.shuffleModeFlow, + val uiState: StateFlow = combine( + jamRoomService.role, + jamRoomService.participants, + jamRoomService.isConnected, + jamRoomService.roomCode, + jamRoomService.connectionError, + settingsProvider.settingsState, + _localError, ) { values -> - val queue: List = values[0] as List - val currentEntry: QueueEntry? = values[1] as QueueEntry? - val playlist: List = values[2] as List - val currentItem: MediaItem? = values[3] as MediaItem? - val playerState: PlayerState = values[4] as PlayerState - val position: kotlin.time.Duration = values[5] as kotlin.time.Duration - val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration - val loop: LoopState = values[7] as LoopState - val shuffle: Boolean = values[8] as Boolean + @Suppress("UNCHECKED_CAST") + val role = values[0] as JamRole? + @Suppress("UNCHECKED_CAST") + val participants = values[1] as List + val isConnected = values[2] as Boolean + val roomCode = values[3] as String? + val connectionError = values[4] as String? + val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings + val localError = values[6] as String? - val isHost = jamSession.role.value == JamRole.Host - - val items: List - val currentIndex: Int - val currentTitle: String? - val currentArtist: String? - val currentCoverUrl: String? - - if (isHost) { - val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) } - val index = if (currentEntry != null) { - queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) } - } else { - -1 - } - items = queueItems.mapIndexed { i, item -> - item.toUiItem(i == index) - } - currentIndex = index - currentTitle = queueItems.getOrNull(index)?.title - currentArtist = queueItems.getOrNull(index)?.artist - currentCoverUrl = queueItems.getOrNull(index)?.coverUrl - } else { - val index = playlist.indexOf(currentItem) - items = playlist.mapIndexed { i, item -> - JamMediaItem.fromMediaItem(item).toUiItem(i == index) - } - currentIndex = index - currentTitle = currentItem?.title - currentArtist = currentItem?.artist - currentCoverUrl = currentItem?.coverURL - } - - JamPlayerUiState( - queue = items, - currentIndex = currentIndex, - currentTitle = currentTitle, - currentArtist = currentArtist, - currentCoverUrl = currentCoverUrl, - isPlaying = playerState == PlayerState.PLAYING, - positionMs = position.inWholeMilliseconds, - durationMs = duration.inWholeMilliseconds, - shuffleEnabled = shuffle, - loopMode = loop.name.lowercase(), + JamUiState( + isActive = role != null, + isConnected = isConnected, + role = role, + participants = participants, + roomCode = roomCode, + brokerHost = settings?.jamBroker?.host.orEmpty(), + brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), + error = localError ?: connectionError, ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) - // ---------- Playback controls ---------- + // ---------- Session lifecycle ---------- - fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle) - - fun skipNext() = sendOrApply(PlaybackCmd.SkipNext) - - fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious) - - fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs)) - - fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index)) - - fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled)) - - fun cycleLoopMode() { - val next = when (jamPlayerState.value.loopMode) { - "none" -> "one" - "one" -> "all" - else -> "none" - } - sendOrApply(PlaybackCmd.SetLoop(next)) - } - - private fun sendOrApply(command: PlaybackCmd) { + fun createSession() { viewModelScope.launch { - if (jamSession.role.value == JamRole.Host) { - applyCommandLocally(command) - } else { - jamSession.sendMessage(JamMessage.PlaybackCommand(command)) - } + jamRoomService.createRoom() + .onFailure { e -> + _localError.value = e.message ?: "Failed to create jam session" + } + .onSuccess { _localError.value = null } } } - private suspend fun applyCommandLocally(command: PlaybackCmd) { - 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) + fun joinWithCode(input: String) { + val code = JamRoomCode.normalize(input) + if (!JamRoomCode.isValid(code)) { + _localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)" + return } + viewModelScope.launch { + jamRoomService.joinRoom(code) + .onFailure { e -> + _localError.value = e.message ?: "Failed to join jam session" + } + .onSuccess { _localError.value = null } + } + } + + fun shareRoomCode() { + val code = uiState.value.roomCode ?: return + shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam") + } + + fun leave() { + viewModelScope.launch { + jamRoomService.leaveRoom() + _localError.value = null + } + } + + fun clearError() { + _localError.value = null } // ---------- Host moderation ---------- fun kickParticipant(participantId: String) { - viewModelScope.launch { jamSession.kickParticipant(participantId) } + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } } fun banParticipant(participantId: String) { - viewModelScope.launch { jamSession.banParticipant(participantId) } - } - - fun createSession() { - viewModelScope.launch { - runCatching { - val offer = jamSession.createSession() - JamInviteCodec.buildHostInvite(localName(), offer) - }.onSuccess { link -> - _uiState.update { it.copy(inviteLink = link, error = null) } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to create session: ${e.message}") } - } - } - } - - fun generateNewInvite() { - viewModelScope.launch { - runCatching { - val invite = jamSession.generateInvite() - JamInviteCodec.buildHostInvite(localName(), invite.sdp) - }.onSuccess { link -> - _uiState.update { it.copy(inviteLink = link, error = null) } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") } - } - } - } - - fun joinWithIncomingInvite() { - val sdp = _uiState.value.incomingOfferSdp ?: return - join(sdp, _uiState.value.incomingHostName) - } - - fun joinWithPasted(input: String) { - val parsed = JamInviteCodec.parse(input) - val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input) - if (sdp == null) { - _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") } - return - } - join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName) - } - - /** - * Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link. - */ - fun submitAnswerPasted(input: String) { - when (val parsed = JamInviteCodec.parse(input.trim())) { - is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName) - else -> { - val sdp = JamInviteCodec.extractSdp(input) - if (sdp == null) { - _uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") } - } else { - acceptAnswerInternal(sdp, "") - } - } - } - } - - fun share(text: String) { - shareService.share(text, "Spotube Group Jam") - } - - fun leave() { - viewModelScope.launch { - jamSession.leave() - deepLinks.clear() - _uiState.update { - JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName) - } - } - } - - fun clearError() { - _uiState.update { it.copy(error = null) } - } - - fun dismissIncomingInvite() { - deepLinks.clear() - _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) } - } - - private fun join(offerSdp: String, hostName: String? = null) { - viewModelScope.launch { - runCatching { - val answer = jamSession.joinSession(offerSdp, hostName) - JamInviteCodec.buildGuestAnswer(localName(), answer) - }.onSuccess { link -> - _uiState.update { - it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null) - } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to join session: ${e.message}") } - } - } - } - - private fun acceptAnswerInternal(answerSdp: String, peerName: String) { - viewModelScope.launch { - val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) } - .getOrDefault(false) - if (!accepted) { - _uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") } - } else { - _uiState.update { it.copy(error = null) } - } - } - } - - private suspend fun handleDeepLink(link: JamInviteLink?) { - when (link) { - is JamInviteLink.HostInvite -> { - if (!jamSession.isActive.value) { - _uiState.update { - it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp) - } - } - } - - is JamInviteLink.GuestAnswer -> { - if (jamSession.role.value == JamRole.Host) { - acceptAnswerInternal(link.sdp, link.peerName) - } - } - - null -> Unit - } - } - - private fun localName(): String = - settingsProvider.settingsState.value?.jamParticipantName.orEmpty() -} - -private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem( - id = if (trackId.isNotBlank()) trackId else url, - title = title, - artist = artist, - album = album, - durationMs = durationMs, - coverUrl = coverUrl, - isCurrent = isCurrent, -) - -private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean { - return when { - this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> - this.track.id == other.track.id - - this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> - this.url == other.url && this.name == other.name - - else -> false + viewModelScope.launch { jamRoomService.banParticipant(participantId) } } } \ 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 1878ae96..3a6792ff 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 @@ -61,8 +61,10 @@ data class UserSettings( val remoteControlDeviceName: String = "", val remoteControlDeviceId: String = "", - // Group Jam (P2P) + // Group Jam (MQTT) val jamParticipantName: String = "", + val jamBroker: JamBroker = JamBroker(), + val lastJamCode: String = "", // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder @@ -86,3 +88,20 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, ) + +/** + * Configuration for the MQTT broker used by Group Jam. The host is a placeholder + * until a real broker is configured; users can self-host and point the app at it. + */ +@Serializable +data class JamBroker( + val name: String = "", + val host: String = "test.mosquitto.org", + val port: Int = 1883, + val useTls: Boolean = false, + val username: String? = null, + val password: String? = null, + val clientIdPrefix: String = "spotube", + val keepAliveSeconds: Int = 30, + val connectionTimeoutSeconds: Int = 10, +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 7601f014..2321ecef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -43,6 +43,7 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection import dev.krtirtho.spotube.modules.settings.sections.cacheSection import dev.krtirtho.spotube.modules.settings.sections.desktopSection import dev.krtirtho.spotube.modules.settings.sections.downloadsSection +import dev.krtirtho.spotube.modules.settings.sections.jamSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.pluginsSection @@ -110,6 +111,11 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, ) + if (settingsState != null) + jamSection( + settings = settingsState!!, + settingsViewModel = settingsViewModel, + ) if (settingsState != null) cacheSection( settings = settingsState!!, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt new file mode 100644 index 00000000..a15326da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -0,0 +1,208 @@ +/* + * 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.settings.sections + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.jam.JamRoomClient +import dev.krtirtho.spotube.modules.settings.SettingsViewModel +import dev.krtirtho.spotube.modules.settings.UserSettings +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 kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.settings_jam_broker_client_id +import spotube.composeapp.generated.resources.settings_jam_broker_host +import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle +import spotube.composeapp.generated.resources.settings_jam_broker_password +import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note +import spotube.composeapp.generated.resources.settings_jam_broker_port +import spotube.composeapp.generated.resources.settings_jam_broker_test +import spotube.composeapp.generated.resources.settings_jam_broker_test_fail +import spotube.composeapp.generated.resources.settings_jam_broker_test_ok +import spotube.composeapp.generated.resources.settings_jam_broker_testing +import spotube.composeapp.generated.resources.settings_jam_broker_tls +import spotube.composeapp.generated.resources.settings_jam_broker_username +import spotube.composeapp.generated.resources.settings_section_jam + +internal fun LazyListScope.jamSection( + settings: UserSettings, + settingsViewModel: SettingsViewModel, +) { + val broker = settings.jamBroker + + settingsSectionHeader(Res.string.settings_section_jam) + settingsSectionCard( + items = listOf( + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_host), + subtitle = stringResource( + Res.string.settings_jam_broker_host_subtitle, + broker.host.ifBlank { "—" }, + broker.port, + ), + value = broker.host, + onValueSaved = { host -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(host = host)) + } + }, + placeholder = "broker.example.com", + icon = { + SettingsItemIcon( + Iconsax.CustomServer, + stringResource(Res.string.settings_jam_broker_host), + ) + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_port), + value = broker.port.toString(), + onValueSaved = { port -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883)) + } + }, + placeholder = "1883", + normalize = { it.filter { c -> c.isDigit() }.take(5) }, + validate = { input -> + val port = input.toIntOrNull() + if (port == null || port !in 1..65535) "Invalid port" else null + }, + ) + }, + { + SwitchSettingCard( + title = stringResource(Res.string.settings_jam_broker_tls), + checked = broker.useTls, + onCheckedChange = { tls -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(useTls = tls)) + } + }, + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_username), + value = broker.username.orEmpty(), + onValueSaved = { username -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(username = username.ifBlank { null })) + } + }, + placeholder = "anonymous", + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_password), + value = broker.password.orEmpty(), + onValueSaved = { password -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(password = password.ifBlank { null })) + } + }, + placeholder = "••••••••", + ) + }, + { + TextInputSettingCard( + title = stringResource(Res.string.settings_jam_broker_client_id), + value = broker.clientIdPrefix, + onValueSaved = { prefix -> + settingsViewModel.updateSettings { + copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" })) + } + }, + placeholder = "spotube", + ) + }, + { + val jamClient = koinInject() + val scope = rememberCoroutineScope() + var testing by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) { + Button( + onClick = { + testing = true + result = null + scope.launch { + val outcome = jamClient.testConnection(broker) + result = outcome.fold( + onSuccess = { ok -> "OK: $ok" }, + onFailure = { e -> "ERR: ${e.message ?: "unknown"}" }, + ) + testing = false + } + }, + enabled = broker.host.isNotBlank() && !testing, + ) { + Text( + text = if (testing) { + stringResource(Res.string.settings_jam_broker_testing) + } else { + stringResource(Res.string.settings_jam_broker_test) + }, + ) + } + result?.let { message -> + val ok = message.startsWith("OK:") + Text( + text = message.removePrefix("OK:").removePrefix("ERR:"), + style = MaterialTheme.typography.bodySmall, + color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + }, + { + Text( + text = stringResource(Res.string.settings_jam_broker_placeholder_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) + }, + ) + ) +} \ No newline at end of file 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 14fe94f9..b81d2686 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 @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberBottomSheetScaffoldState @@ -70,6 +73,7 @@ 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.core.remote.RemotePlaybackController import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent @@ -95,6 +99,13 @@ fun AppShell( content: @Composable () -> Unit, ) { val navigatorCommands: NavigationCommands = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(remotePlaybackController) { + remotePlaybackController.events.collect { message -> + snackbarHostState.showSnackbar(message) + } + } val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() @@ -122,6 +133,13 @@ fun AppShell( PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 96.dp), + ) + val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 71faa53b..c9d1a870 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -68,7 +68,7 @@ class PlayerQueueContentViewModel( } queue.mapIndexed { index, entry -> val title: String - val subtitle: String + var subtitle: String val durationMs: Long val imageUrl: String? @@ -89,6 +89,11 @@ class PlayerQueueContentViewModel( } } + val addedBy = entry.addedBy + if (addedBy.isNotBlank()) { + subtitle = "$subtitle • Added by $addedBy" + } + QueueItemUi( id = "${entry.url}@$index", title = title, diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index dff859ba..f8e0e84e 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -1,9 +1,7 @@ mod metadata; mod discord_rpc; -mod webrtc_p2p; pub use metadata::*; pub use discord_rpc::*; -pub use webrtc_p2p::*; 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 deleted file mode 100644 index c40e9a69..00000000 --- a/composeApp/src/commonMain/rust/webrtc_p2p.rs +++ /dev/null @@ -1,340 +0,0 @@ -/* - * 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 std::time::Duration; - -use parking_lot::Mutex; -use rtc::ice::mdns::MulticastDnsMode; -use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors; -use rtc::peer_connection::configuration::setting_engine::SettingEngine; -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: Arc>>, - 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(); - - // mDNS adds a multicast UDP socket per peer connection. On some platforms - // (notably Android) that socket can stall and ICE gathering then never - // completes. Real-IP host candidates (no mDNS) work fine alongside STUN/TURN, - // so mDNS is disabled. - let mut setting_engine = SettingEngine::default(); - setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled); - - let (gather_tx, gather_rx) = channel::<()>(1); - let channels = Arc::new(Mutex::new(Vec::new())); - let pc_handler = Arc::new(PeerHandlerBridge { - handler: Arc::clone(&handler), - gather_tx, - channels: Arc::clone(&channels), - }); - - let pc = PeerConnectionBuilder::new() - .with_configuration(config) - .with_setting_engine(setting_engine) - .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, - 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. - /// - /// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at - /// least one candidate has landed in the local description, a short grace period - /// is enough — the SDP must never leave candidate-less. Hard cap at 5s. - async fn wait_for_ice_gathering(&self) { - let mut gather_rx = self.gather_rx.lock().clone(); - let started = std::time::Instant::now(); - - loop { - let elapsed = started.elapsed(); - if elapsed >= Duration::from_secs(5) { - log::warn!( - "ICE gathering did not complete within 5s; using the candidates gathered so far" - ); - return; - } - - match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await { - Ok(Some(())) => return, // gathering complete - Ok(None) => return, // handler dropped - Err(_) => {} // timed out, keep waiting - } - - // Grace period once candidates are present, so the SDP always carries them. - if elapsed >= Duration::from_secs(1) { - let sdp = self.pc.local_description().await.map(|d| d.sdp); - if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) { - return; - } - } - } - } -} - -#[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<()>, - channels: Arc>>, -} - -#[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) { - // Register in-band (remote-initiated) channels so send_data() can find - // them — without this, the answering peer can never send anything. - let label = match dc.label().await { - Ok(l) => l, - Err(_) => return, - }; - self.channels - .lock() - .push(DataChannelEntry { dc: Arc::clone(&dc), label }); - 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 b515031d..d0951e49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,8 @@ kotlinx-io = "0.9.1" material3 = "1.10.0-alpha05" kotlinx-serialization-json = "1.11.0" materialKolor = "4.1.1" +mqttClient = "2.1.1" +mqttBuffer = "6.30.8" murmurhash = "0.4.2" newpipeextractor = "v0.26.2" newpipeExtractorKmp = "1.3.0" @@ -122,6 +124,10 @@ 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" } +mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" } +mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } +mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } +mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } From ad994f3ca3024e5603d88a853ff94c70cadb406d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 11 Sep 2026 22:04:59 +0600 Subject: [PATCH 13/16] feat(jam-session): implement track addition to jam session and enhance UI feedback for jam state --- .../krtirtho/spotube/core/jam/JamProtocol.kt | 8 ++ .../spotube/core/jam/JamRoomService.kt | 112 ++++++++++++++++-- .../core/remote/RemotePlaybackController.kt | 72 +++++------ .../core/ui/component/CollectionView.kt | 4 + .../spotube/core/ui/component/TrackList.kt | 19 ++- .../spotube/core/ui/component/TrackOptions.kt | 16 +++ .../spotube/modules/album/AlbumScreen.kt | 8 ++ .../spotube/modules/album/AlbumViewModel.kt | 8 ++ .../spotube/modules/artist/ArtistScreen.kt | 7 ++ .../spotube/modules/artist/ArtistViewModel.kt | 8 ++ .../modules/devices/PlayDestinationPicker.kt | 33 ------ .../modules/playlist/PlaylistScreen.kt | 8 ++ .../modules/playlist/PlaylistViewModel.kt | 8 ++ .../modules/saved_tracks/SavedTracksScreen.kt | 8 ++ .../saved_tracks/SavedTracksViewModel.kt | 8 ++ .../spotube/modules/search/SearchScreen.kt | 24 ++++ .../modules/shell/AppExpandedPlayer.kt | 19 ++- .../spotube/modules/shell/AppLargePlayer.kt | 17 ++- .../shell/player_queue/PlayerQueueContent.kt | 71 ++++++----- .../PlayerQueueContentViewModel.kt | 15 ++- 20 files changed, 353 insertions(+), 120 deletions(-) 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 index ac9e35d5..a51b8cb5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -39,6 +39,14 @@ sealed class JamMessage { val items: List, val currentIndex: Int, val shuffleEnabled: Boolean = false, + /** + * Whether guests should follow the host's current index. True when the + * host manually skipped/jumped or loaded a queue; false when the host + * merely auto-advanced because its song ended (guests stay put). + */ + val follow: Boolean = false, + /** Host's live play state — late-joining guests start with it. */ + val isPlaying: Boolean = false, ) : JamMessage() @Serializable diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index 8107e1cd..d9618e3c 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.random.Random +import kotlin.time.Clock /** * A jam session over MQTT (star topology, host-authoritative queue). @@ -54,6 +55,13 @@ import kotlin.random.Random * - Guests can only add to the queue (suggest); the host applies suggestions. * - If the host leaves, the participant with the lowest client id takes over. */ +private data class PlaybackBroadcast( + val queue: List, + val current: QueueEntry?, + val shuffle: Boolean, + val playerState: PlayerState, +) + class JamRoomService( private val jamClient: JamRoomClient, private val audioPlayer: AudioPlayerInterface, @@ -83,12 +91,28 @@ class JamRoomService( private var localClientId: String = "" private var localDisplayName: String = "" + + /** Display name of the local participant (stamped on items this device adds). */ + val participantDisplayName: String + get() = localDisplayName + private var hostBroadcastJob: Job? = null /** Guest side: last queue snapshot applied to the local player. */ private var lastAppliedItems: List = emptyList() private var lastAppliedIndex = -1 + /** + * Host side: a song completed, so index changes within this window are + * auto-advance (guests must not follow). The completion event, the player + * state change and the media transition arrive as separate flow emissions, + * so the window covers the whole sequence instead of a single flag. + */ + private var autoAdvanceDeadlineMs = 0L + + /** Guest side: playback has started at least once (local control is the guest's own). */ + private var hasStartedPlayback = false + /** Host side: client ids banned for this session. */ private val bannedClientIds = mutableSetOf() @@ -110,6 +134,20 @@ class JamRoomService( jamClient.presence .onEach { onPresence(it) } .launchIn(scope) + // A naturally-completed song means the host's next index change is an + // auto-advance — guests must NOT follow those, only manual skips. + audioPlayer.completionFlow + .onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS } + .launchIn(scope) + // Once a guest has played on its own (or was started by the host), its + // play/pause is its own — the host's play state only starts fresh joiners. + audioPlayer.playerStateFlow + .onEach { state -> + if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) { + hasStartedPlayback = true + } + } + .launchIn(scope) } // ---------- Session lifecycle ---------- @@ -138,6 +176,8 @@ class JamRoomService( lastAppliedIndex = -1 bannedClientIds.clear() leaving = false + autoAdvanceDeadlineMs = 0L + hasStartedPlayback = false startHostBroadcast() persistLastCode(code) code @@ -170,6 +210,7 @@ class JamRoomService( lastAppliedItems = emptyList() lastAppliedIndex = -1 leaving = false + hasStartedPlayback = false persistLastCode(normalized) } } @@ -187,6 +228,7 @@ class JamRoomService( lastAppliedItems = emptyList() lastAppliedIndex = -1 bannedClientIds.clear() + hasStartedPlayback = false } // ---------- Controls (called from the UI) ---------- @@ -250,20 +292,25 @@ class JamRoomService( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, audioPlayer.shuffleModeFlow, - ) { queue, current, shuffle -> Triple(queue, current, shuffle) } - .onEach { (queue, current, shuffle) -> + audioPlayer.playerStateFlow, + ) { queue, current, shuffle, playerState -> + PlaybackBroadcast(queue, current, shuffle, playerState) + } + .onEach { broadcast -> if (_role.value != JamRole.Host) return@onEach - val index = if (current != null) { - queue.indexOfFirst { it.matchesEntry(current) } + val index = if (broadcast.current != null) { + broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) } } else { -1 } - _shuffleEnabled.value = shuffle + _shuffleEnabled.value = broadcast.shuffle jamClient.publishState( JamMessage.QueueState( - items = queue.map(JamMediaItem::fromQueueEntry), + items = broadcast.queue.map(JamMediaItem::fromQueueEntry), currentIndex = index.coerceAtLeast(0), - shuffleEnabled = shuffle, + shuffleEnabled = broadcast.shuffle, + follow = now() > autoAdvanceDeadlineMs, + isPlaying = broadcast.playerState == PlayerState.PLAYING, ) ) } @@ -285,25 +332,54 @@ class JamRoomService( _shuffleEnabled.value = state.shuffleEnabled runCatching { audioPlayer.shuffle(state.shuffleEnabled) } + // Late joiner: the host is already playing, so start immediately. Once + // this guest has played on its own, the host's play state is ignored. + if (state.isPlaying && !hasStartedPlayback) { + runCatching { audioPlayer.play() } + .onFailure { log.w(it) { "Failed to start playback on host play state" } } + } + val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING if (items != lastAppliedItems) { + val previous = lastAppliedItems lastAppliedItems = items - lastAppliedIndex = state.currentIndex + val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value + val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) } + + // The host only appended items (e.g. accepted suggestions): merge + // them in without resetting playback or the guest's position. + if (previous.isNotEmpty() && items.size > previous.size && + items.take(previous.size) == previous && guestIndex >= 0 + ) { + lastAppliedIndex = guestIndex + val appended = items.drop(previous.size) + runCatching { + audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() }) + }.onFailure { log.w(it) { "Failed to append jam queue items" } } + return + } + + // Full re-sync. Keep the guest's current track when it still exists + // in the synced queue; otherwise take the host's position. + val startIndex = if (guestIndex >= 0) guestIndex + else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)) + lastAppliedIndex = startIndex runCatching { audioPlayerQueue.load( entries = items.map { it.toQueueEntry() }, autoPlay = wasPlaying, - startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), + startPosition = startIndex, ) }.onFailure { log.w(it) { "Failed to apply jam queue" } } return } - if (state.currentIndex != lastAppliedIndex) { + // Same queue content: only follow the host when it moved manually + // (skip/jump). Natural auto-advance keeps everyone where they are. + if (state.follow && state.currentIndex != lastAppliedIndex) { lastAppliedIndex = state.currentIndex - // Queue moved on: follow it, but keep this device's play/pause state. runCatching { audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) }.onFailure { log.w(it) { "Failed to follow jam queue index" } } @@ -459,6 +535,17 @@ class JamRoomService( ) } + private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { + if (entry == null) return false + return when (entry) { + is QueueEntry.StreamingTrack -> + trackId.isNotBlank() && entry.track.id == trackId + + is QueueEntry.LocalTrack -> + url.isNotBlank() && entry.url == url && entry.name == title + } + } + private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when { this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> this.track.id == other.track.id @@ -469,7 +556,10 @@ class JamRoomService( else -> false } + private fun now(): Long = Clock.System.now().toEpochMilliseconds() + companion object { private const val HOST_TAKEOVER_DELAY_MS = 1_500L + private const val AUTO_ADVANCE_WINDOW_MS = 2_000L } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 7857b1f7..2367ae1a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -164,52 +164,56 @@ class RemotePlaybackController( _pendingRequest.value = null } + // ---------- Jam actions ---------- + /** - * Routes the pending request into the active jam session. On the host the jam - * queue IS the local queue, so the action runs locally; on a guest the content - * is suggested to the host, which accepts it into the shared queue. + * Adds a single track to the active jam queue. The host applies it to the + * local (shared) queue directly; a guest suggests it to the host over MQTT. */ - fun playOnJam() { - val request = _pendingRequest.value ?: return - _pendingRequest.value = null + fun addTrackToJam(track: MetadataTrack) { + if (jamRoomService.role.value == null) return scope.launch { try { when (jamRoomService.role.value) { - JamRole.Host -> executeLocally(request) - JamRole.Guest -> suggestToJam(request) + JamRole.Host -> audioPlayerQueue.addToQueue( + QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName) + ) + + JamRole.Guest -> jamRoomService.suggestTrack(track) null -> return@launch } - _events.emit(confirmationMessage(request)) + _events.emit("Added to the jam queue") } catch (e: Exception) { - logger.e(e) { "Failed to send content to jam session" } + logger.e(e) { "Failed to add track to jam session" } } } } - private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) { - PlaybackDestinationAction.Play -> "Playing on the jam queue" - PlaybackDestinationAction.AddToQueue -> "Added to the jam queue" - PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue" - } + /** + * Adds multiple tracks to the active jam queue (host applies locally, + * guest suggests to the host). + */ + fun addTracksToJam(tracks: List) { + if (tracks.isEmpty() || jamRoomService.role.value == null) return + scope.launch { + try { + when (jamRoomService.role.value) { + JamRole.Host -> audioPlayerQueue.addAllToQueue( + tracks.map { track -> + QueueEntry.StreamingTrack( + track = track, + url = "", + addedBy = jamRoomService.participantDisplayName, + ) + } + ) - private suspend fun suggestToJam(request: PlaybackDestinationRequest) { - when (request) { - is PlaybackDestinationRequest.Collection -> { - val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id) - if (tracks.isNotEmpty()) { - jamRoomService.suggestPlaylist(tracks) - logger.i { "Suggested ${tracks.size} track(s) to the jam session" } - } - } - - is PlaybackDestinationRequest.Track -> { - jamRoomService.suggestTrack(request.track) - } - - is PlaybackDestinationRequest.Tracks -> { - if (request.tracks.isNotEmpty()) { - jamRoomService.suggestPlaylist(request.tracks) + JamRole.Guest -> jamRoomService.suggestPlaylist(tracks) + null -> return@launch } + _events.emit("Added ${tracks.size} to the jam queue") + } catch (e: Exception) { + logger.e(e) { "Failed to add tracks to jam session" } } } } @@ -217,9 +221,7 @@ class RemotePlaybackController( // ---------- Internals ---------- private fun request(request: PlaybackDestinationRequest) { - // The picker offers "This Device", a connected remote device, and an - // active jam session — show it whenever more than one destination exists. - if (isRemoteConnected() || jamRoomService.role.value != null) { + if (isRemoteConnected()) { _pendingRequest.value = request } else { executeLocally(request) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 02082d96..44ef5360 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -87,6 +87,8 @@ fun CollectionView( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, trailingContent: @Composable () -> Unit = {}, @@ -200,6 +202,8 @@ fun CollectionView( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, trackOptionsState = trackOptionsState, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 8c75b3f1..4873cb30 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -143,6 +143,8 @@ fun TrackList( onBulkAddToQueue: (List) -> Unit = {}, onBulkPlayNext: (List) -> Unit = {}, onBulkAddToPlaylist: (List) -> Unit = {}, + onBulkAddToJam: (List) -> Unit = {}, + isInJam: Boolean = false, currentTrackId: String? = null, isCurrentTrackPlaying: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, @@ -274,6 +276,7 @@ fun TrackList( ) }, trackOptionsState = trackOptionsState(track), + isInJam = isInJam, onShowOptionsClick = { selectedTrackForOptions = track }, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, @@ -442,7 +445,17 @@ fun TrackList( label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", onClick = { onBulkAddToPlaylist(targetTracks) }, ), - ), + ) + if (isInJam) { + listOf( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", + onClick = { onBulkAddToJam(targetTracks) }, + ), + ) + } else { + emptyList() + }, trigger = { onClick -> GroupIconButton( onClick = onClick, @@ -497,6 +510,7 @@ fun TrackList( selectedTrackForOptions = null }, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, ) } } @@ -555,6 +569,7 @@ private fun TrackListRow( onSelectionToggle: (Boolean) -> Unit, onTrackOptionsAction: (TrackOptionsAction) -> Unit, trackOptionsState: TrackOptionsState, + isInJam: Boolean, onShowOptionsClick: () -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, @@ -750,6 +765,7 @@ private fun TrackListRow( state = trackOptionsState, onAction = onTrackOptionsAction, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, + isInJam = isInJam, ) } else { GhostIconButton(onClick = onShowOptionsClick) { @@ -843,6 +859,7 @@ private fun ShimmerTrackListRow( onSelectionToggle = {}, onTrackOptionsAction = {}, trackOptionsState = TrackOptionsState(), + isInJam = false, onShowOptionsClick = {}, onArtistClick = {}, onAlbumClick = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index c401e631..e0cf1874 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext import dev.krtirtho.spotube.resources.iconsax.IconsaxShare sealed interface TrackOptionsAction { + data object AddToJam : TrackOptionsAction data object StartRadio : TrackOptionsAction data object PlayNext : TrackOptionsAction data object AddToQueue : TrackOptionsAction @@ -98,6 +99,7 @@ fun TrackOptions( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, modifier: Modifier = Modifier, + isInJam: Boolean = false, ) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( @@ -105,6 +107,7 @@ fun TrackOptions( state = state, onAction = onAction, onAlbumClick = onAlbumClick, + isInJam = isInJam, ), trigger = { onClick -> GhostIconButton(onClick = onClick) { @@ -129,6 +132,7 @@ fun TrackOptionsBottomSheet( onDismiss: () -> Unit, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, + isInJam: Boolean = false, ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.fillMaxWidth()) { @@ -151,6 +155,7 @@ fun TrackOptionsBottomSheet( onAlbumClick() onDismiss() }, + isInJam = isInJam, ).forEach { item -> Row( modifier = Modifier @@ -241,7 +246,18 @@ private fun buildTrackMenuItems( state: TrackOptionsState, onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, + isInJam: Boolean = false, ): List = buildList { + if (isInJam) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to Jam", + onClick = { onAction(TrackOptionsAction.AddToJam) }, + ), + ) + } + add( AdaptiveMenuItem( icon = Iconsax.IconsaxMusicCircle, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index 8a91a2c1..dbd7da14 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -20,10 +20,13 @@ package dev.krtirtho.spotube.modules.album import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -37,6 +40,9 @@ fun AlbumScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() @@ -92,6 +98,8 @@ fun AlbumScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt index eb2b2ee0..3da95baa 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt @@ -273,6 +273,10 @@ class AlbumViewModel( remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> @@ -333,6 +337,10 @@ class AlbumViewModel( tracks.forEach { track -> downloadManager.enqueue(track) } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" remotePlaybackController.requestTracksAddToQueue(tracks, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index 5b76c144..b0fcdbf7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -63,8 +63,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton @@ -95,6 +97,9 @@ fun ArtistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() @@ -179,6 +184,8 @@ fun ArtistScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt index 2baa9e9d..8c89001a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt @@ -276,6 +276,10 @@ class ArtistViewModel( startTrack = track, ) } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" remotePlaybackController.requestTracksAddToQueue(tracks, artistName) @@ -306,6 +310,10 @@ class ArtistViewModel( is TrackOptionsAction.AddToQueue -> { remotePlaybackController.requestTrackAddToQueue(track) } + + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 906c1492..a8b8213e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient @@ -40,8 +39,6 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist -import kotlinx.coroutines.flow.map import org.koin.compose.koinInject /** @@ -53,11 +50,8 @@ import org.koin.compose.koinInject fun PlayDestinationPickerHost() { val controller = koinInject() val remoteControlClient = koinInject() - val jamRoomService = koinInject() val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - val jamActive by jamRoomService.role.map { it != null } - .collectAsStateWithLifecycle(initialValue = false) val pendingRequest = request ?: return @@ -143,33 +137,6 @@ fun PlayDestinationPickerHost() { }, ) } - - if (jamActive) { - ListRowTile( - onClick = controller::playOnJam, - modifier = Modifier.fillMaxWidth(), - leading = { - Icon( - imageVector = Iconsax.IconsaxMusicPlaylist, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) - }, - title = { - Text( - text = "Jam Session", - style = MaterialTheme.typography.bodyLarge, - ) - }, - subtitle = { - Text( - text = "$actionLabel in the shared jam queue", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - ) - } } }, actions = { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 7a5c0d58..09dc458b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -31,10 +31,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.component.CollectionView @@ -53,6 +56,9 @@ fun PlaylistScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() @@ -127,6 +133,8 @@ fun PlaylistScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, footerContent = footerContent, trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt index 8380e8af..d1ef1088 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt @@ -306,6 +306,10 @@ class PlaylistViewModel( remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> @@ -378,6 +382,10 @@ class PlaylistViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" remotePlaybackController.requestTracksAddToQueue(tracks, title) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 045a0380..4fa10f2a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -20,11 +20,14 @@ package dev.krtirtho.spotube.modules.saved_tracks import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.map import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker @@ -39,6 +42,9 @@ fun SavedTracksScreen( navigationCommands: NavigationCommands ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() @@ -85,6 +91,8 @@ fun SavedTracksScreen( onBulkAddToQueue = viewModel::addTracksToQueue, onBulkPlayNext = viewModel::playTracksNext, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, + onBulkAddToJam = viewModel::addTracksToJam, + isInJam = jamActive, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt index 72d52113..1025bdbd 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksViewModel.kt @@ -248,6 +248,10 @@ class SavedTracksViewModel( remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } + is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() queue.find { entry -> @@ -312,6 +316,10 @@ class SavedTracksViewModel( } } + fun addTracksToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun addTracksToQueue(tracks: List) { remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index b8f699d8..35c50407 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.jam.JamRoomService +import org.koin.compose.koinInject import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField @@ -128,6 +130,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val blacklistRepository: BlacklistRepository = koinInject() val navigationCommands: NavigationCommands = koinInject() val state by viewModel.state.collectAsStateWithLifecycle() + val jamRoomService: JamRoomService = koinInject() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() @@ -188,6 +193,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { is TrackOptionsAction.AddToQueue -> { remotePlaybackController.requestTrackAddToQueue(track) } + is TrackOptionsAction.AddToJam -> { + remotePlaybackController.addTrackToJam(track) + } is TrackOptionsAction.RemoveFromQueue -> { val queue = audioPlayerQueue.getQueue() @@ -248,6 +256,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") } + fun bulkAddToJam(tracks: List) { + remotePlaybackController.addTracksToJam(tracks) + } + fun bulkPlayNext(tracks: List) { remotePlaybackController.requestTracksPlayNext(tracks, "Search results") } @@ -327,6 +339,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -356,6 +370,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { tracksToAddToPlaylist = tracks showAddToPlaylistPicker = true }, + onBulkAddToJam = ::bulkAddToJam, + isInJam = jamActive, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -637,6 +653,8 @@ private fun SearchAllTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -694,6 +712,8 @@ private fun SearchAllTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, @@ -781,6 +801,8 @@ private fun SearchTracksTab( onBulkAddToQueue: (List) -> Unit, onBulkPlayNext: (List) -> Unit, onBulkAddToPlaylist: (List) -> Unit, + onBulkAddToJam: (List) -> Unit, + isInJam: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -810,6 +832,8 @@ private fun SearchTracksTab( onBulkAddToQueue = onBulkAddToQueue, onBulkPlayNext = onBulkPlayNext, onBulkAddToPlaylist = onBulkAddToPlaylist, + onBulkAddToJam = onBulkAddToJam, + isInJam = isInJam, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt index c43ef433..96172f80 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppExpandedPlayer.kt @@ -82,6 +82,8 @@ 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.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.ui.base.BaseUITheme @@ -117,6 +119,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -158,6 +161,10 @@ fun AppExpandedPlayer( ), ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val downloadsViewModel: DownloadsViewModel = koinViewModel() val navigationCommands: NavigationCommands = koinInject() @@ -198,18 +205,22 @@ fun AppExpandedPlayer( } fun onSkipPrevious() { + if (isJamGuest) return scope.launch { audioPlayer.skipToPrevious() } } fun onSkipNext() { + if (isJamGuest) return scope.launch { audioPlayer.skipToNext() } } fun onShuffleToggle() { + if (isJamGuest) return scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } } fun onLoopToggle() { + if (isJamGuest) return scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } } @@ -517,7 +528,7 @@ fun AppExpandedPlayer( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - GhostIconButton(onClick = ::onShuffleToggle) { + GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) { Icon( Iconsax.IconsaxShuffle, contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", @@ -528,7 +539,7 @@ fun AppExpandedPlayer( } ) } - GhostIconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } IconButton( @@ -543,10 +554,10 @@ fun AppExpandedPlayer( modifier = Modifier.size(30.dp), ) } - GhostIconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } - GhostIconButton(onClick = ::onLoopToggle) { + GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) { Icon( imageVector = when (playerUiState.loopState) { LoopState.NONE -> Iconsax.IconsaxRepeateMusic diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt index 9d7ffbd1..a6e09477 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppLargePlayer.kt @@ -65,6 +65,8 @@ 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.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.Slider @@ -93,6 +95,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel @@ -125,6 +128,10 @@ fun AppLargePlayer( ), ) { val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) + val jamRoomService: JamRoomService = koinInject() + val isJamGuest by jamRoomService.role + .map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val scope = rememberCoroutineScope() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() var isSeeking by remember { mutableStateOf(false) } @@ -155,18 +162,22 @@ fun AppLargePlayer( } fun onSkipPrevious() { + if (isJamGuest) return scope.launch { audioPlayer.skipToPrevious() } } fun onSkipNext() { + if (isJamGuest) return scope.launch { audioPlayer.skipToNext() } } fun onShuffleToggle() { + if (isJamGuest) return scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } } fun onLoopToggle() { + if (isJamGuest) return scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } } @@ -294,6 +305,7 @@ fun AppLargePlayer( ) { VariableIconButton( onClick = ::onShuffleToggle, + enabled = !isJamGuest, variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost ) { Icon( @@ -306,7 +318,7 @@ fun AppLargePlayer( } ) } - GhostIconButton(onClick = ::onSkipPrevious) { + GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") } IconButton( @@ -320,11 +332,12 @@ fun AppLargePlayer( contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", ) } - GhostIconButton(onClick = ::onSkipNext) { + GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { Icon(Iconsax.IconsaxNext, contentDescription = "Next") } VariableIconButton( onClick = ::onLoopToggle, + enabled = !isJamGuest, variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline ) { Icon( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 997da78f..0628e243 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -78,12 +78,13 @@ fun PlayerQueueContent( val displayItems = state.displayItems val filterQuery = state.filterQuery val isFiltered = state.isFiltered + val isReadOnly = state.isReadOnly val lazyListState = rememberLazyListState() val reorderableLazyListState = rememberReorderableLazyListState( lazyListState, onMove = { from, to -> - if (isFiltered) return@rememberReorderableLazyListState + if (isFiltered || isReadOnly) return@rememberReorderableLazyListState viewModel.onMove(from.index, to.index) }, ) @@ -118,11 +119,13 @@ fun PlayerQueueContent( singleLine = true, modifier = Modifier.weight(1f), ) - IconButton( - onClick = viewModel::clearQueue, - theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), - ) { - Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + if (!isReadOnly) { + IconButton( + onClick = viewModel::clearQueue, + theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), + ) { + Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") + } } } @@ -144,11 +147,12 @@ fun PlayerQueueContent( val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) QueueItemRow( item = item, - reorderScope = if (isFiltered) null else this, + reorderScope = if (isFiltered || isReadOnly) null else this, onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onDragStarted = { viewModel.onDragStart() }, onDragStopped = { viewModel.onDragStop() }, + showOptions = !isReadOnly, ) } } @@ -167,6 +171,7 @@ private fun QueueItemRow( onRemoveClick: () -> Unit, onDragStarted: () -> Unit, onDragStopped: () -> Unit, + showOptions: Boolean = true, ) { var showMenu by remember { mutableStateOf(false) } @@ -255,31 +260,33 @@ private fun QueueItemRow( Spacer(modifier = Modifier.width(4.dp)) - Box { - GhostIconButton( - onClick = { showMenu = true }, - modifier = Modifier.size(36.dp), - ) { - Icon( - Iconsax.Iconsax3DotsMore, - contentDescription = "More options", - modifier = Modifier.size(18.dp), - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Remove from queue") }, - onClick = { - onRemoveClick() - showMenu = false - }, - leadingIcon = { - Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) - }, - ) + if (showOptions) { + Box { + GhostIconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(36.dp), + ) { + Icon( + Iconsax.Iconsax3DotsMore, + contentDescription = "More options", + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Remove from queue") }, + onClick = { + onRemoveClick() + showMenu = false + }, + leadingIcon = { + Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null) + }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index c9d1a870..ced76d8e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -44,10 +46,13 @@ data class QueueContentUiState( val filterQuery: String = "", val displayItems: List = emptyList(), val isFiltered: Boolean = false, + /** Guests cannot reorder/remove/clear the shared jam queue. */ + val isReadOnly: Boolean = false, ) class PlayerQueueContentViewModel( private val audioPlayerQueue: AudioPlayerQueue, + private val jamRoomService: JamRoomService, ) : ViewModel() { private val queueVisibilityFlow = MutableStateFlow(false) private val queueFilterFlow = MutableStateFlow("") @@ -114,7 +119,8 @@ class PlayerQueueContentViewModel( computedItems, reorderBuffer, queueFilterFlow, - ) { items, buffer, filterQuery -> + jamRoomService.role, + ) { items, buffer, filterQuery, role -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { @@ -129,6 +135,7 @@ class PlayerQueueContentViewModel( filterQuery = filterQuery, displayItems = buffer ?: filtered, isFiltered = isFiltered, + isReadOnly = role == JamRole.Guest, ) }.stateIn( scope = viewModelScope, @@ -156,7 +163,7 @@ class PlayerQueueContentViewModel( } fun removeQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { val currentQueue = audioPlayerQueue.queueFlow.value if (index < currentQueue.size) { @@ -167,12 +174,14 @@ class PlayerQueueContentViewModel( fun moveQueueItem(fromIndex: Int, toIndex: Int) { if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.move(fromIndex, toIndex) } } fun clearQueue() { + if (queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.clear() } @@ -180,11 +189,13 @@ class PlayerQueueContentViewModel( fun onDragStart() { if (reorderBuffer.value != null) return + if (queueContentUiState.value.isReadOnly) return val currentItems = queueContentUiState.value.displayItems reorderBuffer.value = currentItems.toList() } fun onMove(from: Int, to: Int) { + if (queueContentUiState.value.isReadOnly) return val buffer = reorderBuffer.value ?: return if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return val item = buffer[from] From 4c724a333b327f7cce5be2bcf407e6275beee56d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Sep 2026 00:01:34 +0600 Subject: [PATCH 14/16] feat(jam-session): enhance guest functionality with track addition and participant moderation options --- .../spotube/core/jam/JamRoomService.kt | 18 ++- .../core/remote/RemotePlaybackController.kt | 4 +- .../krtirtho/spotube/core/ui/base/Buttons.kt | 16 +- .../core/ui/component/CollectionDetails.kt | 87 ++++++---- .../core/ui/component/CollectionView.kt | 9 +- .../spotube/core/ui/component/TrackList.kt | 62 +++++--- .../spotube/core/ui/component/TrackOptions.kt | 75 +++++---- .../spotube/modules/album/AlbumScreen.kt | 5 + .../spotube/modules/artist/ArtistScreen.kt | 4 + .../modules/playlist/PlaylistScreen.kt | 5 + .../modules/saved_tracks/SavedTracksScreen.kt | 5 + .../spotube/modules/search/SearchScreen.kt | 9 ++ .../spotube/modules/shell/AppShell.kt | 16 +- .../shell/player_queue/PlayerQueueContent.kt | 150 ++++++++++++++++++ .../PlayerQueueContentViewModel.kt | 43 ++++- .../modules/shell/player_queue/QueueSheet.kt | 7 +- 16 files changed, 406 insertions(+), 109 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt index d9618e3c..f8c15a2b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -92,10 +92,14 @@ class JamRoomService( private var localClientId: String = "" private var localDisplayName: String = "" - /** Display name of the local participant (stamped on items this device adds). */ + /** Display name of the local participant. */ val participantDisplayName: String get() = localDisplayName + /** Stable id of the local participant (stamped on items this device adds). */ + val participantClientId: String + get() = localClientId + private var hostBroadcastJob: Job? = null /** Guest side: last queue snapshot applied to the local player. */ @@ -257,7 +261,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestTrack( mediaItem = JamMediaItem.fromTrack(track), - addedBy = localDisplayName, + addedBy = localClientId, ) ) } @@ -267,7 +271,7 @@ class JamRoomService( jamClient.publishCommand( JamMessage.SuggestPlaylist( tracks = tracks.map(JamMediaItem::fromTrack), - addedBy = localDisplayName, + addedBy = localClientId, ) ) } @@ -396,11 +400,15 @@ class JamRoomService( } is JamMessage.SuggestTrack -> { - if (_role.value == JamRole.Host) acceptSuggestion(listOf(message.mediaItem)) + if (_role.value == JamRole.Host) { + acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy))) + } } is JamMessage.SuggestPlaylist -> { - if (_role.value == JamRole.Host) acceptSuggestion(message.tracks) + if (_role.value == JamRole.Host) { + acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) }) + } } is JamMessage.Kick -> { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 2367ae1a..40602ea9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -176,7 +176,7 @@ class RemotePlaybackController( try { when (jamRoomService.role.value) { JamRole.Host -> audioPlayerQueue.addToQueue( - QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantDisplayName) + QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId) ) JamRole.Guest -> jamRoomService.suggestTrack(track) @@ -203,7 +203,7 @@ class RemotePlaybackController( QueueEntry.StreamingTrack( track = track, url = "", - addedBy = jamRoomService.participantDisplayName, + addedBy = jamRoomService.participantClientId, ) } ) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt index edc24ebb..885f6357 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/base/Buttons.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.shadow @@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.User private val BadgeShape = RoundedCornerShape(11.dp) +private const val DisabledContentAlpha = 0.38f + private val ButtonMinHeight = 40.dp private val SquareButtonSize = 40.dp @@ -178,6 +182,7 @@ fun OutlineButton( contentAlignment = Alignment.Center, ) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = { @@ -226,6 +231,7 @@ fun PrimaryButton( ) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = content, @@ -271,6 +277,7 @@ fun SecondaryButton( ) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { Row( + modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), content = content, @@ -488,7 +495,14 @@ fun GroupIconButton( ), contentAlignment = Alignment.Center, ) { - content() + Box( + modifier = Modifier + .fillMaxSize() + .alpha(if (enabled) 1f else DisabledContentAlpha), + contentAlignment = Alignment.Center, + ) { + content() + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt index 7f4335b5..4b9ec5a9 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionDetails.kt @@ -78,6 +78,9 @@ fun CollectionDetails( onShufflePlay: () -> Unit, onAddToQueue: () -> Unit, isPlaying: Boolean = false, + /** Guest in a jam session: play/shuffle are replaced by Add to Jam. */ + isJamGuest: Boolean = false, + onAddToJam: (() -> Unit)? = null, isFollowing: Boolean = false, onFollowClick: () -> Unit = { }, showFollowButton: Boolean = true, @@ -91,39 +94,61 @@ fun CollectionDetails( val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val playPauseButton = @Composable { - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val modifier = if (isCompact) { - Modifier.weight(1f) - } else { - Modifier + if (isJamGuest) { + if (onAddToJam != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + PrimaryButton( + modifier = if (isCompact) Modifier.weight(1f) else Modifier, + onClick = onAddToJam!!, + ) { + Icon( + imageVector = Iconsax.IconsaxAddSquare, + contentDescription = "Add to Jam", + ) + TextWithShimmer( + text = "Add to Jam", + modifier = Modifier.padding(start = 6.dp), + ) + } + } } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val modifier = if (isCompact) { + Modifier.weight(1f) + } else { + Modifier + } - PrimaryButton( - modifier = modifier, - onClick = onPlay, - ) { - Icon( - imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - TextWithShimmer( - text = if (isPlaying) "Pause" else "Play", - modifier = Modifier.padding(start = 6.dp), - ) - } - OutlineButton( - modifier = modifier, - onClick = onShufflePlay - ) { - Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") - TextWithShimmer( - text = "Shuffle", - modifier = Modifier.padding(start = 6.dp), - ) + PrimaryButton( + modifier = modifier, + onClick = onPlay, + ) { + Icon( + imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, + contentDescription = if (isPlaying) "Pause" else "Play", + ) + TextWithShimmer( + text = if (isPlaying) "Pause" else "Play", + modifier = Modifier.padding(start = 6.dp), + ) + } + OutlineButton( + modifier = modifier, + onClick = onShufflePlay + ) { + Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") + TextWithShimmer( + text = "Shuffle", + modifier = Modifier.padding(start = 6.dp), + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt index 44ef5360..e4c9dd31 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/CollectionView.kt @@ -89,6 +89,8 @@ fun CollectionView( onBulkAddToPlaylist: (List) -> Unit = {}, onBulkAddToJam: (List) -> Unit = {}, isInJam: Boolean = false, + isJamGuest: Boolean = false, + onAddToJam: (List) -> Unit = {}, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, footerContent: (@Composable () -> Unit)? = null, trailingContent: @Composable () -> Unit = {}, @@ -116,7 +118,7 @@ fun CollectionView( } else { {} }, - actions = if (isCollapsed) { + actions = if (isCollapsed && !isJamGuest) { { IconButton(onClick = onPlay) { Icon( @@ -161,6 +163,8 @@ fun CollectionView( showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }, ) } } else { @@ -181,6 +185,8 @@ fun CollectionView( showFollowButton = showFollowButton, onEdit = onEdit, sharedElementKey = sharedElementKey, + isJamGuest = isJamGuest, + onAddToJam = { onAddToJam(tracks) }, ) } }, @@ -204,6 +210,7 @@ fun CollectionView( onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, + isJamGuest = isJamGuest, trackOptionsState = trackOptionsState, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt index 4873cb30..a985983a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt @@ -145,6 +145,7 @@ fun TrackList( onBulkAddToPlaylist: (List) -> Unit = {}, onBulkAddToJam: (List) -> Unit = {}, isInJam: Boolean = false, + isJamGuest: Boolean = false, currentTrackId: String? = null, isCurrentTrackPlaying: Boolean = false, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, @@ -249,6 +250,8 @@ fun TrackList( } else { selectedTrackIds + track.id } + } else if (isJamGuest) { + onTrackOptionsAction(track, TrackOptionsAction.AddToJam) } else { onTrackClick(track) } @@ -277,6 +280,7 @@ fun TrackList( }, trackOptionsState = trackOptionsState(track), isInJam = isInJam, + isJamGuest = isJamGuest, onShowOptionsClick = { selectedTrackForOptions = track }, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, @@ -424,28 +428,38 @@ fun TrackList( val isAll = selectedTrackIds.isEmpty() || trackCount == visibleTracks.size AdaptiveDropdownBottomSheet( - items = listOf( - AdaptiveMenuItem( - icon = Iconsax.IconsaxDirectboxReceive, - label = if (isAll) "Download All" else "Download $trackCount", - onClick = { onBulkDownload(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", - onClick = { onBulkAddToQueue(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = if (isAll) "Play All Next" else "Play $trackCount Next", - onClick = { onBulkPlayNext(targetTracks) }, - ), - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicPlaylist, - label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", - onClick = { onBulkAddToPlaylist(targetTracks) }, - ), - ) + if (isInJam) { + items = buildList { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxDirectboxReceive, + label = if (isAll) "Download All" else "Download $trackCount", + onClick = { onBulkDownload(targetTracks) }, + ), + ) + if (!isJamGuest) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", + onClick = { onBulkAddToQueue(targetTracks) }, + ), + ) + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = if (isAll) "Play All Next" else "Play $trackCount Next", + onClick = { onBulkPlayNext(targetTracks) }, + ), + ) + } + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicPlaylist, + label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", + onClick = { onBulkAddToPlaylist(targetTracks) }, + ), + ) + } + if (isInJam) { listOf( AdaptiveMenuItem( icon = Iconsax.IconsaxAddSquare, @@ -511,6 +525,7 @@ fun TrackList( }, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, isInJam = isInJam, + isJamGuest = isJamGuest, ) } } @@ -570,6 +585,7 @@ private fun TrackListRow( onTrackOptionsAction: (TrackOptionsAction) -> Unit, trackOptionsState: TrackOptionsState, isInJam: Boolean, + isJamGuest: Boolean, onShowOptionsClick: () -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, @@ -766,6 +782,7 @@ private fun TrackListRow( onAction = onTrackOptionsAction, onAlbumClick = { track.album?.let { onAlbumClick(it) } }, isInJam = isInJam, + isJamGuest = isJamGuest, ) } else { GhostIconButton(onClick = onShowOptionsClick) { @@ -860,6 +877,7 @@ private fun ShimmerTrackListRow( onTrackOptionsAction = {}, trackOptionsState = TrackOptionsState(), isInJam = false, + isJamGuest = false, onShowOptionsClick = {}, onArtistClick = {}, onAlbumClick = {}, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt index e0cf1874..bccc4fb2 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackOptions.kt @@ -100,6 +100,7 @@ fun TrackOptions( onAlbumClick: () -> Unit, modifier: Modifier = Modifier, isInJam: Boolean = false, + isJamGuest: Boolean = false, ) { AdaptiveDropdownBottomSheet( items = buildTrackMenuItems( @@ -108,6 +109,7 @@ fun TrackOptions( onAction = onAction, onAlbumClick = onAlbumClick, isInJam = isInJam, + isJamGuest = isJamGuest, ), trigger = { onClick -> GhostIconButton(onClick = onClick) { @@ -133,6 +135,7 @@ fun TrackOptionsBottomSheet( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, + isJamGuest: Boolean = false, ) { ModalBottomSheet(onDismissRequest = onDismiss) { Column(modifier = Modifier.fillMaxWidth()) { @@ -156,6 +159,7 @@ fun TrackOptionsBottomSheet( onDismiss() }, isInJam = isInJam, + isJamGuest = isJamGuest, ).forEach { item -> Row( modifier = Modifier @@ -247,6 +251,7 @@ private fun buildTrackMenuItems( onAction: (TrackOptionsAction) -> Unit, onAlbumClick: () -> Unit, isInJam: Boolean = false, + isJamGuest: Boolean = false, ): List = buildList { if (isInJam) { add( @@ -266,40 +271,44 @@ private fun buildTrackMenuItems( ), ) - if (!state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Play next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } else if (state.isInQueue && !state.isCurrentlyPlaying) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxNext, - label = "Move to next", - onClick = { onAction(TrackOptionsAction.PlayNext) }, - ), - ) - } + // A guest's queue is the shared jam queue — mutating it locally is not + // allowed, so queue actions are replaced by "Add to Jam". + if (!isJamGuest) { + if (!state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = "Play next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } else if (state.isInQueue && !state.isCurrentlyPlaying) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxNext, + label = "Move to next", + onClick = { onAction(TrackOptionsAction.PlayNext) }, + ), + ) + } - if (!state.isInQueue) { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxAddSquare, - label = "Add to queue", - onClick = { onAction(TrackOptionsAction.AddToQueue) }, - ), - ) - } else { - add( - AdaptiveMenuItem( - icon = Iconsax.IconsaxMusicSquareRemove, - label = "Remove from queue", - onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, - ), - ) + if (!state.isInQueue) { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxAddSquare, + label = "Add to queue", + onClick = { onAction(TrackOptionsAction.AddToQueue) }, + ), + ) + } else { + add( + AdaptiveMenuItem( + icon = Iconsax.IconsaxMusicSquareRemove, + label = "Remove from queue", + onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, + ), + ) + } } add( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt index dbd7da14..db76747f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumScreen.kt @@ -25,6 +25,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes @@ -43,6 +44,8 @@ fun AlbumScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() @@ -100,6 +103,8 @@ fun AlbumScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt index b0fcdbf7..a38c36e6 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt @@ -63,6 +63,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes @@ -100,6 +101,8 @@ fun ArtistScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() @@ -186,6 +189,7 @@ fun ArtistScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt index 09dc458b..b48f1253 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistScreen.kt @@ -36,6 +36,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes @@ -59,6 +60,8 @@ fun PlaylistScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() @@ -135,6 +138,8 @@ fun PlaylistScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, footerContent = footerContent, trailingContent = { val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt index 4fa10f2a..191923a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/saved_tracks/SavedTracksScreen.kt @@ -26,6 +26,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.navigation.Routes @@ -45,6 +46,8 @@ fun SavedTracksScreen( val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() @@ -93,6 +96,8 @@ fun SavedTracksScreen( onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToJam = viewModel::addTracksToJam, isInJam = jamActive, + isJamGuest = isJamGuest, + onAddToJam = viewModel::addTracksToJam, trailingContent = { AddToPlaylistPicker( visible = showAddToPlaylistPicker, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt index 35c50407..2b2bb588 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt @@ -86,6 +86,7 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.Routes +import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import org.koin.compose.koinInject import dev.krtirtho.spotube.core.remote.RemotePlaybackController @@ -133,6 +134,8 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val jamRoomService: JamRoomService = koinInject() val jamActive by jamRoomService.role.map { it != null } .collectAsStateWithLifecycle(initialValue = false) + val isJamGuest by jamRoomService.role.map { it == JamRole.Guest } + .collectAsStateWithLifecycle(initialValue = false) val selectedType = state.selectedSearchType val scope = rememberCoroutineScope() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() @@ -341,6 +344,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { }, onBulkAddToJam = ::bulkAddToJam, isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -372,6 +376,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { }, onBulkAddToJam = ::bulkAddToJam, isInJam = jamActive, + isJamGuest = isJamGuest, onArtistClick = { artist -> navigationCommands.navigateTo(Routes.Artist(artist.id)) }, @@ -655,6 +660,7 @@ private fun SearchAllTab( onBulkAddToPlaylist: (List) -> Unit, onBulkAddToJam: (List) -> Unit, isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -714,6 +720,7 @@ private fun SearchAllTab( onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, @@ -803,6 +810,7 @@ private fun SearchTracksTab( onBulkAddToPlaylist: (List) -> Unit, onBulkAddToJam: (List) -> Unit, isInJam: Boolean, + isJamGuest: Boolean, onArtistClick: (MetadataArtist.Basic) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit, @@ -834,6 +842,7 @@ private fun SearchTracksTab( onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToJam = onBulkAddToJam, isInJam = isInJam, + isJamGuest = isJamGuest, onArtistClick = onArtistClick, onAlbumClick = onAlbumClick, onArtistsOverflowClick = onArtistsOverflowClick, 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 b81d2686..b044e3a4 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 @@ -133,13 +133,6 @@ fun AppShell( PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 96.dp), - ) - val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) @@ -281,6 +274,15 @@ fun AppShell( } } } + + // Drawn last so it floats above the players/sheets, just above the + // bottom overlay (large player or compact player + bottombar). + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = bottomOverlayInset + 12.dp), + ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt index 0628e243..0042df8d 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContent.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.IconButton @@ -59,12 +65,15 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.copyShape +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch +import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash +import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove import org.koin.compose.viewmodel.koinViewModel import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -79,6 +88,7 @@ fun PlayerQueueContent( val filterQuery = state.filterQuery val isFiltered = state.isFiltered val isReadOnly = state.isReadOnly + var selectedParticipant by remember { mutableStateOf(null) } val lazyListState = rememberLazyListState() val reorderableLazyListState = rememberReorderableLazyListState( @@ -153,16 +163,135 @@ fun PlayerQueueContent( onDragStarted = { viewModel.onDragStart() }, onDragStopped = { viewModel.onDragStop() }, showOptions = !isReadOnly, + enabled = !isReadOnly, + onParticipantClick = { selectedParticipant = it }, ) } } } } } + + selectedParticipant?.let { participant -> + ParticipantDialog( + participant = participant, + isJamHost = state.isJamHost, + onDismiss = { selectedParticipant = null }, + onKick = { viewModel.kickParticipant(participant.id) }, + onBan = { viewModel.banParticipant(participant.id) }, + onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) }, + ) + } } } } +@Composable +private fun ParticipantDialog( + participant: JamParticipant, + isJamHost: Boolean, + onDismiss: () -> Unit, + onKick: () -> Unit, + onBan: () -> Unit, + onRemoveSuggestions: () -> Unit, +) { + AdaptiveDialogBottomSheet( + onDismiss = onDismiss, + title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) }, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(vertical = 8.dp), + ) { + ParticipantAvatar(participant, size = 40) + Text( + text = participant.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + if (participant.isHost) { + Text( + text = "Host", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + if (isJamHost && !participant.isHost) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ListRowTile( + onClick = { + onKick() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Kick") }, + subtitle = { Text("Remove them from the session") }, + ) + ListRowTile( + onClick = { + onBan() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxUserRemove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + title = { Text("Ban") }, + subtitle = { Text("Kick and prevent them from rejoining") }, + ) + ListRowTile( + onClick = { + onRemoveSuggestions() + onDismiss() + }, + leading = { + Icon( + imageVector = Iconsax.IconsaxMusicSquareRemove, + contentDescription = null, + ) + }, + title = { Text("Remove suggestions") }, + subtitle = { Text("Remove every track they added to the queue") }, + ) + } + } + } +} + +@Composable +private fun ParticipantAvatar(participant: JamParticipant, size: Int) { + Box( + modifier = Modifier + .size(size.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + @Composable private fun QueueItemRow( item: QueueItemUi, @@ -172,11 +301,14 @@ private fun QueueItemRow( onDragStarted: () -> Unit, onDragStopped: () -> Unit, showOptions: Boolean = true, + enabled: Boolean = true, + onParticipantClick: (JamParticipant) -> Unit = {}, ) { var showMenu by remember { mutableStateOf(false) } ListRowTile( onClick = onPlayClick, + enabled = enabled, selected = item.isCurrent, modifier = Modifier, leading = { @@ -260,6 +392,24 @@ private fun QueueItemRow( Spacer(modifier = Modifier.width(4.dp)) + item.addedByParticipant?.let { participant -> + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable { onParticipantClick(participant) }, + contentAlignment = Alignment.Center, + ) { + Text( + text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + if (showOptions) { Box { GhostIconButton( diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index ced76d8e..a426a839 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry +import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow @@ -40,6 +41,8 @@ data class QueueItemUi( val isCurrent: Boolean, val imageUrl: String?, val originalIndex: Int, + /** Participant who added this item to the jam queue, if any. */ + val addedByParticipant: JamParticipant? = null, ) data class QueueContentUiState( @@ -48,6 +51,8 @@ data class QueueContentUiState( val isFiltered: Boolean = false, /** Guests cannot reorder/remove/clear the shared jam queue. */ val isReadOnly: Boolean = false, + val isJamHost: Boolean = false, + val participants: List = emptyList(), ) class PlayerQueueContentViewModel( @@ -65,7 +70,8 @@ class PlayerQueueContentViewModel( private val computedItems: StateFlow> = combine( audioPlayerQueue.queueFlow, audioPlayerQueue.currentQueueEntryFlow, - ) { queue, currentEntry -> + jamRoomService.participants, + ) { queue, currentEntry, participants -> val currentIndex = if (currentEntry != null) { queue.indexOfFirst { it.matchesCurrent(currentEntry) } } else { @@ -94,9 +100,9 @@ class PlayerQueueContentViewModel( } } - val addedBy = entry.addedBy - if (addedBy.isNotBlank()) { - subtitle = "$subtitle • Added by $addedBy" + val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy } + if (addedByParticipant != null) { + subtitle = "$subtitle • Added by ${addedByParticipant.displayName}" } QueueItemUi( @@ -107,6 +113,7 @@ class PlayerQueueContentViewModel( isCurrent = index == currentIndex, imageUrl = imageUrl, originalIndex = index, + addedByParticipant = addedByParticipant, ) } }.stateIn( @@ -120,7 +127,8 @@ class PlayerQueueContentViewModel( reorderBuffer, queueFilterFlow, jamRoomService.role, - ) { items, buffer, filterQuery, role -> + jamRoomService.participants, + ) { items, buffer, filterQuery, role, participants -> val normalizedFilter = filterQuery.trim().lowercase() val isFiltered = normalizedFilter.isNotBlank() val filtered = if (isFiltered) { @@ -136,6 +144,8 @@ class PlayerQueueContentViewModel( displayItems = buffer ?: filtered, isFiltered = isFiltered, isReadOnly = role == JamRole.Guest, + isJamHost = role == JamRole.Host, + participants = participants, ) }.stateIn( scope = viewModelScope, @@ -156,7 +166,7 @@ class PlayerQueueContentViewModel( } fun playQueueItem(index: Int) { - if (index < 0) return + if (index < 0 || queueContentUiState.value.isReadOnly) return viewModelScope.launch { audioPlayerQueue.jumpTo(index) } @@ -187,6 +197,27 @@ class PlayerQueueContentViewModel( } } + // ---------- Jam participant moderation (host only) ---------- + + fun kickParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } + } + + fun banParticipant(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { jamRoomService.banParticipant(participantId) } + } + + /** Removes every queue item that the given participant suggested. */ + fun removeParticipantTracks(participantId: String) { + if (!queueContentUiState.value.isJamHost) return + viewModelScope.launch { + val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId } + entries.forEach { audioPlayerQueue.removeFromQueue(it) } + } + } + fun onDragStart() { if (reorderBuffer.value != null) return if (queueContentUiState.value.isReadOnly) return diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt index a0e8220c..47206bab 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/QueueSheet.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset private val SlidingSheetBreakpoint = 840.dp @@ -68,7 +69,11 @@ private fun SlidingQueueSheet( visible = isVisible, modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 12.dp, bottom = 12.dp), + .padding( + top = 12.dp, + end = 12.dp, + bottom = 12.dp + LocalAppShellBottomInset.current, + ), enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), ) { From 6fabc876a547c24bc1f73ffadfbaf09bc150e98b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Sep 2026 00:04:10 +0600 Subject: [PATCH 15/16] chore: remove residual plans --- .opencode/plans/jovial-hopping-hare.md | 821 ------------------------- 1 file changed, 821 deletions(-) delete mode 100644 .opencode/plans/jovial-hopping-hare.md diff --git a/.opencode/plans/jovial-hopping-hare.md b/.opencode/plans/jovial-hopping-hare.md deleted file mode 100644 index bad5a743..00000000 --- a/.opencode/plans/jovial-hopping-hare.md +++ /dev/null @@ -1,821 +0,0 @@ -# WebRTC Support for Group Jam & Remote Control - -## Overview -Add two peer-to-peer features to Spotube: -1. **Listen Together (Group Jam)**: Multi-user synced queue over WebRTC data channels (star topology, manual SDP exchange) -2. **Remote Control**: LAN-only device control via WebSocket on the existing `LocalServer` (extended with control routes). No WebRTC needed for this feature. - -Both features share UI patterns (adaptive dialogs for play interception) but use different transport layers based on their requirements. - ---- - -## Prerequisites (One-Time Setup) - -Before starting implementation: - -1. **Initialize webrtc-rs submodule**: - ```bash - cd build/webrtc-rs && git submodule update --init --recursive - ``` - The `rtc` crate (Sans-I/O core) is a git submodule and must be initialized before building. - -2. **Verify dns-sd-kt availability**: - - Published to Maven Central: `com.appstractive:dns-sd-kt:1.1.0` - - No setup needed; just add to `libs.versions.toml` - -3. **Verify Rust toolchain**: - - Existing uniffi setup already works (discord-rpc, metadata modules) - - Ensure `cargo` is available and can build for all targets - ---- - -## Architecture Decisions (Confirmed) - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| WebRTC implementation | `webrtc-rs` via uniffi | Single codebase, identical behavior across platforms | -| Jam topology | Star (host ↔ peers) | Simpler, scales better, matches host-authority model | -| Remote Control transport | TCP/WebSocket only | LAN-only, so WebRTC is overkill; direct connection is simpler | -| Jam signaling | Manual SDP exchange | No server infrastructure needed; users copy-paste or scan QR | - ---- - -## Phase 0: Rust Uniffi WebRTC Module - -### Goal -Add `webrtc-rs` to the existing Rust crate and expose a uniffi API for WebRTC peer connections and data channels. - -### Library Details (from `build/webrtc-rs`) -- **Crate**: `webrtc` v0.21.0-beta.1 (pure Rust, no external C/C++ libs) -- **Architecture**: Sans-I/O core (`rtc` crate) + async API layer -- **Async runtime**: tokio (default) or smol -- **Crypto**: `ring` (default) or `aws-lc-rs` -- **Key types**: - - `PeerConnection` (trait) — created via `PeerConnectionBuilder::build()` - - `DataChannel` (trait) — created via `peer.create_data_channel()` - - `RTCSessionDescription` — SDP offer/answer - - `RTCIceCandidateInit` — ICE candidates - - `PeerConnectionEventHandler` (trait) — callback interface for events - - `DataChannelEvent` (enum) — polled via `dc.poll().await` -- **Event model**: PeerConnection uses callbacks; DataChannel uses polling -- **Submodule**: `rtc` git submodule must be initialized before building - -### Files to Modify -- `composeApp/Cargo.toml` — add `webrtc` dependency -- `composeApp/src/commonMain/rust/lib.rs` — register new module -- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API - -### Implementation - -1. **Initialize webrtc-rs submodule** (one-time setup): - ```bash - cd build/webrtc-rs && git submodule update --init --recursive - ``` - -2. **Add webrtc-rs dependency** to `composeApp/Cargo.toml`: - ```toml - [dependencies] - webrtc = { path = "../build/webrtc-rs", features = ["runtime-tokio", "crypto-ring"] } - tokio = { version = "1", features = ["full"] } - async-trait = "0.1" - ``` - - **Note**: Using path dependency to the local clone. For production, switch to crates.io version once stable. - -3. **Define uniffi API** in `webrtc_p2p.rs`: - - **Core objects**: - ```rust - #[uniffi::export] - pub struct PeerConnectionWrapper { - pc: Arc, - runtime: Arc, - } - - #[uniffi::export] - impl PeerConnectionWrapper { - pub async fn create_offer(&self) -> Result { - let offer = self.pc.create_offer(None).await?; - Ok(offer.sdp) - } - - pub async fn set_remote_answer(&self, answer: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::answer(answer)?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - pub async fn create_answer(&self) -> Result { - let answer = self.pc.create_answer(None).await?; - Ok(answer.sdp) - } - - pub async fn set_remote_offer(&self, offer: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::offer(offer)?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - pub async fn send_data(&self, channel: String, data: String) -> Result<(), WebrtcError> { - // Find or cache data channel by label - // ... - Ok(()) - } - - pub async fn close(&self) -> Result<(), WebrtcError> { - self.pc.close().await?; - Ok(()) - } - } - ``` - - **Callback interface for events**: - ```rust - #[uniffi::export(callback_interface)] - pub trait PeerConnectionEventHandler { - fn on_ice_candidate(&self, candidate: String); - fn on_connection_state_change(&self, state: String); - fn on_data_channel(&self, label: String); - fn on_data_channel_message(&self, label: String, data: String); - } - ``` - - **Factory function**: - ```rust - #[uniffi::export] - pub async fn create_peer_connection( - ice_servers: Vec, - handler: Arc, - ) -> Result { - // Build RTCConfiguration from ice_servers - // Create MediaEngine, Registry - // Build PeerConnection with handler wrapper - // Spawn task to poll data channel events and forward to handler - Ok(PeerConnectionWrapper { pc, runtime }) - } - ``` - - **Key challenge**: webrtc-rs is fully async, but uniffi callbacks are synchronous. Solution: - - Wrap the `PeerConnectionEventHandler` trait in a Rust adapter that spawns async tasks - - Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks - - For DataChannel polling, spawn a background task that calls `dc.poll().await` in a loop and forwards messages to the Kotlin handler - -4. **Register module** in `lib.rs`: - ```rust - mod webrtc_p2p; - pub use webrtc_p2p::*; - ``` - -5. **Cross-compilation considerations**: - - **Good news**: webrtc-rs is pure Rust (no libwebrtc/BoringSSL C++ deps) - - **Crypto**: `ring` compiles from source for all targets (requires C compiler for Android/iOS) - - **JVM desktop**: Should work out of the box - - **Android**: Requires NDK + `ring` cross-compilation setup (well-supported) - - **iOS**: Requires `ring` cross-compilation for aarch64-apple-ios - - **Gobley plugin**: Already configured for multi-target Rust builds in `composeApp/build.gradle.kts` - -### Verification -- Initialize submodule: `cd build/webrtc-rs && git submodule update --init --recursive` -- Build Rust crate: `cargo build --release` in `composeApp/` -- Verify Kotlin bindings are generated in `uniffi.compose_app.*` -- Write a simple Kotlin test that creates a peer connection and exchanges SDP - ---- - -## Phase 1: Remote Control (LAN-only, extend existing LocalServer) - -### Goal -Allow users to control playback on another device on the same LAN. Opt-in via settings. DNS-SD for discovery (via dns-sd-kt). Extend the existing `LocalServer` with WebSocket routes for control commands — no separate server needed. - -### 1.1 Settings & Permissions - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` — add fields to `UserSettings` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` — add toggle UI -- `composeApp/src/commonMain/composeResources/values/strings.xml` — add strings -- `composeApp/src/androidMain/AndroidManifest.xml` — add permissions -- `iosApp/iosApp/Info.plist` — add Bonjour services - -#### Implementation -1. **Add to `UserSettings`**: - ```kotlin - val allowRemoteControl: Boolean = false, - val allowedRemoteDevices: List = emptyList(), // device IDs - ``` - -2. **Add toggle UI** in `PlaybackSection.kt`: - - Use `SwitchSettingCard` for "Allow remote control" - - Add a "Manage allowed devices" item that navigates to a sub-screen (see `Routes.Blacklist` pattern) - -3. **Add string resources**: - ```xml - Allow Remote Control - Let other devices on your network control playback - ``` - -4. **Android permissions** (dns-sd-kt requires these): - ```xml - - - - - - - - - ``` - - **Note**: dns-sd-kt uses `androidx.startup` to auto-initialize `Context` — no manual init needed. - -5. **iOS Info.plist** (add to `iosApp/iosApp/Info.plist`): - ```xml - NSLocalNetworkUsageDescription - Spotube needs access to your local network to discover and control other devices. - NSBonjourServices - - _spotube-ctrl._tcp - - ``` - - **Note**: dns-sd-kt's Apple backend uses `NWBrowser` (Network.framework) + custom Swift bridge. The `NSBonjourServices` key is required for Bonjour discovery to work. - -### 1.2 DNS-SD Discovery - -#### Library Details (from `build/dns-sd-kt`) -- **Maven Central**: `com.appstractive:dns-sd-kt:1.1.0` -- **KMP library**: supports Android, JVM, iOS (arm64 + simulatorArm64), macOS, tvOS -- **Fully coroutine/Flow-based** — no callback-style API -- **Platform backends**: - - Android: `NsdManager` (pure Kotlin) - - JVM: `JmDNS 3.6.3` (pure Java) - - Apple: `NWBrowser` + `NSNetService` + custom Swift bridge via `spm4kmp` -- **Two-phase resolution**: `DiscoveryEvent.Discovered` → call `resolve()` → `DiscoveryEvent.Resolved` with addresses -- **Auto-init on Android**: uses `androidx.startup` to grab `Context` - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — wraps dns-sd-kt APIs - -#### Files to Modify -- `gradle/libs.versions.toml` — add dns-sd-kt dependency -- `composeApp/build.gradle.kts` — add to commonMain dependencies - -#### Implementation - -1. **Add dns-sd-kt dependency** to `gradle/libs.versions.toml`: - ```toml - [versions] - dns-sd-kt = "1.1.0" - - [libraries] - dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dns-sd-kt" } - ``` - -2. **Add to `composeApp/build.gradle.kts`** in `commonMain.dependencies`: - ```kotlin - implementation(libs.dns.sd.kt) - ``` - -3. **Create `DeviceDiscoveryService`** in `commonMain` (no expect/actual needed — dns-sd-kt handles platform differences): - ```kotlin - class DeviceDiscoveryService { - private val serviceType = "_spotube-ctrl._tcp" - - fun discoverDevices(): Flow = discoverServices(serviceType) - - suspend fun registerDevice(deviceId: String, deviceName: String, port: Int): NetService { - val service = createNetService( - type = serviceType, - name = deviceName, - port = port, - txt = mapOf("deviceId" to deviceId), - ) - service.register() - return service - } - } - ``` - -4. **Usage in ViewModel**: - ```kotlin - // Discover devices - discoveryService.discoverDevices() - .onEach { event -> - when (event) { - is DiscoveryEvent.Discovered -> { - event.resolve() // trigger address resolution - // Add to discovered devices list (addresses may be empty) - } - is DiscoveryEvent.Resolved -> { - // Update with resolved addresses/host - } - is DiscoveryEvent.Removed -> { - // Remove from list - } - } - } - .launchIn(viewModelScope) - ``` - -5. **Register in Koin** in `Modules.kt`: - ```kotlin - single { DeviceDiscoveryService() } - ``` - -6. **No expect/actual needed** — dns-sd-kt is a KMP library that handles platform differences internally. The Apple targets use Swift interop via `spm4kmp`, which is transparent to consumers. - -### 1.3 Control Server (Extend LocalServer) - -#### Decision: Reuse Existing LocalServer -The app already has a Ktor CIO-based `LocalServer` running on `127.0.0.1:` for the playback proxy. We'll extend it with WebSocket routes for control commands. When remote control is enabled, the server binds to `0.0.0.0` (LAN-accessible); otherwise it stays on `127.0.0.1` (local-only). - -#### Files to Modify -- `gradle/libs.versions.toml` — add `ktor-server-websockets`, `ktor-client-websockets` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — add WebSocket routes, conditional bind to `0.0.0.0` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt` — update LocalServer registration - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — handles control messages -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — message definitions -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — wraps AudioPlayerInterface for remote control - -#### Implementation -1. **Add WebSocket dependencies** to `libs.versions.toml`: - ```toml - ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } - ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } - ``` - -2. **Extend `LocalServer.kt`**: - - Add `RemoteControlHandler` constructor parameter - - In `configureRoutes()`, install `WebSockets` plugin and add `/control` WebSocket route - - In `restartServer()`, check `settings.allowRemoteControl`: - - If enabled: bind to `0.0.0.0` (LAN-accessible) - - If disabled: bind to `127.0.0.1` (local-only, current behavior) - - Add a watcher that restarts the server when `allowRemoteControl` setting changes - - ```kotlin - private suspend fun restartServer(port: Int) { - val allowRemoteControl = settingsViewModel.settingsState.value?.allowRemoteControl ?: false - val host = if (allowRemoteControl) "0.0.0.0" else "127.0.0.1" - - serverState.value = embeddedServer( - factory = CIO, - host = host, - port = port, - module = { configureRoutes() } - ).also { engine -> - engine.start(wait = false) - } - } - - private fun Application.configureRoutes() { - install(WebSockets) - routing { - get("/health") { call.respondText("ok") } - // ... existing routes ... - - webSocket("/control") { - remoteControlHandler.handleConnection(this) - } - } - } - ``` - -3. **Define protocol** in `RemoteControlProtocol.kt`: - ```kotlin - @Serializable - sealed class RemoteControlMessage { - @Serializable data class Play(val trackId: String) : RemoteControlMessage() - @Serializable data class Pause(val unit: Unit = Unit) : RemoteControlMessage() - @Serializable data class Seek(val positionMs: Long) : RemoteControlMessage() - @Serializable data class SetVolume(val volume: Float) : RemoteControlMessage() - @Serializable data class AddToQueue(val trackId: String) : RemoteControlMessage() - // ... etc - } - - @Serializable - sealed class RemoteStateUpdate { - @Serializable data class PlayerState(val state: PlayerUiState) : RemoteStateUpdate() - @Serializable data class QueueUpdate(val queue: List) : RemoteStateUpdate() - } - ``` - -4. **Create `RemoteControlHandler`**: - - Handles incoming WebSocket connections on the controlled device - - Checks `settings.allowRemoteControl` before accepting (rejects immediately if disabled) - - Shows connection request dialog (allow/allow-always/deny) via a callback injected from the UI layer - - On acceptance: forwards commands to `AudioPlayerInterface` and `AudioPlayerQueue` - - Broadcasts state updates (player state, queue) to the connected controller - -5. **Create `RemotePlayerProxy`**: - - Wraps `AudioPlayerInterface` and `AudioPlayerQueue` on the controlling device - - Sends commands over WebSocket to the controlled device - - Receives state updates and exposes them as StateFlows - - **Implementation note**: Full interface implementation is complex. Alternative: create a separate `RemotePlayerState` StateFlow that mirrors remote state, and the UI uses it instead of `rememberPlayerUiState()`. - -6. **Register in Koin** in `Modules.kt`: - ```kotlin - single { RemoteControlHandler(get(), get(), get()) } - // LocalServer constructor updated; no other DI changes needed - ``` - -### Key Simplification -By reusing `LocalServer`, we eliminate the need for: -- A separate WebSocket server -- Separate port management -- Duplicate Ktor configuration - -The server becomes a multi-purpose local server: playback proxy (always) + control endpoint (when enabled). - -### 1.4 UI: Devices Screen - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Devices` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Devices" button at bottom -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Devices" icon to TopAppBar actions - -#### Implementation -1. **Add route** to `NavigationModule.kt`: - ```kotlin - @Serializable - data object Devices : Routes - - navigation { - DevicesScreen(...) - } - ``` - -2. **Add sidebar button** in `AppSidebar.kt` (after line 177): - ```kotlin - SidebarItem( - title = "Devices", - icon = Icons.Default.Devices, - onClick = { navigator.navigate(Routes.Devices) } - ) - ``` - -3. **Add TopAppBar action** in `HomeScreen.kt`: - ```kotlin - ApplicationMainBar( - actions = { - IconButton(onClick = { navigator.navigate(Routes.Devices) }) { - Icon(Icons.Default.Devices, "Devices") - } - } - ) - ``` - -4. **DevicesScreen**: - - Shows list of discovered devices (from `DeviceDiscoveryService`) - - Each device shows name, IP, and connection status - - Clicking a device initiates connection (WebSocket) - - After connection, navigates to `RemotePlayerScreen` - -5. **RemotePlayerScreen**: - - Similar to `AppExpandedPlayer` but uses `RemotePlayerProxy` instead of local `AudioPlayerInterface` - - All controls (play/pause/seek/volume/queue) forward to remote device - -### 1.5 Connection Request Flow - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/remote/ConnectionRequestDialog.kt` - -#### Implementation -1. When a new device tries to connect, `RemoteControlService` shows a dialog: - ```kotlin - AdaptiveDialogBottomSheet( - title = { Text("Remote Control Request") }, - content = { - Text("Device '${deviceName}' wants to control playback") - }, - actions = { - Button(onClick = { deny() }) { Text("Deny") } - Button(onClick = { allow(always = false) }) { Text("Allow") } - Button(onClick = { allow(always = true) }) { Text("Allow Always") } - } - ) - ``` - -2. If "Allow Always", add device ID to `settings.allowedRemoteDevices` - ---- - -## Phase 2: Group Jam (WebRTC, Manual SDP) - -### Goal -Multi-user synced queue over WebRTC data channels. Host creates session, shares SDP offer (via copy-paste or QR), guests join. Star topology (host ↔ peers). - -### 2.1 Jam Session Service - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — manages WebRTC connections -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt` — message definitions -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt` — syncs queue state - -#### Implementation -1. **Define protocol** in `JamProtocol.kt`: - ```kotlin - @Serializable - sealed class JamMessage { - // Host → Peers - @Serializable data class QueueState(val queue: List, val currentIndex: Int) : JamMessage() - @Serializable data class PlaybackCommand(val command: PlaybackCommand) : JamMessage() - @Serializable data class ParticipantList(val participants: List) : JamMessage() - - // Peers → Host - @Serializable data class SuggestTrack(val trackId: String) : JamMessage() - @Serializable data class SuggestPlaylist(val playlistId: String) : JamMessage() - @Serializable data class ChatMessage(val text: String) : JamMessage() - } - - data class Participant(val id: String, val name: String, val isHost: Boolean) - ``` - -2. **Create `JamSessionService`** using the uniffi WebRTC API from Phase 0: - ```kotlin - class JamSessionService( - private val audioPlayerQueue: AudioPlayerQueue, - private val audioPlayer: AudioPlayerInterface, - ) { - private var peerConnection: PeerConnectionWrapper? = null - private val _participants = MutableStateFlow>(emptyList()) - val participants: StateFlow> = _participants - - suspend fun createSession(): String { - // Create peer connection with ICE servers - peerConnection = create_peer_connection( - iceServers = listOf("stun:stun.l.google.com:19302"), - handler = object : PeerConnectionEventHandler { - override fun on_ice_candidate(candidate: String) { - // ICE candidates are bundled into SDP (non-trickle mode) - } - override fun on_data_channel_message(label: String, data: String) { - // Parse JamMessage and handle - } - // ... other callbacks - } - ) - - // Create data channel for jam messages - // Create SDP offer and return it for sharing - val offer = peerConnection!!.create_offer() - return offer - } - - suspend fun joinSession(offer: String): String { - // Create peer connection - peerConnection = create_peer_connection(...) - - // Set remote offer and create answer - peerConnection!!.set_remote_offer(offer) - val answer = peerConnection!!.create_answer() - return answer - } - - suspend fun sendMessage(message: JamMessage) { - val json = Json.encodeToString(message) - peerConnection?.send_data("jam", json) - } - } - ``` - - **Key points**: - - Uses `uniffi.compose_app.create_peer_connection()` from Phase 0 - - Host creates multiple peer connections (one per guest) — star topology - - Data channel labeled "jam" for all jam messages - - SDP exchange is manual (copy-paste or QR code) - -3. **Create `QueueSyncManager`**: - - On host: wraps `AudioPlayerQueue`, intercepts all queue mutations, broadcasts them via `JamSessionService.sendMessage()` - - On guest: receives queue mutations, applies them to local queue - - Handles conflict resolution (host authority: host's commands always win) - -4. **Register in Koin**: - ```kotlin - single { JamSessionService(get(), get()) } - ``` - -### 2.2 Jam Session UI - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — create/join session -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — active session view -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/SdpExchangeDialog.kt` — copy-paste SDP - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Jam` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Group Jam" button -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Group Jam" icon to TopAppBar - -#### Implementation -1. **Add route**: - ```kotlin - @Serializable - data object Jam : Routes - - navigation { - JamScreen(...) - } - ``` - -2. **Add sidebar button** (above "Devices"): - ```kotlin - SidebarItem( - title = "Group Jam", - icon = Icons.Default.Group, - onClick = { navigator.navigate(Routes.Jam) } - ) - ``` - -3. **JamScreen**: - - Two tabs: "Create Session" and "Join Session" - - **Create Session**: - - Generates SDP offer via `JamSessionService` - - Shows SDP as copyable text and QR code - - Waits for guests to connect - - **Join Session**: - - Text field to paste SDP offer - - QR code scanner (optional) - - Generates SDP answer and shows it for host to paste back - -4. **JamSessionScreen**: - - Shows list of participants (from `JamSessionService`) - - Shows current track and queue - - Playback controls (only work for host; guests send suggestions) - - Suggest track/playlist buttons - - Chat/messages area (optional) - -5. **SdpExchangeDialog**: - - Shows SDP string in a `TextField` (read-only for offer, editable for answer) - - "Copy" button - - "Paste" button (for answer) - - QR code display (using a QR generation library) - -### 2.3 Play Interception - -#### Files to Modify -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt` - -#### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt` - -#### Implementation -1. **Create `PlayDestinationPicker`**: - ```kotlin - @Composable - fun PlayDestinationPicker( - onPlayLocally: () -> Unit, - onSuggestToJam: () -> Unit, - onDismiss: () -> Unit - ) { - AdaptiveDialogBottomSheet( - title = { Text("Play Where?") }, - content = { - Column { - Button(onClick = onPlayLocally) { Text("Play on This Device") } - Button(onClick = onSuggestToJam) { Text("Suggest to Jam Session") } - } - }, - onDismiss = onDismiss - ) - } - ``` - -2. **Modify ViewModels**: - - In `PlaylistViewModel.playPlaylist()`, check if `JamSessionService.isActive` - - If active, show `PlayDestinationPicker` instead of calling `playbackHelper.playPlaylist()` directly - - If user chooses "Suggest to Jam", call `JamSessionService.suggestPlaylist(playlistId)` - -3. **Apply same pattern** to `AlbumViewModel`, `ArtistScreen`, `SearchScreen` - ---- - -## Phase 3: Deep-Link Handling (Optional Enhancement) - -### Goal -Allow users to open `spotube://jam/` links to join a Jam session. With manual SDP exchange, the deep link can contain a session ID + a short-lived token, and the actual SDP exchange happens in the app. - -### Files to Modify -- `composeApp/src/androidMain/AndroidManifest.xml` — add intent filter -- `iosApp/iosApp/ContentView.swift` — add `onOpenURL` handler -- `composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt` — parse command-line args - -### Files to Create -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/DeepLinkService.kt` — expect interface -- Platform actuals - -### Implementation -1. **Android**: Add intent filter to `MainActivity`: - ```xml - - - - - - - ``` - -2. **iOS**: Add `onOpenURL` in `ContentView.swift`: - ```swift - .onOpenURL { url in - // Pass to Compose via a callback - } - ``` - -3. **Desktop**: Parse `args` in `main.kt`: - ```kotlin - fun main(args: Array) { - val deepLink = args.firstOrNull { it.startsWith("spotube://") } - // Pass to Compose - } - ``` - -4. **DeepLinkService**: Parse URL, navigate to `Routes.Jam(sessionId)` - ---- - -## Implementation Order - -1. **Phase 0**: Rust uniffi WebRTC module (foundation for Jam) -2. **Phase 1**: Remote Control (simpler, LAN-only, no WebRTC needed) - - 1.1 Settings & Permissions - - 1.2 DNS-SD Discovery (using dns-sd-kt) - - 1.3 Extend LocalServer with WebSocket control routes + conditional bind - - 1.4 UI: Devices Screen - - 1.5 Connection Request Flow -3. **Phase 2**: Group Jam (WebRTC, manual SDP) - - 2.1 Jam Session Service - - 2.2 Jam Session UI - - 2.3 Play Interception -4. **Phase 3**: Deep-Link Handling (optional, can be deferred) - ---- - -## Key Files Summary - -### Rust -- `composeApp/Cargo.toml` — add `webrtc` dependency -- `composeApp/src/commonMain/rust/lib.rs` — register `webrtc_p2p` module -- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API - -### Settings -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` - -### Remote Control -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — **NEW**: wraps dns-sd-kt -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — **NEW**: handles WebSocket control connections -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — **NEW**: message definitions -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — **NEW**: remote player state proxy -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — **MODIFIED**: add WebSocket routes, conditional bind - -### Group Jam -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — **NEW** -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — **NEW** - -### Navigation & UI -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` -- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` - -### Permissions -- `composeApp/src/androidMain/AndroidManifest.xml` -- `iosApp/iosApp/Info.plist` - ---- - -## Risks & Mitigations - -| Risk | Mitigation | -|------|------------| -| `webrtc-rs` `rtc` submodule not initialized | Document in setup: `cd build/webrtc-rs && git submodule update --init --recursive` | -| `ring` crypto cross-compilation for Android/iOS | Well-supported; may need NDK env vars for Android. Test early. | -| webrtc-rs is pre-release (0.21.0-beta.1) | API is stabilizing; pin version. Monitor for 1.0 release. | -| dns-sd-kt Apple targets use Swift interop (`spm4kmp`) | Published Maven Central artifacts include cinterop bindings. Should work transparently. | -| Manual SDP exchange is poor UX | Add QR code scanning as an alternative (Phase 2.2) | -| Queue sync conflicts in Jam | Host authority model: host's commands always win | -| WebRTC data channel reliability | Use ordered, reliable data channels (default in webrtc-rs) | -| Uniffi async/sync bridge for webrtc-rs | Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks | - ---- - -## Testing Strategy - -1. **Unit tests**: Test protocol serialization, queue sync logic -2. **Integration tests**: Test WebSocket server/client, DNS-SD discovery -3. **Manual tests**: - - Remote Control: Two devices on same LAN, control playback from one to another - - Group Jam: Three devices (1 host + 2 guests), sync queue and playback -4. **Cross-platform tests**: Verify on Android, iOS, JVM desktop (Linux/Windows/macOS) From df9403f2d3ceb5dbcf56f9d5bbf37fdaafbbc951 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 12 Sep 2026 10:09:03 +0600 Subject: [PATCH 16/16] feat(release): add support for Linux ARM64 builds and artifact uploads --- .github/workflows/spotube-release-binary.yml | 66 +++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index b1c07619..6df4bf5a 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -221,6 +221,67 @@ jobs: with: limit-access-to-actor: true + build-linux-arm64: + name: Linux Desktop (aarch64) + needs: prepare-deps + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + + - name: Download maven local + uses: actions/download-artifact@v4 + with: + name: maven-local + path: ~/.m2/repository/ + + - name: Install packaging tools + run: | + sudo apt-get update + sudo apt-get install -y rpm fuse + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Package Linux + run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm + + - name: Rename Linux artifacts + run: | + DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1) + RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1) + cp "$DEB_FILE" Spotube-linux-aarch64.deb + cp "$RPM_FILE" Spotube-linux-aarch64.rpm + + - name: Upload DEB + uses: actions/upload-artifact@v4 + with: + name: linux-deb-arm64 + path: Spotube-linux-aarch64.deb + + - name: Upload RPM + uses: actions/upload-artifact@v4 + with: + name: linux-rpm-arm64 + path: Spotube-linux-aarch64.rpm + + - name: Setup tmate session on failure + if: ${{ inputs.debug_ssh && failure() }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + build-windows: name: Windows Desktop needs: prepare-deps @@ -321,6 +382,7 @@ jobs: needs: - build-android - build-linux + - build-linux-arm64 - build-windows - build-macos runs-on: ubuntu-latest @@ -352,7 +414,7 @@ jobs: run: | echo "## Downloads" > release-notes.md echo "" >> release-notes.md - for dir in android-apk android-aab linux-deb linux-rpm linux-appimage windows-msi windows-exe macos-dmg; do + for dir in android-apk android-aab linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 windows-msi windows-exe macos-dmg; do if [ -d "$dir" ]; then echo "### $dir" >> release-notes.md for f in "$dir"/*; do @@ -376,6 +438,8 @@ jobs: linux-deb/*.deb linux-rpm/*.rpm linux-appimage/*.AppImage + linux-deb-arm64/*.deb + linux-rpm-arm64/*.rpm windows-msi/*.msi windows-exe/*.exe macos-dmg/*.dmg