Compare commits

..

No commits in common. "6fabc876a547c24bc1f73ffadfbaf09bc150e98b" and "571aea8d38077e1b9320b23c1f01b7d32cdfc21c" have entirely different histories.

47 changed files with 5484 additions and 2069 deletions

View File

@ -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<dyn PeerConnection>,
runtime: Arc<dyn Runtime>,
}
#[uniffi::export]
impl PeerConnectionWrapper {
pub async fn create_offer(&self) -> Result<String, WebrtcError> {
let offer = self.pc.create_offer(None).await?;
Ok(offer.sdp)
}
pub async fn set_remote_answer(&self, answer: String) -> Result<(), WebrtcError> {
let desc = RTCSessionDescription::answer(answer)?;
self.pc.set_remote_description(desc).await?;
Ok(())
}
pub async fn create_answer(&self) -> Result<String, WebrtcError> {
let answer = self.pc.create_answer(None).await?;
Ok(answer.sdp)
}
pub async fn set_remote_offer(&self, offer: String) -> Result<(), WebrtcError> {
let desc = RTCSessionDescription::offer(offer)?;
self.pc.set_remote_description(desc).await?;
Ok(())
}
pub async fn send_data(&self, channel: String, data: String) -> Result<(), WebrtcError> {
// Find or cache data channel by label
// ...
Ok(())
}
pub async fn close(&self) -> Result<(), WebrtcError> {
self.pc.close().await?;
Ok(())
}
}
```
**Callback interface for events**:
```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<String>,
handler: Arc<dyn PeerConnectionEventHandler>,
) -> Result<PeerConnectionWrapper, WebrtcError> {
// Build RTCConfiguration from ice_servers
// Create MediaEngine, Registry
// Build PeerConnection with handler wrapper
// Spawn task to poll data channel events and forward to handler
Ok(PeerConnectionWrapper { pc, runtime })
}
```
**Key challenge**: webrtc-rs is fully async, but uniffi callbacks are synchronous. Solution:
- Wrap the `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<String> = 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
<string name="settings_allow_remote_control_title">Allow Remote Control</string>
<string name="settings_allow_remote_control_subtitle">Let other devices on your network control playback</string>
```
4. **Android permissions** (dns-sd-kt requires these):
```xml
<!-- Already present -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required by dns-sd-kt for mDNS multicast -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- Required on Android 16+ (Baklava) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
```
**Note**: dns-sd-kt uses `androidx.startup` to auto-initialize `Context` — no manual init needed.
5. **iOS Info.plist** (add to `iosApp/iosApp/Info.plist`):
```xml
<key>NSLocalNetworkUsageDescription</key>
<string>Spotube needs access to your local network to discover and control other devices.</string>
<key>NSBonjourServices</key>
<array>
<string>_spotube-ctrl._tcp</string>
</array>
```
**Note**: dns-sd-kt's Apple backend uses `NWBrowser` (Network.framework) + custom Swift bridge. 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<DiscoveryEvent> = discoverServices(serviceType)
suspend fun registerDevice(deviceId: String, deviceName: String, port: Int): NetService {
val service = createNetService(
type = serviceType,
name = deviceName,
port = port,
txt = mapOf("deviceId" to deviceId),
)
service.register()
return service
}
}
```
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:<playbackProxyServerPort>` for the playback proxy. We'll extend it with WebSocket routes for control commands. When remote control is enabled, the server binds to `0.0.0.0` (LAN-accessible); otherwise it stays on `127.0.0.1` (local-only).
#### Files to Modify
- `gradle/libs.versions.toml` — 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<QueueEntry>) : 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<Routes.Devices> {
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<QueueEntry>, val currentIndex: Int) : JamMessage()
@Serializable data class PlaybackCommand(val command: PlaybackCommand) : JamMessage()
@Serializable data class ParticipantList(val participants: List<Participant>) : JamMessage()
// Peers → Host
@Serializable data class SuggestTrack(val trackId: String) : JamMessage()
@Serializable data class SuggestPlaylist(val playlistId: String) : JamMessage()
@Serializable data class ChatMessage(val text: String) : JamMessage()
}
data class Participant(val id: String, val name: String, val isHost: Boolean)
```
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<List<Participant>>(emptyList())
val participants: StateFlow<List<Participant>> = _participants
suspend fun createSession(): String {
// Create peer connection with ICE servers
peerConnection = create_peer_connection(
iceServers = listOf("stun:stun.l.google.com:19302"),
handler = object : PeerConnectionEventHandler {
override fun on_ice_candidate(candidate: String) {
// ICE candidates are bundled into SDP (non-trickle mode)
}
override fun on_data_channel_message(label: String, data: String) {
// Parse JamMessage and handle
}
// ... other callbacks
}
)
// Create data channel for jam messages
// Create SDP offer and return it for sharing
val offer = peerConnection!!.create_offer()
return offer
}
suspend fun joinSession(offer: String): String {
// Create peer connection
peerConnection = create_peer_connection(...)
// Set remote offer and create answer
peerConnection!!.set_remote_offer(offer)
val answer = peerConnection!!.create_answer()
return answer
}
suspend fun sendMessage(message: JamMessage) {
val json = Json.encodeToString(message)
peerConnection?.send_data("jam", json)
}
}
```
**Key points**:
- Uses `uniffi.compose_app.create_peer_connection()` from Phase 0
- Host creates multiple peer connections (one per guest) — star topology
- Data channel labeled "jam" for all jam messages
- SDP exchange is manual (copy-paste or QR code)
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<Routes.Jam> {
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/<session-id>` links to join a Jam session. With manual SDP exchange, the deep link can contain a session ID + a short-lived token, and the actual SDP exchange happens in the app.
### Files to Modify
- `composeApp/src/androidMain/AndroidManifest.xml` — add intent 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
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="spotube" android:host="jam" />
</intent-filter>
```
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<String>) {
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)

2157
composeApp/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,10 @@ discord-rich-presence = "1.1.0"
thiserror = "2.0" thiserror = "2.0"
parking_lot = "0.12" parking_lot = "0.12"
log = "0.4" 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"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
[lib] [lib]

View File

@ -166,12 +166,6 @@ kotlin {
// DLNA // DLNA
implementation(libs.dns.sd.kt) 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 { commonTest.dependencies {

View File

@ -55,6 +55,17 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Group Jam invite/answer deep links: spotube://jam/... -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="jam"
android:scheme="spotube" />
</intent-filter>
</activity> </activity>
<service <service
android:name=".media.PlaybackService" android:name=".media.PlaybackService"

View File

@ -173,19 +173,5 @@
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string> <string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string> <string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
<string name="settings_section_jam">Group Jam</string>
<string name="settings_jam_broker_title">MQTT Broker</string>
<string name="settings_jam_broker_host">Broker host</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Broker port</string>
<string name="settings_jam_broker_tls">Use TLS</string>
<string name="settings_jam_broker_username">Username (optional)</string>
<string name="settings_jam_broker_password">Password (optional)</string>
<string name="settings_jam_broker_client_id">Client ID prefix</string>
<string name="settings_jam_broker_test">Test connection</string>
<string name="settings_jam_broker_testing">Testing…</string>
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Failed: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
</resources> </resources>

View File

@ -33,6 +33,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay 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.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
@ -104,6 +106,12 @@ fun App(
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>() val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) 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( val navigationState = rememberNavigationState(
startRoute = Routes.Home, startRoute = Routes.Home,
topLevelRoutes = TOP_LEVEL_ROUTES topLevelRoutes = TOP_LEVEL_ROUTES

View File

@ -25,7 +25,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
sealed interface QueueEntry { sealed interface QueueEntry {
val url: String val url: String
val addedBy: String
@Serializable @Serializable
@SerialName("streaming") @SerialName("streaming")
@ -33,7 +32,6 @@ sealed interface QueueEntry {
val track: MetadataTrack, val track: MetadataTrack,
override val url: String, override val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
override val addedBy: String = "",
) : QueueEntry ) : QueueEntry
@Serializable @Serializable
@ -44,8 +42,7 @@ sealed interface QueueEntry {
val duration: Long, val duration: Long,
val album: String?, val album: String?,
val coverBytes: ByteArray?, val coverBytes: ByteArray?,
override val url: String, override val url: String
override val addedBy: String = "",
) : QueueEntry { ) : QueueEntry {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@ -59,7 +56,6 @@ sealed interface QueueEntry {
if (album != other.album) return false if (album != other.album) return false
if (!coverBytes.contentEquals(other.coverBytes)) return false if (!coverBytes.contentEquals(other.coverBytes)) return false
if (url != other.url) return false if (url != other.url) return false
if (addedBy != other.addedBy) return false
return true return true
} }
@ -71,7 +67,6 @@ sealed interface QueueEntry {
result = 31 * result + (album?.hashCode() ?: 0) result = 31 * result + (album?.hashCode() ?: 0)
result = 31 * result + (coverBytes?.contentHashCode() ?: 0) result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
result = 31 * result + url.hashCode() result = 31 * result + url.hashCode()
result = 31 * result + addedBy.hashCode()
return result return result
} }
} }

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<JamInviteLink?>(null)
val pendingLink: StateFlow<JamInviteLink?> = _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
}
}

View File

@ -23,10 +23,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
import dev.krtirtho.spotube.core.db.Database 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.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discord.DiscordRpcService import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.jam.JamRoomClient import dev.krtirtho.spotube.core.jam.JamSessionService
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.navigationModule import dev.krtirtho.spotube.core.navigation.navigationModule
import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemoteControlHandler import dev.krtirtho.spotube.core.remote.RemoteControlHandler
@ -185,9 +185,12 @@ val sharedModules = module {
viewModelOf(::RemoteControlViewModel) viewModelOf(::RemoteControlViewModel)
viewModel { viewModel {
JamViewModel( JamViewModel(
jamRoomService = get(), jamSession = get(),
deepLinks = get(),
shareService = get(), shareService = get(),
settingsProvider = get(), settingsProvider = get(),
audioPlayer = get(),
audioPlayerQueue = get(),
) )
} }
@ -236,8 +239,8 @@ val sharedModules = module {
createdAtStart() createdAtStart()
} }
single { RemotePlaybackController(get(), get(), get(), get(), get()) } single { RemotePlaybackController(get(), get(), get(), get(), get()) }
singleOf(::JamRoomClient) single { JamSessionService(get(), get(), get()) }
single { JamRoomService(get(), get(), get(), get()) } singleOf(::JamDeepLinkService)
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() } singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> { single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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=<host name>&sdp=<offer sdp>`
* Guest answer : `spotube://jam/answer?name=<guest name>&sdp=<answer 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
}
}

View File

@ -18,55 +18,62 @@
package dev.krtirtho.spotube.core.jam package dev.krtirtho.spotube.core.jam
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.MediaItem
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 @Serializable
sealed class JamMessage { 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 @Serializable
@SerialName("queueState") @SerialName("queueState")
data class QueueState( data class QueueState(
val items: List<JamMediaItem>, val items: List<JamMediaItem>,
val currentIndex: Int, val currentIndex: Int,
val shuffleEnabled: Boolean = false, val isPlaying: Boolean,
/** val positionMs: Long,
* 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() ) : JamMessage()
@Serializable @Serializable
@SerialName("playbackCommand") @SerialName("playbackCommand")
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage() data class PlaybackCommand(
val command: PlaybackCmd,
) : JamMessage()
@Serializable @Serializable
@SerialName("suggestTrack") @SerialName("suggestTrack")
data class SuggestTrack( data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage()
val mediaItem: JamMediaItem,
val addedBy: String = "",
) : JamMessage()
@Serializable @Serializable
@SerialName("suggestPlaylist") @SerialName("suggestPlaylist")
data class SuggestPlaylist( data class SuggestPlaylist(val tracks: List<JamMediaItem>) : JamMessage()
val tracks: List<JamMediaItem>,
val addedBy: String = "", @Serializable
@SerialName("chat")
data class Chat(
val fromName: String,
val text: String,
) : JamMessage() ) : JamMessage()
@Serializable
@SerialName("participantList")
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
@Serializable @Serializable
@SerialName("kick") @SerialName("kick")
data class Kick( data class Kick(
@ -79,12 +86,24 @@ sealed class JamMessage {
data class Leave(val reason: String = "user_left") : 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 @Serializable
sealed class PlaybackCmd { 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 @Serializable
@SerialName("skipNext") @SerialName("skipNext")
data object SkipNext : PlaybackCmd() data object SkipNext : PlaybackCmd()
@ -93,20 +112,23 @@ sealed class PlaybackCmd {
@SerialName("skipPrevious") @SerialName("skipPrevious")
data object SkipPrevious : PlaybackCmd() 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 @Serializable
@SerialName("jumpTo") @SerialName("jumpTo")
data class JumpTo(val index: Int) : PlaybackCmd() 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 @Serializable
data class JamMediaItem( data class JamMediaItem(
val url: String, val url: String,
@ -117,7 +139,6 @@ data class JamMediaItem(
val durationMs: Long, val durationMs: Long,
val coverUrl: String, val coverUrl: String,
val protocol: String, val protocol: String,
val addedBy: String = "",
) { ) {
companion object { companion object {
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
@ -132,7 +153,6 @@ data class JamMediaItem(
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(), .orEmpty(),
protocol = entry.protocol.name, protocol = entry.protocol.name,
addedBy = entry.addedBy,
) )
is QueueEntry.LocalTrack -> JamMediaItem( is QueueEntry.LocalTrack -> JamMediaItem(
@ -144,7 +164,6 @@ data class JamMediaItem(
durationMs = entry.duration, durationMs = entry.duration,
coverUrl = "", coverUrl = "",
protocol = "PROGRESSIVE", protocol = "PROGRESSIVE",
addedBy = entry.addedBy,
) )
} }
@ -196,3 +215,13 @@ enum class JamRole {
Host, Host,
Guest, 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
}
}

View File

@ -1,323 +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 <https://www.gnu.org/licenses/>.
*/
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<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8)
val state: SharedFlow<JamMessage.QueueState> = _state.asSharedFlow()
private val _commands = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
val commands: SharedFlow<JamMessage> = _commands.asSharedFlow()
private val _presence = MutableStateFlow<Map<String, JamPresence>>(emptyMap())
val presence: StateFlow<Map<String, JamPresence>> = _presence.asStateFlow()
// ---------- Public API ----------
/** One-off connection check used by the settings screen. Returns latency description. */
suspend fun testConnection(broker: JamBroker): Result<String> {
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<Unit> {
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/#"
}

View File

@ -1,47 +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 <https://www.gnu.org/licenses/>.
*/
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 }
}

View File

@ -1,573 +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 <https://www.gnu.org/licenses/>.
*/
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
import kotlin.time.Clock
/**
* 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.
*/
private data class PlaybackBroadcast(
val queue: List<QueueEntry>,
val current: QueueEntry?,
val shuffle: Boolean,
val playerState: PlayerState,
)
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<JamRole?>(null)
val role: StateFlow<JamRole?> = _role.asStateFlow()
private val _participants = MutableStateFlow<List<JamParticipant>>(emptyList())
val participants: StateFlow<List<JamParticipant>> = _participants.asStateFlow()
private val _roomCode = MutableStateFlow<String?>(null)
val roomCode: StateFlow<String?> = _roomCode.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _shuffleEnabled = MutableStateFlow(false)
val shuffleEnabled: StateFlow<Boolean> = _shuffleEnabled.asStateFlow()
private var localClientId: String = ""
private var localDisplayName: String = ""
/** 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. */
private var lastAppliedItems: List<JamMediaItem> = 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<String>()
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)
// 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 ----------
suspend fun createRoom(): Result<String> {
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
autoAdvanceDeadlineMs = 0L
hasStartedPlayback = false
startHostBroadcast()
persistLastCode(code)
code
}
}
suspend fun joinRoom(code: String): Result<Unit> {
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
hasStartedPlayback = 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()
hasStartedPlayback = false
}
// ---------- 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 = localClientId,
)
)
}
suspend fun suggestPlaylist(tracks: List<MetadataTrack>) {
if (tracks.isEmpty()) return
jamClient.publishCommand(
JamMessage.SuggestPlaylist(
tracks = tracks.map(JamMediaItem::fromTrack),
addedBy = localClientId,
)
)
}
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,
audioPlayer.playerStateFlow,
) { queue, current, shuffle, playerState ->
PlaybackBroadcast(queue, current, shuffle, playerState)
}
.onEach { broadcast ->
if (_role.value != JamRole.Host) return@onEach
val index = if (broadcast.current != null) {
broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) }
} else {
-1
}
_shuffleEnabled.value = broadcast.shuffle
jamClient.publishState(
JamMessage.QueueState(
items = broadcast.queue.map(JamMediaItem::fromQueueEntry),
currentIndex = index.coerceAtLeast(0),
shuffleEnabled = broadcast.shuffle,
follow = now() > autoAdvanceDeadlineMs,
isPlaying = broadcast.playerState == PlayerState.PLAYING,
)
)
}
.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) }
// 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
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 = startIndex,
)
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
return
}
// 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
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.copy(addedBy = message.addedBy)))
}
}
is JamMessage.SuggestPlaylist -> {
if (_role.value == JamRole.Host) {
acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) })
}
}
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<JamMediaItem>) {
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<String, JamPresence>) {
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 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
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
this.url == other.url && this.name == other.name
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
}
}

View File

@ -0,0 +1,587 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<JamSessionService>()
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<JamRole?>(null)
val role: StateFlow<JamRole?> = _role.asStateFlow()
private val _participants = MutableStateFlow<List<JamParticipant>>(emptyList())
val participants: StateFlow<List<JamParticipant>> = _participants.asStateFlow()
private val _isActive = MutableStateFlow(false)
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
private val _localParticipantId = MutableStateFlow<String?>(null)
val localParticipantId: StateFlow<String?> = _localParticipantId.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _incomingMessages = MutableSharedFlow<JamMessage>(extraBufferCapacity = 64)
val incomingMessages = _incomingMessages.asSharedFlow()
private val _incomingSuggestions = MutableSharedFlow<JamMessage>(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<String, WebrtcPeerConnection>()
/** Host side: guests whose handshake completed. Keyed by invite id. */
private val connectedGuests = mutableMapOf<String, WebrtcPeerConnection>()
/** Host side: guest device ids, used for bans. */
private val guestDeviceIds = mutableMapOf<String, String>()
/** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */
private val guestConnectionStates = mutableMapOf<String, String>()
/** Host side: device ids banned for this session. */
private val bannedDeviceIds = mutableSetOf<String>()
/** 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<String>()
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<JamMediaItem>,
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<JamMediaItem>) {
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<IceServerConfig> = 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 <T> MutableStateFlow<T>.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)])
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean> = _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<JamMediaItem> = 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<JamMediaItem>) {
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
}
}

View File

@ -21,18 +21,16 @@ import co.touchlab.kermit.Logger
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.JamSessionService
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -89,7 +87,7 @@ class RemotePlaybackController(
private val collectionPlaybackHelper: CollectionPlaybackHelper, private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val jamRoomService: JamRoomService, private val jamSession: JamSessionService,
) : KoinComponent { ) : KoinComponent {
private val logger = Logger.withTag("RemotePlaybackController") private val logger = Logger.withTag("RemotePlaybackController")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -97,10 +95,6 @@ class RemotePlaybackController(
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null) private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow() val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
/** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
val events: SharedFlow<String> = _events.asSharedFlow()
fun isRemoteConnected(): Boolean { fun isRemoteConnected(): Boolean {
return remoteControlClient.connectionState.value is ConnectionState.Connected return remoteControlClient.connectionState.value is ConnectionState.Connected
} }
@ -164,56 +158,45 @@ class RemotePlaybackController(
_pendingRequest.value = null _pendingRequest.value = null
} }
// ---------- Jam actions ----------
/** /**
* Adds a single track to the active jam queue. The host applies it to the * Routes the pending request into the active jam session. On the host the jam
* local (shared) queue directly; a guest suggests it to the host over MQTT. * 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 addTrackToJam(track: MetadataTrack) { fun playOnJam() {
if (jamRoomService.role.value == null) return val request = _pendingRequest.value ?: return
_pendingRequest.value = null
scope.launch { scope.launch {
try { try {
when (jamRoomService.role.value) { when (jamSession.role.value) {
JamRole.Host -> audioPlayerQueue.addToQueue( JamRole.Host -> executeLocally(request)
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId) JamRole.Guest -> suggestToJam(request)
) null -> {}
JamRole.Guest -> jamRoomService.suggestTrack(track)
null -> return@launch
} }
_events.emit("Added to the jam queue")
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Failed to add track to jam session" } logger.e(e) { "Failed to send content to jam session" }
} }
} }
} }
/** private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
* Adds multiple tracks to the active jam queue (host applies locally, when (request) {
* guest suggests to the host). is PlaybackDestinationRequest.Collection -> {
*/ val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
fun addTracksToJam(tracks: List<MetadataTrack>) { if (tracks.isNotEmpty()) {
if (tracks.isEmpty() || jamRoomService.role.value == null) return jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() })
scope.launch { logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
try { }
when (jamRoomService.role.value) {
JamRole.Host -> audioPlayerQueue.addAllToQueue(
tracks.map { track ->
QueueEntry.StreamingTrack(
track = track,
url = "",
addedBy = jamRoomService.participantClientId,
)
} }
)
JamRole.Guest -> jamRoomService.suggestPlaylist(tracks) is PlaybackDestinationRequest.Track -> {
null -> return@launch jamSession.suggestTrack(request.track.toJamMediaItem())
}
is PlaybackDestinationRequest.Tracks -> {
if (request.tracks.isNotEmpty()) {
jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() })
} }
_events.emit("Added ${tracks.size} to the jam queue")
} catch (e: Exception) {
logger.e(e) { "Failed to add tracks to jam session" }
} }
} }
} }
@ -386,3 +369,5 @@ class RemotePlaybackController(
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
} }
} }
private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this)

View File

@ -27,7 +27,6 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@ -49,7 +48,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
@ -73,8 +71,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
import dev.krtirtho.spotube.resources.iconsax.User import dev.krtirtho.spotube.resources.iconsax.User
private val BadgeShape = RoundedCornerShape(11.dp) private val BadgeShape = RoundedCornerShape(11.dp)
private const val DisabledContentAlpha = 0.38f
private val ButtonMinHeight = 40.dp private val ButtonMinHeight = 40.dp
private val SquareButtonSize = 40.dp private val SquareButtonSize = 40.dp
@ -182,7 +178,6 @@ fun OutlineButton(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Row( Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
content = { content = {
@ -231,7 +226,6 @@ fun PrimaryButton(
) { ) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row( Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content, content = content,
@ -277,7 +271,6 @@ fun SecondaryButton(
) { ) {
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) { CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
Row( Row(
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
content = content, content = content,
@ -494,17 +487,10 @@ fun GroupIconButton(
onClick = onClick, onClick = onClick,
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.alpha(if (enabled) 1f else DisabledContentAlpha),
contentAlignment = Alignment.Center,
) { ) {
content() content()
} }
} }
}
@Composable @Composable
fun ButtonGroup( fun ButtonGroup(

View File

@ -78,9 +78,6 @@ fun CollectionDetails(
onShufflePlay: () -> Unit, onShufflePlay: () -> Unit,
onAddToQueue: () -> Unit, onAddToQueue: () -> Unit,
isPlaying: Boolean = false, 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, isFollowing: Boolean = false,
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
@ -94,28 +91,7 @@ fun CollectionDetails(
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable { val playPauseButton = @Composable {
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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
@ -151,7 +127,6 @@ fun CollectionDetails(
} }
} }
} }
}
val actions = @Composable { val actions = @Composable {
ButtonGroup { ButtonGroup {

View File

@ -87,10 +87,6 @@ fun CollectionView(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {}, onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {}, onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {}, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
isInJam: Boolean = false,
isJamGuest: Boolean = false,
onAddToJam: (List<MetadataTrack>) -> Unit = {},
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null, footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {}, trailingContent: @Composable () -> Unit = {},
@ -118,7 +114,7 @@ fun CollectionView(
} else { } else {
{} {}
}, },
actions = if (isCollapsed && !isJamGuest) { actions = if (isCollapsed) {
{ {
IconButton(onClick = onPlay) { IconButton(onClick = onPlay) {
Icon( Icon(
@ -163,8 +159,6 @@ fun CollectionView(
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) },
) )
} }
} else { } else {
@ -185,8 +179,6 @@ fun CollectionView(
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) },
) )
} }
}, },
@ -208,9 +200,6 @@ fun CollectionView(
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
isJamGuest = isJamGuest,
trackOptionsState = trackOptionsState, trackOptionsState = trackOptionsState,
) )
} }

View File

@ -143,9 +143,6 @@ fun TrackList(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {}, onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {}, onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {}, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
isInJam: Boolean = false,
isJamGuest: Boolean = false,
currentTrackId: String? = null, currentTrackId: String? = null,
isCurrentTrackPlaying: Boolean = false, isCurrentTrackPlaying: Boolean = false,
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
@ -250,8 +247,6 @@ fun TrackList(
} else { } else {
selectedTrackIds + track.id selectedTrackIds + track.id
} }
} else if (isJamGuest) {
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
} else { } else {
onTrackClick(track) onTrackClick(track)
} }
@ -279,8 +274,6 @@ fun TrackList(
) )
}, },
trackOptionsState = trackOptionsState(track), trackOptionsState = trackOptionsState(track),
isInJam = isInJam,
isJamGuest = isJamGuest,
onShowOptionsClick = { selectedTrackForOptions = track }, onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@ -428,48 +421,28 @@ fun TrackList(
val isAll = val isAll =
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = buildList { items = listOf(
add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive, icon = Iconsax.IconsaxDirectboxReceive,
label = if (isAll) "Download All" else "Download $trackCount", label = if (isAll) "Download All" else "Download $trackCount",
onClick = { onBulkDownload(targetTracks) }, onClick = { onBulkDownload(targetTracks) },
), ),
)
if (!isJamGuest) {
add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
onClick = { onBulkAddToQueue(targetTracks) }, onClick = { onBulkAddToQueue(targetTracks) },
), ),
)
add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxNext, icon = Iconsax.IconsaxNext,
label = if (isAll) "Play All Next" else "Play $trackCount Next", label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) }, onClick = { onBulkPlayNext(targetTracks) },
), ),
)
}
add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist, icon = Iconsax.IconsaxMusicPlaylist,
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) }, 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 -> trigger = { onClick ->
GroupIconButton( GroupIconButton(
onClick = onClick, onClick = onClick,
@ -524,8 +497,6 @@ fun TrackList(
selectedTrackForOptions = null selectedTrackForOptions = null
}, },
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam,
isJamGuest = isJamGuest,
) )
} }
} }
@ -584,8 +555,6 @@ private fun TrackListRow(
onSelectionToggle: (Boolean) -> Unit, onSelectionToggle: (Boolean) -> Unit,
onTrackOptionsAction: (TrackOptionsAction) -> Unit, onTrackOptionsAction: (TrackOptionsAction) -> Unit,
trackOptionsState: TrackOptionsState, trackOptionsState: TrackOptionsState,
isInJam: Boolean,
isJamGuest: Boolean,
onShowOptionsClick: () -> Unit, onShowOptionsClick: () -> Unit,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
@ -781,8 +750,6 @@ private fun TrackListRow(
state = trackOptionsState, state = trackOptionsState,
onAction = onTrackOptionsAction, onAction = onTrackOptionsAction,
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam,
isJamGuest = isJamGuest,
) )
} else { } else {
GhostIconButton(onClick = onShowOptionsClick) { GhostIconButton(onClick = onShowOptionsClick) {
@ -876,8 +843,6 @@ private fun ShimmerTrackListRow(
onSelectionToggle = {}, onSelectionToggle = {},
onTrackOptionsAction = {}, onTrackOptionsAction = {},
trackOptionsState = TrackOptionsState(), trackOptionsState = TrackOptionsState(),
isInJam = false,
isJamGuest = false,
onShowOptionsClick = {}, onShowOptionsClick = {},
onArtistClick = {}, onArtistClick = {},
onAlbumClick = {}, onAlbumClick = {},

View File

@ -56,7 +56,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
sealed interface TrackOptionsAction { sealed interface TrackOptionsAction {
data object AddToJam : TrackOptionsAction
data object StartRadio : TrackOptionsAction data object StartRadio : TrackOptionsAction
data object PlayNext : TrackOptionsAction data object PlayNext : TrackOptionsAction
data object AddToQueue : TrackOptionsAction data object AddToQueue : TrackOptionsAction
@ -99,8 +98,6 @@ fun TrackOptions(
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isInJam: Boolean = false,
isJamGuest: Boolean = false,
) { ) {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = buildTrackMenuItems( items = buildTrackMenuItems(
@ -108,8 +105,6 @@ fun TrackOptions(
state = state, state = state,
onAction = onAction, onAction = onAction,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
isInJam = isInJam,
isJamGuest = isJamGuest,
), ),
trigger = { onClick -> trigger = { onClick ->
GhostIconButton(onClick = onClick) { GhostIconButton(onClick = onClick) {
@ -134,8 +129,6 @@ fun TrackOptionsBottomSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false,
isJamGuest: Boolean = false,
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
@ -158,8 +151,6 @@ fun TrackOptionsBottomSheet(
onAlbumClick() onAlbumClick()
onDismiss() onDismiss()
}, },
isInJam = isInJam,
isJamGuest = isJamGuest,
).forEach { item -> ).forEach { item ->
Row( Row(
modifier = Modifier modifier = Modifier
@ -250,19 +241,7 @@ private fun buildTrackMenuItems(
state: TrackOptionsState, state: TrackOptionsState,
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false,
isJamGuest: Boolean = false,
): List<AdaptiveMenuItem> = buildList { ): List<AdaptiveMenuItem> = buildList {
if (isInJam) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = "Add to Jam",
onClick = { onAction(TrackOptionsAction.AddToJam) },
),
)
}
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicCircle, icon = Iconsax.IconsaxMusicCircle,
@ -271,9 +250,6 @@ private fun buildTrackMenuItems(
), ),
) )
// 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) { if (!state.isInQueue && !state.isCurrentlyPlaying) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
@ -309,7 +285,6 @@ private fun buildTrackMenuItems(
), ),
) )
} }
}
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(

View File

@ -20,14 +20,10 @@ package dev.krtirtho.spotube.modules.album
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands 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 import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -41,11 +37,6 @@ fun AlbumScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
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 currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle() val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
@ -101,10 +92,6 @@ fun AlbumScreen(
onBulkAddToQueue = viewModel::addTracksToQueue, onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,

View File

@ -273,10 +273,6 @@ class AlbumViewModel(
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> queue.find { entry ->
@ -337,10 +333,6 @@ class AlbumViewModel(
tracks.forEach { track -> downloadManager.enqueue(track) } tracks.forEach { track -> downloadManager.enqueue(track) }
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album" val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestTracksAddToQueue(tracks, title) remotePlaybackController.requestTracksAddToQueue(tracks, title)

View File

@ -63,11 +63,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -98,11 +95,6 @@ fun ArtistScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
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 currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle() val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
@ -187,9 +179,6 @@ fun ArtistScreen(
onBulkAddToQueue = viewModel::addTracksToQueue, onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
) )
} }

View File

@ -276,10 +276,6 @@ class ArtistViewModel(
startTrack = track, startTrack = track,
) )
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist" val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestTracksAddToQueue(tracks, artistName) remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
@ -310,10 +306,6 @@ class ArtistViewModel(
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> queue.find { entry ->

View File

@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ConnectionState
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlClient
@ -39,6 +40,7 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
import org.koin.compose.koinInject import org.koin.compose.koinInject
/** /**
@ -50,8 +52,10 @@ import org.koin.compose.koinInject
fun PlayDestinationPickerHost() { fun PlayDestinationPickerHost() {
val controller = koinInject<RemotePlaybackController>() val controller = koinInject<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>() val remoteControlClient = koinInject<RemoteControlClient>()
val jamSession = koinInject<JamSessionService>()
val request by controller.pendingRequest.collectAsStateWithLifecycle() val request by controller.pendingRequest.collectAsStateWithLifecycle()
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
val jamActive by jamSession.isActive.collectAsStateWithLifecycle()
val pendingRequest = request ?: return val pendingRequest = request ?: return
@ -137,6 +141,33 @@ 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 = { actions = {

View File

@ -17,18 +17,25 @@
package dev.krtirtho.spotube.modules.jam package dev.krtirtho.spotube.modules.jam
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@ -41,28 +48,38 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.jam.JamRole 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.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.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset 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 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 @Composable
fun JamScreen( fun JamScreen(
navigationCommands: NavigationCommands, navigationCommands: NavigationCommands,
@ -93,19 +110,48 @@ fun JamScreen(
} }
when { when {
!state.isActive -> CreateOrJoinView( !state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
state = state, hostName = state.incomingHostName.orEmpty(),
onCreate = viewModel::createSession, onJoin = viewModel::joinWithIncomingInvite,
onJoin = viewModel::joinWithCode, onDismiss = viewModel::dismissIncomingInvite,
) )
else -> SessionView( !state.isActive -> CreateOrJoinView(
onCreate = viewModel::createSession,
onJoin = viewModel::joinWithPasted,
)
state.role == JamRole.Host -> HostSessionView(
state = state, state = state,
onShareCode = viewModel::shareRoomCode, playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
onNewInvite = viewModel::generateNewInvite,
onSubmitAnswer = viewModel::submitAnswerPasted,
onShare = viewModel::share,
onLeave = viewModel::leave, 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, onKick = viewModel::kickParticipant,
onBan = viewModel::banParticipant, 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,
)
} }
} }
} }
@ -127,33 +173,18 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
@Composable @Composable
private fun CreateOrJoinView( private fun CreateOrJoinView(
state: JamUiState,
onCreate: () -> Unit, onCreate: () -> Unit,
onJoin: (String) -> Unit, onJoin: (String) -> Unit,
) { ) {
var tab by rememberSaveable { mutableIntStateOf(0) } var tab by remember { mutableIntStateOf(0) }
var pasted by rememberSaveable { mutableStateOf("") } var pasted by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text( Text(
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", text = "Listen together with friends over a peer-to-peer connection.",
style = MaterialTheme.typography.titleMedium, 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()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton( SegmentedButton(
selected = tab == 0, selected = tab == 0,
@ -170,36 +201,34 @@ private fun CreateOrJoinView(
if (tab == 0) { if (tab == 0) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
text = "Start a session as the host. You'll get a 6-character room code to " + text = "Start a session as the host. You'll get a shareable invite link " +
"share with friends; you control the queue.", "to send to friends; when they accept, they appear here.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button( Button(onClick = onCreate) {
onClick = onCreate,
enabled = state.brokerConfigured,
) {
Text("Create Session") Text("Create Session")
} }
} }
} else { } else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text( Text(
text = "Enter the 6-character room code the host shared with you.", text = "Paste the invite link the host shared with you.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
OutlinedTextField( OutlinedTextField(
value = pasted, value = pasted,
onValueChange = { pasted = JamRoomCode.normalize(it) }, onValueChange = { pasted = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("Room code") }, label = { Text("Invite link") },
placeholder = { Text("ABC123") }, placeholder = { Text("spotube://jam/invite?...") },
singleLine = true, minLines = 2,
maxLines = 6,
) )
Button( Button(
onClick = { onJoin(pasted) }, onClick = { onJoin(pasted) },
enabled = state.brokerConfigured && JamRoomCode.isValid(pasted), enabled = pasted.isNotBlank(),
) { ) {
Text("Join Session") Text("Join Session")
} }
@ -209,54 +238,401 @@ private fun CreateOrJoinView(
} }
@Composable @Composable
private fun SessionView( 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(
state: JamUiState, state: JamUiState,
onShareCode: () -> Unit, playerState: JamPlayerUiState,
onNewInvite: () -> Unit,
onSubmitAnswer: (String) -> Unit,
onShare: (String) -> Unit,
onLeave: () -> Unit, onLeave: () -> Unit,
onTogglePlayPause: () -> Unit,
onSkipNext: () -> Unit,
onSkipPrevious: () -> Unit,
onSeek: (Long) -> Unit,
onJumpTo: (Int) -> Unit,
onToggleShuffle: () -> Unit,
onCycleLoop: () -> Unit,
onKick: (String) -> Unit, onKick: (String) -> Unit,
onBan: (String) -> Unit, onBan: (String) -> Unit,
) { ) {
val isHost = state.role == JamRole.Host val clipboard = LocalClipboardManager.current
var pastedAnswer by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (!state.isConnected) { ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan)
Text(
text = "Connecting to the session…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ParticipantsSection( JamNowPlayingView(
participants = state.participants, playerState = playerState,
isHost = isHost, onTogglePlayPause = onTogglePlayPause,
onKick = onKick, onSkipNext = onSkipNext,
onBan = onBan, onSkipPrevious = onSkipPrevious,
onToggleShuffle = onToggleShuffle,
onCycleLoop = onCycleLoop,
) )
if (isHost) {
HorizontalDivider() HorizontalDivider()
Text( Text(
text = "Invite someone", text = "Invite someone",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
) )
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) 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() HorizontalDivider()
Text( Text(
text = "The queue and playback controls are in the player at the bottom of the app — " + text = "Accept a guest's answer",
"the jam queue is shared with every participant.", style = MaterialTheme.typography.titleSmall,
)
Text(
text = "When your guest sends back their answer link, paste it below.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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) 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<JamQueueUiItem>,
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 @Composable
private fun ParticipantsSection( private fun ParticipantsSection(
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>, participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
@ -308,26 +684,35 @@ 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 @Composable
private fun RoomCodeBox( private fun ShareableLinkBox(
code: String, label: String,
link: String,
onCopy: () -> Unit,
onShare: () -> Unit, onShare: () -> Unit,
) { ) {
val clipboard = LocalClipboardManager.current
val viewModel: JamViewModel = koinViewModel() val viewModel: JamViewModel = koinViewModel()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectionContainer { SelectionContainer {
Text( OutlinedTextField(
text = code, value = link,
style = MaterialTheme.typography.displaySmall, onValueChange = {},
fontWeight = FontWeight.Bold, readOnly = true,
color = MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.padding(vertical = 8.dp), label = { Text(label) },
minLines = 2,
maxLines = 6,
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { clipboard.setText(AnnotatedString(code)) }) { Button(onClick = onCopy) {
Text("Copy") Text("Copy")
} }
if (viewModel.supportsNativeShare) { if (viewModel.supportsNativeShare) {

View File

@ -20,10 +20,22 @@ package dev.krtirtho.spotube.modules.jam
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.PlatformType 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.JamParticipant
import dev.krtirtho.spotube.core.jam.JamRole import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomCode import dev.krtirtho.spotube.core.jam.JamSessionService
import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.jam.PlaybackCmd
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.settings.SettingsProvider import dev.krtirtho.spotube.modules.settings.SettingsProvider
@ -33,6 +45,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class JamUiState( data class JamUiState(
@ -40,104 +53,377 @@ data class JamUiState(
val isConnected: Boolean = false, val isConnected: Boolean = false,
val role: JamRole? = null, val role: JamRole? = null,
val participants: List<JamParticipant> = emptyList(), val participants: List<JamParticipant> = emptyList(),
val roomCode: String? = null, /** Host: deep link containing this session's SDP offer, ready to share. */
val brokerHost: String = "", val inviteLink: String? = null,
val brokerConfigured: Boolean = false, /** 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 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<JamQueueUiItem> = 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( class JamViewModel(
private val jamRoomService: JamRoomService, private val jamSession: JamSessionService,
private val deepLinks: JamDeepLinkService,
private val shareService: ShareService, private val shareService: ShareService,
private val settingsProvider: SettingsProvider, private val settingsProvider: SettingsProvider,
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
) : ViewModel() { ) : ViewModel() {
private val _localError = MutableStateFlow<String?>(null) private val _uiState = MutableStateFlow(JamUiState())
val uiState: StateFlow<JamUiState> = _uiState.asStateFlow()
val supportsNativeShare: Boolean = val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
val uiState: StateFlow<JamUiState> = combine( init {
jamRoomService.role, viewModelScope.launch {
jamRoomService.participants, // Mirror live session state into the UI state.
jamRoomService.isConnected, jamSession.isActive.collect { active ->
jamRoomService.roomCode, _uiState.update {
jamRoomService.connectionError, it.copy(
settingsProvider.settingsState, isActive = active,
_localError, isConnected = jamSession.isConnected.value,
) { values -> role = jamSession.role.value,
@Suppress("UNCHECKED_CAST") participants = jamSession.participants.value,
val role = values[0] as JamRole? inviteLink = if (!active) null else it.inviteLink,
@Suppress("UNCHECKED_CAST") answerLink = if (!active) null else it.answerLink,
val participants = values[1] as List<JamParticipant> incomingOfferSdp = if (!active) it.incomingOfferSdp else null,
val isConnected = values[2] as Boolean incomingHostName = if (!active) it.incomingHostName else null,
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?
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), JamUiState())
// ---------- Session lifecycle ----------
fun createSession() {
viewModelScope.launch {
jamRoomService.createRoom()
.onFailure { e ->
_localError.value = e.message ?: "Failed to create jam session"
}
.onSuccess { _localError.value = null }
} }
} }
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 { viewModelScope.launch {
jamRoomService.joinRoom(code) jamSession.participants.collect { participants ->
.onFailure { e -> _uiState.update { it.copy(participants = participants) }
_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 { viewModelScope.launch {
jamRoomService.leaveRoom() jamSession.isConnected.collect { connected ->
_localError.value = null _uiState.update { it.copy(isConnected = connected) }
}
}
viewModelScope.launch {
deepLinks.pendingLink.collect { link ->
handleDeepLink(link)
}
} }
} }
fun clearError() { /**
_localError.value = null * 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<JamPlayerUiState> = combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.playlistFlow,
audioPlayer.currentMediaItemFlow,
audioPlayer.playerStateFlow,
audioPlayer.positionFlow,
audioPlayer.durationFlow,
audioPlayer.loopStateFlow,
audioPlayer.shuffleModeFlow,
) { values ->
val queue: List<QueueEntry> = values[0] as List<QueueEntry>
val currentEntry: QueueEntry? = values[1] as QueueEntry?
val playlist: List<MediaItem> = values[2] as List<MediaItem>
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<JamQueueUiItem>
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 ---------- // ---------- Host moderation ----------
fun kickParticipant(participantId: String) { fun kickParticipant(participantId: String) {
viewModelScope.launch { jamRoomService.kickParticipant(participantId) } viewModelScope.launch { jamSession.kickParticipant(participantId) }
} }
fun banParticipant(participantId: String) { fun banParticipant(participantId: String) {
viewModelScope.launch { jamRoomService.banParticipant(participantId) } 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
} }
} }

View File

@ -31,14 +31,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands 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 import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.CollectionView
@ -57,11 +53,6 @@ fun PlaylistScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
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 currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle() val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
@ -136,10 +127,6 @@ fun PlaylistScreen(
onBulkAddToQueue = viewModel::addTracksToQueue, onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
footerContent = footerContent, footerContent = footerContent,
trailingContent = { trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist

View File

@ -306,10 +306,6 @@ class PlaylistViewModel(
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> queue.find { entry ->
@ -382,10 +378,6 @@ class PlaylistViewModel(
} }
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist" val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestTracksAddToQueue(tracks, title) remotePlaybackController.requestTracksAddToQueue(tracks, title)

View File

@ -20,15 +20,11 @@ package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands 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 import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.CollectionView import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -43,11 +39,6 @@ fun SavedTracksScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
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 currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle() val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
@ -94,10 +85,6 @@ fun SavedTracksScreen(
onBulkAddToQueue = viewModel::addTracksToQueue, onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext, onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = { trailingContent = {
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,

View File

@ -248,10 +248,6 @@ class SavedTracksViewModel(
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { entry -> queue.find { entry ->
@ -316,10 +312,6 @@ class SavedTracksViewModel(
} }
} }
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) { fun addTracksToQueue(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks") remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
} }

View File

@ -86,9 +86,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes 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 import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
@ -131,11 +128,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val blacklistRepository: BlacklistRepository = koinInject() val blacklistRepository: BlacklistRepository = koinInject()
val navigationCommands: NavigationCommands = koinInject() val navigationCommands: NavigationCommands = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
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 selectedType = state.selectedSearchType
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -196,9 +188,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.AddToQueue -> { is TrackOptionsAction.AddToQueue -> {
remotePlaybackController.requestTrackAddToQueue(track) remotePlaybackController.requestTrackAddToQueue(track)
} }
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> { is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
@ -259,10 +248,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results") remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
} }
fun bulkAddToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun bulkPlayNext(tracks: List<MetadataTrack>) { fun bulkPlayNext(tracks: List<MetadataTrack>) {
remotePlaybackController.requestTracksPlayNext(tracks, "Search results") remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
} }
@ -342,9 +327,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist -> onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
@ -374,9 +356,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true showAddToPlaylistPicker = true
}, },
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist -> onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
@ -658,9 +637,6 @@ private fun SearchAllTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -718,9 +694,6 @@ private fun SearchAllTab(
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick, onArtistsOverflowClick = onArtistsOverflowClick,
@ -808,9 +781,6 @@ private fun SearchTracksTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit, onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit, onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit, onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit, onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -840,9 +810,6 @@ private fun SearchTracksTab(
onBulkAddToQueue = onBulkAddToQueue, onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext, onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist, onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick, onArtistsOverflowClick = onArtistsOverflowClick,

View File

@ -61,10 +61,8 @@ data class UserSettings(
val remoteControlDeviceName: String = "", val remoteControlDeviceName: String = "",
val remoteControlDeviceId: String = "", val remoteControlDeviceId: String = "",
// Group Jam (MQTT) // Group Jam (P2P)
val jamParticipantName: String = "", val jamParticipantName: String = "",
val jamBroker: JamBroker = JamBroker(),
val lastJamCode: String = "",
// Downloads // Downloads
val overloadedDownloadFolder: String? = null, // When null, uses default music folder val overloadedDownloadFolder: String? = null, // When null, uses default music folder
@ -88,20 +86,3 @@ data class UserSettings(
// Updates // Updates
val autoCheckForUpdates: Boolean = true, 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,
)

View File

@ -43,7 +43,6 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
import dev.krtirtho.spotube.modules.settings.sections.cacheSection import dev.krtirtho.spotube.modules.settings.sections.cacheSection
import dev.krtirtho.spotube.modules.settings.sections.desktopSection import dev.krtirtho.spotube.modules.settings.sections.desktopSection
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection 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.languageRegionSection
import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
@ -111,11 +110,6 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
navigatorCommands = navigatorCommands, navigatorCommands = navigatorCommands,
requestLocalNetworkPermission = requestLocalNetworkPermission, requestLocalNetworkPermission = requestLocalNetworkPermission,
) )
if (settingsState != null)
jamSection(
settings = settingsState!!,
settingsViewModel = settingsViewModel,
)
if (settingsState != null) if (settingsState != null)
cacheSection( cacheSection(
settings = settingsState!!, settings = settingsState!!,

View File

@ -1,208 +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 <https://www.gnu.org/licenses/>.
*/
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<JamRoomClient>()
val scope = rememberCoroutineScope()
var testing by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<String?>(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),
)
},
)
)
}

View File

@ -82,8 +82,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.BaseUITheme import dev.krtirtho.spotube.core.ui.base.BaseUITheme
@ -119,7 +117,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -161,10 +158,6 @@ fun AppExpandedPlayer(
), ),
) { ) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) 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 scope = rememberCoroutineScope()
val downloadsViewModel: DownloadsViewModel = koinViewModel() val downloadsViewModel: DownloadsViewModel = koinViewModel()
val navigationCommands: NavigationCommands = koinInject() val navigationCommands: NavigationCommands = koinInject()
@ -205,22 +198,18 @@ fun AppExpandedPlayer(
} }
fun onSkipPrevious() { fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() } scope.launch { audioPlayer.skipToPrevious() }
} }
fun onSkipNext() { fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() } scope.launch { audioPlayer.skipToNext() }
} }
fun onShuffleToggle() { fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
} }
fun onLoopToggle() { fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
} }
@ -528,7 +517,7 @@ fun AppExpandedPlayer(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) { GhostIconButton(onClick = ::onShuffleToggle) {
Icon( Icon(
Iconsax.IconsaxShuffle, Iconsax.IconsaxShuffle,
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle", contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
@ -539,7 +528,7 @@ fun AppExpandedPlayer(
} }
) )
} }
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { GhostIconButton(onClick = ::onSkipPrevious) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
} }
IconButton( IconButton(
@ -554,10 +543,10 @@ fun AppExpandedPlayer(
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
) )
} }
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { GhostIconButton(onClick = ::onSkipNext) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next") Icon(Iconsax.IconsaxNext, contentDescription = "Next")
} }
GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) { GhostIconButton(onClick = ::onLoopToggle) {
Icon( Icon(
imageVector = when (playerUiState.loopState) { imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic LoopState.NONE -> Iconsax.IconsaxRepeateMusic

View File

@ -65,8 +65,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.Slider import dev.krtirtho.spotube.core.ui.base.Slider
@ -95,7 +93,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2 import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -128,10 +125,6 @@ fun AppLargePlayer(
), ),
) { ) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue) 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 scope = rememberCoroutineScope()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
var isSeeking by remember { mutableStateOf(false) } var isSeeking by remember { mutableStateOf(false) }
@ -162,22 +155,18 @@ fun AppLargePlayer(
} }
fun onSkipPrevious() { fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() } scope.launch { audioPlayer.skipToPrevious() }
} }
fun onSkipNext() { fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() } scope.launch { audioPlayer.skipToNext() }
} }
fun onShuffleToggle() { fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) } scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
} }
fun onLoopToggle() { fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) } scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
} }
@ -305,7 +294,6 @@ fun AppLargePlayer(
) { ) {
VariableIconButton( VariableIconButton(
onClick = ::onShuffleToggle, onClick = ::onShuffleToggle,
enabled = !isJamGuest,
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
) { ) {
Icon( Icon(
@ -318,7 +306,7 @@ fun AppLargePlayer(
} }
) )
} }
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) { GhostIconButton(onClick = ::onSkipPrevious) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
} }
IconButton( IconButton(
@ -332,12 +320,11 @@ fun AppLargePlayer(
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause", contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
) )
} }
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) { GhostIconButton(onClick = ::onSkipNext) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next") Icon(Iconsax.IconsaxNext, contentDescription = "Next")
} }
VariableIconButton( VariableIconButton(
onClick = ::onLoopToggle, onClick = ::onLoopToggle,
enabled = !isJamGuest,
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
) { ) {
Icon( Icon(

View File

@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
@ -42,8 +41,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
@ -73,7 +70,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost 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.devices.PlayDestinationPickerHost
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
@ -99,13 +95,6 @@ fun AppShell(
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val navigatorCommands: NavigationCommands = koinInject() 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 isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
@ -274,15 +263,6 @@ 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),
)
} }
} }

View File

@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -34,12 +33,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@ -57,7 +52,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage 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.Card
import dev.krtirtho.spotube.core.ui.base.GhostIconButton import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton import dev.krtirtho.spotube.core.ui.base.IconButton
@ -65,15 +59,12 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape 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.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch 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.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
@ -87,14 +78,12 @@ fun PlayerQueueContent(
val displayItems = state.displayItems val displayItems = state.displayItems
val filterQuery = state.filterQuery val filterQuery = state.filterQuery
val isFiltered = state.isFiltered val isFiltered = state.isFiltered
val isReadOnly = state.isReadOnly
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState( val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState, lazyListState,
onMove = { from, to -> onMove = { from, to ->
if (isFiltered || isReadOnly) return@rememberReorderableLazyListState if (isFiltered) return@rememberReorderableLazyListState
viewModel.onMove(from.index, to.index) viewModel.onMove(from.index, to.index)
}, },
) )
@ -129,7 +118,6 @@ fun PlayerQueueContent(
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (!isReadOnly) {
IconButton( IconButton(
onClick = viewModel::clearQueue, onClick = viewModel::clearQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small), theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
@ -137,7 +125,6 @@ fun PlayerQueueContent(
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue") Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
} }
} }
}
if (displayItems.isEmpty()) { if (displayItems.isEmpty()) {
Text( Text(
@ -157,140 +144,20 @@ fun PlayerQueueContent(
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp) val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow( QueueItemRow(
item = item, item = item,
reorderScope = if (isFiltered || isReadOnly) null else this, reorderScope = if (isFiltered) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) }, onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) }, onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
onDragStarted = { viewModel.onDragStart() }, onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() }, 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 @Composable
private fun QueueItemRow( private fun QueueItemRow(
@ -300,15 +167,11 @@ private fun QueueItemRow(
onRemoveClick: () -> Unit, onRemoveClick: () -> Unit,
onDragStarted: () -> Unit, onDragStarted: () -> Unit,
onDragStopped: () -> Unit, onDragStopped: () -> Unit,
showOptions: Boolean = true,
enabled: Boolean = true,
onParticipantClick: (JamParticipant) -> Unit = {},
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
ListRowTile( ListRowTile(
onClick = onPlayClick, onClick = onPlayClick,
enabled = enabled,
selected = item.isCurrent, selected = item.isCurrent,
modifier = Modifier, modifier = Modifier,
leading = { leading = {
@ -392,25 +255,6 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp)) 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 { Box {
GhostIconButton( GhostIconButton(
onClick = { showMenu = true }, onClick = { showMenu = true },
@ -439,6 +283,5 @@ private fun QueueItemRow(
} }
} }
} }
}
) )
} }

View File

@ -21,9 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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 import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -41,23 +38,16 @@ data class QueueItemUi(
val isCurrent: Boolean, val isCurrent: Boolean,
val imageUrl: String?, val imageUrl: String?,
val originalIndex: Int, val originalIndex: Int,
/** Participant who added this item to the jam queue, if any. */
val addedByParticipant: JamParticipant? = null,
) )
data class QueueContentUiState( data class QueueContentUiState(
val filterQuery: String = "", val filterQuery: String = "",
val displayItems: List<QueueItemUi> = emptyList(), val displayItems: List<QueueItemUi> = emptyList(),
val isFiltered: Boolean = false, val isFiltered: Boolean = false,
/** Guests cannot reorder/remove/clear the shared jam queue. */
val isReadOnly: Boolean = false,
val isJamHost: Boolean = false,
val participants: List<JamParticipant> = emptyList(),
) )
class PlayerQueueContentViewModel( class PlayerQueueContentViewModel(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val jamRoomService: JamRoomService,
) : ViewModel() { ) : ViewModel() {
private val queueVisibilityFlow = MutableStateFlow(false) private val queueVisibilityFlow = MutableStateFlow(false)
private val queueFilterFlow = MutableStateFlow("") private val queueFilterFlow = MutableStateFlow("")
@ -70,8 +60,7 @@ class PlayerQueueContentViewModel(
private val computedItems: StateFlow<List<QueueItemUi>> = combine( private val computedItems: StateFlow<List<QueueItemUi>> = combine(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
jamRoomService.participants, ) { queue, currentEntry ->
) { queue, currentEntry, participants ->
val currentIndex = if (currentEntry != null) { val currentIndex = if (currentEntry != null) {
queue.indexOfFirst { it.matchesCurrent(currentEntry) } queue.indexOfFirst { it.matchesCurrent(currentEntry) }
} else { } else {
@ -79,7 +68,7 @@ class PlayerQueueContentViewModel(
} }
queue.mapIndexed { index, entry -> queue.mapIndexed { index, entry ->
val title: String val title: String
var subtitle: String val subtitle: String
val durationMs: Long val durationMs: Long
val imageUrl: String? val imageUrl: String?
@ -100,11 +89,6 @@ class PlayerQueueContentViewModel(
} }
} }
val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy }
if (addedByParticipant != null) {
subtitle = "$subtitle • Added by ${addedByParticipant.displayName}"
}
QueueItemUi( QueueItemUi(
id = "${entry.url}@$index", id = "${entry.url}@$index",
title = title, title = title,
@ -113,7 +97,6 @@ class PlayerQueueContentViewModel(
isCurrent = index == currentIndex, isCurrent = index == currentIndex,
imageUrl = imageUrl, imageUrl = imageUrl,
originalIndex = index, originalIndex = index,
addedByParticipant = addedByParticipant,
) )
} }
}.stateIn( }.stateIn(
@ -126,9 +109,7 @@ class PlayerQueueContentViewModel(
computedItems, computedItems,
reorderBuffer, reorderBuffer,
queueFilterFlow, queueFilterFlow,
jamRoomService.role, ) { items, buffer, filterQuery ->
jamRoomService.participants,
) { items, buffer, filterQuery, role, participants ->
val normalizedFilter = filterQuery.trim().lowercase() val normalizedFilter = filterQuery.trim().lowercase()
val isFiltered = normalizedFilter.isNotBlank() val isFiltered = normalizedFilter.isNotBlank()
val filtered = if (isFiltered) { val filtered = if (isFiltered) {
@ -143,9 +124,6 @@ class PlayerQueueContentViewModel(
filterQuery = filterQuery, filterQuery = filterQuery,
displayItems = buffer ?: filtered, displayItems = buffer ?: filtered,
isFiltered = isFiltered, isFiltered = isFiltered,
isReadOnly = role == JamRole.Guest,
isJamHost = role == JamRole.Host,
participants = participants,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -166,14 +144,14 @@ class PlayerQueueContentViewModel(
} }
fun playQueueItem(index: Int) { fun playQueueItem(index: Int) {
if (index < 0 || queueContentUiState.value.isReadOnly) return if (index < 0) return
viewModelScope.launch { viewModelScope.launch {
audioPlayerQueue.jumpTo(index) audioPlayerQueue.jumpTo(index)
} }
} }
fun removeQueueItem(index: Int) { fun removeQueueItem(index: Int) {
if (index < 0 || queueContentUiState.value.isReadOnly) return if (index < 0) return
viewModelScope.launch { viewModelScope.launch {
val currentQueue = audioPlayerQueue.queueFlow.value val currentQueue = audioPlayerQueue.queueFlow.value
if (index < currentQueue.size) { if (index < currentQueue.size) {
@ -184,49 +162,24 @@ class PlayerQueueContentViewModel(
fun moveQueueItem(fromIndex: Int, toIndex: Int) { fun moveQueueItem(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
if (queueContentUiState.value.isReadOnly) return
viewModelScope.launch { viewModelScope.launch {
audioPlayerQueue.move(fromIndex, toIndex) audioPlayerQueue.move(fromIndex, toIndex)
} }
} }
fun clearQueue() { fun clearQueue() {
if (queueContentUiState.value.isReadOnly) return
viewModelScope.launch { viewModelScope.launch {
audioPlayerQueue.clear() audioPlayerQueue.clear()
} }
} }
// ---------- 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() { fun onDragStart() {
if (reorderBuffer.value != null) return if (reorderBuffer.value != null) return
if (queueContentUiState.value.isReadOnly) return
val currentItems = queueContentUiState.value.displayItems val currentItems = queueContentUiState.value.displayItems
reorderBuffer.value = currentItems.toList() reorderBuffer.value = currentItems.toList()
} }
fun onMove(from: Int, to: Int) { fun onMove(from: Int, to: Int) {
if (queueContentUiState.value.isReadOnly) return
val buffer = reorderBuffer.value ?: return val buffer = reorderBuffer.value ?: return
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
val item = buffer[from] val item = buffer[from]

View File

@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
private val SlidingSheetBreakpoint = 840.dp private val SlidingSheetBreakpoint = 840.dp
@ -69,11 +68,7 @@ private fun SlidingQueueSheet(
visible = isVisible, visible = isVisible,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding( .padding(top = 12.dp, end = 12.dp, bottom = 12.dp),
top = 12.dp,
end = 12.dp,
bottom = 12.dp + LocalAppShellBottomInset.current,
),
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(), enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(), exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
) { ) {

View File

@ -1,7 +1,9 @@
mod metadata; mod metadata;
mod discord_rpc; mod discord_rpc;
mod webrtc_p2p;
pub use metadata::*; pub use metadata::*;
pub use discord_rpc::*; pub use discord_rpc::*;
pub use webrtc_p2p::*;
uniffi::setup_scaffolding!(); uniffi::setup_scaffolding!();

View File

@ -0,0 +1,340 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<webrtc::error::Error> for WebrtcError {
fn from(e: webrtc::error::Error) -> Self {
WebrtcError::Internal {
reason: format!("{e:?}"),
}
}
}
#[derive(uniffi::Record)]
pub struct IceServerConfig {
pub urls: Vec<String>,
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<dyn DataChannel>,
label: String,
}
#[derive(uniffi::Object)]
pub struct WebrtcPeerConnection {
pc: Arc<dyn PeerConnection>,
handler: Arc<dyn WebrtcEventHandler>,
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
gather_rx: Mutex<webrtc::runtime::Receiver<()>>,
}
#[uniffi::export(async_runtime = "tokio")]
pub async fn create_webrtc_peer_connection(
ice_servers: Vec<IceServerConfig>,
handler: Box<dyn WebrtcEventHandler>,
) -> Result<Arc<WebrtcPeerConnection>, WebrtcError> {
let handler: Arc<dyn WebrtcEventHandler> = 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<dyn PeerConnection>,
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<String, WebrtcError> {
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<String, WebrtcError> {
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<String> {
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::<RTCDataChannelInit>)
.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<Arc<dyn DataChannel>> = {
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<dyn WebrtcEventHandler>,
gather_tx: webrtc::runtime::Sender<()>,
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
}
#[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<dyn DataChannel>) {
// 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<dyn DataChannel>,
handler: Arc<dyn WebrtcEventHandler>,
) {
::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;
}
}
_ => {}
}
}
});
}

View File

@ -44,8 +44,6 @@ kotlinx-io = "0.9.1"
material3 = "1.10.0-alpha05" material3 = "1.10.0-alpha05"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
materialKolor = "4.1.1" materialKolor = "4.1.1"
mqttClient = "2.1.1"
mqttBuffer = "6.30.8"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.2" newpipeextractor = "v0.26.2"
newpipeExtractorKmp = "1.3.0" newpipeExtractorKmp = "1.3.0"
@ -124,10 +122,6 @@ 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-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-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" } 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" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }