mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
Compare commits
4 Commits
571aea8d38
...
6fabc876a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fabc876a5 | ||
|
|
4c724a333b | ||
|
|
ad994f3ca3 | ||
|
|
a2b9f4178c |
@ -1,821 +0,0 @@
|
||||
# WebRTC Support for Group Jam & Remote Control
|
||||
|
||||
## Overview
|
||||
Add two peer-to-peer features to Spotube:
|
||||
1. **Listen Together (Group Jam)**: Multi-user synced queue over WebRTC data channels (star topology, manual SDP exchange)
|
||||
2. **Remote Control**: LAN-only device control via WebSocket on the existing `LocalServer` (extended with control routes). No WebRTC needed for this feature.
|
||||
|
||||
Both features share UI patterns (adaptive dialogs for play interception) but use different transport layers based on their requirements.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (One-Time Setup)
|
||||
|
||||
Before starting implementation:
|
||||
|
||||
1. **Initialize webrtc-rs submodule**:
|
||||
```bash
|
||||
cd build/webrtc-rs && git submodule update --init --recursive
|
||||
```
|
||||
The `rtc` crate (Sans-I/O core) is a git submodule and must be initialized before building.
|
||||
|
||||
2. **Verify dns-sd-kt availability**:
|
||||
- Published to Maven Central: `com.appstractive:dns-sd-kt:1.1.0`
|
||||
- No setup needed; just add to `libs.versions.toml`
|
||||
|
||||
3. **Verify Rust toolchain**:
|
||||
- Existing uniffi setup already works (discord-rpc, metadata modules)
|
||||
- Ensure `cargo` is available and can build for all targets
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions (Confirmed)
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| WebRTC implementation | `webrtc-rs` via uniffi | Single codebase, identical behavior across platforms |
|
||||
| Jam topology | Star (host ↔ peers) | Simpler, scales better, matches host-authority model |
|
||||
| Remote Control transport | TCP/WebSocket only | LAN-only, so WebRTC is overkill; direct connection is simpler |
|
||||
| Jam signaling | Manual SDP exchange | No server infrastructure needed; users copy-paste or scan QR |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Rust Uniffi WebRTC Module
|
||||
|
||||
### Goal
|
||||
Add `webrtc-rs` to the existing Rust crate and expose a uniffi API for WebRTC peer connections and data channels.
|
||||
|
||||
### Library Details (from `build/webrtc-rs`)
|
||||
- **Crate**: `webrtc` v0.21.0-beta.1 (pure Rust, no external C/C++ libs)
|
||||
- **Architecture**: Sans-I/O core (`rtc` crate) + async API layer
|
||||
- **Async runtime**: tokio (default) or smol
|
||||
- **Crypto**: `ring` (default) or `aws-lc-rs`
|
||||
- **Key types**:
|
||||
- `PeerConnection` (trait) — created via `PeerConnectionBuilder::build()`
|
||||
- `DataChannel` (trait) — created via `peer.create_data_channel()`
|
||||
- `RTCSessionDescription` — SDP offer/answer
|
||||
- `RTCIceCandidateInit` — ICE candidates
|
||||
- `PeerConnectionEventHandler` (trait) — callback interface for events
|
||||
- `DataChannelEvent` (enum) — polled via `dc.poll().await`
|
||||
- **Event model**: PeerConnection uses callbacks; DataChannel uses polling
|
||||
- **Submodule**: `rtc` git submodule must be initialized before building
|
||||
|
||||
### Files to Modify
|
||||
- `composeApp/Cargo.toml` — add `webrtc` dependency
|
||||
- `composeApp/src/commonMain/rust/lib.rs` — register new module
|
||||
- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API
|
||||
|
||||
### Implementation
|
||||
|
||||
1. **Initialize webrtc-rs submodule** (one-time setup):
|
||||
```bash
|
||||
cd build/webrtc-rs && git submodule update --init --recursive
|
||||
```
|
||||
|
||||
2. **Add webrtc-rs dependency** to `composeApp/Cargo.toml`:
|
||||
```toml
|
||||
[dependencies]
|
||||
webrtc = { path = "../build/webrtc-rs", features = ["runtime-tokio", "crypto-ring"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
```
|
||||
|
||||
**Note**: Using path dependency to the local clone. For production, switch to crates.io version once stable.
|
||||
|
||||
3. **Define uniffi API** in `webrtc_p2p.rs`:
|
||||
|
||||
**Core objects**:
|
||||
```rust
|
||||
#[uniffi::export]
|
||||
pub struct PeerConnectionWrapper {
|
||||
pc: Arc<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
2157
composeApp/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -10,10 +10,6 @@ discord-rich-presence = "1.1.0"
|
||||
thiserror = "2.0"
|
||||
parking_lot = "0.12"
|
||||
log = "0.4"
|
||||
webrtc = "0.20.4"
|
||||
rtc = "0.20.4"
|
||||
async-trait = "0.1"
|
||||
bytes = "1"
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
|
||||
[lib]
|
||||
|
||||
@ -166,6 +166,12 @@ kotlin {
|
||||
|
||||
// DLNA
|
||||
implementation(libs.dns.sd.kt)
|
||||
|
||||
// mqtt client for jam-session
|
||||
implementation(libs.mqtt.client)
|
||||
implementation(libs.mqtt.x.models)
|
||||
implementation(libs.mqtt.buffer)
|
||||
implementation(libs.mqtt.buffer.codec)
|
||||
}
|
||||
}
|
||||
commonTest.dependencies {
|
||||
|
||||
@ -55,17 +55,6 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
<service
|
||||
android:name=".media.PlaybackService"
|
||||
|
||||
@ -173,5 +173,19 @@
|
||||
<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_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>
|
||||
|
||||
|
||||
@ -33,8 +33,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
|
||||
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||
@ -106,12 +104,6 @@ fun App(
|
||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
||||
|
||||
val jamDeepLinks: JamDeepLinkService = koinInject()
|
||||
DisposableEffect(Unit) {
|
||||
ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) }
|
||||
onDispose { ExternalUriHandler.listener = null }
|
||||
}
|
||||
|
||||
val navigationState = rememberNavigationState(
|
||||
startRoute = Routes.Home,
|
||||
topLevelRoutes = TOP_LEVEL_ROUTES
|
||||
|
||||
@ -25,6 +25,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
sealed interface QueueEntry {
|
||||
val url: String
|
||||
val addedBy: String
|
||||
|
||||
@Serializable
|
||||
@SerialName("streaming")
|
||||
@ -32,6 +33,7 @@ sealed interface QueueEntry {
|
||||
val track: MetadataTrack,
|
||||
override val url: String,
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry
|
||||
|
||||
@Serializable
|
||||
@ -42,7 +44,8 @@ sealed interface QueueEntry {
|
||||
val duration: Long,
|
||||
val album: String?,
|
||||
val coverBytes: ByteArray?,
|
||||
override val url: String
|
||||
override val url: String,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
@ -56,6 +59,7 @@ sealed interface QueueEntry {
|
||||
if (album != other.album) return false
|
||||
if (!coverBytes.contentEquals(other.coverBytes)) return false
|
||||
if (url != other.url) return false
|
||||
if (addedBy != other.addedBy) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@ -67,6 +71,7 @@ sealed interface QueueEntry {
|
||||
result = 31 * result + (album?.hashCode() ?: 0)
|
||||
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
|
||||
result = 31 * result + url.hashCode()
|
||||
result = 31 * result + addedBy.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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
|
||||
}
|
||||
}
|
||||
@ -23,10 +23,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
|
||||
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
||||
import dev.krtirtho.spotube.core.db.Database
|
||||
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
|
||||
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
||||
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomClient
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.navigation.navigationModule
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||
@ -185,12 +185,9 @@ val sharedModules = module {
|
||||
viewModelOf(::RemoteControlViewModel)
|
||||
viewModel {
|
||||
JamViewModel(
|
||||
jamSession = get(),
|
||||
deepLinks = get(),
|
||||
jamRoomService = get(),
|
||||
shareService = get(),
|
||||
settingsProvider = get(),
|
||||
audioPlayer = get(),
|
||||
audioPlayerQueue = get(),
|
||||
)
|
||||
}
|
||||
|
||||
@ -239,8 +236,8 @@ val sharedModules = module {
|
||||
createdAtStart()
|
||||
}
|
||||
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
||||
single { JamSessionService(get(), get(), get()) }
|
||||
singleOf(::JamDeepLinkService)
|
||||
singleOf(::JamRoomClient)
|
||||
single { JamRoomService(get(), get(), get(), get()) }
|
||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||
single<AudioPlayerQueue> {
|
||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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
|
||||
}
|
||||
}
|
||||
@ -18,61 +18,54 @@
|
||||
package dev.krtirtho.spotube.core.jam
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Jam messages exchanged over MQTT.
|
||||
*
|
||||
* - `state` topic: [QueueState] (retained, host -> everyone)
|
||||
* - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist]
|
||||
* (anyone -> host, except Kick which is host -> guest)
|
||||
* - `presence/{clientId}` topic: [JamPresence] (retained, one per participant)
|
||||
*/
|
||||
@Serializable
|
||||
sealed class JamMessage {
|
||||
@Serializable
|
||||
@SerialName("hello")
|
||||
data class Hello(
|
||||
val displayName: String,
|
||||
val deviceId: String,
|
||||
) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("welcome")
|
||||
data class Welcome(
|
||||
val hostName: String,
|
||||
val participantId: String,
|
||||
) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("queueState")
|
||||
data class QueueState(
|
||||
val items: List<JamMediaItem>,
|
||||
val currentIndex: Int,
|
||||
val isPlaying: Boolean,
|
||||
val positionMs: Long,
|
||||
val shuffleEnabled: Boolean = false,
|
||||
/**
|
||||
* Whether guests should follow the host's current index. True when the
|
||||
* host manually skipped/jumped or loaded a queue; false when the host
|
||||
* merely auto-advanced because its song ended (guests stay put).
|
||||
*/
|
||||
val follow: Boolean = false,
|
||||
/** Host's live play state — late-joining guests start with it. */
|
||||
val isPlaying: Boolean = false,
|
||||
) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playbackCommand")
|
||||
data class PlaybackCommand(
|
||||
val command: PlaybackCmd,
|
||||
) : JamMessage()
|
||||
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggestTrack")
|
||||
data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggestPlaylist")
|
||||
data class SuggestPlaylist(val tracks: List<JamMediaItem>) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("chat")
|
||||
data class Chat(
|
||||
val fromName: String,
|
||||
val text: String,
|
||||
data class SuggestTrack(
|
||||
val mediaItem: JamMediaItem,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("participantList")
|
||||
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
|
||||
@SerialName("suggestPlaylist")
|
||||
data class SuggestPlaylist(
|
||||
val tracks: List<JamMediaItem>,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("kick")
|
||||
@ -86,24 +79,12 @@ sealed class JamMessage {
|
||||
data class Leave(val reason: String = "user_left") : JamMessage()
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback commands. Only queue navigation is global — play/pause, seek,
|
||||
* volume, shuffle and loop are local to each participant.
|
||||
*/
|
||||
@Serializable
|
||||
sealed class PlaybackCmd {
|
||||
@Serializable
|
||||
@SerialName("play")
|
||||
data object Play : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("pause")
|
||||
data object Pause : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("toggle")
|
||||
data object Toggle : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("seek")
|
||||
data class Seek(val positionMs: Long) : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("skipNext")
|
||||
data object SkipNext : PlaybackCmd()
|
||||
@ -112,23 +93,20 @@ sealed class PlaybackCmd {
|
||||
@SerialName("skipPrevious")
|
||||
data object SkipPrevious : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("setVolume")
|
||||
data class SetVolume(val volume: Float) : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("setLoop")
|
||||
data class SetLoop(val loop: String) : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("setShuffle")
|
||||
data class SetShuffle(val enabled: Boolean) : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("jumpTo")
|
||||
data class JumpTo(val index: Int) : PlaybackCmd()
|
||||
}
|
||||
|
||||
/** Retained per-participant presence entry (with an MQTT Last Will for leave). */
|
||||
@Serializable
|
||||
data class JamPresence(
|
||||
val clientId: String,
|
||||
val displayName: String,
|
||||
val isHost: Boolean,
|
||||
val left: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class JamMediaItem(
|
||||
val url: String,
|
||||
@ -139,6 +117,7 @@ data class JamMediaItem(
|
||||
val durationMs: Long,
|
||||
val coverUrl: String,
|
||||
val protocol: String,
|
||||
val addedBy: String = "",
|
||||
) {
|
||||
companion object {
|
||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||
@ -153,6 +132,7 @@ data class JamMediaItem(
|
||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
.orEmpty(),
|
||||
protocol = entry.protocol.name,
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
|
||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||
@ -164,6 +144,7 @@ data class JamMediaItem(
|
||||
durationMs = entry.duration,
|
||||
coverUrl = "",
|
||||
protocol = "PROGRESSIVE",
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
}
|
||||
|
||||
@ -214,14 +195,4 @@ data class JamParticipant(
|
||||
enum class JamRole {
|
||||
Host,
|
||||
Guest,
|
||||
}
|
||||
|
||||
object JamLoopMapping {
|
||||
fun toString(state: LoopState): String = state.name.lowercase()
|
||||
fun fromString(value: String): LoopState = when (value.lowercase()) {
|
||||
"none" -> LoopState.NONE
|
||||
"one" -> LoopState.ONE
|
||||
"all" -> LoopState.ALL
|
||||
else -> LoopState.NONE
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,323 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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/#"
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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 }
|
||||
}
|
||||
@ -0,0 +1,573 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@ -1,587 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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)])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,316 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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
|
||||
}
|
||||
}
|
||||
@ -21,16 +21,18 @@ import co.touchlab.kermit.Logger
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.jam.JamMediaItem
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
@ -87,7 +89,7 @@ class RemotePlaybackController(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val jamSession: JamSessionService,
|
||||
private val jamRoomService: JamRoomService,
|
||||
) : KoinComponent {
|
||||
private val logger = Logger.withTag("RemotePlaybackController")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@ -95,6 +97,10 @@ class RemotePlaybackController(
|
||||
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
|
||||
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 {
|
||||
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
||||
}
|
||||
@ -158,45 +164,56 @@ class RemotePlaybackController(
|
||||
_pendingRequest.value = null
|
||||
}
|
||||
|
||||
// ---------- Jam actions ----------
|
||||
|
||||
/**
|
||||
* Routes the pending request into the active jam session. On the host the jam
|
||||
* queue IS the local queue, so the action runs locally; on a guest the content
|
||||
* is suggested to the host, which accepts it into the shared queue.
|
||||
* Adds a single track to the active jam queue. The host applies it to the
|
||||
* local (shared) queue directly; a guest suggests it to the host over MQTT.
|
||||
*/
|
||||
fun playOnJam() {
|
||||
val request = _pendingRequest.value ?: return
|
||||
_pendingRequest.value = null
|
||||
fun addTrackToJam(track: MetadataTrack) {
|
||||
if (jamRoomService.role.value == null) return
|
||||
scope.launch {
|
||||
try {
|
||||
when (jamSession.role.value) {
|
||||
JamRole.Host -> executeLocally(request)
|
||||
JamRole.Guest -> suggestToJam(request)
|
||||
null -> {}
|
||||
when (jamRoomService.role.value) {
|
||||
JamRole.Host -> audioPlayerQueue.addToQueue(
|
||||
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId)
|
||||
)
|
||||
|
||||
JamRole.Guest -> jamRoomService.suggestTrack(track)
|
||||
null -> return@launch
|
||||
}
|
||||
_events.emit("Added to the jam queue")
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to send content to jam session" }
|
||||
logger.e(e) { "Failed to add track to jam session" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
|
||||
when (request) {
|
||||
is PlaybackDestinationRequest.Collection -> {
|
||||
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
|
||||
if (tracks.isNotEmpty()) {
|
||||
jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() })
|
||||
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adds multiple tracks to the active jam queue (host applies locally,
|
||||
* guest suggests to the host).
|
||||
*/
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
if (tracks.isEmpty() || jamRoomService.role.value == null) return
|
||||
scope.launch {
|
||||
try {
|
||||
when (jamRoomService.role.value) {
|
||||
JamRole.Host -> audioPlayerQueue.addAllToQueue(
|
||||
tracks.map { track ->
|
||||
QueueEntry.StreamingTrack(
|
||||
track = track,
|
||||
url = "",
|
||||
addedBy = jamRoomService.participantClientId,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
is PlaybackDestinationRequest.Track -> {
|
||||
jamSession.suggestTrack(request.track.toJamMediaItem())
|
||||
}
|
||||
|
||||
is PlaybackDestinationRequest.Tracks -> {
|
||||
if (request.tracks.isNotEmpty()) {
|
||||
jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() })
|
||||
JamRole.Guest -> jamRoomService.suggestPlaylist(tracks)
|
||||
null -> return@launch
|
||||
}
|
||||
_events.emit("Added ${tracks.size} to the jam queue")
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to add tracks to jam session" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -368,6 +385,4 @@ class RemotePlaybackController(
|
||||
album?.id == other.album?.id &&
|
||||
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this)
|
||||
}
|
||||
@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
|
||||
private val BadgeShape = RoundedCornerShape(11.dp)
|
||||
private const val DisabledContentAlpha = 0.38f
|
||||
|
||||
private val ButtonMinHeight = 40.dp
|
||||
private val SquareButtonSize = 40.dp
|
||||
|
||||
@ -178,6 +182,7 @@ fun OutlineButton(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = {
|
||||
@ -226,6 +231,7 @@ fun PrimaryButton(
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||
Row(
|
||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content,
|
||||
@ -271,6 +277,7 @@ fun SecondaryButton(
|
||||
) {
|
||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||
Row(
|
||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content,
|
||||
@ -488,7 +495,14 @@ fun GroupIconButton(
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -78,6 +78,9 @@ fun CollectionDetails(
|
||||
onShufflePlay: () -> Unit,
|
||||
onAddToQueue: () -> Unit,
|
||||
isPlaying: Boolean = false,
|
||||
/** Guest in a jam session: play/shuffle are replaced by Add to Jam. */
|
||||
isJamGuest: Boolean = false,
|
||||
onAddToJam: (() -> Unit)? = null,
|
||||
isFollowing: Boolean = false,
|
||||
onFollowClick: () -> Unit = { },
|
||||
showFollowButton: Boolean = true,
|
||||
@ -91,39 +94,61 @@ fun CollectionDetails(
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
|
||||
val playPauseButton = @Composable {
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val modifier = if (isCompact) {
|
||||
Modifier.weight(1f)
|
||||
} else {
|
||||
Modifier
|
||||
if (isJamGuest) {
|
||||
if (onAddToJam != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
PrimaryButton(
|
||||
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
||||
onClick = onAddToJam!!,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to Jam",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = "Add to Jam",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val modifier = if (isCompact) {
|
||||
Modifier.weight(1f)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
modifier = modifier,
|
||||
onClick = onPlay,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = if (isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
OutlineButton(
|
||||
modifier = modifier,
|
||||
onClick = onShufflePlay
|
||||
) {
|
||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(
|
||||
text = "Shuffle",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
PrimaryButton(
|
||||
modifier = modifier,
|
||||
onClick = onPlay,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = if (isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
OutlineButton(
|
||||
modifier = modifier,
|
||||
onClick = onShufflePlay
|
||||
) {
|
||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(
|
||||
text = "Shuffle",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,10 @@ fun CollectionView(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkPlayNext: (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() },
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: @Composable () -> Unit = {},
|
||||
@ -114,7 +118,7 @@ fun CollectionView(
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
actions = if (isCollapsed) {
|
||||
actions = if (isCollapsed && !isJamGuest) {
|
||||
{
|
||||
IconButton(onClick = onPlay) {
|
||||
Icon(
|
||||
@ -159,6 +163,8 @@ fun CollectionView(
|
||||
showFollowButton = showFollowButton,
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@ -179,6 +185,8 @@ fun CollectionView(
|
||||
showFollowButton = showFollowButton,
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
}
|
||||
},
|
||||
@ -200,6 +208,9 @@ fun CollectionView(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
trackOptionsState = trackOptionsState,
|
||||
)
|
||||
}
|
||||
|
||||
@ -143,6 +143,9 @@ fun TrackList(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
currentTrackId: String? = null,
|
||||
isCurrentTrackPlaying: Boolean = false,
|
||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||
@ -247,6 +250,8 @@ fun TrackList(
|
||||
} else {
|
||||
selectedTrackIds + track.id
|
||||
}
|
||||
} else if (isJamGuest) {
|
||||
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
||||
} else {
|
||||
onTrackClick(track)
|
||||
}
|
||||
@ -274,6 +279,8 @@ fun TrackList(
|
||||
)
|
||||
},
|
||||
trackOptionsState = trackOptionsState(track),
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
@ -421,28 +428,48 @@ fun TrackList(
|
||||
val isAll =
|
||||
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = if (isAll) "Download All" else "Download $trackCount",
|
||||
onClick = { onBulkDownload(targetTracks) },
|
||||
),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||
onClick = { onBulkAddToQueue(targetTracks) },
|
||||
),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||
onClick = { onBulkPlayNext(targetTracks) },
|
||||
),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||
),
|
||||
),
|
||||
items = buildList {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = if (isAll) "Download All" else "Download $trackCount",
|
||||
onClick = { onBulkDownload(targetTracks) },
|
||||
),
|
||||
)
|
||||
if (!isJamGuest) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||
onClick = { onBulkAddToQueue(targetTracks) },
|
||||
),
|
||||
)
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||
onClick = { onBulkPlayNext(targetTracks) },
|
||||
),
|
||||
)
|
||||
}
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||
),
|
||||
)
|
||||
} + if (isInJam) {
|
||||
listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||
onClick = { onBulkAddToJam(targetTracks) },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
onClick = onClick,
|
||||
@ -497,6 +524,8 @@ fun TrackList(
|
||||
selectedTrackForOptions = null
|
||||
},
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -555,6 +584,8 @@ private fun TrackListRow(
|
||||
onSelectionToggle: (Boolean) -> Unit,
|
||||
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
||||
trackOptionsState: TrackOptionsState,
|
||||
isInJam: Boolean,
|
||||
isJamGuest: Boolean,
|
||||
onShowOptionsClick: () -> Unit,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
@ -750,6 +781,8 @@ private fun TrackListRow(
|
||||
state = trackOptionsState,
|
||||
onAction = onTrackOptionsAction,
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
} else {
|
||||
GhostIconButton(onClick = onShowOptionsClick) {
|
||||
@ -843,6 +876,8 @@ private fun ShimmerTrackListRow(
|
||||
onSelectionToggle = {},
|
||||
onTrackOptionsAction = {},
|
||||
trackOptionsState = TrackOptionsState(),
|
||||
isInJam = false,
|
||||
isJamGuest = false,
|
||||
onShowOptionsClick = {},
|
||||
onArtistClick = {},
|
||||
onAlbumClick = {},
|
||||
|
||||
@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
|
||||
sealed interface TrackOptionsAction {
|
||||
data object AddToJam : TrackOptionsAction
|
||||
data object StartRadio : TrackOptionsAction
|
||||
data object PlayNext : TrackOptionsAction
|
||||
data object AddToQueue : TrackOptionsAction
|
||||
@ -98,6 +99,8 @@ fun TrackOptions(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = buildTrackMenuItems(
|
||||
@ -105,6 +108,8 @@ fun TrackOptions(
|
||||
state = state,
|
||||
onAction = onAction,
|
||||
onAlbumClick = onAlbumClick,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
),
|
||||
trigger = { onClick ->
|
||||
GhostIconButton(onClick = onClick) {
|
||||
@ -129,6 +134,8 @@ fun TrackOptionsBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@ -151,6 +158,8 @@ fun TrackOptionsBottomSheet(
|
||||
onAlbumClick()
|
||||
onDismiss()
|
||||
},
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
).forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -241,7 +250,19 @@ private fun buildTrackMenuItems(
|
||||
state: TrackOptionsState,
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
): List<AdaptiveMenuItem> = buildList {
|
||||
if (isInJam) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to Jam",
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicCircle,
|
||||
@ -250,40 +271,44 @@ private fun buildTrackMenuItems(
|
||||
),
|
||||
)
|
||||
|
||||
if (!state.isInQueue && !state.isCurrentlyPlaying) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Play next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Move to next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
}
|
||||
// A guest's queue is the shared jam queue — mutating it locally is not
|
||||
// allowed, so queue actions are replaced by "Add to Jam".
|
||||
if (!isJamGuest) {
|
||||
if (!state.isInQueue && !state.isCurrentlyPlaying) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Play next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Move to next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.isInQueue) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to queue",
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||
label = "Remove from queue",
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
||||
),
|
||||
)
|
||||
if (!state.isInQueue) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to queue",
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
||||
),
|
||||
)
|
||||
} else {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||
label = "Remove from queue",
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
add(
|
||||
|
||||
@ -20,10 +20,14 @@ package dev.krtirtho.spotube.modules.album
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.map
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.jam.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.ui.component.CollectionView
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
@ -37,6 +41,11 @@ fun AlbumScreen(
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val jamActive by jamRoomService.role.map { it != null }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
||||
@ -92,6 +101,10 @@ fun AlbumScreen(
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
onBulkAddToJam = viewModel::addTracksToJam,
|
||||
isInJam = jamActive,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = viewModel::addTracksToJam,
|
||||
trailingContent = {
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
|
||||
@ -273,6 +273,10 @@ class AlbumViewModel(
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
@ -333,6 +337,10 @@ class AlbumViewModel(
|
||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||
}
|
||||
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||
|
||||
@ -63,8 +63,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import org.koin.compose.koinInject
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||
@ -95,6 +98,11 @@ fun ArtistScreen(
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val jamActive by jamRoomService.role.map { it != null }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
||||
@ -179,6 +187,9 @@ fun ArtistScreen(
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
onBulkAddToJam = viewModel::addTracksToJam,
|
||||
isInJam = jamActive,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -276,6 +276,10 @@ class ArtistViewModel(
|
||||
startTrack = track,
|
||||
)
|
||||
}
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
|
||||
@ -306,6 +310,10 @@ class ArtistViewModel(
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
|
||||
@ -30,7 +30,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||
import dev.krtirtho.spotube.core.remote.ConnectionState
|
||||
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
|
||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||
@ -40,7 +39,6 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/**
|
||||
@ -52,10 +50,8 @@ import org.koin.compose.koinInject
|
||||
fun PlayDestinationPickerHost() {
|
||||
val controller = koinInject<RemotePlaybackController>()
|
||||
val remoteControlClient = koinInject<RemoteControlClient>()
|
||||
val jamSession = koinInject<JamSessionService>()
|
||||
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
||||
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
|
||||
val jamActive by jamSession.isActive.collectAsStateWithLifecycle()
|
||||
|
||||
val pendingRequest = request ?: return
|
||||
|
||||
@ -141,33 +137,6 @@ fun PlayDestinationPickerHost() {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (jamActive) {
|
||||
ListRowTile(
|
||||
onClick = controller::playOnJam,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicPlaylist,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Jam Session",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = "$actionLabel in the shared jam queue",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
|
||||
@ -17,25 +17,18 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.jam
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@ -48,38 +41,28 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomCode
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
/**
|
||||
* Group Jam session screen. Playback controls and the queue live in the app's
|
||||
* regular player / queue sheet (the shared jam queue is the local queue), so
|
||||
* this screen only covers participation and session management.
|
||||
*/
|
||||
@Composable
|
||||
fun JamScreen(
|
||||
navigationCommands: NavigationCommands,
|
||||
@ -110,48 +93,19 @@ fun JamScreen(
|
||||
}
|
||||
|
||||
when {
|
||||
!state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
|
||||
hostName = state.incomingHostName.orEmpty(),
|
||||
onJoin = viewModel::joinWithIncomingInvite,
|
||||
onDismiss = viewModel::dismissIncomingInvite,
|
||||
)
|
||||
|
||||
!state.isActive -> CreateOrJoinView(
|
||||
state = state,
|
||||
onCreate = viewModel::createSession,
|
||||
onJoin = viewModel::joinWithPasted,
|
||||
onJoin = viewModel::joinWithCode,
|
||||
)
|
||||
|
||||
state.role == JamRole.Host -> HostSessionView(
|
||||
else -> SessionView(
|
||||
state = state,
|
||||
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
|
||||
onNewInvite = viewModel::generateNewInvite,
|
||||
onSubmitAnswer = viewModel::submitAnswerPasted,
|
||||
onShare = viewModel::share,
|
||||
onShareCode = viewModel::shareRoomCode,
|
||||
onLeave = viewModel::leave,
|
||||
onTogglePlayPause = viewModel::togglePlayPause,
|
||||
onSkipNext = viewModel::skipNext,
|
||||
onSkipPrevious = viewModel::skipPrevious,
|
||||
onSeek = viewModel::seek,
|
||||
onJumpTo = viewModel::jumpTo,
|
||||
onToggleShuffle = viewModel::toggleShuffle,
|
||||
onCycleLoop = viewModel::cycleLoopMode,
|
||||
onKick = viewModel::kickParticipant,
|
||||
onBan = viewModel::banParticipant,
|
||||
)
|
||||
|
||||
else -> GuestSessionView(
|
||||
state = state,
|
||||
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
|
||||
onShare = viewModel::share,
|
||||
onLeave = viewModel::leave,
|
||||
onTogglePlayPause = viewModel::togglePlayPause,
|
||||
onSkipNext = viewModel::skipNext,
|
||||
onSkipPrevious = viewModel::skipPrevious,
|
||||
onSeek = viewModel::seek,
|
||||
onJumpTo = viewModel::jumpTo,
|
||||
onToggleShuffle = viewModel::toggleShuffle,
|
||||
onCycleLoop = viewModel::cycleLoopMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -173,18 +127,33 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun CreateOrJoinView(
|
||||
state: JamUiState,
|
||||
onCreate: () -> Unit,
|
||||
onJoin: (String) -> Unit,
|
||||
) {
|
||||
var tab by remember { mutableIntStateOf(0) }
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
var pasted by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
text = "Listen together with friends over a peer-to-peer connection.",
|
||||
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
if (!state.brokerConfigured) {
|
||||
Text(
|
||||
text = "No jam broker configured — set one up in Settings to host or join a session.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Broker: ${state.brokerHost}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
SegmentedButton(
|
||||
selected = tab == 0,
|
||||
@ -201,34 +170,36 @@ private fun CreateOrJoinView(
|
||||
if (tab == 0) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Start a session as the host. You'll get a shareable invite link " +
|
||||
"to send to friends; when they accept, they appear here.",
|
||||
text = "Start a session as the host. You'll get a 6-character room code to " +
|
||||
"share with friends; you control the queue.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onCreate) {
|
||||
Button(
|
||||
onClick = onCreate,
|
||||
enabled = state.brokerConfigured,
|
||||
) {
|
||||
Text("Create Session")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Paste the invite link the host shared with you.",
|
||||
text = "Enter the 6-character room code the host shared with you.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = pasted,
|
||||
onValueChange = { pasted = it },
|
||||
onValueChange = { pasted = JamRoomCode.normalize(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Invite link") },
|
||||
placeholder = { Text("spotube://jam/invite?...") },
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
label = { Text("Room code") },
|
||||
placeholder = { Text("ABC123") },
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = { onJoin(pasted) },
|
||||
enabled = pasted.isNotBlank(),
|
||||
enabled = state.brokerConfigured && JamRoomCode.isValid(pasted),
|
||||
) {
|
||||
Text("Join Session")
|
||||
}
|
||||
@ -238,401 +209,54 @@ private fun CreateOrJoinView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingInviteView(
|
||||
hostName: String,
|
||||
onJoin: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "$hostName invited you to a jam session",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onJoin) {
|
||||
Text("Join")
|
||||
}
|
||||
OutlinedButton(onClick = onDismiss) {
|
||||
Text("Ignore")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HostSessionView(
|
||||
private fun SessionView(
|
||||
state: JamUiState,
|
||||
playerState: JamPlayerUiState,
|
||||
onNewInvite: () -> Unit,
|
||||
onSubmitAnswer: (String) -> Unit,
|
||||
onShare: (String) -> Unit,
|
||||
onShareCode: () -> Unit,
|
||||
onLeave: () -> Unit,
|
||||
onTogglePlayPause: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
onSkipPrevious: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onJumpTo: (Int) -> Unit,
|
||||
onToggleShuffle: () -> Unit,
|
||||
onCycleLoop: () -> Unit,
|
||||
onKick: (String) -> Unit,
|
||||
onBan: (String) -> Unit,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var pastedAnswer by rememberSaveable { mutableStateOf("") }
|
||||
val isHost = state.role == JamRole.Host
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan)
|
||||
|
||||
JamNowPlayingView(
|
||||
playerState = playerState,
|
||||
onTogglePlayPause = onTogglePlayPause,
|
||||
onSkipNext = onSkipNext,
|
||||
onSkipPrevious = onSkipPrevious,
|
||||
onToggleShuffle = onToggleShuffle,
|
||||
onCycleLoop = onCycleLoop,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = "Invite someone",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
val inviteLink = state.inviteLink
|
||||
if (inviteLink != null) {
|
||||
ShareableLinkBox(
|
||||
label = "Invite link",
|
||||
link = inviteLink,
|
||||
onCopy = { clipboard.setText(AnnotatedString(inviteLink)) },
|
||||
onShare = { onShare(inviteLink) },
|
||||
if (!state.isConnected) {
|
||||
Text(
|
||||
text = "Connecting to the session…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedButton(onClick = onNewInvite) {
|
||||
Text("Generate new invite")
|
||||
|
||||
ParticipantsSection(
|
||||
participants = state.participants,
|
||||
isHost = isHost,
|
||||
onKick = onKick,
|
||||
onBan = onBan,
|
||||
)
|
||||
|
||||
if (isHost) {
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = "Invite someone",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = "Accept a guest's answer",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = "When your guest sends back their answer link, paste it below.",
|
||||
text = "The queue and playback controls are in the player at the bottom of the app — " +
|
||||
"the jam queue is shared with every participant.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = pastedAnswer,
|
||||
onValueChange = { pastedAnswer = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Answer link or SDP") },
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
onSubmitAnswer(pastedAnswer)
|
||||
pastedAnswer = ""
|
||||
},
|
||||
enabled = pastedAnswer.isNotBlank(),
|
||||
) {
|
||||
Text("Accept Answer")
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
JamQueueView(
|
||||
queue = playerState.queue,
|
||||
onJumpTo = onJumpTo,
|
||||
)
|
||||
|
||||
LeaveButton(onLeave)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GuestSessionView(
|
||||
state: JamUiState,
|
||||
playerState: JamPlayerUiState,
|
||||
onShare: (String) -> Unit,
|
||||
onLeave: () -> Unit,
|
||||
onTogglePlayPause: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
onSkipPrevious: () -> Unit,
|
||||
onSeek: (Long) -> Unit,
|
||||
onJumpTo: (Int) -> Unit,
|
||||
onToggleShuffle: () -> Unit,
|
||||
onCycleLoop: () -> Unit,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {})
|
||||
|
||||
val answerLink = state.answerLink
|
||||
when {
|
||||
state.isConnected -> {
|
||||
JamNowPlayingView(
|
||||
playerState = playerState,
|
||||
onTogglePlayPause = onTogglePlayPause,
|
||||
onSkipNext = onSkipNext,
|
||||
onSkipPrevious = onSkipPrevious,
|
||||
onToggleShuffle = onToggleShuffle,
|
||||
onCycleLoop = onCycleLoop,
|
||||
)
|
||||
|
||||
JamQueueView(
|
||||
queue = playerState.queue,
|
||||
onJumpTo = onJumpTo,
|
||||
)
|
||||
}
|
||||
|
||||
answerLink == null -> {
|
||||
Text(
|
||||
text = "Connecting to the session...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = "Almost there! Send your answer back to the host:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
ShareableLinkBox(
|
||||
label = "Answer link",
|
||||
link = answerLink,
|
||||
onCopy = { clipboard.setText(AnnotatedString(answerLink)) },
|
||||
onShare = { onShare(answerLink) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LeaveButton(onLeave)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JamNowPlayingView(
|
||||
playerState: JamPlayerUiState,
|
||||
onTogglePlayPause: () -> Unit,
|
||||
onSkipNext: () -> Unit,
|
||||
onSkipPrevious: () -> Unit,
|
||||
onToggleShuffle: () -> Unit,
|
||||
onCycleLoop: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() },
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = playerState.currentTitle ?: "Nothing playing",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = playerState.currentArtist ?: "—",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = formatJamDuration(playerState.positionMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = formatJamDuration(playerState.durationMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onToggleShuffle,
|
||||
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShuffle,
|
||||
contentDescription = "Shuffle",
|
||||
tint = if (playerState.shuffleEnabled) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onSkipPrevious,
|
||||
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||
) {
|
||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||
}
|
||||
IconButton(
|
||||
onClick = onTogglePlayPause,
|
||||
theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape),
|
||||
modifier = Modifier.size(64.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (playerState.isPlaying) {
|
||||
Iconsax.IconsaxPause
|
||||
} else {
|
||||
Iconsax.IconsaxPlay
|
||||
},
|
||||
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onSkipNext,
|
||||
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||
) {
|
||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||
}
|
||||
IconButton(
|
||||
onClick = onCycleLoop,
|
||||
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxRepeateMusic,
|
||||
contentDescription = "Loop mode",
|
||||
tint = if (playerState.loopMode != "none") {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JamQueueView(
|
||||
queue: List<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
|
||||
private fun ParticipantsSection(
|
||||
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
|
||||
@ -684,35 +308,26 @@ private fun ParticipantsSection(
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatJamDuration(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareableLinkBox(
|
||||
label: String,
|
||||
link: String,
|
||||
onCopy: () -> Unit,
|
||||
private fun RoomCodeBox(
|
||||
code: String,
|
||||
onShare: () -> Unit,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val viewModel: JamViewModel = koinViewModel()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SelectionContainer {
|
||||
OutlinedTextField(
|
||||
value = link,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(label) },
|
||||
minLines = 2,
|
||||
maxLines = 6,
|
||||
Text(
|
||||
text = code,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = onCopy) {
|
||||
Button(onClick = { clipboard.setText(AnnotatedString(code)) }) {
|
||||
Text("Copy")
|
||||
}
|
||||
if (viewModel.supportsNativeShare) {
|
||||
|
||||
@ -20,22 +20,10 @@ package dev.krtirtho.spotube.modules.jam
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||
import dev.krtirtho.spotube.core.jam.JamInviteCodec
|
||||
import dev.krtirtho.spotube.core.jam.JamInviteLink
|
||||
import dev.krtirtho.spotube.core.jam.JamLoopMapping
|
||||
import dev.krtirtho.spotube.core.jam.JamMediaItem
|
||||
import dev.krtirtho.spotube.core.jam.JamMessage
|
||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||
import dev.krtirtho.spotube.core.jam.PlaybackCmd
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomCode
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
@ -45,7 +33,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class JamUiState(
|
||||
@ -53,377 +40,104 @@ data class JamUiState(
|
||||
val isConnected: Boolean = false,
|
||||
val role: JamRole? = null,
|
||||
val participants: List<JamParticipant> = emptyList(),
|
||||
/** Host: deep link containing this session's SDP offer, ready to share. */
|
||||
val inviteLink: String? = null,
|
||||
/** Guest: deep link containing our SDP answer, to send back to the host. */
|
||||
val answerLink: String? = null,
|
||||
/** Guest: offer received via deep link (or paste), waiting for confirmation. */
|
||||
val incomingHostName: String? = null,
|
||||
val incomingOfferSdp: String? = null,
|
||||
val roomCode: String? = null,
|
||||
val brokerHost: String = "",
|
||||
val brokerConfigured: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
data class JamQueueUiItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String,
|
||||
val durationMs: Long,
|
||||
val coverUrl: String,
|
||||
val isCurrent: Boolean,
|
||||
)
|
||||
|
||||
data class JamPlayerUiState(
|
||||
val queue: List<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(
|
||||
private val jamSession: JamSessionService,
|
||||
private val deepLinks: JamDeepLinkService,
|
||||
private val jamRoomService: JamRoomService,
|
||||
private val shareService: ShareService,
|
||||
private val settingsProvider: SettingsProvider,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(JamUiState())
|
||||
val uiState: StateFlow<JamUiState> = _uiState.asStateFlow()
|
||||
private val _localError = MutableStateFlow<String?>(null)
|
||||
|
||||
val supportsNativeShare: Boolean =
|
||||
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
// Mirror live session state into the UI state.
|
||||
jamSession.isActive.collect { active ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActive = active,
|
||||
isConnected = jamSession.isConnected.value,
|
||||
role = jamSession.role.value,
|
||||
participants = jamSession.participants.value,
|
||||
inviteLink = if (!active) null else it.inviteLink,
|
||||
answerLink = if (!active) null else it.answerLink,
|
||||
incomingOfferSdp = if (!active) it.incomingOfferSdp else null,
|
||||
incomingHostName = if (!active) it.incomingHostName else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
jamSession.participants.collect { participants ->
|
||||
_uiState.update { it.copy(participants = participants) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
jamSession.isConnected.collect { connected ->
|
||||
_uiState.update { it.copy(isConnected = connected) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
deepLinks.pendingLink.collect { link ->
|
||||
handleDeepLink(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The jam player state: the shared queue + current playback, built from the
|
||||
* local player (the host's queue IS the jam queue; on guests the synced
|
||||
* mirror lives in the local player).
|
||||
*/
|
||||
val jamPlayerState: StateFlow<JamPlayerUiState> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.playlistFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.positionFlow,
|
||||
audioPlayer.durationFlow,
|
||||
audioPlayer.loopStateFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
val uiState: StateFlow<JamUiState> = combine(
|
||||
jamRoomService.role,
|
||||
jamRoomService.participants,
|
||||
jamRoomService.isConnected,
|
||||
jamRoomService.roomCode,
|
||||
jamRoomService.connectionError,
|
||||
settingsProvider.settingsState,
|
||||
_localError,
|
||||
) { 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
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val role = values[0] as JamRole?
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val participants = values[1] as List<JamParticipant>
|
||||
val isConnected = values[2] as Boolean
|
||||
val roomCode = values[3] as String?
|
||||
val connectionError = values[4] as String?
|
||||
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
|
||||
val localError = values[6] as String?
|
||||
|
||||
val isHost = jamSession.role.value == JamRole.Host
|
||||
|
||||
val items: List<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(),
|
||||
JamUiState(
|
||||
isActive = role != null,
|
||||
isConnected = isConnected,
|
||||
role = role,
|
||||
participants = participants,
|
||||
roomCode = roomCode,
|
||||
brokerHost = settings?.jamBroker?.host.orEmpty(),
|
||||
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(),
|
||||
error = localError ?: connectionError,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState())
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
|
||||
|
||||
// ---------- Playback controls ----------
|
||||
// ---------- Session lifecycle ----------
|
||||
|
||||
fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle)
|
||||
|
||||
fun skipNext() = sendOrApply(PlaybackCmd.SkipNext)
|
||||
|
||||
fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious)
|
||||
|
||||
fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs))
|
||||
|
||||
fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index))
|
||||
|
||||
fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled))
|
||||
|
||||
fun cycleLoopMode() {
|
||||
val next = when (jamPlayerState.value.loopMode) {
|
||||
"none" -> "one"
|
||||
"one" -> "all"
|
||||
else -> "none"
|
||||
}
|
||||
sendOrApply(PlaybackCmd.SetLoop(next))
|
||||
}
|
||||
|
||||
private fun sendOrApply(command: PlaybackCmd) {
|
||||
fun createSession() {
|
||||
viewModelScope.launch {
|
||||
if (jamSession.role.value == JamRole.Host) {
|
||||
applyCommandLocally(command)
|
||||
} else {
|
||||
jamSession.sendMessage(JamMessage.PlaybackCommand(command))
|
||||
}
|
||||
jamRoomService.createRoom()
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: "Failed to create jam session"
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyCommandLocally(command: PlaybackCmd) {
|
||||
when (command) {
|
||||
PlaybackCmd.Play -> audioPlayer.play()
|
||||
PlaybackCmd.Pause -> audioPlayer.pause()
|
||||
PlaybackCmd.Toggle -> {
|
||||
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
||||
audioPlayer.pause()
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
||||
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
|
||||
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
|
||||
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
|
||||
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
|
||||
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
|
||||
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
|
||||
fun joinWithCode(input: String) {
|
||||
val code = JamRoomCode.normalize(input)
|
||||
if (!JamRoomCode.isValid(code)) {
|
||||
_localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)"
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
jamRoomService.joinRoom(code)
|
||||
.onFailure { e ->
|
||||
_localError.value = e.message ?: "Failed to join jam session"
|
||||
}
|
||||
.onSuccess { _localError.value = null }
|
||||
}
|
||||
}
|
||||
|
||||
fun shareRoomCode() {
|
||||
val code = uiState.value.roomCode ?: return
|
||||
shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam")
|
||||
}
|
||||
|
||||
fun leave() {
|
||||
viewModelScope.launch {
|
||||
jamRoomService.leaveRoom()
|
||||
_localError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_localError.value = null
|
||||
}
|
||||
|
||||
// ---------- Host moderation ----------
|
||||
|
||||
fun kickParticipant(participantId: String) {
|
||||
viewModelScope.launch { jamSession.kickParticipant(participantId) }
|
||||
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
||||
}
|
||||
|
||||
fun banParticipant(participantId: String) {
|
||||
viewModelScope.launch { jamSession.banParticipant(participantId) }
|
||||
}
|
||||
|
||||
fun createSession() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val offer = jamSession.createSession()
|
||||
JamInviteCodec.buildHostInvite(localName(), offer)
|
||||
}.onSuccess { link ->
|
||||
_uiState.update { it.copy(inviteLink = link, error = null) }
|
||||
}.onFailure { e ->
|
||||
_uiState.update { it.copy(error = "Failed to create session: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun generateNewInvite() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val invite = jamSession.generateInvite()
|
||||
JamInviteCodec.buildHostInvite(localName(), invite.sdp)
|
||||
}.onSuccess { link ->
|
||||
_uiState.update { it.copy(inviteLink = link, error = null) }
|
||||
}.onFailure { e ->
|
||||
_uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun joinWithIncomingInvite() {
|
||||
val sdp = _uiState.value.incomingOfferSdp ?: return
|
||||
join(sdp, _uiState.value.incomingHostName)
|
||||
}
|
||||
|
||||
fun joinWithPasted(input: String) {
|
||||
val parsed = JamInviteCodec.parse(input)
|
||||
val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input)
|
||||
if (sdp == null) {
|
||||
_uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") }
|
||||
return
|
||||
}
|
||||
join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link.
|
||||
*/
|
||||
fun submitAnswerPasted(input: String) {
|
||||
when (val parsed = JamInviteCodec.parse(input.trim())) {
|
||||
is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName)
|
||||
else -> {
|
||||
val sdp = JamInviteCodec.extractSdp(input)
|
||||
if (sdp == null) {
|
||||
_uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") }
|
||||
} else {
|
||||
acceptAnswerInternal(sdp, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun share(text: String) {
|
||||
shareService.share(text, "Spotube Group Jam")
|
||||
}
|
||||
|
||||
fun leave() {
|
||||
viewModelScope.launch {
|
||||
jamSession.leave()
|
||||
deepLinks.clear()
|
||||
_uiState.update {
|
||||
JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
fun dismissIncomingInvite() {
|
||||
deepLinks.clear()
|
||||
_uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) }
|
||||
}
|
||||
|
||||
private fun join(offerSdp: String, hostName: String? = null) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val answer = jamSession.joinSession(offerSdp, hostName)
|
||||
JamInviteCodec.buildGuestAnswer(localName(), answer)
|
||||
}.onSuccess { link ->
|
||||
_uiState.update {
|
||||
it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
_uiState.update { it.copy(error = "Failed to join session: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptAnswerInternal(answerSdp: String, peerName: String) {
|
||||
viewModelScope.launch {
|
||||
val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) }
|
||||
.getOrDefault(false)
|
||||
if (!accepted) {
|
||||
_uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") }
|
||||
} else {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleDeepLink(link: JamInviteLink?) {
|
||||
when (link) {
|
||||
is JamInviteLink.HostInvite -> {
|
||||
if (!jamSession.isActive.value) {
|
||||
_uiState.update {
|
||||
it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is JamInviteLink.GuestAnswer -> {
|
||||
if (jamSession.role.value == JamRole.Host) {
|
||||
acceptAnswerInternal(link.sdp, link.peerName)
|
||||
}
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun localName(): String =
|
||||
settingsProvider.settingsState.value?.jamParticipantName.orEmpty()
|
||||
}
|
||||
|
||||
private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem(
|
||||
id = if (trackId.isNotBlank()) trackId else url,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
durationMs = durationMs,
|
||||
coverUrl = coverUrl,
|
||||
isCurrent = isCurrent,
|
||||
)
|
||||
|
||||
private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean {
|
||||
return when {
|
||||
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
||||
this.track.id == other.track.id
|
||||
|
||||
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
|
||||
this.url == other.url && this.name == other.name
|
||||
|
||||
else -> false
|
||||
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
||||
}
|
||||
}
|
||||
@ -31,10 +31,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.map
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.jam.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.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||
@ -53,6 +57,11 @@ fun PlaylistScreen(
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val jamActive by jamRoomService.role.map { it != null }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
||||
@ -127,6 +136,10 @@ fun PlaylistScreen(
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
onBulkAddToJam = viewModel::addTracksToJam,
|
||||
isInJam = jamActive,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = viewModel::addTracksToJam,
|
||||
footerContent = footerContent,
|
||||
trailingContent = {
|
||||
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||
|
||||
@ -306,6 +306,10 @@ class PlaylistViewModel(
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
@ -378,6 +382,10 @@ class PlaylistViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||
|
||||
@ -20,11 +20,15 @@ package dev.krtirtho.spotube.modules.saved_tracks
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.map
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.jam.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.ui.component.CollectionView
|
||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||
@ -39,6 +43,11 @@ fun SavedTracksScreen(
|
||||
navigationCommands: NavigationCommands
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val jamActive by jamRoomService.role.map { it != null }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||
@ -85,6 +94,10 @@ fun SavedTracksScreen(
|
||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||
onBulkPlayNext = viewModel::playTracksNext,
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
onBulkAddToJam = viewModel::addTracksToJam,
|
||||
isInJam = jamActive,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = viewModel::addTracksToJam,
|
||||
trailingContent = {
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
|
||||
@ -248,6 +248,10 @@ class SavedTracksViewModel(
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
queue.find { entry ->
|
||||
@ -312,6 +316,10 @@ class SavedTracksViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
|
||||
}
|
||||
|
||||
@ -86,6 +86,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import org.koin.compose.koinInject
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.share.ShareService
|
||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||
@ -128,6 +131,11 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
val blacklistRepository: BlacklistRepository = koinInject()
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val jamActive by jamRoomService.role.map { it != null }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val selectedType = state.selectedSearchType
|
||||
val scope = rememberCoroutineScope()
|
||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||
@ -188,6 +196,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
is TrackOptionsAction.AddToQueue -> {
|
||||
remotePlaybackController.requestTrackAddToQueue(track)
|
||||
}
|
||||
is TrackOptionsAction.AddToJam -> {
|
||||
remotePlaybackController.addTrackToJam(track)
|
||||
}
|
||||
|
||||
is TrackOptionsAction.RemoveFromQueue -> {
|
||||
val queue = audioPlayerQueue.getQueue()
|
||||
@ -248,6 +259,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
|
||||
}
|
||||
|
||||
fun bulkAddToJam(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.addTracksToJam(tracks)
|
||||
}
|
||||
|
||||
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
||||
}
|
||||
@ -327,6 +342,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onBulkAddToJam = ::bulkAddToJam,
|
||||
isInJam = jamActive,
|
||||
isJamGuest = isJamGuest,
|
||||
onArtistClick = { artist ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
@ -356,6 +374,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
tracksToAddToPlaylist = tracks
|
||||
showAddToPlaylistPicker = true
|
||||
},
|
||||
onBulkAddToJam = ::bulkAddToJam,
|
||||
isInJam = jamActive,
|
||||
isJamGuest = isJamGuest,
|
||||
onArtistClick = { artist ->
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
@ -637,6 +658,9 @@ private fun SearchAllTab(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||
isInJam: Boolean,
|
||||
isJamGuest: Boolean,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
@ -694,6 +718,9 @@ private fun SearchAllTab(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
@ -781,6 +808,9 @@ private fun SearchTracksTab(
|
||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||
isInJam: Boolean,
|
||||
isJamGuest: Boolean,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||
@ -810,6 +840,9 @@ private fun SearchTracksTab(
|
||||
onBulkAddToQueue = onBulkAddToQueue,
|
||||
onBulkPlayNext = onBulkPlayNext,
|
||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
|
||||
@ -61,8 +61,10 @@ data class UserSettings(
|
||||
val remoteControlDeviceName: String = "",
|
||||
val remoteControlDeviceId: String = "",
|
||||
|
||||
// Group Jam (P2P)
|
||||
// Group Jam (MQTT)
|
||||
val jamParticipantName: String = "",
|
||||
val jamBroker: JamBroker = JamBroker(),
|
||||
val lastJamCode: String = "",
|
||||
|
||||
// Downloads
|
||||
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
||||
@ -86,3 +88,20 @@ data class UserSettings(
|
||||
// Updates
|
||||
val autoCheckForUpdates: Boolean = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder
|
||||
* until a real broker is configured; users can self-host and point the app at it.
|
||||
*/
|
||||
@Serializable
|
||||
data class JamBroker(
|
||||
val name: String = "",
|
||||
val host: String = "test.mosquitto.org",
|
||||
val port: Int = 1883,
|
||||
val useTls: Boolean = false,
|
||||
val username: String? = null,
|
||||
val password: String? = null,
|
||||
val clientIdPrefix: String = "spotube",
|
||||
val keepAliveSeconds: Int = 30,
|
||||
val connectionTimeoutSeconds: Int = 10,
|
||||
)
|
||||
|
||||
@ -43,6 +43,7 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.cacheSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.jamSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
||||
@ -110,6 +111,11 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
)
|
||||
if (settingsState != null)
|
||||
jamSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (settingsState != null)
|
||||
cacheSection(
|
||||
settings = settingsState!!,
|
||||
|
||||
@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -82,6 +82,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
||||
@ -117,6 +119,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
||||
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
|
||||
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
@ -158,6 +161,10 @@ fun AppExpandedPlayer(
|
||||
),
|
||||
) {
|
||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val isJamGuest by jamRoomService.role
|
||||
.map { it == JamRole.Guest }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val scope = rememberCoroutineScope()
|
||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
@ -198,18 +205,22 @@ fun AppExpandedPlayer(
|
||||
}
|
||||
|
||||
fun onSkipPrevious() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.skipToPrevious() }
|
||||
}
|
||||
|
||||
fun onSkipNext() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.skipToNext() }
|
||||
}
|
||||
|
||||
fun onShuffleToggle() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
||||
}
|
||||
|
||||
fun onLoopToggle() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
||||
}
|
||||
|
||||
@ -517,7 +528,7 @@ fun AppExpandedPlayer(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GhostIconButton(onClick = ::onShuffleToggle) {
|
||||
GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) {
|
||||
Icon(
|
||||
Iconsax.IconsaxShuffle,
|
||||
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
||||
@ -528,7 +539,7 @@ fun AppExpandedPlayer(
|
||||
}
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipPrevious) {
|
||||
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
|
||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||
}
|
||||
IconButton(
|
||||
@ -543,10 +554,10 @@ fun AppExpandedPlayer(
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipNext) {
|
||||
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
|
||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||
}
|
||||
GhostIconButton(onClick = ::onLoopToggle) {
|
||||
GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) {
|
||||
Icon(
|
||||
imageVector = when (playerUiState.loopState) {
|
||||
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
||||
|
||||
@ -65,6 +65,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.Slider
|
||||
@ -93,6 +95,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
|
||||
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
@ -125,6 +128,10 @@ fun AppLargePlayer(
|
||||
),
|
||||
) {
|
||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||
val jamRoomService: JamRoomService = koinInject()
|
||||
val isJamGuest by jamRoomService.role
|
||||
.map { it == JamRole.Guest }
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||
var isSeeking by remember { mutableStateOf(false) }
|
||||
@ -155,18 +162,22 @@ fun AppLargePlayer(
|
||||
}
|
||||
|
||||
fun onSkipPrevious() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.skipToPrevious() }
|
||||
}
|
||||
|
||||
fun onSkipNext() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.skipToNext() }
|
||||
}
|
||||
|
||||
fun onShuffleToggle() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
||||
}
|
||||
|
||||
fun onLoopToggle() {
|
||||
if (isJamGuest) return
|
||||
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
||||
}
|
||||
|
||||
@ -294,6 +305,7 @@ fun AppLargePlayer(
|
||||
) {
|
||||
VariableIconButton(
|
||||
onClick = ::onShuffleToggle,
|
||||
enabled = !isJamGuest,
|
||||
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
||||
) {
|
||||
Icon(
|
||||
@ -306,7 +318,7 @@ fun AppLargePlayer(
|
||||
}
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipPrevious) {
|
||||
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
|
||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||
}
|
||||
IconButton(
|
||||
@ -320,11 +332,12 @@ fun AppLargePlayer(
|
||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = ::onSkipNext) {
|
||||
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
|
||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||
}
|
||||
VariableIconButton(
|
||||
onClick = ::onLoopToggle,
|
||||
enabled = !isJamGuest,
|
||||
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||
@ -70,6 +73,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
|
||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
||||
@ -95,6 +99,13 @@ fun AppShell(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val navigatorCommands: NavigationCommands = koinInject()
|
||||
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(remotePlaybackController) {
|
||||
remotePlaybackController.events.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
||||
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
||||
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
||||
@ -263,6 +274,15 @@ fun AppShell(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn last so it floats above the players/sheets, just above the
|
||||
// bottom overlay (large player or compact player + bottombar).
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = bottomOverlayInset + 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||
@ -59,12 +65,15 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
@ -78,12 +87,14 @@ fun PlayerQueueContent(
|
||||
val displayItems = state.displayItems
|
||||
val filterQuery = state.filterQuery
|
||||
val isFiltered = state.isFiltered
|
||||
val isReadOnly = state.isReadOnly
|
||||
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||
lazyListState,
|
||||
onMove = { from, to ->
|
||||
if (isFiltered) return@rememberReorderableLazyListState
|
||||
if (isFiltered || isReadOnly) return@rememberReorderableLazyListState
|
||||
viewModel.onMove(from.index, to.index)
|
||||
},
|
||||
)
|
||||
@ -118,11 +129,13 @@ fun PlayerQueueContent(
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = viewModel::clearQueue,
|
||||
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
|
||||
) {
|
||||
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
|
||||
if (!isReadOnly) {
|
||||
IconButton(
|
||||
onClick = viewModel::clearQueue,
|
||||
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
|
||||
) {
|
||||
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,21 +157,141 @@ fun PlayerQueueContent(
|
||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
||||
QueueItemRow(
|
||||
item = item,
|
||||
reorderScope = if (isFiltered) null else this,
|
||||
reorderScope = if (isFiltered || isReadOnly) null else this,
|
||||
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
|
||||
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
|
||||
onDragStarted = { viewModel.onDragStart() },
|
||||
onDragStopped = { viewModel.onDragStop() },
|
||||
showOptions = !isReadOnly,
|
||||
enabled = !isReadOnly,
|
||||
onParticipantClick = { selectedParticipant = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedParticipant?.let { participant ->
|
||||
ParticipantDialog(
|
||||
participant = participant,
|
||||
isJamHost = state.isJamHost,
|
||||
onDismiss = { selectedParticipant = null },
|
||||
onKick = { viewModel.kickParticipant(participant.id) },
|
||||
onBan = { viewModel.banParticipant(participant.id) },
|
||||
onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantDialog(
|
||||
participant: JamParticipant,
|
||||
isJamHost: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onKick: () -> Unit,
|
||||
onBan: () -> Unit,
|
||||
onRemoveSuggestions: () -> Unit,
|
||||
) {
|
||||
AdaptiveDialogBottomSheet(
|
||||
onDismiss = onDismiss,
|
||||
title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
) {
|
||||
ParticipantAvatar(participant, size = 40)
|
||||
Text(
|
||||
text = participant.displayName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (participant.isHost) {
|
||||
Text(
|
||||
text = "Host",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isJamHost && !participant.isHost) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
ListRowTile(
|
||||
onClick = {
|
||||
onKick()
|
||||
onDismiss()
|
||||
},
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = { Text("Kick") },
|
||||
subtitle = { Text("Remove them from the session") },
|
||||
)
|
||||
ListRowTile(
|
||||
onClick = {
|
||||
onBan()
|
||||
onDismiss()
|
||||
},
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxUserRemove,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = { Text("Ban") },
|
||||
subtitle = { Text("Kick and prevent them from rejoining") },
|
||||
)
|
||||
ListRowTile(
|
||||
onClick = {
|
||||
onRemoveSuggestions()
|
||||
onDismiss()
|
||||
},
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicSquareRemove,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = { Text("Remove suggestions") },
|
||||
subtitle = { Text("Remove every track they added to the queue") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantAvatar(participant: JamParticipant, size: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueItemRow(
|
||||
item: QueueItemUi,
|
||||
@ -167,11 +300,15 @@ private fun QueueItemRow(
|
||||
onRemoveClick: () -> Unit,
|
||||
onDragStarted: () -> Unit,
|
||||
onDragStopped: () -> Unit,
|
||||
showOptions: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
onParticipantClick: (JamParticipant) -> Unit = {},
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
|
||||
ListRowTile(
|
||||
onClick = onPlayClick,
|
||||
enabled = enabled,
|
||||
selected = item.isCurrent,
|
||||
modifier = Modifier,
|
||||
leading = {
|
||||
@ -255,31 +392,51 @@ private fun QueueItemRow(
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Box {
|
||||
GhostIconButton(
|
||||
onClick = { showMenu = true },
|
||||
modifier = Modifier.size(36.dp),
|
||||
item.addedByParticipant?.let { participant ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { onParticipantClick(participant) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "More options",
|
||||
modifier = Modifier.size(18.dp),
|
||||
Text(
|
||||
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove from queue") },
|
||||
onClick = {
|
||||
onRemoveClick()
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
if (showOptions) {
|
||||
Box {
|
||||
GhostIconButton(
|
||||
onClick = { showMenu = true },
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "More options",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remove from queue") },
|
||||
onClick = {
|
||||
onRemoveClick()
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||
import dev.krtirtho.spotube.core.jam.JamRole
|
||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -38,16 +41,23 @@ data class QueueItemUi(
|
||||
val isCurrent: Boolean,
|
||||
val imageUrl: String?,
|
||||
val originalIndex: Int,
|
||||
/** Participant who added this item to the jam queue, if any. */
|
||||
val addedByParticipant: JamParticipant? = null,
|
||||
)
|
||||
|
||||
data class QueueContentUiState(
|
||||
val filterQuery: String = "",
|
||||
val displayItems: List<QueueItemUi> = emptyList(),
|
||||
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(
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val jamRoomService: JamRoomService,
|
||||
) : ViewModel() {
|
||||
private val queueVisibilityFlow = MutableStateFlow(false)
|
||||
private val queueFilterFlow = MutableStateFlow("")
|
||||
@ -60,7 +70,8 @@ class PlayerQueueContentViewModel(
|
||||
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
) { queue, currentEntry ->
|
||||
jamRoomService.participants,
|
||||
) { queue, currentEntry, participants ->
|
||||
val currentIndex = if (currentEntry != null) {
|
||||
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
||||
} else {
|
||||
@ -68,7 +79,7 @@ class PlayerQueueContentViewModel(
|
||||
}
|
||||
queue.mapIndexed { index, entry ->
|
||||
val title: String
|
||||
val subtitle: String
|
||||
var subtitle: String
|
||||
val durationMs: Long
|
||||
val imageUrl: String?
|
||||
|
||||
@ -89,6 +100,11 @@ class PlayerQueueContentViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy }
|
||||
if (addedByParticipant != null) {
|
||||
subtitle = "$subtitle • Added by ${addedByParticipant.displayName}"
|
||||
}
|
||||
|
||||
QueueItemUi(
|
||||
id = "${entry.url}@$index",
|
||||
title = title,
|
||||
@ -97,6 +113,7 @@ class PlayerQueueContentViewModel(
|
||||
isCurrent = index == currentIndex,
|
||||
imageUrl = imageUrl,
|
||||
originalIndex = index,
|
||||
addedByParticipant = addedByParticipant,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
@ -109,7 +126,9 @@ class PlayerQueueContentViewModel(
|
||||
computedItems,
|
||||
reorderBuffer,
|
||||
queueFilterFlow,
|
||||
) { items, buffer, filterQuery ->
|
||||
jamRoomService.role,
|
||||
jamRoomService.participants,
|
||||
) { items, buffer, filterQuery, role, participants ->
|
||||
val normalizedFilter = filterQuery.trim().lowercase()
|
||||
val isFiltered = normalizedFilter.isNotBlank()
|
||||
val filtered = if (isFiltered) {
|
||||
@ -124,6 +143,9 @@ class PlayerQueueContentViewModel(
|
||||
filterQuery = filterQuery,
|
||||
displayItems = buffer ?: filtered,
|
||||
isFiltered = isFiltered,
|
||||
isReadOnly = role == JamRole.Guest,
|
||||
isJamHost = role == JamRole.Host,
|
||||
participants = participants,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@ -144,14 +166,14 @@ class PlayerQueueContentViewModel(
|
||||
}
|
||||
|
||||
fun playQueueItem(index: Int) {
|
||||
if (index < 0) return
|
||||
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.jumpTo(index)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeQueueItem(index: Int) {
|
||||
if (index < 0) return
|
||||
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
||||
viewModelScope.launch {
|
||||
val currentQueue = audioPlayerQueue.queueFlow.value
|
||||
if (index < currentQueue.size) {
|
||||
@ -162,24 +184,49 @@ class PlayerQueueContentViewModel(
|
||||
|
||||
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
||||
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.move(fromIndex, toIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Jam participant moderation (host only) ----------
|
||||
|
||||
fun kickParticipant(participantId: String) {
|
||||
if (!queueContentUiState.value.isJamHost) return
|
||||
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
||||
}
|
||||
|
||||
fun banParticipant(participantId: String) {
|
||||
if (!queueContentUiState.value.isJamHost) return
|
||||
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
||||
}
|
||||
|
||||
/** Removes every queue item that the given participant suggested. */
|
||||
fun removeParticipantTracks(participantId: String) {
|
||||
if (!queueContentUiState.value.isJamHost) return
|
||||
viewModelScope.launch {
|
||||
val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId }
|
||||
entries.forEach { audioPlayerQueue.removeFromQueue(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onDragStart() {
|
||||
if (reorderBuffer.value != null) return
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
val currentItems = queueContentUiState.value.displayItems
|
||||
reorderBuffer.value = currentItems.toList()
|
||||
}
|
||||
|
||||
fun onMove(from: Int, to: Int) {
|
||||
if (queueContentUiState.value.isReadOnly) return
|
||||
val buffer = reorderBuffer.value ?: return
|
||||
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
|
||||
val item = buffer[from]
|
||||
|
||||
@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
|
||||
private val SlidingSheetBreakpoint = 840.dp
|
||||
|
||||
@ -68,7 +69,11 @@ private fun SlidingQueueSheet(
|
||||
visible = isVisible,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 12.dp, end = 12.dp, bottom = 12.dp),
|
||||
.padding(
|
||||
top = 12.dp,
|
||||
end = 12.dp,
|
||||
bottom = 12.dp + LocalAppShellBottomInset.current,
|
||||
),
|
||||
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
|
||||
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
|
||||
) {
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
mod metadata;
|
||||
mod discord_rpc;
|
||||
mod webrtc_p2p;
|
||||
|
||||
pub use metadata::*;
|
||||
pub use discord_rpc::*;
|
||||
pub use webrtc_p2p::*;
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@ -1,340 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -44,6 +44,8 @@ kotlinx-io = "0.9.1"
|
||||
material3 = "1.10.0-alpha05"
|
||||
kotlinx-serialization-json = "1.11.0"
|
||||
materialKolor = "4.1.1"
|
||||
mqttClient = "2.1.1"
|
||||
mqttBuffer = "6.30.8"
|
||||
murmurhash = "0.4.2"
|
||||
newpipeextractor = "v0.26.2"
|
||||
newpipeExtractorKmp = "1.3.0"
|
||||
@ -122,6 +124,10 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
||||
mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" }
|
||||
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
|
||||
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
|
||||
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
|
||||
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
||||
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user