mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
Compare commits
No commits in common. "6fabc876a547c24bc1f73ffadfbaf09bc150e98b" and "571aea8d38077e1b9320b23c1f01b7d32cdfc21c" have entirely different histories.
6fabc876a5
...
571aea8d38
821
.opencode/plans/jovial-hopping-hare.md
Normal file
821
.opencode/plans/jovial-hopping-hare.md
Normal file
@ -0,0 +1,821 @@
|
|||||||
|
# WebRTC Support for Group Jam & Remote Control
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Add two peer-to-peer features to Spotube:
|
||||||
|
1. **Listen Together (Group Jam)**: Multi-user synced queue over WebRTC data channels (star topology, manual SDP exchange)
|
||||||
|
2. **Remote Control**: LAN-only device control via WebSocket on the existing `LocalServer` (extended with control routes). No WebRTC needed for this feature.
|
||||||
|
|
||||||
|
Both features share UI patterns (adaptive dialogs for play interception) but use different transport layers based on their requirements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites (One-Time Setup)
|
||||||
|
|
||||||
|
Before starting implementation:
|
||||||
|
|
||||||
|
1. **Initialize webrtc-rs submodule**:
|
||||||
|
```bash
|
||||||
|
cd build/webrtc-rs && git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
The `rtc` crate (Sans-I/O core) is a git submodule and must be initialized before building.
|
||||||
|
|
||||||
|
2. **Verify dns-sd-kt availability**:
|
||||||
|
- Published to Maven Central: `com.appstractive:dns-sd-kt:1.1.0`
|
||||||
|
- No setup needed; just add to `libs.versions.toml`
|
||||||
|
|
||||||
|
3. **Verify Rust toolchain**:
|
||||||
|
- Existing uniffi setup already works (discord-rpc, metadata modules)
|
||||||
|
- Ensure `cargo` is available and can build for all targets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Decisions (Confirmed)
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| WebRTC implementation | `webrtc-rs` via uniffi | Single codebase, identical behavior across platforms |
|
||||||
|
| Jam topology | Star (host ↔ peers) | Simpler, scales better, matches host-authority model |
|
||||||
|
| Remote Control transport | TCP/WebSocket only | LAN-only, so WebRTC is overkill; direct connection is simpler |
|
||||||
|
| Jam signaling | Manual SDP exchange | No server infrastructure needed; users copy-paste or scan QR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: Rust Uniffi WebRTC Module
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Add `webrtc-rs` to the existing Rust crate and expose a uniffi API for WebRTC peer connections and data channels.
|
||||||
|
|
||||||
|
### Library Details (from `build/webrtc-rs`)
|
||||||
|
- **Crate**: `webrtc` v0.21.0-beta.1 (pure Rust, no external C/C++ libs)
|
||||||
|
- **Architecture**: Sans-I/O core (`rtc` crate) + async API layer
|
||||||
|
- **Async runtime**: tokio (default) or smol
|
||||||
|
- **Crypto**: `ring` (default) or `aws-lc-rs`
|
||||||
|
- **Key types**:
|
||||||
|
- `PeerConnection` (trait) — created via `PeerConnectionBuilder::build()`
|
||||||
|
- `DataChannel` (trait) — created via `peer.create_data_channel()`
|
||||||
|
- `RTCSessionDescription` — SDP offer/answer
|
||||||
|
- `RTCIceCandidateInit` — ICE candidates
|
||||||
|
- `PeerConnectionEventHandler` (trait) — callback interface for events
|
||||||
|
- `DataChannelEvent` (enum) — polled via `dc.poll().await`
|
||||||
|
- **Event model**: PeerConnection uses callbacks; DataChannel uses polling
|
||||||
|
- **Submodule**: `rtc` git submodule must be initialized before building
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `composeApp/Cargo.toml` — add `webrtc` dependency
|
||||||
|
- `composeApp/src/commonMain/rust/lib.rs` — register new module
|
||||||
|
- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. **Initialize webrtc-rs submodule** (one-time setup):
|
||||||
|
```bash
|
||||||
|
cd build/webrtc-rs && git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add webrtc-rs dependency** to `composeApp/Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
webrtc = { path = "../build/webrtc-rs", features = ["runtime-tokio", "crypto-ring"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Using path dependency to the local clone. For production, switch to crates.io version once stable.
|
||||||
|
|
||||||
|
3. **Define uniffi API** in `webrtc_p2p.rs`:
|
||||||
|
|
||||||
|
**Core objects**:
|
||||||
|
```rust
|
||||||
|
#[uniffi::export]
|
||||||
|
pub struct PeerConnectionWrapper {
|
||||||
|
pc: Arc<dyn PeerConnection>,
|
||||||
|
runtime: Arc<dyn Runtime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl PeerConnectionWrapper {
|
||||||
|
pub async fn create_offer(&self) -> Result<String, WebrtcError> {
|
||||||
|
let offer = self.pc.create_offer(None).await?;
|
||||||
|
Ok(offer.sdp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_remote_answer(&self, answer: String) -> Result<(), WebrtcError> {
|
||||||
|
let desc = RTCSessionDescription::answer(answer)?;
|
||||||
|
self.pc.set_remote_description(desc).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_answer(&self) -> Result<String, WebrtcError> {
|
||||||
|
let answer = self.pc.create_answer(None).await?;
|
||||||
|
Ok(answer.sdp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_remote_offer(&self, offer: String) -> Result<(), WebrtcError> {
|
||||||
|
let desc = RTCSessionDescription::offer(offer)?;
|
||||||
|
self.pc.set_remote_description(desc).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_data(&self, channel: String, data: String) -> Result<(), WebrtcError> {
|
||||||
|
// Find or cache data channel by label
|
||||||
|
// ...
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close(&self) -> Result<(), WebrtcError> {
|
||||||
|
self.pc.close().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Callback interface for events**:
|
||||||
|
```rust
|
||||||
|
#[uniffi::export(callback_interface)]
|
||||||
|
pub trait PeerConnectionEventHandler {
|
||||||
|
fn on_ice_candidate(&self, candidate: String);
|
||||||
|
fn on_connection_state_change(&self, state: String);
|
||||||
|
fn on_data_channel(&self, label: String);
|
||||||
|
fn on_data_channel_message(&self, label: String, data: String);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Factory function**:
|
||||||
|
```rust
|
||||||
|
#[uniffi::export]
|
||||||
|
pub async fn create_peer_connection(
|
||||||
|
ice_servers: Vec<String>,
|
||||||
|
handler: Arc<dyn PeerConnectionEventHandler>,
|
||||||
|
) -> Result<PeerConnectionWrapper, WebrtcError> {
|
||||||
|
// Build RTCConfiguration from ice_servers
|
||||||
|
// Create MediaEngine, Registry
|
||||||
|
// Build PeerConnection with handler wrapper
|
||||||
|
// Spawn task to poll data channel events and forward to handler
|
||||||
|
Ok(PeerConnectionWrapper { pc, runtime })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key challenge**: webrtc-rs is fully async, but uniffi callbacks are synchronous. Solution:
|
||||||
|
- Wrap the `PeerConnectionEventHandler` trait in a Rust adapter that spawns async tasks
|
||||||
|
- Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks
|
||||||
|
- For DataChannel polling, spawn a background task that calls `dc.poll().await` in a loop and forwards messages to the Kotlin handler
|
||||||
|
|
||||||
|
4. **Register module** in `lib.rs`:
|
||||||
|
```rust
|
||||||
|
mod webrtc_p2p;
|
||||||
|
pub use webrtc_p2p::*;
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Cross-compilation considerations**:
|
||||||
|
- **Good news**: webrtc-rs is pure Rust (no libwebrtc/BoringSSL C++ deps)
|
||||||
|
- **Crypto**: `ring` compiles from source for all targets (requires C compiler for Android/iOS)
|
||||||
|
- **JVM desktop**: Should work out of the box
|
||||||
|
- **Android**: Requires NDK + `ring` cross-compilation setup (well-supported)
|
||||||
|
- **iOS**: Requires `ring` cross-compilation for aarch64-apple-ios
|
||||||
|
- **Gobley plugin**: Already configured for multi-target Rust builds in `composeApp/build.gradle.kts`
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Initialize submodule: `cd build/webrtc-rs && git submodule update --init --recursive`
|
||||||
|
- Build Rust crate: `cargo build --release` in `composeApp/`
|
||||||
|
- Verify Kotlin bindings are generated in `uniffi.compose_app.*`
|
||||||
|
- Write a simple Kotlin test that creates a peer connection and exchanges SDP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Remote Control (LAN-only, extend existing LocalServer)
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Allow users to control playback on another device on the same LAN. Opt-in via settings. DNS-SD for discovery (via dns-sd-kt). Extend the existing `LocalServer` with WebSocket routes for control commands — no separate server needed.
|
||||||
|
|
||||||
|
### 1.1 Settings & Permissions
|
||||||
|
|
||||||
|
#### Files to Modify
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt` — add fields to `UserSettings`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt` — add toggle UI
|
||||||
|
- `composeApp/src/commonMain/composeResources/values/strings.xml` — add strings
|
||||||
|
- `composeApp/src/androidMain/AndroidManifest.xml` — add permissions
|
||||||
|
- `iosApp/iosApp/Info.plist` — add Bonjour services
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
1. **Add to `UserSettings`**:
|
||||||
|
```kotlin
|
||||||
|
val allowRemoteControl: Boolean = false,
|
||||||
|
val allowedRemoteDevices: List<String> = emptyList(), // device IDs
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add toggle UI** in `PlaybackSection.kt`:
|
||||||
|
- Use `SwitchSettingCard` for "Allow remote control"
|
||||||
|
- Add a "Manage allowed devices" item that navigates to a sub-screen (see `Routes.Blacklist` pattern)
|
||||||
|
|
||||||
|
3. **Add string resources**:
|
||||||
|
```xml
|
||||||
|
<string name="settings_allow_remote_control_title">Allow Remote Control</string>
|
||||||
|
<string name="settings_allow_remote_control_subtitle">Let other devices on your network control playback</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Android permissions** (dns-sd-kt requires these):
|
||||||
|
```xml
|
||||||
|
<!-- Already present -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- Required by dns-sd-kt for mDNS multicast -->
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
|
||||||
|
<!-- Required on Android 16+ (Baklava) -->
|
||||||
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: dns-sd-kt uses `androidx.startup` to auto-initialize `Context` — no manual init needed.
|
||||||
|
|
||||||
|
5. **iOS Info.plist** (add to `iosApp/iosApp/Info.plist`):
|
||||||
|
```xml
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Spotube needs access to your local network to discover and control other devices.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_spotube-ctrl._tcp</string>
|
||||||
|
</array>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: dns-sd-kt's Apple backend uses `NWBrowser` (Network.framework) + custom Swift bridge. The `NSBonjourServices` key is required for Bonjour discovery to work.
|
||||||
|
|
||||||
|
### 1.2 DNS-SD Discovery
|
||||||
|
|
||||||
|
#### Library Details (from `build/dns-sd-kt`)
|
||||||
|
- **Maven Central**: `com.appstractive:dns-sd-kt:1.1.0`
|
||||||
|
- **KMP library**: supports Android, JVM, iOS (arm64 + simulatorArm64), macOS, tvOS
|
||||||
|
- **Fully coroutine/Flow-based** — no callback-style API
|
||||||
|
- **Platform backends**:
|
||||||
|
- Android: `NsdManager` (pure Kotlin)
|
||||||
|
- JVM: `JmDNS 3.6.3` (pure Java)
|
||||||
|
- Apple: `NWBrowser` + `NSNetService` + custom Swift bridge via `spm4kmp`
|
||||||
|
- **Two-phase resolution**: `DiscoveryEvent.Discovered` → call `resolve()` → `DiscoveryEvent.Resolved` with addresses
|
||||||
|
- **Auto-init on Android**: uses `androidx.startup` to grab `Context`
|
||||||
|
|
||||||
|
#### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — wraps dns-sd-kt APIs
|
||||||
|
|
||||||
|
#### Files to Modify
|
||||||
|
- `gradle/libs.versions.toml` — add dns-sd-kt dependency
|
||||||
|
- `composeApp/build.gradle.kts` — add to commonMain dependencies
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
|
||||||
|
1. **Add dns-sd-kt dependency** to `gradle/libs.versions.toml`:
|
||||||
|
```toml
|
||||||
|
[versions]
|
||||||
|
dns-sd-kt = "1.1.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dns-sd-kt" }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add to `composeApp/build.gradle.kts`** in `commonMain.dependencies`:
|
||||||
|
```kotlin
|
||||||
|
implementation(libs.dns.sd.kt)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create `DeviceDiscoveryService`** in `commonMain` (no expect/actual needed — dns-sd-kt handles platform differences):
|
||||||
|
```kotlin
|
||||||
|
class DeviceDiscoveryService {
|
||||||
|
private val serviceType = "_spotube-ctrl._tcp"
|
||||||
|
|
||||||
|
fun discoverDevices(): Flow<DiscoveryEvent> = discoverServices(serviceType)
|
||||||
|
|
||||||
|
suspend fun registerDevice(deviceId: String, deviceName: String, port: Int): NetService {
|
||||||
|
val service = createNetService(
|
||||||
|
type = serviceType,
|
||||||
|
name = deviceName,
|
||||||
|
port = port,
|
||||||
|
txt = mapOf("deviceId" to deviceId),
|
||||||
|
)
|
||||||
|
service.register()
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Usage in ViewModel**:
|
||||||
|
```kotlin
|
||||||
|
// Discover devices
|
||||||
|
discoveryService.discoverDevices()
|
||||||
|
.onEach { event ->
|
||||||
|
when (event) {
|
||||||
|
is DiscoveryEvent.Discovered -> {
|
||||||
|
event.resolve() // trigger address resolution
|
||||||
|
// Add to discovered devices list (addresses may be empty)
|
||||||
|
}
|
||||||
|
is DiscoveryEvent.Resolved -> {
|
||||||
|
// Update with resolved addresses/host
|
||||||
|
}
|
||||||
|
is DiscoveryEvent.Removed -> {
|
||||||
|
// Remove from list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Register in Koin** in `Modules.kt`:
|
||||||
|
```kotlin
|
||||||
|
single { DeviceDiscoveryService() }
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **No expect/actual needed** — dns-sd-kt is a KMP library that handles platform differences internally. The Apple targets use Swift interop via `spm4kmp`, which is transparent to consumers.
|
||||||
|
|
||||||
|
### 1.3 Control Server (Extend LocalServer)
|
||||||
|
|
||||||
|
#### Decision: Reuse Existing LocalServer
|
||||||
|
The app already has a Ktor CIO-based `LocalServer` running on `127.0.0.1:<playbackProxyServerPort>` for the playback proxy. We'll extend it with WebSocket routes for control commands. When remote control is enabled, the server binds to `0.0.0.0` (LAN-accessible); otherwise it stays on `127.0.0.1` (local-only).
|
||||||
|
|
||||||
|
#### Files to Modify
|
||||||
|
- `gradle/libs.versions.toml` — add `ktor-server-websockets`, `ktor-client-websockets`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — add WebSocket routes, conditional bind to `0.0.0.0`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt` — update LocalServer registration
|
||||||
|
|
||||||
|
#### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — handles control messages
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — message definitions
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — wraps AudioPlayerInterface for remote control
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
1. **Add WebSocket dependencies** to `libs.versions.toml`:
|
||||||
|
```toml
|
||||||
|
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
||||||
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Extend `LocalServer.kt`**:
|
||||||
|
- Add `RemoteControlHandler` constructor parameter
|
||||||
|
- In `configureRoutes()`, install `WebSockets` plugin and add `/control` WebSocket route
|
||||||
|
- In `restartServer()`, check `settings.allowRemoteControl`:
|
||||||
|
- If enabled: bind to `0.0.0.0` (LAN-accessible)
|
||||||
|
- If disabled: bind to `127.0.0.1` (local-only, current behavior)
|
||||||
|
- Add a watcher that restarts the server when `allowRemoteControl` setting changes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private suspend fun restartServer(port: Int) {
|
||||||
|
val allowRemoteControl = settingsViewModel.settingsState.value?.allowRemoteControl ?: false
|
||||||
|
val host = if (allowRemoteControl) "0.0.0.0" else "127.0.0.1"
|
||||||
|
|
||||||
|
serverState.value = embeddedServer(
|
||||||
|
factory = CIO,
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
module = { configureRoutes() }
|
||||||
|
).also { engine ->
|
||||||
|
engine.start(wait = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Application.configureRoutes() {
|
||||||
|
install(WebSockets)
|
||||||
|
routing {
|
||||||
|
get("/health") { call.respondText("ok") }
|
||||||
|
// ... existing routes ...
|
||||||
|
|
||||||
|
webSocket("/control") {
|
||||||
|
remoteControlHandler.handleConnection(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Define protocol** in `RemoteControlProtocol.kt`:
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
sealed class RemoteControlMessage {
|
||||||
|
@Serializable data class Play(val trackId: String) : RemoteControlMessage()
|
||||||
|
@Serializable data class Pause(val unit: Unit = Unit) : RemoteControlMessage()
|
||||||
|
@Serializable data class Seek(val positionMs: Long) : RemoteControlMessage()
|
||||||
|
@Serializable data class SetVolume(val volume: Float) : RemoteControlMessage()
|
||||||
|
@Serializable data class AddToQueue(val trackId: String) : RemoteControlMessage()
|
||||||
|
// ... etc
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class RemoteStateUpdate {
|
||||||
|
@Serializable data class PlayerState(val state: PlayerUiState) : RemoteStateUpdate()
|
||||||
|
@Serializable data class QueueUpdate(val queue: List<QueueEntry>) : RemoteStateUpdate()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Create `RemoteControlHandler`**:
|
||||||
|
- Handles incoming WebSocket connections on the controlled device
|
||||||
|
- Checks `settings.allowRemoteControl` before accepting (rejects immediately if disabled)
|
||||||
|
- Shows connection request dialog (allow/allow-always/deny) via a callback injected from the UI layer
|
||||||
|
- On acceptance: forwards commands to `AudioPlayerInterface` and `AudioPlayerQueue`
|
||||||
|
- Broadcasts state updates (player state, queue) to the connected controller
|
||||||
|
|
||||||
|
5. **Create `RemotePlayerProxy`**:
|
||||||
|
- Wraps `AudioPlayerInterface` and `AudioPlayerQueue` on the controlling device
|
||||||
|
- Sends commands over WebSocket to the controlled device
|
||||||
|
- Receives state updates and exposes them as StateFlows
|
||||||
|
- **Implementation note**: Full interface implementation is complex. Alternative: create a separate `RemotePlayerState` StateFlow that mirrors remote state, and the UI uses it instead of `rememberPlayerUiState()`.
|
||||||
|
|
||||||
|
6. **Register in Koin** in `Modules.kt`:
|
||||||
|
```kotlin
|
||||||
|
single { RemoteControlHandler(get(), get(), get()) }
|
||||||
|
// LocalServer constructor updated; no other DI changes needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Simplification
|
||||||
|
By reusing `LocalServer`, we eliminate the need for:
|
||||||
|
- A separate WebSocket server
|
||||||
|
- Separate port management
|
||||||
|
- Duplicate Ktor configuration
|
||||||
|
|
||||||
|
The server becomes a multi-purpose local server: playback proxy (always) + control endpoint (when enabled).
|
||||||
|
|
||||||
|
### 1.4 UI: Devices Screen
|
||||||
|
|
||||||
|
#### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesViewModel.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt`
|
||||||
|
|
||||||
|
#### Files to Modify
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Devices`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Devices" button at bottom
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Devices" icon to TopAppBar actions
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
1. **Add route** to `NavigationModule.kt`:
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
data object Devices : Routes
|
||||||
|
|
||||||
|
navigation<Routes.Devices> {
|
||||||
|
DevicesScreen(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add sidebar button** in `AppSidebar.kt` (after line 177):
|
||||||
|
```kotlin
|
||||||
|
SidebarItem(
|
||||||
|
title = "Devices",
|
||||||
|
icon = Icons.Default.Devices,
|
||||||
|
onClick = { navigator.navigate(Routes.Devices) }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add TopAppBar action** in `HomeScreen.kt`:
|
||||||
|
```kotlin
|
||||||
|
ApplicationMainBar(
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { navigator.navigate(Routes.Devices) }) {
|
||||||
|
Icon(Icons.Default.Devices, "Devices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **DevicesScreen**:
|
||||||
|
- Shows list of discovered devices (from `DeviceDiscoveryService`)
|
||||||
|
- Each device shows name, IP, and connection status
|
||||||
|
- Clicking a device initiates connection (WebSocket)
|
||||||
|
- After connection, navigates to `RemotePlayerScreen`
|
||||||
|
|
||||||
|
5. **RemotePlayerScreen**:
|
||||||
|
- Similar to `AppExpandedPlayer` but uses `RemotePlayerProxy` instead of local `AudioPlayerInterface`
|
||||||
|
- All controls (play/pause/seek/volume/queue) forward to remote device
|
||||||
|
|
||||||
|
### 1.5 Connection Request Flow
|
||||||
|
|
||||||
|
#### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/remote/ConnectionRequestDialog.kt`
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
1. When a new device tries to connect, `RemoteControlService` shows a dialog:
|
||||||
|
```kotlin
|
||||||
|
AdaptiveDialogBottomSheet(
|
||||||
|
title = { Text("Remote Control Request") },
|
||||||
|
content = {
|
||||||
|
Text("Device '${deviceName}' wants to control playback")
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(onClick = { deny() }) { Text("Deny") }
|
||||||
|
Button(onClick = { allow(always = false) }) { Text("Allow") }
|
||||||
|
Button(onClick = { allow(always = true) }) { Text("Allow Always") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. If "Allow Always", add device ID to `settings.allowedRemoteDevices`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Group Jam (WebRTC, Manual SDP)
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Multi-user synced queue over WebRTC data channels. Host creates session, shares SDP offer (via copy-paste or QR), guests join. Star topology (host ↔ peers).
|
||||||
|
|
||||||
|
### 2.1 Jam Session Service
|
||||||
|
|
||||||
|
#### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — manages WebRTC connections
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt` — message definitions
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt` — syncs queue state
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
1. **Define protocol** in `JamProtocol.kt`:
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
sealed class JamMessage {
|
||||||
|
// Host → Peers
|
||||||
|
@Serializable data class QueueState(val queue: List<QueueEntry>, val currentIndex: Int) : JamMessage()
|
||||||
|
@Serializable data class PlaybackCommand(val command: PlaybackCommand) : JamMessage()
|
||||||
|
@Serializable data class ParticipantList(val participants: List<Participant>) : JamMessage()
|
||||||
|
|
||||||
|
// Peers → Host
|
||||||
|
@Serializable data class SuggestTrack(val trackId: String) : JamMessage()
|
||||||
|
@Serializable data class SuggestPlaylist(val playlistId: String) : JamMessage()
|
||||||
|
@Serializable data class ChatMessage(val text: String) : JamMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Participant(val id: String, val name: String, val isHost: Boolean)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create `JamSessionService`** using the uniffi WebRTC API from Phase 0:
|
||||||
|
```kotlin
|
||||||
|
class JamSessionService(
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
) {
|
||||||
|
private var peerConnection: PeerConnectionWrapper? = null
|
||||||
|
private val _participants = MutableStateFlow<List<Participant>>(emptyList())
|
||||||
|
val participants: StateFlow<List<Participant>> = _participants
|
||||||
|
|
||||||
|
suspend fun createSession(): String {
|
||||||
|
// Create peer connection with ICE servers
|
||||||
|
peerConnection = create_peer_connection(
|
||||||
|
iceServers = listOf("stun:stun.l.google.com:19302"),
|
||||||
|
handler = object : PeerConnectionEventHandler {
|
||||||
|
override fun on_ice_candidate(candidate: String) {
|
||||||
|
// ICE candidates are bundled into SDP (non-trickle mode)
|
||||||
|
}
|
||||||
|
override fun on_data_channel_message(label: String, data: String) {
|
||||||
|
// Parse JamMessage and handle
|
||||||
|
}
|
||||||
|
// ... other callbacks
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create data channel for jam messages
|
||||||
|
// Create SDP offer and return it for sharing
|
||||||
|
val offer = peerConnection!!.create_offer()
|
||||||
|
return offer
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun joinSession(offer: String): String {
|
||||||
|
// Create peer connection
|
||||||
|
peerConnection = create_peer_connection(...)
|
||||||
|
|
||||||
|
// Set remote offer and create answer
|
||||||
|
peerConnection!!.set_remote_offer(offer)
|
||||||
|
val answer = peerConnection!!.create_answer()
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(message: JamMessage) {
|
||||||
|
val json = Json.encodeToString(message)
|
||||||
|
peerConnection?.send_data("jam", json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points**:
|
||||||
|
- Uses `uniffi.compose_app.create_peer_connection()` from Phase 0
|
||||||
|
- Host creates multiple peer connections (one per guest) — star topology
|
||||||
|
- Data channel labeled "jam" for all jam messages
|
||||||
|
- SDP exchange is manual (copy-paste or QR code)
|
||||||
|
|
||||||
|
3. **Create `QueueSyncManager`**:
|
||||||
|
- On host: wraps `AudioPlayerQueue`, intercepts all queue mutations, broadcasts them via `JamSessionService.sendMessage()`
|
||||||
|
- On guest: receives queue mutations, applies them to local queue
|
||||||
|
- Handles conflict resolution (host authority: host's commands always win)
|
||||||
|
|
||||||
|
4. **Register in Koin**:
|
||||||
|
```kotlin
|
||||||
|
single { JamSessionService(get(), get()) }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Jam Session UI
|
||||||
|
|
||||||
|
#### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — create/join session
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — active session view
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/SdpExchangeDialog.kt` — copy-paste SDP
|
||||||
|
|
||||||
|
#### Files to Modify
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt` — add `Routes.Jam`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt` — add "Group Jam" button
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt` — add "Group Jam" icon to TopAppBar
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
1. **Add route**:
|
||||||
|
```kotlin
|
||||||
|
@Serializable
|
||||||
|
data object Jam : Routes
|
||||||
|
|
||||||
|
navigation<Routes.Jam> {
|
||||||
|
JamScreen(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add sidebar button** (above "Devices"):
|
||||||
|
```kotlin
|
||||||
|
SidebarItem(
|
||||||
|
title = "Group Jam",
|
||||||
|
icon = Icons.Default.Group,
|
||||||
|
onClick = { navigator.navigate(Routes.Jam) }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **JamScreen**:
|
||||||
|
- Two tabs: "Create Session" and "Join Session"
|
||||||
|
- **Create Session**:
|
||||||
|
- Generates SDP offer via `JamSessionService`
|
||||||
|
- Shows SDP as copyable text and QR code
|
||||||
|
- Waits for guests to connect
|
||||||
|
- **Join Session**:
|
||||||
|
- Text field to paste SDP offer
|
||||||
|
- QR code scanner (optional)
|
||||||
|
- Generates SDP answer and shows it for host to paste back
|
||||||
|
|
||||||
|
4. **JamSessionScreen**:
|
||||||
|
- Shows list of participants (from `JamSessionService`)
|
||||||
|
- Shows current track and queue
|
||||||
|
- Playback controls (only work for host; guests send suggestions)
|
||||||
|
- Suggest track/playlist buttons
|
||||||
|
- Chat/messages area (optional)
|
||||||
|
|
||||||
|
5. **SdpExchangeDialog**:
|
||||||
|
- Shows SDP string in a `TextField` (read-only for offer, editable for answer)
|
||||||
|
- "Copy" button
|
||||||
|
- "Paste" button (for answer)
|
||||||
|
- QR code display (using a QR generation library)
|
||||||
|
|
||||||
|
### 2.3 Play Interception
|
||||||
|
|
||||||
|
#### Files to Modify
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/playlist/PlaylistViewModel.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/album/AlbumViewModel.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/search/SearchScreen.kt`
|
||||||
|
|
||||||
|
#### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/PlayDestinationPicker.kt`
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
1. **Create `PlayDestinationPicker`**:
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun PlayDestinationPicker(
|
||||||
|
onPlayLocally: () -> Unit,
|
||||||
|
onSuggestToJam: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
AdaptiveDialogBottomSheet(
|
||||||
|
title = { Text("Play Where?") },
|
||||||
|
content = {
|
||||||
|
Column {
|
||||||
|
Button(onClick = onPlayLocally) { Text("Play on This Device") }
|
||||||
|
Button(onClick = onSuggestToJam) { Text("Suggest to Jam Session") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Modify ViewModels**:
|
||||||
|
- In `PlaylistViewModel.playPlaylist()`, check if `JamSessionService.isActive`
|
||||||
|
- If active, show `PlayDestinationPicker` instead of calling `playbackHelper.playPlaylist()` directly
|
||||||
|
- If user chooses "Suggest to Jam", call `JamSessionService.suggestPlaylist(playlistId)`
|
||||||
|
|
||||||
|
3. **Apply same pattern** to `AlbumViewModel`, `ArtistScreen`, `SearchScreen`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Deep-Link Handling (Optional Enhancement)
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Allow users to open `spotube://jam/<session-id>` links to join a Jam session. With manual SDP exchange, the deep link can contain a session ID + a short-lived token, and the actual SDP exchange happens in the app.
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
- `composeApp/src/androidMain/AndroidManifest.xml` — add intent filter
|
||||||
|
- `iosApp/iosApp/ContentView.swift` — add `onOpenURL` handler
|
||||||
|
- `composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt` — parse command-line args
|
||||||
|
|
||||||
|
### Files to Create
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/DeepLinkService.kt` — expect interface
|
||||||
|
- Platform actuals
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
1. **Android**: Add intent filter to `MainActivity`:
|
||||||
|
```xml
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotube" android:host="jam" />
|
||||||
|
</intent-filter>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **iOS**: Add `onOpenURL` in `ContentView.swift`:
|
||||||
|
```swift
|
||||||
|
.onOpenURL { url in
|
||||||
|
// Pass to Compose via a callback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Desktop**: Parse `args` in `main.kt`:
|
||||||
|
```kotlin
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
val deepLink = args.firstOrNull { it.startsWith("spotube://") }
|
||||||
|
// Pass to Compose
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **DeepLinkService**: Parse URL, navigate to `Routes.Jam(sessionId)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Phase 0**: Rust uniffi WebRTC module (foundation for Jam)
|
||||||
|
2. **Phase 1**: Remote Control (simpler, LAN-only, no WebRTC needed)
|
||||||
|
- 1.1 Settings & Permissions
|
||||||
|
- 1.2 DNS-SD Discovery (using dns-sd-kt)
|
||||||
|
- 1.3 Extend LocalServer with WebSocket control routes + conditional bind
|
||||||
|
- 1.4 UI: Devices Screen
|
||||||
|
- 1.5 Connection Request Flow
|
||||||
|
3. **Phase 2**: Group Jam (WebRTC, manual SDP)
|
||||||
|
- 2.1 Jam Session Service
|
||||||
|
- 2.2 Jam Session UI
|
||||||
|
- 2.3 Play Interception
|
||||||
|
4. **Phase 3**: Deep-Link Handling (optional, can be deferred)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files Summary
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
- `composeApp/Cargo.toml` — add `webrtc` dependency
|
||||||
|
- `composeApp/src/commonMain/rust/lib.rs` — register `webrtc_p2p` module
|
||||||
|
- `composeApp/src/commonMain/rust/webrtc_p2p.rs` — **NEW**: uniffi API
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/PlaybackSection.kt`
|
||||||
|
|
||||||
|
### Remote Control
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/discovery/DeviceDiscoveryService.kt` — **NEW**: wraps dns-sd-kt
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlHandler.kt` — **NEW**: handles WebSocket control connections
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemoteControlProtocol.kt` — **NEW**: message definitions
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlayerProxy.kt` — **NEW**: remote player state proxy
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/DevicesScreen.kt` — **NEW**
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/RemotePlayerScreen.kt` — **NEW**
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/server/LocalServer.kt` — **MODIFIED**: add WebSocket routes, conditional bind
|
||||||
|
|
||||||
|
### Group Jam
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt` — **NEW**
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt` — **NEW**
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamSessionScreen.kt` — **NEW**
|
||||||
|
|
||||||
|
### Navigation & UI
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/navigation/NavigationModule.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppSidebar.kt`
|
||||||
|
- `composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt`
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
- `composeApp/src/androidMain/AndroidManifest.xml`
|
||||||
|
- `iosApp/iosApp/Info.plist`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| `webrtc-rs` `rtc` submodule not initialized | Document in setup: `cd build/webrtc-rs && git submodule update --init --recursive` |
|
||||||
|
| `ring` crypto cross-compilation for Android/iOS | Well-supported; may need NDK env vars for Android. Test early. |
|
||||||
|
| webrtc-rs is pre-release (0.21.0-beta.1) | API is stabilizing; pin version. Monitor for 1.0 release. |
|
||||||
|
| dns-sd-kt Apple targets use Swift interop (`spm4kmp`) | Published Maven Central artifacts include cinterop bindings. Should work transparently. |
|
||||||
|
| Manual SDP exchange is poor UX | Add QR code scanning as an alternative (Phase 2.2) |
|
||||||
|
| Queue sync conflicts in Jam | Host authority model: host's commands always win |
|
||||||
|
| WebRTC data channel reliability | Use ordered, reliable data channels (default in webrtc-rs) |
|
||||||
|
| Uniffi async/sync bridge for webrtc-rs | Use `tokio::sync::mpsc` channels to bridge async events → sync callbacks |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. **Unit tests**: Test protocol serialization, queue sync logic
|
||||||
|
2. **Integration tests**: Test WebSocket server/client, DNS-SD discovery
|
||||||
|
3. **Manual tests**:
|
||||||
|
- Remote Control: Two devices on same LAN, control playback from one to another
|
||||||
|
- Group Jam: Three devices (1 host + 2 guests), sync queue and playback
|
||||||
|
4. **Cross-platform tests**: Verify on Android, iOS, JVM desktop (Linux/Windows/macOS)
|
||||||
2157
composeApp/Cargo.lock
generated
2157
composeApp/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,10 @@ discord-rich-presence = "1.1.0"
|
|||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
webrtc = "0.20.4"
|
||||||
|
rtc = "0.20.4"
|
||||||
|
async-trait = "0.1"
|
||||||
|
bytes = "1"
|
||||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@ -166,12 +166,6 @@ kotlin {
|
|||||||
|
|
||||||
// DLNA
|
// DLNA
|
||||||
implementation(libs.dns.sd.kt)
|
implementation(libs.dns.sd.kt)
|
||||||
|
|
||||||
// mqtt client for jam-session
|
|
||||||
implementation(libs.mqtt.client)
|
|
||||||
implementation(libs.mqtt.x.models)
|
|
||||||
implementation(libs.mqtt.buffer)
|
|
||||||
implementation(libs.mqtt.buffer.codec)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
|
|||||||
@ -55,6 +55,17 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Group Jam invite/answer deep links: spotube://jam/... -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="jam"
|
||||||
|
android:scheme="spotube" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<service
|
<service
|
||||||
android:name=".media.PlaybackService"
|
android:name=".media.PlaybackService"
|
||||||
|
|||||||
@ -173,19 +173,5 @@
|
|||||||
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
|
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
|
||||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||||
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
|
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
|
||||||
<string name="settings_section_jam">Group Jam</string>
|
|
||||||
<string name="settings_jam_broker_title">MQTT Broker</string>
|
|
||||||
<string name="settings_jam_broker_host">Broker host</string>
|
|
||||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
|
||||||
<string name="settings_jam_broker_port">Broker port</string>
|
|
||||||
<string name="settings_jam_broker_tls">Use TLS</string>
|
|
||||||
<string name="settings_jam_broker_username">Username (optional)</string>
|
|
||||||
<string name="settings_jam_broker_password">Password (optional)</string>
|
|
||||||
<string name="settings_jam_broker_client_id">Client ID prefix</string>
|
|
||||||
<string name="settings_jam_broker_test">Test connection</string>
|
|
||||||
<string name="settings_jam_broker_testing">Testing…</string>
|
|
||||||
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
|
|
||||||
<string name="settings_jam_broker_test_fail">Failed: %1$s</string>
|
|
||||||
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
|
||||||
|
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||||
@ -104,6 +106,12 @@ fun App(
|
|||||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
||||||
|
|
||||||
|
val jamDeepLinks: JamDeepLinkService = koinInject()
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) }
|
||||||
|
onDispose { ExternalUriHandler.listener = null }
|
||||||
|
}
|
||||||
|
|
||||||
val navigationState = rememberNavigationState(
|
val navigationState = rememberNavigationState(
|
||||||
startRoute = Routes.Home,
|
startRoute = Routes.Home,
|
||||||
topLevelRoutes = TOP_LEVEL_ROUTES
|
topLevelRoutes = TOP_LEVEL_ROUTES
|
||||||
|
|||||||
@ -25,7 +25,6 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
sealed interface QueueEntry {
|
sealed interface QueueEntry {
|
||||||
val url: String
|
val url: String
|
||||||
val addedBy: String
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("streaming")
|
@SerialName("streaming")
|
||||||
@ -33,7 +32,6 @@ sealed interface QueueEntry {
|
|||||||
val track: MetadataTrack,
|
val track: MetadataTrack,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||||
override val addedBy: String = "",
|
|
||||||
) : QueueEntry
|
) : QueueEntry
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -44,8 +42,7 @@ sealed interface QueueEntry {
|
|||||||
val duration: Long,
|
val duration: Long,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
val coverBytes: ByteArray?,
|
val coverBytes: ByteArray?,
|
||||||
override val url: String,
|
override val url: String
|
||||||
override val addedBy: String = "",
|
|
||||||
) : QueueEntry {
|
) : QueueEntry {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@ -59,7 +56,6 @@ sealed interface QueueEntry {
|
|||||||
if (album != other.album) return false
|
if (album != other.album) return false
|
||||||
if (!coverBytes.contentEquals(other.coverBytes)) return false
|
if (!coverBytes.contentEquals(other.coverBytes)) return false
|
||||||
if (url != other.url) return false
|
if (url != other.url) return false
|
||||||
if (addedBy != other.addedBy) return false
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -71,7 +67,6 @@ sealed interface QueueEntry {
|
|||||||
result = 31 * result + (album?.hashCode() ?: 0)
|
result = 31 * result + (album?.hashCode() ?: 0)
|
||||||
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
|
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
|
||||||
result = 31 * result + url.hashCode()
|
result = 31 * result + url.hashCode()
|
||||||
result = 31 * result + addedBy.hashCode()
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.deeplink
|
||||||
|
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamInviteCodec
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamInviteLink
|
||||||
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI,
|
||||||
|
* and navigates to [Routes.Jam] so the user lands where the link is handled.
|
||||||
|
*/
|
||||||
|
class JamDeepLinkService(
|
||||||
|
private val navigationCommands: NavigationCommands,
|
||||||
|
) {
|
||||||
|
private val _pendingLink = MutableStateFlow<JamInviteLink?>(null)
|
||||||
|
val pendingLink: StateFlow<JamInviteLink?> = _pendingLink.asStateFlow()
|
||||||
|
|
||||||
|
fun handleUri(uri: String) {
|
||||||
|
val link = JamInviteCodec.parse(uri) ?: return
|
||||||
|
_pendingLink.value = link
|
||||||
|
navigationCommands.navigateTo(Routes.Jam)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consumes the currently pending link (if any). */
|
||||||
|
fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null }
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_pendingLink.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,10 +23,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
||||||
import dev.krtirtho.spotube.core.db.Database
|
import dev.krtirtho.spotube.core.db.Database
|
||||||
|
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||||
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
|
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
|
||||||
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomClient
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import dev.krtirtho.spotube.core.navigation.navigationModule
|
import dev.krtirtho.spotube.core.navigation.navigationModule
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||||
@ -185,9 +185,12 @@ val sharedModules = module {
|
|||||||
viewModelOf(::RemoteControlViewModel)
|
viewModelOf(::RemoteControlViewModel)
|
||||||
viewModel {
|
viewModel {
|
||||||
JamViewModel(
|
JamViewModel(
|
||||||
jamRoomService = get(),
|
jamSession = get(),
|
||||||
|
deepLinks = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
settingsProvider = get(),
|
settingsProvider = get(),
|
||||||
|
audioPlayer = get(),
|
||||||
|
audioPlayerQueue = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -236,8 +239,8 @@ val sharedModules = module {
|
|||||||
createdAtStart()
|
createdAtStart()
|
||||||
}
|
}
|
||||||
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
||||||
singleOf(::JamRoomClient)
|
single { JamSessionService(get(), get(), get()) }
|
||||||
single { JamRoomService(get(), get(), get(), get()) }
|
singleOf(::JamDeepLinkService)
|
||||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||||
|
|||||||
@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import io.ktor.http.decodeURLQueryComponent
|
||||||
|
import io.ktor.http.encodeURLParameter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDP payloads exchanged between jam peers are wrapped into `spotube://` deep links
|
||||||
|
* so they can be shared through any messaging medium. The SDP blob is percent-encoded
|
||||||
|
* as a query parameter.
|
||||||
|
*
|
||||||
|
* Host invite : `spotube://jam/invite?name=<host name>&sdp=<offer sdp>`
|
||||||
|
* Guest answer : `spotube://jam/answer?name=<guest name>&sdp=<answer sdp>`
|
||||||
|
*/
|
||||||
|
sealed interface JamInviteLink {
|
||||||
|
val peerName: String
|
||||||
|
val sdp: String
|
||||||
|
|
||||||
|
data class HostInvite(
|
||||||
|
override val peerName: String,
|
||||||
|
override val sdp: String,
|
||||||
|
) : JamInviteLink
|
||||||
|
|
||||||
|
data class GuestAnswer(
|
||||||
|
override val peerName: String,
|
||||||
|
override val sdp: String,
|
||||||
|
) : JamInviteLink
|
||||||
|
}
|
||||||
|
|
||||||
|
object JamInviteCodec {
|
||||||
|
const val SCHEME = "spotube"
|
||||||
|
const val INVITE_PATH = "jam/invite"
|
||||||
|
const val ANSWER_PATH = "jam/answer"
|
||||||
|
|
||||||
|
fun buildHostInvite(hostName: String, offerSdp: String): String =
|
||||||
|
buildLink(INVITE_PATH, hostName, offerSdp)
|
||||||
|
|
||||||
|
fun buildGuestAnswer(guestName: String, answerSdp: String): String =
|
||||||
|
buildLink(ANSWER_PATH, guestName, answerSdp)
|
||||||
|
|
||||||
|
private fun buildLink(path: String, peerName: String, sdp: String): String =
|
||||||
|
"$SCHEME://$path?name=${peerName.encodeURLParameter()}" +
|
||||||
|
"&sdp=${sdp.encodeURLParameter()}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a `spotude://jam/...` link. Returns null for foreign or malformed URIs.
|
||||||
|
* Parsing is done manually — generic URI parsers normalize unknown schemes in
|
||||||
|
* ways that mangle percent-encoded multi-line payloads.
|
||||||
|
*/
|
||||||
|
fun parse(rawUri: String): JamInviteLink? {
|
||||||
|
val uri = rawUri.trim()
|
||||||
|
if (!uri.startsWith("$SCHEME://", ignoreCase = true)) return null
|
||||||
|
|
||||||
|
val withoutScheme = uri.substring(SCHEME.length + 3)
|
||||||
|
val queryStart = withoutScheme.indexOf('?')
|
||||||
|
if (queryStart < 0) return null
|
||||||
|
|
||||||
|
val path = withoutScheme.take(queryStart).trim('/').lowercase()
|
||||||
|
val params = withoutScheme.substring(queryStart + 1)
|
||||||
|
.split('&')
|
||||||
|
.mapNotNull { pair ->
|
||||||
|
val separator = pair.indexOf('=')
|
||||||
|
if (separator <= 0) return@mapNotNull null
|
||||||
|
pair.take(separator) to pair.substring(separator + 1)
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
|
||||||
|
val sdp = params["sdp"]?.decodeURLQueryComponent()?.takeIf { it.isNotBlank() }
|
||||||
|
?: return null
|
||||||
|
val peerName = params["name"]?.decodeURLQueryComponent().orEmpty()
|
||||||
|
|
||||||
|
return when (path) {
|
||||||
|
INVITE_PATH -> JamInviteLink.HostInvite(peerName, sdp)
|
||||||
|
ANSWER_PATH -> JamInviteLink.GuestAnswer(peerName, sdp)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts an SDP payload from user input which may either be a full
|
||||||
|
* `spotube://` deep link or a raw SDP body pasted by hand.
|
||||||
|
*/
|
||||||
|
fun extractSdp(rawInput: String): String? {
|
||||||
|
val input = rawInput.trim()
|
||||||
|
parse(input)?.let { return it.sdp }
|
||||||
|
// Heuristic for raw SDP: first line is the session description header
|
||||||
|
return if (input.startsWith("v=", ignoreCase = false)) input else null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,55 +18,62 @@
|
|||||||
package dev.krtirtho.spotube.core.jam
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
|
||||||
* Jam messages exchanged over MQTT.
|
|
||||||
*
|
|
||||||
* - `state` topic: [QueueState] (retained, host -> everyone)
|
|
||||||
* - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist]
|
|
||||||
* (anyone -> host, except Kick which is host -> guest)
|
|
||||||
* - `presence/{clientId}` topic: [JamPresence] (retained, one per participant)
|
|
||||||
*/
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class JamMessage {
|
sealed class JamMessage {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("hello")
|
||||||
|
data class Hello(
|
||||||
|
val displayName: String,
|
||||||
|
val deviceId: String,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("welcome")
|
||||||
|
data class Welcome(
|
||||||
|
val hostName: String,
|
||||||
|
val participantId: String,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("queueState")
|
@SerialName("queueState")
|
||||||
data class QueueState(
|
data class QueueState(
|
||||||
val items: List<JamMediaItem>,
|
val items: List<JamMediaItem>,
|
||||||
val currentIndex: Int,
|
val currentIndex: Int,
|
||||||
val shuffleEnabled: Boolean = false,
|
val isPlaying: Boolean,
|
||||||
/**
|
val positionMs: Long,
|
||||||
* Whether guests should follow the host's current index. True when the
|
|
||||||
* host manually skipped/jumped or loaded a queue; false when the host
|
|
||||||
* merely auto-advanced because its song ended (guests stay put).
|
|
||||||
*/
|
|
||||||
val follow: Boolean = false,
|
|
||||||
/** Host's live play state — late-joining guests start with it. */
|
|
||||||
val isPlaying: Boolean = false,
|
|
||||||
) : JamMessage()
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playbackCommand")
|
@SerialName("playbackCommand")
|
||||||
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
|
data class PlaybackCommand(
|
||||||
|
val command: PlaybackCmd,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("suggestTrack")
|
@SerialName("suggestTrack")
|
||||||
data class SuggestTrack(
|
data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage()
|
||||||
val mediaItem: JamMediaItem,
|
|
||||||
val addedBy: String = "",
|
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("suggestPlaylist")
|
@SerialName("suggestPlaylist")
|
||||||
data class SuggestPlaylist(
|
data class SuggestPlaylist(val tracks: List<JamMediaItem>) : JamMessage()
|
||||||
val tracks: List<JamMediaItem>,
|
|
||||||
val addedBy: String = "",
|
@Serializable
|
||||||
|
@SerialName("chat")
|
||||||
|
data class Chat(
|
||||||
|
val fromName: String,
|
||||||
|
val text: String,
|
||||||
) : JamMessage()
|
) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("participantList")
|
||||||
|
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("kick")
|
@SerialName("kick")
|
||||||
data class Kick(
|
data class Kick(
|
||||||
@ -79,12 +86,24 @@ sealed class JamMessage {
|
|||||||
data class Leave(val reason: String = "user_left") : JamMessage()
|
data class Leave(val reason: String = "user_left") : JamMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Playback commands. Only queue navigation is global — play/pause, seek,
|
|
||||||
* volume, shuffle and loop are local to each participant.
|
|
||||||
*/
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class PlaybackCmd {
|
sealed class PlaybackCmd {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("play")
|
||||||
|
data object Play : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pause")
|
||||||
|
data object Pause : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("toggle")
|
||||||
|
data object Toggle : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("seek")
|
||||||
|
data class Seek(val positionMs: Long) : PlaybackCmd()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("skipNext")
|
@SerialName("skipNext")
|
||||||
data object SkipNext : PlaybackCmd()
|
data object SkipNext : PlaybackCmd()
|
||||||
@ -93,20 +112,23 @@ sealed class PlaybackCmd {
|
|||||||
@SerialName("skipPrevious")
|
@SerialName("skipPrevious")
|
||||||
data object SkipPrevious : PlaybackCmd()
|
data object SkipPrevious : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setVolume")
|
||||||
|
data class SetVolume(val volume: Float) : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setLoop")
|
||||||
|
data class SetLoop(val loop: String) : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setShuffle")
|
||||||
|
data class SetShuffle(val enabled: Boolean) : PlaybackCmd()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("jumpTo")
|
@SerialName("jumpTo")
|
||||||
data class JumpTo(val index: Int) : PlaybackCmd()
|
data class JumpTo(val index: Int) : PlaybackCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retained per-participant presence entry (with an MQTT Last Will for leave). */
|
|
||||||
@Serializable
|
|
||||||
data class JamPresence(
|
|
||||||
val clientId: String,
|
|
||||||
val displayName: String,
|
|
||||||
val isHost: Boolean,
|
|
||||||
val left: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JamMediaItem(
|
data class JamMediaItem(
|
||||||
val url: String,
|
val url: String,
|
||||||
@ -117,7 +139,6 @@ data class JamMediaItem(
|
|||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val protocol: String,
|
val protocol: String,
|
||||||
val addedBy: String = "",
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||||
@ -132,7 +153,6 @@ data class JamMediaItem(
|
|||||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
protocol = entry.protocol.name,
|
protocol = entry.protocol.name,
|
||||||
addedBy = entry.addedBy,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||||
@ -144,7 +164,6 @@ data class JamMediaItem(
|
|||||||
durationMs = entry.duration,
|
durationMs = entry.duration,
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
protocol = "PROGRESSIVE",
|
protocol = "PROGRESSIVE",
|
||||||
addedBy = entry.addedBy,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,4 +214,14 @@ data class JamParticipant(
|
|||||||
enum class JamRole {
|
enum class JamRole {
|
||||||
Host,
|
Host,
|
||||||
Guest,
|
Guest,
|
||||||
|
}
|
||||||
|
|
||||||
|
object JamLoopMapping {
|
||||||
|
fun toString(state: LoopState): String = state.name.lowercase()
|
||||||
|
fun fromString(value: String): LoopState = when (value.lowercase()) {
|
||||||
|
"none" -> LoopState.NONE
|
||||||
|
"one" -> LoopState.ONE
|
||||||
|
"all" -> LoopState.ALL
|
||||||
|
else -> LoopState.NONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,323 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.jam
|
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import com.ditchoom.buffer.Charset
|
|
||||||
import com.ditchoom.buffer.codec.asReadBuffer
|
|
||||||
import com.ditchoom.buffer.toReadBuffer
|
|
||||||
import com.ditchoom.mqtt.client.ConnectionState
|
|
||||||
import com.ditchoom.mqtt.client.MqttClient
|
|
||||||
import com.ditchoom.mqtt.connection.MqttConnectionOptions
|
|
||||||
import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec
|
|
||||||
import com.ditchoom.mqtt.controlpacket.QualityOfService
|
|
||||||
import com.ditchoom.mqtt.controlpacket.TopicName
|
|
||||||
import com.ditchoom.mqtt.controlpacket.WillConfig
|
|
||||||
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
|
||||||
import dev.krtirtho.spotube.modules.settings.JamBroker
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
|
||||||
*
|
|
||||||
* Topics (room code `C`):
|
|
||||||
* - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone)
|
|
||||||
* - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest)
|
|
||||||
* - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will
|
|
||||||
* (`left = true`) so a dropped client disappears from the room automatically.
|
|
||||||
*
|
|
||||||
* The library keeps the connection alive (auto-reconnect + backoff); this class only
|
|
||||||
* re-establishes the room subscription and re-publishes presence after a reconnect.
|
|
||||||
*/
|
|
||||||
class JamRoomClient : KoinComponent {
|
|
||||||
private val log = Logger.withTag("JamRoomClient")
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
classDiscriminator = "type"
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private var client: MqttClient? = null
|
|
||||||
|
|
||||||
private var roomCode: String? = null
|
|
||||||
private var localPresence: JamPresence? = null
|
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
|
||||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
|
||||||
|
|
||||||
private val _connectionError = MutableStateFlow<String?>(null)
|
|
||||||
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
|
||||||
|
|
||||||
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8)
|
|
||||||
val state: SharedFlow<JamMessage.QueueState> = _state.asSharedFlow()
|
|
||||||
|
|
||||||
private val _commands = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
|
|
||||||
val commands: SharedFlow<JamMessage> = _commands.asSharedFlow()
|
|
||||||
|
|
||||||
private val _presence = MutableStateFlow<Map<String, JamPresence>>(emptyMap())
|
|
||||||
val presence: StateFlow<Map<String, JamPresence>> = _presence.asStateFlow()
|
|
||||||
|
|
||||||
// ---------- Public API ----------
|
|
||||||
|
|
||||||
/** One-off connection check used by the settings screen. Returns latency description. */
|
|
||||||
suspend fun testConnection(broker: JamBroker): Result<String> {
|
|
||||||
if (broker.host.isBlank()) return Result.failure(IllegalArgumentException("Broker host is empty"))
|
|
||||||
val started = kotlin.time.TimeSource.Monotonic.markNow()
|
|
||||||
return runCatching {
|
|
||||||
val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test")
|
|
||||||
try {
|
|
||||||
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
|
||||||
client.awaitConnectivity()
|
|
||||||
}
|
|
||||||
"Connected in ${started.elapsedNow().inWholeMilliseconds} ms"
|
|
||||||
} finally {
|
|
||||||
runCatching { client.shutdown(sendDisconnect = true, drain = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Connects to [code] on [broker] and starts routing room messages. */
|
|
||||||
suspend fun connect(
|
|
||||||
broker: JamBroker,
|
|
||||||
code: String,
|
|
||||||
clientId: String,
|
|
||||||
displayName: String,
|
|
||||||
isHost: Boolean,
|
|
||||||
): Result<Unit> {
|
|
||||||
disconnect()
|
|
||||||
if (broker.host.isBlank()) {
|
|
||||||
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
|
||||||
}
|
|
||||||
return runCatching {
|
|
||||||
roomCode = code
|
|
||||||
localPresence = JamPresence(
|
|
||||||
clientId = clientId,
|
|
||||||
displayName = displayName,
|
|
||||||
isHost = isHost,
|
|
||||||
left = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
val mqtt = startClient(broker, clientId)
|
|
||||||
client = mqtt
|
|
||||||
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
|
||||||
mqtt.awaitConnectivity()
|
|
||||||
}
|
|
||||||
_connectionError.value = null
|
|
||||||
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
|
|
||||||
|
|
||||||
mqtt.connectionState
|
|
||||||
.onEach { onConnectionStateChanged(it) }
|
|
||||||
.launchIn(scope)
|
|
||||||
Unit
|
|
||||||
}.onFailure { e ->
|
|
||||||
log.w(e) { "Failed to connect to jam broker" }
|
|
||||||
_connectionError.value = e.message ?: "Connection failed"
|
|
||||||
_isConnected.value = false
|
|
||||||
runCatching { client?.shutdown(sendDisconnect = true, drain = false) }
|
|
||||||
client = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun publishState(state: JamMessage.QueueState) {
|
|
||||||
val code = roomCode ?: return
|
|
||||||
publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun publishCommand(message: JamMessage) {
|
|
||||||
val code = roomCode ?: return
|
|
||||||
publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Publishes our own (retained) presence. Re-published after every reconnect. */
|
|
||||||
suspend fun publishPresence() {
|
|
||||||
val code = roomCode ?: return
|
|
||||||
val presence = localPresence ?: return
|
|
||||||
publishJson(
|
|
||||||
presenceTopic(code, presence.clientId),
|
|
||||||
json.encodeToString(JamPresence.serializer(), presence),
|
|
||||||
retain = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Marks us as the host in presence (host takeover). */
|
|
||||||
suspend fun claimHost() {
|
|
||||||
val presence = localPresence ?: return
|
|
||||||
localPresence = presence.copy(isHost = true)
|
|
||||||
publishPresence()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Graceful leave: publish `left = true` before disconnecting. */
|
|
||||||
suspend fun leavePresence() {
|
|
||||||
val presence = localPresence ?: return
|
|
||||||
val code = roomCode ?: return
|
|
||||||
runCatching {
|
|
||||||
publishJson(
|
|
||||||
presenceTopic(code, presence.clientId),
|
|
||||||
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
|
||||||
retain = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun disconnect() {
|
|
||||||
roomCode = null
|
|
||||||
localPresence = null
|
|
||||||
_isConnected.value = false
|
|
||||||
_presence.value = emptyMap()
|
|
||||||
val current = client
|
|
||||||
client = null
|
|
||||||
runCatching { current?.shutdown(sendDisconnect = true, drain = false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Internals ----------
|
|
||||||
|
|
||||||
private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient {
|
|
||||||
val connection = MqttConnectionOptions.SocketConnection(
|
|
||||||
host = broker.host,
|
|
||||||
port = broker.port,
|
|
||||||
tlsEnabled = broker.useTls,
|
|
||||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
|
|
||||||
)
|
|
||||||
val code = roomCode ?: "unset"
|
|
||||||
val will = WillConfig.Enabled(
|
|
||||||
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
|
||||||
payload = json.encodeToString(
|
|
||||||
JamPresence.serializer(),
|
|
||||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
|
||||||
).toReadBuffer(Charset.UTF8),
|
|
||||||
qos = QualityOfService.AT_LEAST_ONCE,
|
|
||||||
retain = true,
|
|
||||||
)
|
|
||||||
val request = ConnectionRequest(
|
|
||||||
clientId = clientId,
|
|
||||||
keepAliveSeconds = broker.keepAliveSeconds,
|
|
||||||
cleanStart = true,
|
|
||||||
userName = broker.username,
|
|
||||||
password = broker.password,
|
|
||||||
will = will,
|
|
||||||
)
|
|
||||||
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
|
||||||
val brokerRef = persistence.addBroker(connection, request)
|
|
||||||
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onConnectionStateChanged(state: ConnectionState) {
|
|
||||||
when (state) {
|
|
||||||
is ConnectionState.Connected -> {
|
|
||||||
_isConnected.value = true
|
|
||||||
_connectionError.value = null
|
|
||||||
scope.launch {
|
|
||||||
// Every connection (initial + reconnects) must re-establish the
|
|
||||||
// broker-side subscription (clean session) and re-publish our
|
|
||||||
// retained presence to clear any Last Will. Re-subscribing with
|
|
||||||
// the same filter replaces the previous dispatcher handler.
|
|
||||||
subscribeRoom()
|
|
||||||
publishPresence()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ConnectionState.Disconnected, ConnectionState.Handshaking -> {
|
|
||||||
_isConnected.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
_isConnected.value = false
|
|
||||||
_connectionError.value = "Connection lost"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun subscribeRoom() {
|
|
||||||
val mqtt = client ?: return
|
|
||||||
val code = roomCode ?: return
|
|
||||||
val operation = mqtt.subscribe(
|
|
||||||
roomFilter(code),
|
|
||||||
OpaquePublishPayloadCodec,
|
|
||||||
QualityOfService.AT_LEAST_ONCE,
|
|
||||||
) { publish, payload ->
|
|
||||||
route(publish.topic.toString(), payload)
|
|
||||||
}
|
|
||||||
runCatching { operation.subAck.await() }
|
|
||||||
.onFailure { log.w(it) { "Subscribe ack failed for room $code" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) {
|
|
||||||
val text = runCatching {
|
|
||||||
val buffer = payload.handle.asReadBuffer()
|
|
||||||
buffer.readString(buffer.remaining(), Charset.UTF8)
|
|
||||||
}.getOrElse { e ->
|
|
||||||
log.w(e) { "Failed to read jam payload on $topic" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
when {
|
|
||||||
topic.endsWith("/state") -> {
|
|
||||||
_state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text))
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.endsWith("/cmd") -> {
|
|
||||||
_commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text))
|
|
||||||
}
|
|
||||||
|
|
||||||
topic.contains("/presence/") -> {
|
|
||||||
val presence = json.decodeFromString(JamPresence.serializer(), text)
|
|
||||||
_presence.value = _presence.value + (presence.clientId to presence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
log.w(e) { "Failed to decode jam message on $topic: $text" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun publishJson(topic: String, payload: String, retain: Boolean) {
|
|
||||||
val mqtt = client ?: return
|
|
||||||
runCatching {
|
|
||||||
mqtt.publish(
|
|
||||||
topicName = topic,
|
|
||||||
qos = QualityOfService.AT_LEAST_ONCE,
|
|
||||||
payload = payload.toReadBuffer(Charset.UTF8),
|
|
||||||
retain = retain,
|
|
||||||
)
|
|
||||||
}.onFailure { e ->
|
|
||||||
log.w(e) { "Failed to publish to $topic" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stateTopic(code: String) = "spotube/jam/$code/state"
|
|
||||||
private fun cmdTopic(code: String) = "spotube/jam/$code/cmd"
|
|
||||||
private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId"
|
|
||||||
private fun roomFilter(code: String) = "spotube/jam/$code/#"
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.jam
|
|
||||||
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Six-character room codes shared verbally / by text. Codes are opaque keys used
|
|
||||||
* to namespace the MQTT topics of a jam room — they carry no connection details.
|
|
||||||
*
|
|
||||||
* The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to
|
|
||||||
* read aloud and retype.
|
|
||||||
*/
|
|
||||||
object JamRoomCode {
|
|
||||||
const val LENGTH = 6
|
|
||||||
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
|
|
||||||
fun generate(): String = buildString(LENGTH) {
|
|
||||||
repeat(LENGTH) {
|
|
||||||
append(ALPHABET[Random.nextInt(ALPHABET.length)])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */
|
|
||||||
fun normalize(input: String): String = input
|
|
||||||
.uppercase()
|
|
||||||
.filter { it.isLetterOrDigit() }
|
|
||||||
.take(LENGTH)
|
|
||||||
|
|
||||||
fun isValid(code: String): Boolean =
|
|
||||||
code.length == LENGTH && code.all { it in ALPHABET }
|
|
||||||
}
|
|
||||||
@ -1,573 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.jam
|
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A jam session over MQTT (star topology, host-authoritative queue).
|
|
||||||
*
|
|
||||||
* Sync rules:
|
|
||||||
* - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo]
|
|
||||||
* from anyone are applied by the host, then broadcast via the retained state topic.
|
|
||||||
* - Play/pause, seek, volume and loop are local to each device — never broadcast.
|
|
||||||
* When the queue moves on, a paused participant stays paused; a playing one
|
|
||||||
* keeps playing the new current item.
|
|
||||||
* - Shuffle is host-only; guests mirror the host's shuffle setting.
|
|
||||||
* - Guests can only add to the queue (suggest); the host applies suggestions.
|
|
||||||
* - If the host leaves, the participant with the lowest client id takes over.
|
|
||||||
*/
|
|
||||||
private data class PlaybackBroadcast(
|
|
||||||
val queue: List<QueueEntry>,
|
|
||||||
val current: QueueEntry?,
|
|
||||||
val shuffle: Boolean,
|
|
||||||
val playerState: PlayerState,
|
|
||||||
)
|
|
||||||
|
|
||||||
class JamRoomService(
|
|
||||||
private val jamClient: JamRoomClient,
|
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
) {
|
|
||||||
private val log = Logger.withTag("JamRoomService")
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
|
|
||||||
private val _role = MutableStateFlow<JamRole?>(null)
|
|
||||||
val role: StateFlow<JamRole?> = _role.asStateFlow()
|
|
||||||
|
|
||||||
private val _participants = MutableStateFlow<List<JamParticipant>>(emptyList())
|
|
||||||
val participants: StateFlow<List<JamParticipant>> = _participants.asStateFlow()
|
|
||||||
|
|
||||||
private val _roomCode = MutableStateFlow<String?>(null)
|
|
||||||
val roomCode: StateFlow<String?> = _roomCode.asStateFlow()
|
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
|
||||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
|
||||||
|
|
||||||
private val _connectionError = MutableStateFlow<String?>(null)
|
|
||||||
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
|
||||||
|
|
||||||
private val _shuffleEnabled = MutableStateFlow(false)
|
|
||||||
val shuffleEnabled: StateFlow<Boolean> = _shuffleEnabled.asStateFlow()
|
|
||||||
|
|
||||||
private var localClientId: String = ""
|
|
||||||
private var localDisplayName: String = ""
|
|
||||||
|
|
||||||
/** Display name of the local participant. */
|
|
||||||
val participantDisplayName: String
|
|
||||||
get() = localDisplayName
|
|
||||||
|
|
||||||
/** Stable id of the local participant (stamped on items this device adds). */
|
|
||||||
val participantClientId: String
|
|
||||||
get() = localClientId
|
|
||||||
|
|
||||||
private var hostBroadcastJob: Job? = null
|
|
||||||
|
|
||||||
/** Guest side: last queue snapshot applied to the local player. */
|
|
||||||
private var lastAppliedItems: List<JamMediaItem> = emptyList()
|
|
||||||
private var lastAppliedIndex = -1
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Host side: a song completed, so index changes within this window are
|
|
||||||
* auto-advance (guests must not follow). The completion event, the player
|
|
||||||
* state change and the media transition arrive as separate flow emissions,
|
|
||||||
* so the window covers the whole sequence instead of a single flag.
|
|
||||||
*/
|
|
||||||
private var autoAdvanceDeadlineMs = 0L
|
|
||||||
|
|
||||||
/** Guest side: playback has started at least once (local control is the guest's own). */
|
|
||||||
private var hasStartedPlayback = false
|
|
||||||
|
|
||||||
/** Host side: client ids banned for this session. */
|
|
||||||
private val bannedClientIds = mutableSetOf<String>()
|
|
||||||
|
|
||||||
private var leaving = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
jamClient.isConnected
|
|
||||||
.onEach { _isConnected.value = it }
|
|
||||||
.launchIn(scope)
|
|
||||||
jamClient.connectionError
|
|
||||||
.onEach { _connectionError.value = it }
|
|
||||||
.launchIn(scope)
|
|
||||||
jamClient.state
|
|
||||||
.onEach { onRemoteState(it) }
|
|
||||||
.launchIn(scope)
|
|
||||||
jamClient.commands
|
|
||||||
.onEach { onCommand(it) }
|
|
||||||
.launchIn(scope)
|
|
||||||
jamClient.presence
|
|
||||||
.onEach { onPresence(it) }
|
|
||||||
.launchIn(scope)
|
|
||||||
// A naturally-completed song means the host's next index change is an
|
|
||||||
// auto-advance — guests must NOT follow those, only manual skips.
|
|
||||||
audioPlayer.completionFlow
|
|
||||||
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
|
|
||||||
.launchIn(scope)
|
|
||||||
// Once a guest has played on its own (or was started by the host), its
|
|
||||||
// play/pause is its own — the host's play state only starts fresh joiners.
|
|
||||||
audioPlayer.playerStateFlow
|
|
||||||
.onEach { state ->
|
|
||||||
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) {
|
|
||||||
hasStartedPlayback = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.launchIn(scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Session lifecycle ----------
|
|
||||||
|
|
||||||
suspend fun createRoom(): Result<String> {
|
|
||||||
val broker = settingsRepository.userSettings.value.jamBroker
|
|
||||||
if (broker.host.isBlank()) {
|
|
||||||
return Result.failure(IllegalStateException("No jam broker configured"))
|
|
||||||
}
|
|
||||||
val code = JamRoomCode.generate()
|
|
||||||
localClientId = newClientId(broker)
|
|
||||||
val name = participantName("Host")
|
|
||||||
localDisplayName = name
|
|
||||||
|
|
||||||
return jamClient.connect(
|
|
||||||
broker = broker,
|
|
||||||
code = code,
|
|
||||||
clientId = localClientId,
|
|
||||||
displayName = name,
|
|
||||||
isHost = true,
|
|
||||||
).map {
|
|
||||||
_role.value = JamRole.Host
|
|
||||||
_roomCode.value = code
|
|
||||||
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
|
||||||
lastAppliedItems = emptyList()
|
|
||||||
lastAppliedIndex = -1
|
|
||||||
bannedClientIds.clear()
|
|
||||||
leaving = false
|
|
||||||
autoAdvanceDeadlineMs = 0L
|
|
||||||
hasStartedPlayback = false
|
|
||||||
startHostBroadcast()
|
|
||||||
persistLastCode(code)
|
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun joinRoom(code: String): Result<Unit> {
|
|
||||||
val broker = settingsRepository.userSettings.value.jamBroker
|
|
||||||
if (broker.host.isBlank()) {
|
|
||||||
return Result.failure(IllegalStateException("No jam broker configured"))
|
|
||||||
}
|
|
||||||
val normalized = JamRoomCode.normalize(code)
|
|
||||||
if (!JamRoomCode.isValid(normalized)) {
|
|
||||||
return Result.failure(IllegalArgumentException("Invalid room code"))
|
|
||||||
}
|
|
||||||
localClientId = newClientId(broker)
|
|
||||||
val name = participantName("Guest")
|
|
||||||
localDisplayName = name
|
|
||||||
|
|
||||||
return jamClient.connect(
|
|
||||||
broker = broker,
|
|
||||||
code = normalized,
|
|
||||||
clientId = localClientId,
|
|
||||||
displayName = name,
|
|
||||||
isHost = false,
|
|
||||||
).map {
|
|
||||||
_role.value = JamRole.Guest
|
|
||||||
_roomCode.value = normalized
|
|
||||||
_participants.value = emptyList()
|
|
||||||
lastAppliedItems = emptyList()
|
|
||||||
lastAppliedIndex = -1
|
|
||||||
leaving = false
|
|
||||||
hasStartedPlayback = false
|
|
||||||
persistLastCode(normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun leaveRoom() {
|
|
||||||
leaving = true
|
|
||||||
stopHostBroadcast()
|
|
||||||
runCatching { jamClient.leavePresence() }
|
|
||||||
jamClient.disconnect()
|
|
||||||
_role.value = null
|
|
||||||
_roomCode.value = null
|
|
||||||
_participants.value = emptyList()
|
|
||||||
_shuffleEnabled.value = false
|
|
||||||
_isConnected.value = false
|
|
||||||
lastAppliedItems = emptyList()
|
|
||||||
lastAppliedIndex = -1
|
|
||||||
bannedClientIds.clear()
|
|
||||||
hasStartedPlayback = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Controls (called from the UI) ----------
|
|
||||||
|
|
||||||
fun skipNext() {
|
|
||||||
publishCommand(PlaybackCmd.SkipNext)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipPrevious() {
|
|
||||||
publishCommand(PlaybackCmd.SkipPrevious)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun jumpTo(index: Int) {
|
|
||||||
publishCommand(PlaybackCmd.JumpTo(index))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */
|
|
||||||
fun toggleShuffle() {
|
|
||||||
if (_role.value != JamRole.Host) return
|
|
||||||
scope.launch {
|
|
||||||
runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun suggestTrack(track: MetadataTrack) {
|
|
||||||
jamClient.publishCommand(
|
|
||||||
JamMessage.SuggestTrack(
|
|
||||||
mediaItem = JamMediaItem.fromTrack(track),
|
|
||||||
addedBy = localClientId,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun suggestPlaylist(tracks: List<MetadataTrack>) {
|
|
||||||
if (tracks.isEmpty()) return
|
|
||||||
jamClient.publishCommand(
|
|
||||||
JamMessage.SuggestPlaylist(
|
|
||||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
|
||||||
addedBy = localClientId,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") {
|
|
||||||
if (_role.value != JamRole.Host) return
|
|
||||||
jamClient.publishCommand(JamMessage.Kick(participantId, reason))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun banParticipant(participantId: String) {
|
|
||||||
if (_role.value != JamRole.Host) return
|
|
||||||
bannedClientIds += participantId
|
|
||||||
kickParticipant(participantId, "banned by host")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Host: broadcast ----------
|
|
||||||
|
|
||||||
private fun startHostBroadcast() {
|
|
||||||
if (hostBroadcastJob?.isActive == true) return
|
|
||||||
hostBroadcastJob = scope.launch {
|
|
||||||
combine(
|
|
||||||
audioPlayerQueue.queueFlow,
|
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
|
||||||
audioPlayer.shuffleModeFlow,
|
|
||||||
audioPlayer.playerStateFlow,
|
|
||||||
) { queue, current, shuffle, playerState ->
|
|
||||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
|
||||||
}
|
|
||||||
.onEach { broadcast ->
|
|
||||||
if (_role.value != JamRole.Host) return@onEach
|
|
||||||
val index = if (broadcast.current != null) {
|
|
||||||
broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) }
|
|
||||||
} else {
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
_shuffleEnabled.value = broadcast.shuffle
|
|
||||||
jamClient.publishState(
|
|
||||||
JamMessage.QueueState(
|
|
||||||
items = broadcast.queue.map(JamMediaItem::fromQueueEntry),
|
|
||||||
currentIndex = index.coerceAtLeast(0),
|
|
||||||
shuffleEnabled = broadcast.shuffle,
|
|
||||||
follow = now() > autoAdvanceDeadlineMs,
|
|
||||||
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.launchIn(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopHostBroadcast() {
|
|
||||||
hostBroadcastJob?.cancel()
|
|
||||||
hostBroadcastJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Guest: apply remote state ----------
|
|
||||||
|
|
||||||
private suspend fun onRemoteState(state: JamMessage.QueueState) {
|
|
||||||
if (_role.value != JamRole.Guest) return
|
|
||||||
if (leaving) return
|
|
||||||
|
|
||||||
_shuffleEnabled.value = state.shuffleEnabled
|
|
||||||
runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
|
|
||||||
|
|
||||||
// Late joiner: the host is already playing, so start immediately. Once
|
|
||||||
// this guest has played on its own, the host's play state is ignored.
|
|
||||||
if (state.isPlaying && !hasStartedPlayback) {
|
|
||||||
runCatching { audioPlayer.play() }
|
|
||||||
.onFailure { log.w(it) { "Failed to start playback on host play state" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
|
|
||||||
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
|
|
||||||
|
|
||||||
if (items != lastAppliedItems) {
|
|
||||||
val previous = lastAppliedItems
|
|
||||||
lastAppliedItems = items
|
|
||||||
val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value
|
|
||||||
val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) }
|
|
||||||
|
|
||||||
// The host only appended items (e.g. accepted suggestions): merge
|
|
||||||
// them in without resetting playback or the guest's position.
|
|
||||||
if (previous.isNotEmpty() && items.size > previous.size &&
|
|
||||||
items.take(previous.size) == previous && guestIndex >= 0
|
|
||||||
) {
|
|
||||||
lastAppliedIndex = guestIndex
|
|
||||||
val appended = items.drop(previous.size)
|
|
||||||
runCatching {
|
|
||||||
audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() })
|
|
||||||
}.onFailure { log.w(it) { "Failed to append jam queue items" } }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full re-sync. Keep the guest's current track when it still exists
|
|
||||||
// in the synced queue; otherwise take the host's position.
|
|
||||||
val startIndex = if (guestIndex >= 0) guestIndex
|
|
||||||
else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))
|
|
||||||
lastAppliedIndex = startIndex
|
|
||||||
runCatching {
|
|
||||||
audioPlayerQueue.load(
|
|
||||||
entries = items.map { it.toQueueEntry() },
|
|
||||||
autoPlay = wasPlaying,
|
|
||||||
startPosition = startIndex,
|
|
||||||
)
|
|
||||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same queue content: only follow the host when it moved manually
|
|
||||||
// (skip/jump). Natural auto-advance keeps everyone where they are.
|
|
||||||
if (state.follow && state.currentIndex != lastAppliedIndex) {
|
|
||||||
lastAppliedIndex = state.currentIndex
|
|
||||||
runCatching {
|
|
||||||
audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false)
|
|
||||||
}.onFailure { log.w(it) { "Failed to follow jam queue index" } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Incoming commands ----------
|
|
||||||
|
|
||||||
private suspend fun onCommand(message: JamMessage) {
|
|
||||||
when (message) {
|
|
||||||
is JamMessage.PlaybackCommand -> {
|
|
||||||
if (_role.value != JamRole.Host) return
|
|
||||||
applyCommand(message.command)
|
|
||||||
}
|
|
||||||
|
|
||||||
is JamMessage.SuggestTrack -> {
|
|
||||||
if (_role.value == JamRole.Host) {
|
|
||||||
acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is JamMessage.SuggestPlaylist -> {
|
|
||||||
if (_role.value == JamRole.Host) {
|
|
||||||
acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is JamMessage.Kick -> {
|
|
||||||
if (_role.value == JamRole.Guest && message.participantId == localClientId) {
|
|
||||||
log.i { "Kicked from jam room: ${message.reason}" }
|
|
||||||
leaveRoom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun applyCommand(command: PlaybackCmd) {
|
|
||||||
when (command) {
|
|
||||||
PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() }
|
|
||||||
PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() }
|
|
||||||
is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun acceptSuggestion(items: List<JamMediaItem>) {
|
|
||||||
if (items.isEmpty()) return
|
|
||||||
log.i { "Accepting ${items.size} suggested item(s) into the jam queue" }
|
|
||||||
runCatching {
|
|
||||||
audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Presence & host takeover ----------
|
|
||||||
|
|
||||||
private fun onPresence(all: Map<String, JamPresence>) {
|
|
||||||
if (_role.value == null) return
|
|
||||||
val live = all.values.filter { !it.left }
|
|
||||||
_participants.value = live
|
|
||||||
.sortedBy { it.clientId }
|
|
||||||
.map { JamParticipant(it.clientId, it.displayName, it.isHost) }
|
|
||||||
|
|
||||||
// Host-side: auto-kick banned participants that rejoin.
|
|
||||||
if (_role.value == JamRole.Host) {
|
|
||||||
live.filter { it.clientId in bannedClientIds }.forEach { banned ->
|
|
||||||
scope.launch { kickParticipant(banned.clientId, "banned by host") }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val host = live.firstOrNull { it.isHost }
|
|
||||||
if (host != null) return
|
|
||||||
|
|
||||||
// Host left: the lowest client id takes over (deterministic, clock-free).
|
|
||||||
val candidate = live.minByOrNull { it.clientId } ?: return
|
|
||||||
if (candidate.clientId != localClientId) return
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
delay(HOST_TAKEOVER_DELAY_MS)
|
|
||||||
val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost }
|
|
||||||
if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch
|
|
||||||
log.i { "Taking over as jam host (previous host left)" }
|
|
||||||
_role.value = JamRole.Host
|
|
||||||
jamClient.claimHost()
|
|
||||||
startHostBroadcast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
|
||||||
|
|
||||||
private fun publishCommand(command: PlaybackCmd) {
|
|
||||||
scope.launch {
|
|
||||||
jamClient.publishCommand(JamMessage.PlaybackCommand(command))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun participantName(fallbackPrefix: String): String {
|
|
||||||
val configured = settingsRepository.userSettings.value.jamParticipantName
|
|
||||||
return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun newClientId(broker: dev.krtirtho.spotube.modules.settings.JamBroker): String {
|
|
||||||
val suffix = buildString(6) {
|
|
||||||
val chars = "0123456789abcdef"
|
|
||||||
repeat(6) { append(chars[Random.nextInt(chars.length)]) }
|
|
||||||
}
|
|
||||||
return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun persistLastCode(code: String) {
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
val settings = settingsRepository.userSettings.value
|
|
||||||
if (settings.lastJamCode != code) {
|
|
||||||
settingsRepository.updateSettings(settings.copy(lastJamCode = code))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
|
|
||||||
trackId.isNotBlank() -> QueueEntry.StreamingTrack(
|
|
||||||
track = MetadataTrack(
|
|
||||||
id = trackId,
|
|
||||||
title = title,
|
|
||||||
durationMs = durationMs,
|
|
||||||
trackNumber = null,
|
|
||||||
discNumber = null,
|
|
||||||
artists = listOf(
|
|
||||||
MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null)
|
|
||||||
),
|
|
||||||
album = null,
|
|
||||||
// Keep the cover art flowing to participants — the wire carries it
|
|
||||||
// as coverUrl, so mirror it back into the reconstructed thumbnails.
|
|
||||||
thumbnails = coverUrl.takeIf { it.isNotBlank() }
|
|
||||||
?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) },
|
|
||||||
explicit = null,
|
|
||||||
popularity = null,
|
|
||||||
isrcCode = null,
|
|
||||||
externalUri = null,
|
|
||||||
),
|
|
||||||
url = "",
|
|
||||||
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
|
||||||
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
|
||||||
addedBy = addedBy,
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> QueueEntry.LocalTrack(
|
|
||||||
name = title,
|
|
||||||
artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() },
|
|
||||||
duration = durationMs,
|
|
||||||
album = album.ifBlank { null },
|
|
||||||
coverBytes = null,
|
|
||||||
url = url,
|
|
||||||
addedBy = addedBy,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
|
||||||
if (entry == null) return false
|
|
||||||
return when (entry) {
|
|
||||||
is QueueEntry.StreamingTrack ->
|
|
||||||
trackId.isNotBlank() && entry.track.id == trackId
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack ->
|
|
||||||
url.isNotBlank() && entry.url == url && entry.name == title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when {
|
|
||||||
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
|
||||||
this.track.id == other.track.id
|
|
||||||
|
|
||||||
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
|
|
||||||
this.url == other.url && this.name == other.name
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val HOST_TAKEOVER_DELAY_MS = 1_500L
|
|
||||||
private const val AUTO_ADVANCE_WINDOW_MS = 2_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,587 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import uniffi.compose_app.IceServerConfig
|
||||||
|
import uniffi.compose_app.WebrtcEventHandler
|
||||||
|
import uniffi.compose_app.WebrtcPeerConnection
|
||||||
|
import uniffi.compose_app.createWebrtcPeerConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An invite generated by the host for one guest slot. The [sdp] offer is shared
|
||||||
|
* via a deep link; once the guest's answer comes back, [JamSessionService.acceptAnswer]
|
||||||
|
* completes the handshake for that slot.
|
||||||
|
*/
|
||||||
|
data class JamInvite(
|
||||||
|
val id: String,
|
||||||
|
val sdp: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the peer connections of a jam session (star topology: host relays state
|
||||||
|
* to all guests) and the hello/welcome handshake, participant bookkeeping and
|
||||||
|
* kick/ban. Playback & queue synchronization itself is delegated to
|
||||||
|
* [QueueSyncManager], which runs while a session is active.
|
||||||
|
*/
|
||||||
|
class JamSessionService(
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val settingsProvider: SettingsProvider,
|
||||||
|
) : KoinComponent {
|
||||||
|
val logger by injectLogger<JamSessionService>()
|
||||||
|
private val log = Logger.withTag("JamSessionService")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback/queue synchronization. Owned by this service (not a Koin bean) so
|
||||||
|
* the two don't form a circular dependency; it's started/stopped with the
|
||||||
|
* session lifecycle.
|
||||||
|
*/
|
||||||
|
private val queueSyncManager = QueueSyncManager(
|
||||||
|
audioPlayer = audioPlayer,
|
||||||
|
audioPlayerQueue = audioPlayerQueue,
|
||||||
|
jamSession = this,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
classDiscriminator = "type"
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _role = MutableStateFlow<JamRole?>(null)
|
||||||
|
val role: StateFlow<JamRole?> = _role.asStateFlow()
|
||||||
|
|
||||||
|
private val _participants = MutableStateFlow<List<JamParticipant>>(emptyList())
|
||||||
|
val participants: StateFlow<List<JamParticipant>> = _participants.asStateFlow()
|
||||||
|
|
||||||
|
private val _isActive = MutableStateFlow(false)
|
||||||
|
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
|
||||||
|
|
||||||
|
private val _localParticipantId = MutableStateFlow<String?>(null)
|
||||||
|
val localParticipantId: StateFlow<String?> = _localParticipantId.asStateFlow()
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _incomingMessages = MutableSharedFlow<JamMessage>(extraBufferCapacity = 64)
|
||||||
|
val incomingMessages = _incomingMessages.asSharedFlow()
|
||||||
|
|
||||||
|
private val _incomingSuggestions = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
|
||||||
|
val incomingSuggestions = _incomingSuggestions.asSharedFlow()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
/** Host side: invites whose answers have not arrived yet. */
|
||||||
|
private val pendingInvites = mutableMapOf<String, WebrtcPeerConnection>()
|
||||||
|
|
||||||
|
/** Host side: guests whose handshake completed. Keyed by invite id. */
|
||||||
|
private val connectedGuests = mutableMapOf<String, WebrtcPeerConnection>()
|
||||||
|
|
||||||
|
/** Host side: guest device ids, used for bans. */
|
||||||
|
private val guestDeviceIds = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
/** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */
|
||||||
|
private val guestConnectionStates = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
/** Host side: device ids banned for this session. */
|
||||||
|
private val bannedDeviceIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
/** Guest side: the single connection to the host. */
|
||||||
|
private var hostConnection: WebrtcPeerConnection? = null
|
||||||
|
|
||||||
|
private var hostDisplayName: String = "Host"
|
||||||
|
private var guestDisplayName: String = "Guest"
|
||||||
|
|
||||||
|
suspend fun createSession(): String {
|
||||||
|
log.i { "Creating jam session" }
|
||||||
|
hostDisplayName = resolveParticipantName(defaultPrefix = "Host")
|
||||||
|
|
||||||
|
_role.value = JamRole.Host
|
||||||
|
_localParticipantId.value = "host"
|
||||||
|
_participants.value = listOf(
|
||||||
|
JamParticipant(
|
||||||
|
id = "host",
|
||||||
|
displayName = hostDisplayName,
|
||||||
|
isHost = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_isActive.value = true
|
||||||
|
queueSyncManager.start()
|
||||||
|
|
||||||
|
return generateInvite().sdp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new invite (peer connection + SDP offer with bundled ICE candidates).
|
||||||
|
* Each invite admits exactly one guest.
|
||||||
|
*/
|
||||||
|
suspend fun generateInvite(): JamInvite {
|
||||||
|
if (_role.value != JamRole.Host) {
|
||||||
|
error("generateInvite can only be called by the host")
|
||||||
|
}
|
||||||
|
val inviteId = "guest-${randomShortId()}"
|
||||||
|
log.i { "Generating invite $inviteId" }
|
||||||
|
|
||||||
|
val pc = createWebrtcPeerConnection(
|
||||||
|
iceServers = defaultIceServers(),
|
||||||
|
handler = guestEventHandler(inviteId),
|
||||||
|
)
|
||||||
|
|
||||||
|
pc.createDataChannel(CHANNEL_LABEL)
|
||||||
|
val offer = pc.createOffer()
|
||||||
|
pendingInvites[inviteId] = pc
|
||||||
|
return JamInvite(id = inviteId, sdp = offer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes a guest's handshake: applies their SDP answer to the peer connection
|
||||||
|
* created for [inviteId] and adds them to the participant list.
|
||||||
|
*
|
||||||
|
* When [inviteId] is null, the oldest still-pending invite is used — convenient
|
||||||
|
* when an answer deep link arrives out of band.
|
||||||
|
*
|
||||||
|
* @param answerSdp raw SDP answer body (not a deep link).
|
||||||
|
* @param peerName display name of the guest, taken from their answer link if available.
|
||||||
|
*/
|
||||||
|
suspend fun acceptAnswer(inviteId: String?, answerSdp: String, peerName: String): Boolean {
|
||||||
|
if (_role.value != JamRole.Host) {
|
||||||
|
log.w { "acceptAnswer ignored: not hosting" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val resolvedId = inviteId ?: pendingInvites.keys.firstOrNull()
|
||||||
|
if (resolvedId == null) {
|
||||||
|
log.w { "acceptAnswer: no pending invite" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val pc = pendingInvites.remove(resolvedId)
|
||||||
|
if (pc == null) {
|
||||||
|
log.w { "acceptAnswer: no pending invite '$resolvedId'" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runCatching { pc.setRemoteAnswer(answerSdp) }
|
||||||
|
.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to apply answer for $inviteId" }
|
||||||
|
scope.launch { runCatching { pc.shutdown() } }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
connectedGuests[resolvedId] = pc
|
||||||
|
_participants.update { current ->
|
||||||
|
current + JamParticipant(
|
||||||
|
id = resolvedId,
|
||||||
|
displayName = peerName.ifBlank { "Guest-${resolvedId.takeLast(4)}" },
|
||||||
|
isHost = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
log.i { "Guest $resolvedId ($peerName) joined" }
|
||||||
|
broadcastParticipantList()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun joinSession(offerSdp: String, hostName: String? = null): String {
|
||||||
|
log.i { "Joining jam session" }
|
||||||
|
guestDisplayName = resolveParticipantName(defaultPrefix = "Guest")
|
||||||
|
|
||||||
|
val pc = createWebrtcPeerConnection(
|
||||||
|
iceServers = defaultIceServers(),
|
||||||
|
handler = eventHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
hostConnection = pc
|
||||||
|
_role.value = JamRole.Guest
|
||||||
|
_localParticipantId.value = null
|
||||||
|
_participants.value = listOf(
|
||||||
|
JamParticipant(
|
||||||
|
id = "host",
|
||||||
|
displayName = hostName?.ifBlank { null } ?: "Host",
|
||||||
|
isHost = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_isActive.value = true
|
||||||
|
queueSyncManager.start()
|
||||||
|
|
||||||
|
// The data channel arrives in-band from the host's offer via on_data_channel;
|
||||||
|
// we only answer here.
|
||||||
|
pc.setRemoteOffer(offerSdp)
|
||||||
|
val answer = pc.createAnswer()
|
||||||
|
log.i { "Generated SDP answer (length=${answer.length})" }
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(message: JamMessage, guestId: String? = null) {
|
||||||
|
val payload = json.encodeToString(JamMessage.serializer(), message)
|
||||||
|
when (_role.value) {
|
||||||
|
JamRole.Host -> {
|
||||||
|
if (guestId != null) {
|
||||||
|
val pc = connectedGuests[guestId] ?: return
|
||||||
|
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
|
||||||
|
.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to send to guest $guestId" }
|
||||||
|
onSendFailure(guestId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val dead = mutableListOf<String>()
|
||||||
|
connectedGuests.forEach { (id, pc) ->
|
||||||
|
runCatching { pc.sendData(CHANNEL_LABEL, payload) }
|
||||||
|
.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to send to guest $id" }
|
||||||
|
dead += id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dead.forEach { id -> onSendFailure(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JamRole.Guest -> {
|
||||||
|
runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) }
|
||||||
|
.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to send to host" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> log.w { "sendMessage called while no session is active" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A send to a guest failed. If that guest's connection has already given up
|
||||||
|
* (failed/closed), drop them from the session; while the connection is merely
|
||||||
|
* "connecting" the channel may simply not be open yet, so keep them.
|
||||||
|
*/
|
||||||
|
private fun onSendFailure(guestId: String) {
|
||||||
|
val state = guestConnectionStates[guestId]
|
||||||
|
if (state == "failed" || state == "closed" || state == "disconnected") {
|
||||||
|
scope.launch { removeGuest(guestId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun leave() {
|
||||||
|
log.i { "Leaving jam session" }
|
||||||
|
queueSyncManager.stop()
|
||||||
|
runCatching { sendMessage(JamMessage.Leave()) }
|
||||||
|
shutdownAll()
|
||||||
|
_role.value = null
|
||||||
|
_participants.value = emptyList()
|
||||||
|
_isActive.value = false
|
||||||
|
_isConnected.value = false
|
||||||
|
_localParticipantId.value = null
|
||||||
|
guestDeviceIds.clear()
|
||||||
|
guestConnectionStates.clear()
|
||||||
|
bannedDeviceIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun broadcastPlaybackCommand(command: PlaybackCmd) {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
sendMessage(JamMessage.PlaybackCommand(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun broadcastQueueState(
|
||||||
|
items: List<JamMediaItem>,
|
||||||
|
currentIndex: Int,
|
||||||
|
isPlaying: Boolean,
|
||||||
|
positionMs: Long,
|
||||||
|
) {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
sendMessage(JamMessage.QueueState(items, currentIndex, isPlaying, positionMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun suggestTrack(mediaItem: JamMediaItem) {
|
||||||
|
if (_role.value != JamRole.Guest) return
|
||||||
|
sendMessage(JamMessage.SuggestTrack(mediaItem))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun suggestPlaylist(tracks: List<JamMediaItem>) {
|
||||||
|
if (_role.value != JamRole.Guest) return
|
||||||
|
sendMessage(JamMessage.SuggestPlaylist(tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Host moderation ----------
|
||||||
|
|
||||||
|
suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
log.i { "Kicking participant $participantId" }
|
||||||
|
sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId)
|
||||||
|
removeGuest(participantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun banParticipant(participantId: String) {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
val deviceId = guestDeviceIds[participantId]
|
||||||
|
if (deviceId != null) {
|
||||||
|
bannedDeviceIds += deviceId
|
||||||
|
log.i { "Banning device $deviceId (participant $participantId)" }
|
||||||
|
}
|
||||||
|
kickParticipant(participantId, "banned by host")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun removeGuest(guestId: String) {
|
||||||
|
val pc = connectedGuests.remove(guestId)
|
||||||
|
runCatching { pc?.shutdown() }
|
||||||
|
guestDeviceIds.remove(guestId)
|
||||||
|
guestConnectionStates.remove(guestId)
|
||||||
|
_participants.update { current ->
|
||||||
|
current.filterNot { it.id == guestId }
|
||||||
|
}
|
||||||
|
broadcastParticipantList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun broadcastParticipantList() {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
sendMessage(JamMessage.ParticipantList(_participants.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICE servers for global peer-to-peer jam sessions: multiple STUN servers for
|
||||||
|
* NAT traversal plus a TURN relay for symmetric NATs and strict firewalls.
|
||||||
|
*/
|
||||||
|
private fun defaultIceServers(): List<IceServerConfig> = listOf(
|
||||||
|
IceServerConfig(
|
||||||
|
urls = listOf(
|
||||||
|
"stun:stun.cloudflare.com:3478",
|
||||||
|
"stun:stun1.l.google.com:19302",
|
||||||
|
"stun:stun.l.google.com:19302",
|
||||||
|
),
|
||||||
|
username = "",
|
||||||
|
credential = "",
|
||||||
|
),
|
||||||
|
IceServerConfig(
|
||||||
|
urls = listOf("turn:openrelay.metered.ca:80"),
|
||||||
|
username = "openrelayproject",
|
||||||
|
credential = "openrelayproject",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun resolveParticipantName(defaultPrefix: String): String {
|
||||||
|
val settings = settingsProvider.settingsState.first()
|
||||||
|
return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" }
|
||||||
|
?: "$defaultPrefix-${randomShortId()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localDeviceId(): String {
|
||||||
|
return settingsProvider.settingsState.value?.remoteControlDeviceId
|
||||||
|
?: "device-${randomShortId()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-guest handler so messages received on a guest's connection can be
|
||||||
|
* attributed back to that guest (needed for kick-on-leave and targeted sends).
|
||||||
|
*/
|
||||||
|
private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler {
|
||||||
|
override fun onIceCandidate(candidate: String) {
|
||||||
|
log.i { "[$guestId] ICE candidate: $candidate" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIceGatheringStateChange(state: String) {
|
||||||
|
log.i { "[$guestId] ICE gathering state: $state" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionStateChange(state: String) {
|
||||||
|
log.i { "[$guestId] Connection state: $state" }
|
||||||
|
guestConnectionStates[guestId] = state
|
||||||
|
if (state == "failed" || state == "closed") {
|
||||||
|
scope.launch { removeGuest(guestId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelOpen(label: String) {
|
||||||
|
log.i { "[$guestId] Data channel '$label' open" }
|
||||||
|
_isConnected.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelMessage(label: String, data: String) {
|
||||||
|
handleIncomingMessage(data, fromGuestId = guestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelClose(label: String) {
|
||||||
|
log.i { "[$guestId] Data channel closed" }
|
||||||
|
if (_role.value == JamRole.Host) {
|
||||||
|
scope.launch { removeGuest(guestId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val eventHandler = object : WebrtcEventHandler {
|
||||||
|
override fun onIceCandidate(candidate: String) {
|
||||||
|
log.i { "ICE candidate: $candidate" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIceGatheringStateChange(state: String) {
|
||||||
|
log.i { "ICE gathering state: $state" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionStateChange(state: String) {
|
||||||
|
log.i { "Connection state: $state" }
|
||||||
|
if (state == "failed" || state == "closed") {
|
||||||
|
scope.launch { leave() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelOpen(label: String) {
|
||||||
|
log.i { "Data channel '$label' open" }
|
||||||
|
_isConnected.value = true
|
||||||
|
// Introduce ourselves so the host can fill in our name and hand us
|
||||||
|
// our participant id.
|
||||||
|
scope.launch {
|
||||||
|
sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelMessage(label: String, data: String) {
|
||||||
|
handleIncomingMessage(data, fromGuestId = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelClose(label: String) {
|
||||||
|
log.i { "Data channel closed" }
|
||||||
|
scope.launch { leave() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIncomingMessage(data: String, fromGuestId: String?) {
|
||||||
|
try {
|
||||||
|
val message = json.decodeFromString(JamMessage.serializer(), data)
|
||||||
|
_incomingMessages.tryEmit(message)
|
||||||
|
when (message) {
|
||||||
|
is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> {
|
||||||
|
_incomingSuggestions.tryEmit(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Hello -> {
|
||||||
|
if (_role.value == JamRole.Host && fromGuestId != null) {
|
||||||
|
handleHello(fromGuestId, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Welcome -> {
|
||||||
|
if (_role.value == JamRole.Guest) {
|
||||||
|
_localParticipantId.value = message.participantId
|
||||||
|
_participants.update { current ->
|
||||||
|
current.map { participant ->
|
||||||
|
if (participant.isHost) {
|
||||||
|
participant.copy(displayName = message.hostName.ifBlank { participant.displayName })
|
||||||
|
} else {
|
||||||
|
participant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.i { "Welcome: joined as ${message.participantId}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.ParticipantList -> {
|
||||||
|
if (_role.value == JamRole.Guest) {
|
||||||
|
_participants.value = message.participants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Kick -> {
|
||||||
|
if (_role.value == JamRole.Guest) {
|
||||||
|
log.i { "Kicked by host: ${message.reason}" }
|
||||||
|
scope.launch { leave() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Leave -> {
|
||||||
|
if (_role.value == JamRole.Host && fromGuestId != null) {
|
||||||
|
scope.launch { removeGuest(fromGuestId) }
|
||||||
|
} else if (_role.value == JamRole.Guest) {
|
||||||
|
scope.launch { leave() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.w(e) { "Failed to parse jam message" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleHello(guestId: String, hello: JamMessage.Hello) {
|
||||||
|
val deviceId = hello.deviceId
|
||||||
|
if (deviceId in bannedDeviceIds) {
|
||||||
|
log.w { "Rejecting banned device $deviceId" }
|
||||||
|
scope.launch {
|
||||||
|
sendMessage(
|
||||||
|
JamMessage.Kick(guestId, "banned by host"),
|
||||||
|
guestId = guestId,
|
||||||
|
)
|
||||||
|
removeGuest(guestId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guestDeviceIds[guestId] = deviceId
|
||||||
|
_participants.update { current ->
|
||||||
|
current.map { participant ->
|
||||||
|
if (participant.id == guestId) {
|
||||||
|
participant.copy(displayName = hello.displayName.ifBlank { participant.displayName })
|
||||||
|
} else {
|
||||||
|
participant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
sendMessage(
|
||||||
|
JamMessage.Welcome(hostDisplayName, guestId),
|
||||||
|
guestId = guestId,
|
||||||
|
)
|
||||||
|
broadcastParticipantList()
|
||||||
|
// Give the newly joined guest the current queue + playback state.
|
||||||
|
queueSyncManager.broadcastNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun shutdownAll() {
|
||||||
|
pendingInvites.values.forEach { runCatching { it.shutdown() } }
|
||||||
|
connectedGuests.values.forEach { runCatching { it.shutdown() } }
|
||||||
|
runCatching { hostConnection?.shutdown() }
|
||||||
|
pendingInvites.clear()
|
||||||
|
connectedGuests.clear()
|
||||||
|
hostConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CHANNEL_LABEL = "jam"
|
||||||
|
|
||||||
|
private fun <T> MutableStateFlow<T>.update(transform: (T) -> T) {
|
||||||
|
value = transform(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomShortId(): String {
|
||||||
|
val chars = "0123456789abcdef"
|
||||||
|
return buildString(8) {
|
||||||
|
repeat(8) {
|
||||||
|
append(chars[kotlin.random.Random.nextInt(chars.length)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,316 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps playback in sync across a jam session (star topology).
|
||||||
|
*
|
||||||
|
* On the **host**: applies incoming playback commands and guest suggestions to the
|
||||||
|
* host's player, and broadcasts the current queue + playback state to all guests
|
||||||
|
* (on queue changes and periodically, so play/pause/seek/position propagate).
|
||||||
|
*
|
||||||
|
* On the **guest**: mirrors the host's queue into the local player and applies
|
||||||
|
* playback commands. The guest's queue is read-only — the host has authority.
|
||||||
|
*/
|
||||||
|
class QueueSyncManager(
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val jamSession: JamSessionService,
|
||||||
|
) {
|
||||||
|
private val log = Logger.withTag("QueueSyncManager")
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private val _isSyncing = MutableStateFlow(false)
|
||||||
|
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
||||||
|
|
||||||
|
private var hostBroadcastJob: Job? = null
|
||||||
|
private var hostCommandJob: Job? = null
|
||||||
|
private var guestApplyJob: Job? = null
|
||||||
|
private var guestCommandJob: Job? = null
|
||||||
|
|
||||||
|
/** Guest side: the last applied queue snapshot, used to detect real queue changes. */
|
||||||
|
private var lastAppliedItems: List<JamMediaItem> = emptyList()
|
||||||
|
|
||||||
|
/** Guest side: tracks the player was told to start playing from. */
|
||||||
|
private var lastAppliedCurrentIndex = -1
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (_isSyncing.value) return
|
||||||
|
_isSyncing.value = true
|
||||||
|
|
||||||
|
when (jamSession.role.value) {
|
||||||
|
JamRole.Host -> startHostSync()
|
||||||
|
JamRole.Guest -> startGuestSync()
|
||||||
|
null -> {
|
||||||
|
_isSyncing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
_isSyncing.value = false
|
||||||
|
hostBroadcastJob?.cancel()
|
||||||
|
hostCommandJob?.cancel()
|
||||||
|
guestApplyJob?.cancel()
|
||||||
|
guestCommandJob?.cancel()
|
||||||
|
hostBroadcastJob = null
|
||||||
|
hostCommandJob = null
|
||||||
|
guestApplyJob = null
|
||||||
|
guestCommandJob = null
|
||||||
|
lastAppliedItems = emptyList()
|
||||||
|
lastAppliedCurrentIndex = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Host side ----------
|
||||||
|
|
||||||
|
private fun startHostSync() {
|
||||||
|
// Apply commands/suggestions coming from guests.
|
||||||
|
hostCommandJob = scope.launch {
|
||||||
|
jamSession.role.first { it != null }
|
||||||
|
if (jamSession.role.value != JamRole.Host) return@launch
|
||||||
|
|
||||||
|
jamSession.incomingMessages.collect { message ->
|
||||||
|
when (message) {
|
||||||
|
is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command)
|
||||||
|
is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem))
|
||||||
|
is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast state on queue changes and periodically.
|
||||||
|
hostBroadcastJob = scope.launch {
|
||||||
|
jamSession.role.first { it != null }
|
||||||
|
if (jamSession.role.value != JamRole.Host) return@launch
|
||||||
|
|
||||||
|
// Queue changes (separate coroutine — collect() never returns).
|
||||||
|
launch {
|
||||||
|
audioPlayerQueue.queueFlow.collect {
|
||||||
|
broadcastCurrentState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic tick so play/pause/seek/position propagate to guests.
|
||||||
|
while (isActive) {
|
||||||
|
delay(2_000)
|
||||||
|
broadcastCurrentState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immediately pushes the current queue + playback state to all guests. */
|
||||||
|
suspend fun broadcastNow() {
|
||||||
|
if (jamSession.role.value == JamRole.Host) {
|
||||||
|
broadcastCurrentState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun broadcastCurrentState() {
|
||||||
|
val queue = audioPlayerQueue.getQueue()
|
||||||
|
val current = audioPlayerQueue.getCurrentQueueEntry()
|
||||||
|
val currentIndex = if (current != null) {
|
||||||
|
queue.indexOfFirst { it.matchesEntry(current) }
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
jamSession.broadcastQueueState(
|
||||||
|
items = queue.map(JamMediaItem::fromQueueEntry),
|
||||||
|
currentIndex = currentIndex.coerceAtLeast(0),
|
||||||
|
isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
|
||||||
|
positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acceptSuggestion(items: List<JamMediaItem>) {
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
val entries = items.map { it.toQueueEntry() }
|
||||||
|
log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" }
|
||||||
|
audioPlayerQueue.addAllToQueue(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Guest side ----------
|
||||||
|
|
||||||
|
private fun startGuestSync() {
|
||||||
|
guestApplyJob = scope.launch {
|
||||||
|
jamSession.incomingMessages.collect { message ->
|
||||||
|
if (message !is JamMessage.QueueState) return@collect
|
||||||
|
applyQueueState(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guestCommandJob = scope.launch {
|
||||||
|
jamSession.incomingMessages.collect { message ->
|
||||||
|
if (message !is JamMessage.PlaybackCommand) return@collect
|
||||||
|
applyPlaybackCommand(message.command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyQueueState(state: JamMessage.QueueState) {
|
||||||
|
log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" }
|
||||||
|
|
||||||
|
// Items that carry neither a track id nor a usable URL can't be played
|
||||||
|
// on this device — skip them instead of crashing the player.
|
||||||
|
val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
|
||||||
|
|
||||||
|
val queueChanged = playableItems != lastAppliedItems
|
||||||
|
if (queueChanged) {
|
||||||
|
lastAppliedItems = playableItems
|
||||||
|
lastAppliedCurrentIndex = state.currentIndex
|
||||||
|
val entries = playableItems.map { it.toQueueEntry() }
|
||||||
|
runCatching {
|
||||||
|
// Load through the queue repository (like the host does) so the
|
||||||
|
// stream proxy can resolve the tracks — it only knows tracks in
|
||||||
|
// queueFlow.
|
||||||
|
audioPlayerQueue.load(
|
||||||
|
entries = entries,
|
||||||
|
autoPlay = state.isPlaying,
|
||||||
|
startPosition = state.currentIndex.coerceIn(0, entries.lastIndex.coerceAtLeast(0)),
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
log.e(e) { "Failed to apply jam queue to local player" }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same queue: just sync playback state. Avoid seeking on every tick unless
|
||||||
|
// the drift is meaningful.
|
||||||
|
if (state.currentIndex != lastAppliedCurrentIndex) {
|
||||||
|
lastAppliedCurrentIndex = state.currentIndex
|
||||||
|
runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) }
|
||||||
|
.onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } }
|
||||||
|
}
|
||||||
|
val currentState = audioPlayer.playerStateFlow.value
|
||||||
|
if (state.isPlaying && currentState != PlayerState.PLAYING) {
|
||||||
|
audioPlayer.play()
|
||||||
|
} else if (!state.isPlaying && currentState == PlayerState.PLAYING) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
}
|
||||||
|
val driftMs = kotlin.math.abs(
|
||||||
|
audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs
|
||||||
|
)
|
||||||
|
if (driftMs > POSITION_SYNC_THRESHOLD_MS) {
|
||||||
|
runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) }
|
||||||
|
.onFailure { e -> log.w(e) { "Failed to sync position" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyPlaybackCommand(command: PlaybackCmd) {
|
||||||
|
log.d { "Applying playback command: $command" }
|
||||||
|
runCatching {
|
||||||
|
when (command) {
|
||||||
|
PlaybackCmd.Play -> audioPlayer.play()
|
||||||
|
PlaybackCmd.Pause -> audioPlayer.pause()
|
||||||
|
PlaybackCmd.Toggle -> {
|
||||||
|
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
||||||
|
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
|
||||||
|
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
|
||||||
|
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
|
||||||
|
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
|
||||||
|
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
|
||||||
|
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to apply playback command: $command" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a queue entry from a jam media item. Streaming tracks carry their id
|
||||||
|
* so the device's own queue/stream proxy can resolve a playable URL later.
|
||||||
|
*/
|
||||||
|
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
|
||||||
|
trackId.isNotBlank() -> QueueEntry.StreamingTrack(
|
||||||
|
track = MetadataTrack(
|
||||||
|
id = trackId,
|
||||||
|
title = title,
|
||||||
|
durationMs = durationMs,
|
||||||
|
trackNumber = null,
|
||||||
|
discNumber = null,
|
||||||
|
artists = listOf(
|
||||||
|
MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null)
|
||||||
|
),
|
||||||
|
album = null,
|
||||||
|
thumbnails = null,
|
||||||
|
explicit = null,
|
||||||
|
popularity = null,
|
||||||
|
isrcCode = null,
|
||||||
|
externalUri = null,
|
||||||
|
),
|
||||||
|
url = "",
|
||||||
|
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||||
|
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> QueueEntry.LocalTrack(
|
||||||
|
name = title,
|
||||||
|
artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
|
duration = durationMs,
|
||||||
|
album = album.ifBlank { null },
|
||||||
|
coverBytes = null,
|
||||||
|
url = url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean {
|
||||||
|
return when {
|
||||||
|
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
||||||
|
this.track.id == other.track.id
|
||||||
|
|
||||||
|
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
|
||||||
|
this.url == other.url && this.name == other.name
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Seek the guest only when its position drifts more than this from the host. */
|
||||||
|
private const val POSITION_SYNC_THRESHOLD_MS = 3_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,18 +21,16 @@ import co.touchlab.kermit.Logger
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamMediaItem
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
@ -89,7 +87,7 @@ class RemotePlaybackController(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val jamRoomService: JamRoomService,
|
private val jamSession: JamSessionService,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
private val logger = Logger.withTag("RemotePlaybackController")
|
private val logger = Logger.withTag("RemotePlaybackController")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@ -97,10 +95,6 @@ class RemotePlaybackController(
|
|||||||
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
|
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
|
||||||
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
|
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
|
||||||
|
|
||||||
/** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */
|
|
||||||
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
|
||||||
val events: SharedFlow<String> = _events.asSharedFlow()
|
|
||||||
|
|
||||||
fun isRemoteConnected(): Boolean {
|
fun isRemoteConnected(): Boolean {
|
||||||
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
return remoteControlClient.connectionState.value is ConnectionState.Connected
|
||||||
}
|
}
|
||||||
@ -164,56 +158,45 @@ class RemotePlaybackController(
|
|||||||
_pendingRequest.value = null
|
_pendingRequest.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Jam actions ----------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a single track to the active jam queue. The host applies it to the
|
* Routes the pending request into the active jam session. On the host the jam
|
||||||
* local (shared) queue directly; a guest suggests it to the host over MQTT.
|
* queue IS the local queue, so the action runs locally; on a guest the content
|
||||||
|
* is suggested to the host, which accepts it into the shared queue.
|
||||||
*/
|
*/
|
||||||
fun addTrackToJam(track: MetadataTrack) {
|
fun playOnJam() {
|
||||||
if (jamRoomService.role.value == null) return
|
val request = _pendingRequest.value ?: return
|
||||||
|
_pendingRequest.value = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
when (jamRoomService.role.value) {
|
when (jamSession.role.value) {
|
||||||
JamRole.Host -> audioPlayerQueue.addToQueue(
|
JamRole.Host -> executeLocally(request)
|
||||||
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId)
|
JamRole.Guest -> suggestToJam(request)
|
||||||
)
|
null -> {}
|
||||||
|
|
||||||
JamRole.Guest -> jamRoomService.suggestTrack(track)
|
|
||||||
null -> return@launch
|
|
||||||
}
|
}
|
||||||
_events.emit("Added to the jam queue")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.e(e) { "Failed to add track to jam session" }
|
logger.e(e) { "Failed to send content to jam session" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private suspend fun suggestToJam(request: PlaybackDestinationRequest) {
|
||||||
* Adds multiple tracks to the active jam queue (host applies locally,
|
when (request) {
|
||||||
* guest suggests to the host).
|
is PlaybackDestinationRequest.Collection -> {
|
||||||
*/
|
val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id)
|
||||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
if (tracks.isNotEmpty()) {
|
||||||
if (tracks.isEmpty() || jamRoomService.role.value == null) return
|
jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() })
|
||||||
scope.launch {
|
logger.i { "Suggested ${tracks.size} track(s) to the jam session" }
|
||||||
try {
|
}
|
||||||
when (jamRoomService.role.value) {
|
}
|
||||||
JamRole.Host -> audioPlayerQueue.addAllToQueue(
|
|
||||||
tracks.map { track ->
|
is PlaybackDestinationRequest.Track -> {
|
||||||
QueueEntry.StreamingTrack(
|
jamSession.suggestTrack(request.track.toJamMediaItem())
|
||||||
track = track,
|
}
|
||||||
url = "",
|
|
||||||
addedBy = jamRoomService.participantClientId,
|
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" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -385,4 +368,6 @@ class RemotePlaybackController(
|
|||||||
album?.id == other.album?.id &&
|
album?.id == other.album?.id &&
|
||||||
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this)
|
||||||
@ -27,7 +27,6 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
@ -49,7 +48,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
@ -73,8 +71,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
|
|
||||||
private val BadgeShape = RoundedCornerShape(11.dp)
|
private val BadgeShape = RoundedCornerShape(11.dp)
|
||||||
private const val DisabledContentAlpha = 0.38f
|
|
||||||
|
|
||||||
private val ButtonMinHeight = 40.dp
|
private val ButtonMinHeight = 40.dp
|
||||||
private val SquareButtonSize = 40.dp
|
private val SquareButtonSize = 40.dp
|
||||||
|
|
||||||
@ -182,7 +178,6 @@ fun OutlineButton(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = {
|
content = {
|
||||||
@ -231,7 +226,6 @@ fun PrimaryButton(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = content,
|
content = content,
|
||||||
@ -277,7 +271,6 @@ fun SecondaryButton(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = content,
|
content = content,
|
||||||
@ -495,14 +488,7 @@ fun GroupIconButton(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
content()
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -78,9 +78,6 @@ fun CollectionDetails(
|
|||||||
onShufflePlay: () -> Unit,
|
onShufflePlay: () -> Unit,
|
||||||
onAddToQueue: () -> Unit,
|
onAddToQueue: () -> Unit,
|
||||||
isPlaying: Boolean = false,
|
isPlaying: Boolean = false,
|
||||||
/** Guest in a jam session: play/shuffle are replaced by Add to Jam. */
|
|
||||||
isJamGuest: Boolean = false,
|
|
||||||
onAddToJam: (() -> Unit)? = null,
|
|
||||||
isFollowing: Boolean = false,
|
isFollowing: Boolean = false,
|
||||||
onFollowClick: () -> Unit = { },
|
onFollowClick: () -> Unit = { },
|
||||||
showFollowButton: Boolean = true,
|
showFollowButton: Boolean = true,
|
||||||
@ -94,61 +91,39 @@ fun CollectionDetails(
|
|||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||||
|
|
||||||
val playPauseButton = @Composable {
|
val playPauseButton = @Composable {
|
||||||
if (isJamGuest) {
|
|
||||||
if (onAddToJam != null) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
PrimaryButton(
|
|
||||||
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
|
||||||
onClick = onAddToJam!!,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
|
||||||
contentDescription = "Add to Jam",
|
|
||||||
)
|
|
||||||
TextWithShimmer(
|
|
||||||
text = "Add to Jam",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
val modifier = if (isCompact) {
|
|
||||||
Modifier.weight(1f)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
|
|
||||||
PrimaryButton(
|
Row(
|
||||||
modifier = modifier,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = onPlay,
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
val modifier = if (isCompact) {
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
Modifier.weight(1f)
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
} else {
|
||||||
)
|
Modifier
|
||||||
TextWithShimmer(
|
}
|
||||||
text = if (isPlaying) "Pause" else "Play",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
PrimaryButton(
|
||||||
)
|
modifier = modifier,
|
||||||
}
|
onClick = onPlay,
|
||||||
OutlineButton(
|
) {
|
||||||
modifier = modifier,
|
Icon(
|
||||||
onClick = onShufflePlay
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
) {
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
)
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = "Shuffle",
|
text = if (isPlaying) "Pause" else "Play",
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
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,10 +87,6 @@ fun CollectionView(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
|
||||||
isInJam: Boolean = false,
|
|
||||||
isJamGuest: Boolean = false,
|
|
||||||
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
footerContent: (@Composable () -> Unit)? = null,
|
footerContent: (@Composable () -> Unit)? = null,
|
||||||
trailingContent: @Composable () -> Unit = {},
|
trailingContent: @Composable () -> Unit = {},
|
||||||
@ -118,7 +114,7 @@ fun CollectionView(
|
|||||||
} else {
|
} else {
|
||||||
{}
|
{}
|
||||||
},
|
},
|
||||||
actions = if (isCollapsed && !isJamGuest) {
|
actions = if (isCollapsed) {
|
||||||
{
|
{
|
||||||
IconButton(onClick = onPlay) {
|
IconButton(onClick = onPlay) {
|
||||||
Icon(
|
Icon(
|
||||||
@ -163,8 +159,6 @@ fun CollectionView(
|
|||||||
showFollowButton = showFollowButton,
|
showFollowButton = showFollowButton,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onAddToJam = { onAddToJam(tracks) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -185,8 +179,6 @@ fun CollectionView(
|
|||||||
showFollowButton = showFollowButton,
|
showFollowButton = showFollowButton,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onAddToJam = { onAddToJam(tracks) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -208,9 +200,6 @@ fun CollectionView(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onBulkAddToJam = onBulkAddToJam,
|
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
trackOptionsState = trackOptionsState,
|
trackOptionsState = trackOptionsState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -143,9 +143,6 @@ fun TrackList(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
|
||||||
isInJam: Boolean = false,
|
|
||||||
isJamGuest: Boolean = false,
|
|
||||||
currentTrackId: String? = null,
|
currentTrackId: String? = null,
|
||||||
isCurrentTrackPlaying: Boolean = false,
|
isCurrentTrackPlaying: Boolean = false,
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
@ -250,8 +247,6 @@ fun TrackList(
|
|||||||
} else {
|
} else {
|
||||||
selectedTrackIds + track.id
|
selectedTrackIds + track.id
|
||||||
}
|
}
|
||||||
} else if (isJamGuest) {
|
|
||||||
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
|
||||||
} else {
|
} else {
|
||||||
onTrackClick(track)
|
onTrackClick(track)
|
||||||
}
|
}
|
||||||
@ -279,8 +274,6 @@ fun TrackList(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trackOptionsState = trackOptionsState(track),
|
trackOptionsState = trackOptionsState(track),
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
@ -428,48 +421,28 @@ fun TrackList(
|
|||||||
val isAll =
|
val isAll =
|
||||||
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = buildList {
|
items = listOf(
|
||||||
add(
|
AdaptiveMenuItem(
|
||||||
AdaptiveMenuItem(
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
label = if (isAll) "Download All" else "Download $trackCount",
|
||||||
label = if (isAll) "Download All" else "Download $trackCount",
|
onClick = { onBulkDownload(targetTracks) },
|
||||||
onClick = { onBulkDownload(targetTracks) },
|
),
|
||||||
),
|
AdaptiveMenuItem(
|
||||||
)
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
if (!isJamGuest) {
|
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||||
add(
|
onClick = { onBulkAddToQueue(targetTracks) },
|
||||||
AdaptiveMenuItem(
|
),
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
AdaptiveMenuItem(
|
||||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
icon = Iconsax.IconsaxNext,
|
||||||
onClick = { onBulkAddToQueue(targetTracks) },
|
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||||
),
|
onClick = { onBulkPlayNext(targetTracks) },
|
||||||
)
|
),
|
||||||
add(
|
AdaptiveMenuItem(
|
||||||
AdaptiveMenuItem(
|
icon = Iconsax.IconsaxMusicPlaylist,
|
||||||
icon = Iconsax.IconsaxNext,
|
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||||
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 ->
|
trigger = { onClick ->
|
||||||
GroupIconButton(
|
GroupIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@ -524,8 +497,6 @@ fun TrackList(
|
|||||||
selectedTrackForOptions = null
|
selectedTrackForOptions = null
|
||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -584,8 +555,6 @@ private fun TrackListRow(
|
|||||||
onSelectionToggle: (Boolean) -> Unit,
|
onSelectionToggle: (Boolean) -> Unit,
|
||||||
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
||||||
trackOptionsState: TrackOptionsState,
|
trackOptionsState: TrackOptionsState,
|
||||||
isInJam: Boolean,
|
|
||||||
isJamGuest: Boolean,
|
|
||||||
onShowOptionsClick: () -> Unit,
|
onShowOptionsClick: () -> Unit,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
@ -781,8 +750,6 @@ private fun TrackListRow(
|
|||||||
state = trackOptionsState,
|
state = trackOptionsState,
|
||||||
onAction = onTrackOptionsAction,
|
onAction = onTrackOptionsAction,
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
GhostIconButton(onClick = onShowOptionsClick) {
|
GhostIconButton(onClick = onShowOptionsClick) {
|
||||||
@ -876,8 +843,6 @@ private fun ShimmerTrackListRow(
|
|||||||
onSelectionToggle = {},
|
onSelectionToggle = {},
|
||||||
onTrackOptionsAction = {},
|
onTrackOptionsAction = {},
|
||||||
trackOptionsState = TrackOptionsState(),
|
trackOptionsState = TrackOptionsState(),
|
||||||
isInJam = false,
|
|
||||||
isJamGuest = false,
|
|
||||||
onShowOptionsClick = {},
|
onShowOptionsClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
|
|||||||
@ -56,7 +56,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||||
|
|
||||||
sealed interface TrackOptionsAction {
|
sealed interface TrackOptionsAction {
|
||||||
data object AddToJam : TrackOptionsAction
|
|
||||||
data object StartRadio : TrackOptionsAction
|
data object StartRadio : TrackOptionsAction
|
||||||
data object PlayNext : TrackOptionsAction
|
data object PlayNext : TrackOptionsAction
|
||||||
data object AddToQueue : TrackOptionsAction
|
data object AddToQueue : TrackOptionsAction
|
||||||
@ -99,8 +98,6 @@ fun TrackOptions(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isInJam: Boolean = false,
|
|
||||||
isJamGuest: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = buildTrackMenuItems(
|
items = buildTrackMenuItems(
|
||||||
@ -108,8 +105,6 @@ fun TrackOptions(
|
|||||||
state = state,
|
state = state,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
),
|
),
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GhostIconButton(onClick = onClick) {
|
GhostIconButton(onClick = onClick) {
|
||||||
@ -134,8 +129,6 @@ fun TrackOptionsBottomSheet(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
|
||||||
isJamGuest: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
@ -158,8 +151,6 @@ fun TrackOptionsBottomSheet(
|
|||||||
onAlbumClick()
|
onAlbumClick()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
).forEach { item ->
|
).forEach { item ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -250,19 +241,7 @@ private fun buildTrackMenuItems(
|
|||||||
state: TrackOptionsState,
|
state: TrackOptionsState,
|
||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
|
||||||
isJamGuest: Boolean = false,
|
|
||||||
): List<AdaptiveMenuItem> = buildList {
|
): List<AdaptiveMenuItem> = buildList {
|
||||||
if (isInJam) {
|
|
||||||
add(
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
|
||||||
label = "Add to Jam",
|
|
||||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicCircle,
|
icon = Iconsax.IconsaxMusicCircle,
|
||||||
@ -271,44 +250,40 @@ private fun buildTrackMenuItems(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// A guest's queue is the shared jam queue — mutating it locally is not
|
if (!state.isInQueue && !state.isCurrentlyPlaying) {
|
||||||
// allowed, so queue actions are replaced by "Add to Jam".
|
add(
|
||||||
if (!isJamGuest) {
|
AdaptiveMenuItem(
|
||||||
if (!state.isInQueue && !state.isCurrentlyPlaying) {
|
icon = Iconsax.IconsaxNext,
|
||||||
add(
|
label = "Play next",
|
||||||
AdaptiveMenuItem(
|
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||||
icon = Iconsax.IconsaxNext,
|
),
|
||||||
label = "Play next",
|
)
|
||||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||||
),
|
add(
|
||||||
)
|
AdaptiveMenuItem(
|
||||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
icon = Iconsax.IconsaxNext,
|
||||||
add(
|
label = "Move to next",
|
||||||
AdaptiveMenuItem(
|
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||||
icon = Iconsax.IconsaxNext,
|
),
|
||||||
label = "Move to next",
|
)
|
||||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
}
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.isInQueue) {
|
if (!state.isInQueue) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = "Add to queue",
|
label = "Add to queue",
|
||||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||||
label = "Remove from queue",
|
label = "Remove from queue",
|
||||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
add(
|
add(
|
||||||
|
|||||||
@ -20,14 +20,10 @@ package dev.krtirtho.spotube.modules.album
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
@ -41,11 +37,6 @@ fun AlbumScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
||||||
@ -101,10 +92,6 @@ fun AlbumScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
|
||||||
isInJam = jamActive,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onAddToJam = viewModel::addTracksToJam,
|
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
|
|||||||
@ -273,10 +273,6 @@ class AlbumViewModel(
|
|||||||
remotePlaybackController.requestTrackAddToQueue(track)
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToJam -> {
|
|
||||||
remotePlaybackController.addTrackToJam(track)
|
|
||||||
}
|
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
queue.find { entry ->
|
||||||
@ -337,10 +333,6 @@ class AlbumViewModel(
|
|||||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
|
||||||
remotePlaybackController.addTracksToJam(tracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||||
|
|||||||
@ -63,11 +63,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||||
@ -98,11 +95,6 @@ fun ArtistScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
||||||
@ -187,9 +179,6 @@ fun ArtistScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
|
||||||
isInJam = jamActive,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -276,10 +276,6 @@ class ArtistViewModel(
|
|||||||
startTrack = track,
|
startTrack = track,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
|
||||||
remotePlaybackController.addTracksToJam(tracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
|
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
|
||||||
@ -310,10 +306,6 @@ class ArtistViewModel(
|
|||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
remotePlaybackController.requestTrackAddToQueue(track)
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToJam -> {
|
|
||||||
remotePlaybackController.addTrackToJam(track)
|
|
||||||
}
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
queue.find { entry ->
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
import dev.krtirtho.spotube.core.remote.ConnectionState
|
import dev.krtirtho.spotube.core.remote.ConnectionState
|
||||||
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
|
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||||
@ -39,6 +40,7 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,8 +52,10 @@ import org.koin.compose.koinInject
|
|||||||
fun PlayDestinationPickerHost() {
|
fun PlayDestinationPickerHost() {
|
||||||
val controller = koinInject<RemotePlaybackController>()
|
val controller = koinInject<RemotePlaybackController>()
|
||||||
val remoteControlClient = koinInject<RemoteControlClient>()
|
val remoteControlClient = koinInject<RemoteControlClient>()
|
||||||
|
val jamSession = koinInject<JamSessionService>()
|
||||||
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
val request by controller.pendingRequest.collectAsStateWithLifecycle()
|
||||||
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
|
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
|
||||||
|
val jamActive by jamSession.isActive.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val pendingRequest = request ?: return
|
val pendingRequest = request ?: return
|
||||||
|
|
||||||
@ -137,6 +141,33 @@ fun PlayDestinationPickerHost() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jamActive) {
|
||||||
|
ListRowTile(
|
||||||
|
onClick = controller::playOnJam,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxMusicPlaylist,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Jam Session",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
subtitle = {
|
||||||
|
Text(
|
||||||
|
text = "$actionLabel in the shared jam queue",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
|||||||
@ -17,18 +17,25 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.jam
|
package dev.krtirtho.spotube.modules.jam
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@ -41,28 +48,38 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomCode
|
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
/**
|
|
||||||
* Group Jam session screen. Playback controls and the queue live in the app's
|
|
||||||
* regular player / queue sheet (the shared jam queue is the local queue), so
|
|
||||||
* this screen only covers participation and session management.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun JamScreen(
|
fun JamScreen(
|
||||||
navigationCommands: NavigationCommands,
|
navigationCommands: NavigationCommands,
|
||||||
@ -93,19 +110,48 @@ fun JamScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
!state.isActive -> CreateOrJoinView(
|
!state.isActive && state.incomingOfferSdp != null -> IncomingInviteView(
|
||||||
state = state,
|
hostName = state.incomingHostName.orEmpty(),
|
||||||
onCreate = viewModel::createSession,
|
onJoin = viewModel::joinWithIncomingInvite,
|
||||||
onJoin = viewModel::joinWithCode,
|
onDismiss = viewModel::dismissIncomingInvite,
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> SessionView(
|
!state.isActive -> CreateOrJoinView(
|
||||||
|
onCreate = viewModel::createSession,
|
||||||
|
onJoin = viewModel::joinWithPasted,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.role == JamRole.Host -> HostSessionView(
|
||||||
state = state,
|
state = state,
|
||||||
onShareCode = viewModel::shareRoomCode,
|
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
|
||||||
|
onNewInvite = viewModel::generateNewInvite,
|
||||||
|
onSubmitAnswer = viewModel::submitAnswerPasted,
|
||||||
|
onShare = viewModel::share,
|
||||||
onLeave = viewModel::leave,
|
onLeave = viewModel::leave,
|
||||||
|
onTogglePlayPause = viewModel::togglePlayPause,
|
||||||
|
onSkipNext = viewModel::skipNext,
|
||||||
|
onSkipPrevious = viewModel::skipPrevious,
|
||||||
|
onSeek = viewModel::seek,
|
||||||
|
onJumpTo = viewModel::jumpTo,
|
||||||
|
onToggleShuffle = viewModel::toggleShuffle,
|
||||||
|
onCycleLoop = viewModel::cycleLoopMode,
|
||||||
onKick = viewModel::kickParticipant,
|
onKick = viewModel::kickParticipant,
|
||||||
onBan = viewModel::banParticipant,
|
onBan = viewModel::banParticipant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
else -> GuestSessionView(
|
||||||
|
state = state,
|
||||||
|
playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value,
|
||||||
|
onShare = viewModel::share,
|
||||||
|
onLeave = viewModel::leave,
|
||||||
|
onTogglePlayPause = viewModel::togglePlayPause,
|
||||||
|
onSkipNext = viewModel::skipNext,
|
||||||
|
onSkipPrevious = viewModel::skipPrevious,
|
||||||
|
onSeek = viewModel::seek,
|
||||||
|
onJumpTo = viewModel::jumpTo,
|
||||||
|
onToggleShuffle = viewModel::toggleShuffle,
|
||||||
|
onCycleLoop = viewModel::cycleLoopMode,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,33 +173,18 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CreateOrJoinView(
|
private fun CreateOrJoinView(
|
||||||
state: JamUiState,
|
|
||||||
onCreate: () -> Unit,
|
onCreate: () -> Unit,
|
||||||
onJoin: (String) -> Unit,
|
onJoin: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
var tab by remember { mutableIntStateOf(0) }
|
||||||
var pasted by rememberSaveable { mutableStateOf("") }
|
var pasted by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.",
|
text = "Listen together with friends over a peer-to-peer connection.",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!state.brokerConfigured) {
|
|
||||||
Text(
|
|
||||||
text = "No jam broker configured — set one up in Settings to host or join a session.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Broker: ${state.brokerHost}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
selected = tab == 0,
|
selected = tab == 0,
|
||||||
@ -170,36 +201,34 @@ private fun CreateOrJoinView(
|
|||||||
if (tab == 0) {
|
if (tab == 0) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Start a session as the host. You'll get a 6-character room code to " +
|
text = "Start a session as the host. You'll get a shareable invite link " +
|
||||||
"share with friends; you control the queue.",
|
"to send to friends; when they accept, they appear here.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(
|
Button(onClick = onCreate) {
|
||||||
onClick = onCreate,
|
|
||||||
enabled = state.brokerConfigured,
|
|
||||||
) {
|
|
||||||
Text("Create Session")
|
Text("Create Session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = "Enter the 6-character room code the host shared with you.",
|
text = "Paste the invite link the host shared with you.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = pasted,
|
value = pasted,
|
||||||
onValueChange = { pasted = JamRoomCode.normalize(it) },
|
onValueChange = { pasted = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Room code") },
|
label = { Text("Invite link") },
|
||||||
placeholder = { Text("ABC123") },
|
placeholder = { Text("spotube://jam/invite?...") },
|
||||||
singleLine = true,
|
minLines = 2,
|
||||||
|
maxLines = 6,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { onJoin(pasted) },
|
onClick = { onJoin(pasted) },
|
||||||
enabled = state.brokerConfigured && JamRoomCode.isValid(pasted),
|
enabled = pasted.isNotBlank(),
|
||||||
) {
|
) {
|
||||||
Text("Join Session")
|
Text("Join Session")
|
||||||
}
|
}
|
||||||
@ -209,54 +238,401 @@ private fun CreateOrJoinView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SessionView(
|
private fun IncomingInviteView(
|
||||||
|
hostName: String,
|
||||||
|
onJoin: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "$hostName invited you to a jam session",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onJoin) {
|
||||||
|
Text("Join")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onDismiss) {
|
||||||
|
Text("Ignore")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HostSessionView(
|
||||||
state: JamUiState,
|
state: JamUiState,
|
||||||
onShareCode: () -> Unit,
|
playerState: JamPlayerUiState,
|
||||||
|
onNewInvite: () -> Unit,
|
||||||
|
onSubmitAnswer: (String) -> Unit,
|
||||||
|
onShare: (String) -> Unit,
|
||||||
onLeave: () -> Unit,
|
onLeave: () -> Unit,
|
||||||
|
onTogglePlayPause: () -> Unit,
|
||||||
|
onSkipNext: () -> Unit,
|
||||||
|
onSkipPrevious: () -> Unit,
|
||||||
|
onSeek: (Long) -> Unit,
|
||||||
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleLoop: () -> Unit,
|
||||||
onKick: (String) -> Unit,
|
onKick: (String) -> Unit,
|
||||||
onBan: (String) -> Unit,
|
onBan: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val isHost = state.role == JamRole.Host
|
val clipboard = LocalClipboardManager.current
|
||||||
|
var pastedAnswer by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
if (!state.isConnected) {
|
ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan)
|
||||||
Text(
|
|
||||||
text = "Connecting to the session…",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ParticipantsSection(
|
JamNowPlayingView(
|
||||||
participants = state.participants,
|
playerState = playerState,
|
||||||
isHost = isHost,
|
onTogglePlayPause = onTogglePlayPause,
|
||||||
onKick = onKick,
|
onSkipNext = onSkipNext,
|
||||||
onBan = onBan,
|
onSkipPrevious = onSkipPrevious,
|
||||||
|
onToggleShuffle = onToggleShuffle,
|
||||||
|
onCycleLoop = onCycleLoop,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isHost) {
|
HorizontalDivider()
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Invite someone",
|
text = "Invite someone",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
val inviteLink = state.inviteLink
|
||||||
|
if (inviteLink != null) {
|
||||||
|
ShareableLinkBox(
|
||||||
|
label = "Invite link",
|
||||||
|
link = inviteLink,
|
||||||
|
onCopy = { clipboard.setText(AnnotatedString(inviteLink)) },
|
||||||
|
onShare = { onShare(inviteLink) },
|
||||||
)
|
)
|
||||||
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode)
|
}
|
||||||
|
OutlinedButton(onClick = onNewInvite) {
|
||||||
|
Text("Generate new invite")
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "The queue and playback controls are in the player at the bottom of the app — " +
|
text = "Accept a guest's answer",
|
||||||
"the jam queue is shared with every participant.",
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "When your guest sends back their answer link, paste it below.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pastedAnswer,
|
||||||
|
onValueChange = { pastedAnswer = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Answer link or SDP") },
|
||||||
|
minLines = 2,
|
||||||
|
maxLines = 6,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSubmitAnswer(pastedAnswer)
|
||||||
|
pastedAnswer = ""
|
||||||
|
},
|
||||||
|
enabled = pastedAnswer.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Accept Answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
JamQueueView(
|
||||||
|
queue = playerState.queue,
|
||||||
|
onJumpTo = onJumpTo,
|
||||||
|
)
|
||||||
|
|
||||||
LeaveButton(onLeave)
|
LeaveButton(onLeave)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GuestSessionView(
|
||||||
|
state: JamUiState,
|
||||||
|
playerState: JamPlayerUiState,
|
||||||
|
onShare: (String) -> Unit,
|
||||||
|
onLeave: () -> Unit,
|
||||||
|
onTogglePlayPause: () -> Unit,
|
||||||
|
onSkipNext: () -> Unit,
|
||||||
|
onSkipPrevious: () -> Unit,
|
||||||
|
onSeek: (Long) -> Unit,
|
||||||
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleLoop: () -> Unit,
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {})
|
||||||
|
|
||||||
|
val answerLink = state.answerLink
|
||||||
|
when {
|
||||||
|
state.isConnected -> {
|
||||||
|
JamNowPlayingView(
|
||||||
|
playerState = playerState,
|
||||||
|
onTogglePlayPause = onTogglePlayPause,
|
||||||
|
onSkipNext = onSkipNext,
|
||||||
|
onSkipPrevious = onSkipPrevious,
|
||||||
|
onToggleShuffle = onToggleShuffle,
|
||||||
|
onCycleLoop = onCycleLoop,
|
||||||
|
)
|
||||||
|
|
||||||
|
JamQueueView(
|
||||||
|
queue = playerState.queue,
|
||||||
|
onJumpTo = onJumpTo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
answerLink == null -> {
|
||||||
|
Text(
|
||||||
|
text = "Connecting to the session...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Text(
|
||||||
|
text = "Almost there! Send your answer back to the host:",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
ShareableLinkBox(
|
||||||
|
label = "Answer link",
|
||||||
|
link = answerLink,
|
||||||
|
onCopy = { clipboard.setText(AnnotatedString(answerLink)) },
|
||||||
|
onShare = { onShare(answerLink) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LeaveButton(onLeave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JamNowPlayingView(
|
||||||
|
playerState: JamPlayerUiState,
|
||||||
|
onTogglePlayPause: () -> Unit,
|
||||||
|
onSkipNext: () -> Unit,
|
||||||
|
onSkipPrevious: () -> Unit,
|
||||||
|
onToggleShuffle: () -> Unit,
|
||||||
|
onCycleLoop: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() },
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = playerState.currentTitle ?: "Nothing playing",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = playerState.currentArtist ?: "—",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatJamDuration(playerState.positionMs),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatJamDuration(playerState.durationMs),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onToggleShuffle,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxShuffle,
|
||||||
|
contentDescription = "Shuffle",
|
||||||
|
tint = if (playerState.shuffleEnabled) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onSkipPrevious,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onTogglePlayPause,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape),
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (playerState.isPlaying) {
|
||||||
|
Iconsax.IconsaxPause
|
||||||
|
} else {
|
||||||
|
Iconsax.IconsaxPlay
|
||||||
|
},
|
||||||
|
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onSkipNext,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onCycleLoop,
|
||||||
|
theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxRepeateMusic,
|
||||||
|
contentDescription = "Loop mode",
|
||||||
|
tint = if (playerState.loopMode != "none") {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JamQueueView(
|
||||||
|
queue: List<JamQueueUiItem>,
|
||||||
|
onJumpTo: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { expanded = !expanded },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Queue (${queue.size})",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxArrowDown4,
|
||||||
|
contentDescription = if (expanded) "Collapse queue" else "Expand queue",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.graphicsLayer { rotationZ = if (expanded) 180f else 0f },
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else if (expanded) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 280.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
itemsIndexed(queue) { index, item ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onJumpTo(index) }
|
||||||
|
.padding(vertical = 6.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.coverUrl.takeIf { it.isNotBlank() },
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (item.isCurrent) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.artist,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formatJamDuration(item.durationMs),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Tap to view the shared queue.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ParticipantsSection(
|
private fun ParticipantsSection(
|
||||||
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
|
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
|
||||||
@ -308,26 +684,35 @@ private fun ParticipantsSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatJamDuration(ms: Long): String {
|
||||||
|
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||||
|
val minutes = totalSeconds / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoomCodeBox(
|
private fun ShareableLinkBox(
|
||||||
code: String,
|
label: String,
|
||||||
|
link: String,
|
||||||
|
onCopy: () -> Unit,
|
||||||
onShare: () -> Unit,
|
onShare: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
val viewModel: JamViewModel = koinViewModel()
|
val viewModel: JamViewModel = koinViewModel()
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
OutlinedTextField(
|
||||||
text = code,
|
value = link,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
onValueChange = {},
|
||||||
fontWeight = FontWeight.Bold,
|
readOnly = true,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
label = { Text(label) },
|
||||||
|
minLines = 2,
|
||||||
|
maxLines = 6,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Button(onClick = { clipboard.setText(AnnotatedString(code)) }) {
|
Button(onClick = onCopy) {
|
||||||
Text("Copy")
|
Text("Copy")
|
||||||
}
|
}
|
||||||
if (viewModel.supportsNativeShare) {
|
if (viewModel.supportsNativeShare) {
|
||||||
|
|||||||
@ -20,10 +20,22 @@ package dev.krtirtho.spotube.modules.jam
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.spotube.PlatformType
|
import dev.krtirtho.spotube.PlatformType
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamInviteCodec
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamInviteLink
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamLoopMapping
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamMediaItem
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamMessage
|
||||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomCode
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
import dev.krtirtho.spotube.core.jam.PlaybackCmd
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
@ -33,6 +45,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class JamUiState(
|
data class JamUiState(
|
||||||
@ -40,104 +53,377 @@ data class JamUiState(
|
|||||||
val isConnected: Boolean = false,
|
val isConnected: Boolean = false,
|
||||||
val role: JamRole? = null,
|
val role: JamRole? = null,
|
||||||
val participants: List<JamParticipant> = emptyList(),
|
val participants: List<JamParticipant> = emptyList(),
|
||||||
val roomCode: String? = null,
|
/** Host: deep link containing this session's SDP offer, ready to share. */
|
||||||
val brokerHost: String = "",
|
val inviteLink: String? = null,
|
||||||
val brokerConfigured: Boolean = false,
|
/** Guest: deep link containing our SDP answer, to send back to the host. */
|
||||||
|
val answerLink: String? = null,
|
||||||
|
/** Guest: offer received via deep link (or paste), waiting for confirmation. */
|
||||||
|
val incomingHostName: String? = null,
|
||||||
|
val incomingOfferSdp: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class JamQueueUiItem(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artist: String,
|
||||||
|
val album: String,
|
||||||
|
val durationMs: Long,
|
||||||
|
val coverUrl: String,
|
||||||
|
val isCurrent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class JamPlayerUiState(
|
||||||
|
val queue: List<JamQueueUiItem> = emptyList(),
|
||||||
|
val currentIndex: Int = -1,
|
||||||
|
val currentTitle: String? = null,
|
||||||
|
val currentArtist: String? = null,
|
||||||
|
val currentCoverUrl: String? = null,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val positionMs: Long = 0,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val shuffleEnabled: Boolean = false,
|
||||||
|
val loopMode: String = "none",
|
||||||
|
)
|
||||||
|
|
||||||
class JamViewModel(
|
class JamViewModel(
|
||||||
private val jamRoomService: JamRoomService,
|
private val jamSession: JamSessionService,
|
||||||
|
private val deepLinks: JamDeepLinkService,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider,
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _localError = MutableStateFlow<String?>(null)
|
private val _uiState = MutableStateFlow(JamUiState())
|
||||||
|
val uiState: StateFlow<JamUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
val supportsNativeShare: Boolean =
|
val supportsNativeShare: Boolean =
|
||||||
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
||||||
|
|
||||||
val uiState: StateFlow<JamUiState> = combine(
|
init {
|
||||||
jamRoomService.role,
|
viewModelScope.launch {
|
||||||
jamRoomService.participants,
|
// Mirror live session state into the UI state.
|
||||||
jamRoomService.isConnected,
|
jamSession.isActive.collect { active ->
|
||||||
jamRoomService.roomCode,
|
_uiState.update {
|
||||||
jamRoomService.connectionError,
|
it.copy(
|
||||||
settingsProvider.settingsState,
|
isActive = active,
|
||||||
_localError,
|
isConnected = jamSession.isConnected.value,
|
||||||
|
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,
|
||||||
) { values ->
|
) { values ->
|
||||||
@Suppress("UNCHECKED_CAST")
|
val queue: List<QueueEntry> = values[0] as List<QueueEntry>
|
||||||
val role = values[0] as JamRole?
|
val currentEntry: QueueEntry? = values[1] as QueueEntry?
|
||||||
@Suppress("UNCHECKED_CAST")
|
val playlist: List<MediaItem> = values[2] as List<MediaItem>
|
||||||
val participants = values[1] as List<JamParticipant>
|
val currentItem: MediaItem? = values[3] as MediaItem?
|
||||||
val isConnected = values[2] as Boolean
|
val playerState: PlayerState = values[4] as PlayerState
|
||||||
val roomCode = values[3] as String?
|
val position: kotlin.time.Duration = values[5] as kotlin.time.Duration
|
||||||
val connectionError = values[4] as String?
|
val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration
|
||||||
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
|
val loop: LoopState = values[7] as LoopState
|
||||||
val localError = values[6] as String?
|
val shuffle: Boolean = values[8] as Boolean
|
||||||
|
|
||||||
JamUiState(
|
val isHost = jamSession.role.value == JamRole.Host
|
||||||
isActive = role != null,
|
|
||||||
isConnected = isConnected,
|
val items: List<JamQueueUiItem>
|
||||||
role = role,
|
val currentIndex: Int
|
||||||
participants = participants,
|
val currentTitle: String?
|
||||||
roomCode = roomCode,
|
val currentArtist: String?
|
||||||
brokerHost = settings?.jamBroker?.host.orEmpty(),
|
val currentCoverUrl: String?
|
||||||
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(),
|
|
||||||
error = localError ?: connectionError,
|
if (isHost) {
|
||||||
|
val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) }
|
||||||
|
val index = if (currentEntry != null) {
|
||||||
|
queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) }
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
items = queueItems.mapIndexed { i, item ->
|
||||||
|
item.toUiItem(i == index)
|
||||||
|
}
|
||||||
|
currentIndex = index
|
||||||
|
currentTitle = queueItems.getOrNull(index)?.title
|
||||||
|
currentArtist = queueItems.getOrNull(index)?.artist
|
||||||
|
currentCoverUrl = queueItems.getOrNull(index)?.coverUrl
|
||||||
|
} else {
|
||||||
|
val index = playlist.indexOf(currentItem)
|
||||||
|
items = playlist.mapIndexed { i, item ->
|
||||||
|
JamMediaItem.fromMediaItem(item).toUiItem(i == index)
|
||||||
|
}
|
||||||
|
currentIndex = index
|
||||||
|
currentTitle = currentItem?.title
|
||||||
|
currentArtist = currentItem?.artist
|
||||||
|
currentCoverUrl = currentItem?.coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
JamPlayerUiState(
|
||||||
|
queue = items,
|
||||||
|
currentIndex = currentIndex,
|
||||||
|
currentTitle = currentTitle,
|
||||||
|
currentArtist = currentArtist,
|
||||||
|
currentCoverUrl = currentCoverUrl,
|
||||||
|
isPlaying = playerState == PlayerState.PLAYING,
|
||||||
|
positionMs = position.inWholeMilliseconds,
|
||||||
|
durationMs = duration.inWholeMilliseconds,
|
||||||
|
shuffleEnabled = shuffle,
|
||||||
|
loopMode = loop.name.lowercase(),
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState())
|
||||||
|
|
||||||
// ---------- Session lifecycle ----------
|
// ---------- Playback controls ----------
|
||||||
|
|
||||||
fun createSession() {
|
fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle)
|
||||||
|
|
||||||
|
fun skipNext() = sendOrApply(PlaybackCmd.SkipNext)
|
||||||
|
|
||||||
|
fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious)
|
||||||
|
|
||||||
|
fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs))
|
||||||
|
|
||||||
|
fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index))
|
||||||
|
|
||||||
|
fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled))
|
||||||
|
|
||||||
|
fun cycleLoopMode() {
|
||||||
|
val next = when (jamPlayerState.value.loopMode) {
|
||||||
|
"none" -> "one"
|
||||||
|
"one" -> "all"
|
||||||
|
else -> "none"
|
||||||
|
}
|
||||||
|
sendOrApply(PlaybackCmd.SetLoop(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendOrApply(command: PlaybackCmd) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
jamRoomService.createRoom()
|
if (jamSession.role.value == JamRole.Host) {
|
||||||
.onFailure { e ->
|
applyCommandLocally(command)
|
||||||
_localError.value = e.message ?: "Failed to create jam session"
|
} else {
|
||||||
|
jamSession.sendMessage(JamMessage.PlaybackCommand(command))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyCommandLocally(command: PlaybackCmd) {
|
||||||
|
when (command) {
|
||||||
|
PlaybackCmd.Play -> audioPlayer.play()
|
||||||
|
PlaybackCmd.Pause -> audioPlayer.pause()
|
||||||
|
PlaybackCmd.Toggle -> {
|
||||||
|
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
}
|
}
|
||||||
.onSuccess { _localError.value = null }
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun joinWithCode(input: String) {
|
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
||||||
val code = JamRoomCode.normalize(input)
|
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
|
||||||
if (!JamRoomCode.isValid(code)) {
|
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
|
||||||
_localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)"
|
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
|
||||||
return
|
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
|
||||||
|
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
|
||||||
|
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
|
||||||
}
|
}
|
||||||
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 ----------
|
// ---------- Host moderation ----------
|
||||||
|
|
||||||
fun kickParticipant(participantId: String) {
|
fun kickParticipant(participantId: String) {
|
||||||
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
viewModelScope.launch { jamSession.kickParticipant(participantId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun banParticipant(participantId: String) {
|
fun banParticipant(participantId: String) {
|
||||||
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
viewModelScope.launch { jamSession.banParticipant(participantId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSession() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val offer = jamSession.createSession()
|
||||||
|
JamInviteCodec.buildHostInvite(localName(), offer)
|
||||||
|
}.onSuccess { link ->
|
||||||
|
_uiState.update { it.copy(inviteLink = link, error = null) }
|
||||||
|
}.onFailure { e ->
|
||||||
|
_uiState.update { it.copy(error = "Failed to create session: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateNewInvite() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val invite = jamSession.generateInvite()
|
||||||
|
JamInviteCodec.buildHostInvite(localName(), invite.sdp)
|
||||||
|
}.onSuccess { link ->
|
||||||
|
_uiState.update { it.copy(inviteLink = link, error = null) }
|
||||||
|
}.onFailure { e ->
|
||||||
|
_uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinWithIncomingInvite() {
|
||||||
|
val sdp = _uiState.value.incomingOfferSdp ?: return
|
||||||
|
join(sdp, _uiState.value.incomingHostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinWithPasted(input: String) {
|
||||||
|
val parsed = JamInviteCodec.parse(input)
|
||||||
|
val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input)
|
||||||
|
if (sdp == null) {
|
||||||
|
_uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link.
|
||||||
|
*/
|
||||||
|
fun submitAnswerPasted(input: String) {
|
||||||
|
when (val parsed = JamInviteCodec.parse(input.trim())) {
|
||||||
|
is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName)
|
||||||
|
else -> {
|
||||||
|
val sdp = JamInviteCodec.extractSdp(input)
|
||||||
|
if (sdp == null) {
|
||||||
|
_uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") }
|
||||||
|
} else {
|
||||||
|
acceptAnswerInternal(sdp, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun share(text: String) {
|
||||||
|
shareService.share(text, "Spotube Group Jam")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun leave() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
jamSession.leave()
|
||||||
|
deepLinks.clear()
|
||||||
|
_uiState.update {
|
||||||
|
JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_uiState.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissIncomingInvite() {
|
||||||
|
deepLinks.clear()
|
||||||
|
_uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun join(offerSdp: String, hostName: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
val answer = jamSession.joinSession(offerSdp, hostName)
|
||||||
|
JamInviteCodec.buildGuestAnswer(localName(), answer)
|
||||||
|
}.onSuccess { link ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
_uiState.update { it.copy(error = "Failed to join session: ${e.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acceptAnswerInternal(answerSdp: String, peerName: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) }
|
||||||
|
.getOrDefault(false)
|
||||||
|
if (!accepted) {
|
||||||
|
_uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") }
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleDeepLink(link: JamInviteLink?) {
|
||||||
|
when (link) {
|
||||||
|
is JamInviteLink.HostInvite -> {
|
||||||
|
if (!jamSession.isActive.value) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamInviteLink.GuestAnswer -> {
|
||||||
|
if (jamSession.role.value == JamRole.Host) {
|
||||||
|
acceptAnswerInternal(link.sdp, link.peerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun localName(): String =
|
||||||
|
settingsProvider.settingsState.value?.jamParticipantName.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem(
|
||||||
|
id = if (trackId.isNotBlank()) trackId else url,
|
||||||
|
title = title,
|
||||||
|
artist = artist,
|
||||||
|
album = album,
|
||||||
|
durationMs = durationMs,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
isCurrent = isCurrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean {
|
||||||
|
return when {
|
||||||
|
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
||||||
|
this.track.id == other.track.id
|
||||||
|
|
||||||
|
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
|
||||||
|
this.url == other.url && this.name == other.name
|
||||||
|
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -31,14 +31,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
@ -57,11 +53,6 @@ fun PlaylistScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
||||||
@ -136,10 +127,6 @@ fun PlaylistScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
|
||||||
isInJam = jamActive,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onAddToJam = viewModel::addTracksToJam,
|
|
||||||
footerContent = footerContent,
|
footerContent = footerContent,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||||
|
|||||||
@ -306,10 +306,6 @@ class PlaylistViewModel(
|
|||||||
remotePlaybackController.requestTrackAddToQueue(track)
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToJam -> {
|
|
||||||
remotePlaybackController.addTrackToJam(track)
|
|
||||||
}
|
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
queue.find { entry ->
|
||||||
@ -382,10 +378,6 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
|
||||||
remotePlaybackController.addTracksToJam(tracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||||
|
|||||||
@ -20,15 +20,11 @@ package dev.krtirtho.spotube.modules.saved_tracks
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
@ -43,11 +39,6 @@ fun SavedTracksScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
@ -94,10 +85,6 @@ fun SavedTracksScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
|
||||||
isInJam = jamActive,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onAddToJam = viewModel::addTracksToJam,
|
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
|
|||||||
@ -248,10 +248,6 @@ class SavedTracksViewModel(
|
|||||||
remotePlaybackController.requestTrackAddToQueue(track)
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToJam -> {
|
|
||||||
remotePlaybackController.addTrackToJam(track)
|
|
||||||
}
|
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
queue.find { entry ->
|
||||||
@ -316,10 +312,6 @@ class SavedTracksViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
|
||||||
remotePlaybackController.addTracksToJam(tracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
|
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,9 +86,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||||
@ -131,11 +128,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
val blacklistRepository: BlacklistRepository = koinInject()
|
val blacklistRepository: BlacklistRepository = koinInject()
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val jamActive by jamRoomService.role.map { it != null }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val selectedType = state.selectedSearchType
|
val selectedType = state.selectedSearchType
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
@ -196,9 +188,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
remotePlaybackController.requestTrackAddToQueue(track)
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
}
|
}
|
||||||
is TrackOptionsAction.AddToJam -> {
|
|
||||||
remotePlaybackController.addTrackToJam(track)
|
|
||||||
}
|
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
@ -259,10 +248,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
|
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkAddToJam(tracks: List<MetadataTrack>) {
|
|
||||||
remotePlaybackController.addTracksToJam(tracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||||
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
||||||
}
|
}
|
||||||
@ -342,9 +327,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
tracksToAddToPlaylist = tracks
|
tracksToAddToPlaylist = tracks
|
||||||
showAddToPlaylistPicker = true
|
showAddToPlaylistPicker = true
|
||||||
},
|
},
|
||||||
onBulkAddToJam = ::bulkAddToJam,
|
|
||||||
isInJam = jamActive,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
@ -374,9 +356,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
tracksToAddToPlaylist = tracks
|
tracksToAddToPlaylist = tracks
|
||||||
showAddToPlaylistPicker = true
|
showAddToPlaylistPicker = true
|
||||||
},
|
},
|
||||||
onBulkAddToJam = ::bulkAddToJam,
|
|
||||||
isInJam = jamActive,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
@ -658,9 +637,6 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
|
||||||
isInJam: Boolean,
|
|
||||||
isJamGuest: Boolean,
|
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||||
@ -718,9 +694,6 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onBulkAddToJam = onBulkAddToJam,
|
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
@ -808,9 +781,6 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
|
||||||
isInJam: Boolean,
|
|
||||||
isJamGuest: Boolean,
|
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||||
@ -840,9 +810,6 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
onBulkAddToJam = onBulkAddToJam,
|
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
|
|||||||
@ -61,10 +61,8 @@ data class UserSettings(
|
|||||||
val remoteControlDeviceName: String = "",
|
val remoteControlDeviceName: String = "",
|
||||||
val remoteControlDeviceId: String = "",
|
val remoteControlDeviceId: String = "",
|
||||||
|
|
||||||
// Group Jam (MQTT)
|
// Group Jam (P2P)
|
||||||
val jamParticipantName: String = "",
|
val jamParticipantName: String = "",
|
||||||
val jamBroker: JamBroker = JamBroker(),
|
|
||||||
val lastJamCode: String = "",
|
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
||||||
@ -88,20 +86,3 @@ data class UserSettings(
|
|||||||
// Updates
|
// Updates
|
||||||
val autoCheckForUpdates: Boolean = true,
|
val autoCheckForUpdates: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder
|
|
||||||
* until a real broker is configured; users can self-host and point the app at it.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class JamBroker(
|
|
||||||
val name: String = "",
|
|
||||||
val host: String = "test.mosquitto.org",
|
|
||||||
val port: Int = 1883,
|
|
||||||
val useTls: Boolean = false,
|
|
||||||
val username: String? = null,
|
|
||||||
val password: String? = null,
|
|
||||||
val clientIdPrefix: String = "spotube",
|
|
||||||
val keepAliveSeconds: Int = 30,
|
|
||||||
val connectionTimeoutSeconds: Int = 10,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -43,7 +43,6 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
|
|||||||
import dev.krtirtho.spotube.modules.settings.sections.cacheSection
|
import dev.krtirtho.spotube.modules.settings.sections.cacheSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.jamSection
|
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
||||||
@ -111,11 +110,6 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
|||||||
navigatorCommands = navigatorCommands,
|
navigatorCommands = navigatorCommands,
|
||||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||||
)
|
)
|
||||||
if (settingsState != null)
|
|
||||||
jamSection(
|
|
||||||
settings = settingsState!!,
|
|
||||||
settingsViewModel = settingsViewModel,
|
|
||||||
)
|
|
||||||
if (settingsState != null)
|
if (settingsState != null)
|
||||||
cacheSection(
|
cacheSection(
|
||||||
settings = settingsState!!,
|
settings = settingsState!!,
|
||||||
|
|||||||
@ -1,208 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.modules.settings.sections
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomClient
|
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
|
||||||
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
|
||||||
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_client_id
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_host
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_password
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_port
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_test
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_test_fail
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_test_ok
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_testing
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_tls
|
|
||||||
import spotube.composeapp.generated.resources.settings_jam_broker_username
|
|
||||||
import spotube.composeapp.generated.resources.settings_section_jam
|
|
||||||
|
|
||||||
internal fun LazyListScope.jamSection(
|
|
||||||
settings: UserSettings,
|
|
||||||
settingsViewModel: SettingsViewModel,
|
|
||||||
) {
|
|
||||||
val broker = settings.jamBroker
|
|
||||||
|
|
||||||
settingsSectionHeader(Res.string.settings_section_jam)
|
|
||||||
settingsSectionCard(
|
|
||||||
items = listOf(
|
|
||||||
{
|
|
||||||
TextInputSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_jam_broker_host),
|
|
||||||
subtitle = stringResource(
|
|
||||||
Res.string.settings_jam_broker_host_subtitle,
|
|
||||||
broker.host.ifBlank { "—" },
|
|
||||||
broker.port,
|
|
||||||
),
|
|
||||||
value = broker.host,
|
|
||||||
onValueSaved = { host ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(jamBroker = jamBroker.copy(host = host))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = "broker.example.com",
|
|
||||||
icon = {
|
|
||||||
SettingsItemIcon(
|
|
||||||
Iconsax.CustomServer,
|
|
||||||
stringResource(Res.string.settings_jam_broker_host),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
TextInputSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_jam_broker_port),
|
|
||||||
value = broker.port.toString(),
|
|
||||||
onValueSaved = { port ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = "1883",
|
|
||||||
normalize = { it.filter { c -> c.isDigit() }.take(5) },
|
|
||||||
validate = { input ->
|
|
||||||
val port = input.toIntOrNull()
|
|
||||||
if (port == null || port !in 1..65535) "Invalid port" else null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SwitchSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_jam_broker_tls),
|
|
||||||
checked = broker.useTls,
|
|
||||||
onCheckedChange = { tls ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(jamBroker = jamBroker.copy(useTls = tls))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
TextInputSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_jam_broker_username),
|
|
||||||
value = broker.username.orEmpty(),
|
|
||||||
onValueSaved = { username ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = "anonymous",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
TextInputSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_jam_broker_password),
|
|
||||||
value = broker.password.orEmpty(),
|
|
||||||
onValueSaved = { password ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = "••••••••",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
TextInputSettingCard(
|
|
||||||
title = stringResource(Res.string.settings_jam_broker_client_id),
|
|
||||||
value = broker.clientIdPrefix,
|
|
||||||
onValueSaved = { prefix ->
|
|
||||||
settingsViewModel.updateSettings {
|
|
||||||
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
placeholder = "spotube",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
val jamClient = koinInject<JamRoomClient>()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var testing by remember { mutableStateOf(false) }
|
|
||||||
var result by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
testing = true
|
|
||||||
result = null
|
|
||||||
scope.launch {
|
|
||||||
val outcome = jamClient.testConnection(broker)
|
|
||||||
result = outcome.fold(
|
|
||||||
onSuccess = { ok -> "OK: $ok" },
|
|
||||||
onFailure = { e -> "ERR: ${e.message ?: "unknown"}" },
|
|
||||||
)
|
|
||||||
testing = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = broker.host.isNotBlank() && !testing,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (testing) {
|
|
||||||
stringResource(Res.string.settings_jam_broker_testing)
|
|
||||||
} else {
|
|
||||||
stringResource(Res.string.settings_jam_broker_test)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
result?.let { message ->
|
|
||||||
val ok = message.startsWith("OK:")
|
|
||||||
Text(
|
|
||||||
text = message.removePrefix("OK:").removePrefix("ERR:"),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(start = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -82,8 +82,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
||||||
@ -119,7 +117,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
|
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
|
||||||
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@ -161,10 +158,6 @@ fun AppExpandedPlayer(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val isJamGuest by jamRoomService.role
|
|
||||||
.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
@ -205,22 +198,18 @@ fun AppExpandedPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipPrevious() {
|
fun onSkipPrevious() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.skipToPrevious() }
|
scope.launch { audioPlayer.skipToPrevious() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipNext() {
|
fun onSkipNext() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.skipToNext() }
|
scope.launch { audioPlayer.skipToNext() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShuffleToggle() {
|
fun onShuffleToggle() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onLoopToggle() {
|
fun onLoopToggle() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -528,7 +517,7 @@ fun AppExpandedPlayer(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) {
|
GhostIconButton(onClick = ::onShuffleToggle) {
|
||||||
Icon(
|
Icon(
|
||||||
Iconsax.IconsaxShuffle,
|
Iconsax.IconsaxShuffle,
|
||||||
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
||||||
@ -539,7 +528,7 @@ fun AppExpandedPlayer(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
|
GhostIconButton(onClick = ::onSkipPrevious) {
|
||||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -554,10 +543,10 @@ fun AppExpandedPlayer(
|
|||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(30.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
|
GhostIconButton(onClick = ::onSkipNext) {
|
||||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) {
|
GhostIconButton(onClick = ::onLoopToggle) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = when (playerUiState.loopState) {
|
imageVector = when (playerUiState.loopState) {
|
||||||
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
||||||
|
|||||||
@ -65,8 +65,6 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.Slider
|
import dev.krtirtho.spotube.core.ui.base.Slider
|
||||||
@ -95,7 +93,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
|
||||||
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@ -128,10 +125,6 @@ fun AppLargePlayer(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val isJamGuest by jamRoomService.role
|
|
||||||
.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
var isSeeking by remember { mutableStateOf(false) }
|
var isSeeking by remember { mutableStateOf(false) }
|
||||||
@ -162,22 +155,18 @@ fun AppLargePlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipPrevious() {
|
fun onSkipPrevious() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.skipToPrevious() }
|
scope.launch { audioPlayer.skipToPrevious() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipNext() {
|
fun onSkipNext() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.skipToNext() }
|
scope.launch { audioPlayer.skipToNext() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShuffleToggle() {
|
fun onShuffleToggle() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onLoopToggle() {
|
fun onLoopToggle() {
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,7 +294,6 @@ fun AppLargePlayer(
|
|||||||
) {
|
) {
|
||||||
VariableIconButton(
|
VariableIconButton(
|
||||||
onClick = ::onShuffleToggle,
|
onClick = ::onShuffleToggle,
|
||||||
enabled = !isJamGuest,
|
|
||||||
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@ -318,7 +306,7 @@ fun AppLargePlayer(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
|
GhostIconButton(onClick = ::onSkipPrevious) {
|
||||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -332,12 +320,11 @@ fun AppLargePlayer(
|
|||||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
|
GhostIconButton(onClick = ::onSkipNext) {
|
||||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||||
}
|
}
|
||||||
VariableIconButton(
|
VariableIconButton(
|
||||||
onClick = ::onLoopToggle,
|
onClick = ::onLoopToggle,
|
||||||
enabled = !isJamGuest,
|
|
||||||
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
@ -42,8 +41,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
@ -73,7 +70,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationState
|
|||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
||||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
|
||||||
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
|
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
|
||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
||||||
@ -99,13 +95,6 @@ fun AppShell(
|
|||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val navigatorCommands: NavigationCommands = koinInject()
|
val navigatorCommands: NavigationCommands = koinInject()
|
||||||
val remotePlaybackController: RemotePlaybackController = koinInject()
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
LaunchedEffect(remotePlaybackController) {
|
|
||||||
remotePlaybackController.events.collect { message ->
|
|
||||||
snackbarHostState.showSnackbar(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
||||||
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
||||||
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
||||||
@ -274,15 +263,6 @@ fun AppShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drawn last so it floats above the players/sheets, just above the
|
|
||||||
// bottom overlay (large player or compact player + bottombar).
|
|
||||||
SnackbarHost(
|
|
||||||
hostState = snackbarHostState,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = bottomOverlayInset + 12.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -34,12 +33,8 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@ -57,7 +52,6 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
import dev.krtirtho.spotube.core.ui.base.Card
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
@ -65,15 +59,12 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile
|
|||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
@ -87,14 +78,12 @@ fun PlayerQueueContent(
|
|||||||
val displayItems = state.displayItems
|
val displayItems = state.displayItems
|
||||||
val filterQuery = state.filterQuery
|
val filterQuery = state.filterQuery
|
||||||
val isFiltered = state.isFiltered
|
val isFiltered = state.isFiltered
|
||||||
val isReadOnly = state.isReadOnly
|
|
||||||
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
|
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val reorderableLazyListState = rememberReorderableLazyListState(
|
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||||
lazyListState,
|
lazyListState,
|
||||||
onMove = { from, to ->
|
onMove = { from, to ->
|
||||||
if (isFiltered || isReadOnly) return@rememberReorderableLazyListState
|
if (isFiltered) return@rememberReorderableLazyListState
|
||||||
viewModel.onMove(from.index, to.index)
|
viewModel.onMove(from.index, to.index)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -129,13 +118,11 @@ fun PlayerQueueContent(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (!isReadOnly) {
|
IconButton(
|
||||||
IconButton(
|
onClick = viewModel::clearQueue,
|
||||||
onClick = viewModel::clearQueue,
|
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
|
||||||
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
|
) {
|
||||||
) {
|
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
|
||||||
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,141 +144,21 @@ fun PlayerQueueContent(
|
|||||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
||||||
QueueItemRow(
|
QueueItemRow(
|
||||||
item = item,
|
item = item,
|
||||||
reorderScope = if (isFiltered || isReadOnly) null else this,
|
reorderScope = if (isFiltered) null else this,
|
||||||
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
|
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
|
||||||
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
|
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
|
||||||
onDragStarted = { viewModel.onDragStart() },
|
onDragStarted = { viewModel.onDragStart() },
|
||||||
onDragStopped = { viewModel.onDragStop() },
|
onDragStopped = { viewModel.onDragStop() },
|
||||||
showOptions = !isReadOnly,
|
|
||||||
enabled = !isReadOnly,
|
|
||||||
onParticipantClick = { selectedParticipant = it },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedParticipant?.let { participant ->
|
|
||||||
ParticipantDialog(
|
|
||||||
participant = participant,
|
|
||||||
isJamHost = state.isJamHost,
|
|
||||||
onDismiss = { selectedParticipant = null },
|
|
||||||
onKick = { viewModel.kickParticipant(participant.id) },
|
|
||||||
onBan = { viewModel.banParticipant(participant.id) },
|
|
||||||
onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ParticipantDialog(
|
|
||||||
participant: JamParticipant,
|
|
||||||
isJamHost: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onKick: () -> Unit,
|
|
||||||
onBan: () -> Unit,
|
|
||||||
onRemoveSuggestions: () -> Unit,
|
|
||||||
) {
|
|
||||||
AdaptiveDialogBottomSheet(
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) },
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
ParticipantAvatar(participant, size = 40)
|
|
||||||
Text(
|
|
||||||
text = participant.displayName,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
if (participant.isHost) {
|
|
||||||
Text(
|
|
||||||
text = "Host",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isJamHost && !participant.isHost) {
|
|
||||||
HorizontalDivider(
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
ListRowTile(
|
|
||||||
onClick = {
|
|
||||||
onKick()
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxCloseSquare,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = { Text("Kick") },
|
|
||||||
subtitle = { Text("Remove them from the session") },
|
|
||||||
)
|
|
||||||
ListRowTile(
|
|
||||||
onClick = {
|
|
||||||
onBan()
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxUserRemove,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = { Text("Ban") },
|
|
||||||
subtitle = { Text("Kick and prevent them from rejoining") },
|
|
||||||
)
|
|
||||||
ListRowTile(
|
|
||||||
onClick = {
|
|
||||||
onRemoveSuggestions()
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxMusicSquareRemove,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = { Text("Remove suggestions") },
|
|
||||||
subtitle = { Text("Remove every track they added to the queue") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ParticipantAvatar(participant: JamParticipant, size: Int) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(size.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueItemRow(
|
private fun QueueItemRow(
|
||||||
item: QueueItemUi,
|
item: QueueItemUi,
|
||||||
@ -300,15 +167,11 @@ private fun QueueItemRow(
|
|||||||
onRemoveClick: () -> Unit,
|
onRemoveClick: () -> Unit,
|
||||||
onDragStarted: () -> Unit,
|
onDragStarted: () -> Unit,
|
||||||
onDragStopped: () -> Unit,
|
onDragStopped: () -> Unit,
|
||||||
showOptions: Boolean = true,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onParticipantClick: (JamParticipant) -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ListRowTile(
|
ListRowTile(
|
||||||
onClick = onPlayClick,
|
onClick = onPlayClick,
|
||||||
enabled = enabled,
|
|
||||||
selected = item.isCurrent,
|
selected = item.isCurrent,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
leading = {
|
leading = {
|
||||||
@ -392,51 +255,31 @@ private fun QueueItemRow(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
item.addedByParticipant?.let { participant ->
|
Box {
|
||||||
Box(
|
GhostIconButton(
|
||||||
modifier = Modifier
|
onClick = { showMenu = true },
|
||||||
.size(28.dp)
|
modifier = Modifier.size(36.dp),
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.clickable { onParticipantClick(participant) },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
Iconsax.Iconsax3DotsMore,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
contentDescription = "More options",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
DropdownMenu(
|
||||||
}
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false },
|
||||||
if (showOptions) {
|
) {
|
||||||
Box {
|
DropdownMenuItem(
|
||||||
GhostIconButton(
|
text = { Text("Remove from queue") },
|
||||||
onClick = { showMenu = true },
|
onClick = {
|
||||||
modifier = Modifier.size(36.dp),
|
onRemoveClick()
|
||||||
) {
|
showMenu = false
|
||||||
Icon(
|
},
|
||||||
Iconsax.Iconsax3DotsMore,
|
leadingIcon = {
|
||||||
contentDescription = "More options",
|
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
|
||||||
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,9 +21,6 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.jam.JamParticipant
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRole
|
|
||||||
import dev.krtirtho.spotube.core.jam.JamRoomService
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@ -41,23 +38,16 @@ data class QueueItemUi(
|
|||||||
val isCurrent: Boolean,
|
val isCurrent: Boolean,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val originalIndex: Int,
|
val originalIndex: Int,
|
||||||
/** Participant who added this item to the jam queue, if any. */
|
|
||||||
val addedByParticipant: JamParticipant? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContentUiState(
|
data class QueueContentUiState(
|
||||||
val filterQuery: String = "",
|
val filterQuery: String = "",
|
||||||
val displayItems: List<QueueItemUi> = emptyList(),
|
val displayItems: List<QueueItemUi> = emptyList(),
|
||||||
val isFiltered: Boolean = false,
|
val isFiltered: Boolean = false,
|
||||||
/** Guests cannot reorder/remove/clear the shared jam queue. */
|
|
||||||
val isReadOnly: Boolean = false,
|
|
||||||
val isJamHost: Boolean = false,
|
|
||||||
val participants: List<JamParticipant> = emptyList(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlayerQueueContentViewModel(
|
class PlayerQueueContentViewModel(
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val jamRoomService: JamRoomService,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val queueVisibilityFlow = MutableStateFlow(false)
|
private val queueVisibilityFlow = MutableStateFlow(false)
|
||||||
private val queueFilterFlow = MutableStateFlow("")
|
private val queueFilterFlow = MutableStateFlow("")
|
||||||
@ -70,8 +60,7 @@ class PlayerQueueContentViewModel(
|
|||||||
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
||||||
audioPlayerQueue.queueFlow,
|
audioPlayerQueue.queueFlow,
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
jamRoomService.participants,
|
) { queue, currentEntry ->
|
||||||
) { queue, currentEntry, participants ->
|
|
||||||
val currentIndex = if (currentEntry != null) {
|
val currentIndex = if (currentEntry != null) {
|
||||||
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
||||||
} else {
|
} else {
|
||||||
@ -79,7 +68,7 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
queue.mapIndexed { index, entry ->
|
queue.mapIndexed { index, entry ->
|
||||||
val title: String
|
val title: String
|
||||||
var subtitle: String
|
val subtitle: String
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
val imageUrl: String?
|
val imageUrl: String?
|
||||||
|
|
||||||
@ -100,11 +89,6 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val addedByParticipant = participants.firstOrNull { it.id == entry.addedBy }
|
|
||||||
if (addedByParticipant != null) {
|
|
||||||
subtitle = "$subtitle • Added by ${addedByParticipant.displayName}"
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueItemUi(
|
QueueItemUi(
|
||||||
id = "${entry.url}@$index",
|
id = "${entry.url}@$index",
|
||||||
title = title,
|
title = title,
|
||||||
@ -113,7 +97,6 @@ class PlayerQueueContentViewModel(
|
|||||||
isCurrent = index == currentIndex,
|
isCurrent = index == currentIndex,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
originalIndex = index,
|
originalIndex = index,
|
||||||
addedByParticipant = addedByParticipant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@ -126,9 +109,7 @@ class PlayerQueueContentViewModel(
|
|||||||
computedItems,
|
computedItems,
|
||||||
reorderBuffer,
|
reorderBuffer,
|
||||||
queueFilterFlow,
|
queueFilterFlow,
|
||||||
jamRoomService.role,
|
) { items, buffer, filterQuery ->
|
||||||
jamRoomService.participants,
|
|
||||||
) { items, buffer, filterQuery, role, participants ->
|
|
||||||
val normalizedFilter = filterQuery.trim().lowercase()
|
val normalizedFilter = filterQuery.trim().lowercase()
|
||||||
val isFiltered = normalizedFilter.isNotBlank()
|
val isFiltered = normalizedFilter.isNotBlank()
|
||||||
val filtered = if (isFiltered) {
|
val filtered = if (isFiltered) {
|
||||||
@ -143,9 +124,6 @@ class PlayerQueueContentViewModel(
|
|||||||
filterQuery = filterQuery,
|
filterQuery = filterQuery,
|
||||||
displayItems = buffer ?: filtered,
|
displayItems = buffer ?: filtered,
|
||||||
isFiltered = isFiltered,
|
isFiltered = isFiltered,
|
||||||
isReadOnly = role == JamRole.Guest,
|
|
||||||
isJamHost = role == JamRole.Host,
|
|
||||||
participants = participants,
|
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@ -166,14 +144,14 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playQueueItem(index: Int) {
|
fun playQueueItem(index: Int) {
|
||||||
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
if (index < 0) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioPlayerQueue.jumpTo(index)
|
audioPlayerQueue.jumpTo(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeQueueItem(index: Int) {
|
fun removeQueueItem(index: Int) {
|
||||||
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
if (index < 0) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val currentQueue = audioPlayerQueue.queueFlow.value
|
val currentQueue = audioPlayerQueue.queueFlow.value
|
||||||
if (index < currentQueue.size) {
|
if (index < currentQueue.size) {
|
||||||
@ -184,49 +162,24 @@ class PlayerQueueContentViewModel(
|
|||||||
|
|
||||||
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
||||||
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
||||||
if (queueContentUiState.value.isReadOnly) return
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioPlayerQueue.move(fromIndex, toIndex)
|
audioPlayerQueue.move(fromIndex, toIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearQueue() {
|
fun clearQueue() {
|
||||||
if (queueContentUiState.value.isReadOnly) return
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioPlayerQueue.clear()
|
audioPlayerQueue.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Jam participant moderation (host only) ----------
|
|
||||||
|
|
||||||
fun kickParticipant(participantId: String) {
|
|
||||||
if (!queueContentUiState.value.isJamHost) return
|
|
||||||
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun banParticipant(participantId: String) {
|
|
||||||
if (!queueContentUiState.value.isJamHost) return
|
|
||||||
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Removes every queue item that the given participant suggested. */
|
|
||||||
fun removeParticipantTracks(participantId: String) {
|
|
||||||
if (!queueContentUiState.value.isJamHost) return
|
|
||||||
viewModelScope.launch {
|
|
||||||
val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId }
|
|
||||||
entries.forEach { audioPlayerQueue.removeFromQueue(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDragStart() {
|
fun onDragStart() {
|
||||||
if (reorderBuffer.value != null) return
|
if (reorderBuffer.value != null) return
|
||||||
if (queueContentUiState.value.isReadOnly) return
|
|
||||||
val currentItems = queueContentUiState.value.displayItems
|
val currentItems = queueContentUiState.value.displayItems
|
||||||
reorderBuffer.value = currentItems.toList()
|
reorderBuffer.value = currentItems.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMove(from: Int, to: Int) {
|
fun onMove(from: Int, to: Int) {
|
||||||
if (queueContentUiState.value.isReadOnly) return
|
|
||||||
val buffer = reorderBuffer.value ?: return
|
val buffer = reorderBuffer.value ?: return
|
||||||
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
|
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
|
||||||
val item = buffer[from]
|
val item = buffer[from]
|
||||||
|
|||||||
@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
|
||||||
|
|
||||||
private val SlidingSheetBreakpoint = 840.dp
|
private val SlidingSheetBreakpoint = 840.dp
|
||||||
|
|
||||||
@ -69,11 +68,7 @@ private fun SlidingQueueSheet(
|
|||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(
|
.padding(top = 12.dp, end = 12.dp, bottom = 12.dp),
|
||||||
top = 12.dp,
|
|
||||||
end = 12.dp,
|
|
||||||
bottom = 12.dp + LocalAppShellBottomInset.current,
|
|
||||||
),
|
|
||||||
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
|
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
|
||||||
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
|
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
mod metadata;
|
mod metadata;
|
||||||
mod discord_rpc;
|
mod discord_rpc;
|
||||||
|
mod webrtc_p2p;
|
||||||
|
|
||||||
pub use metadata::*;
|
pub use metadata::*;
|
||||||
pub use discord_rpc::*;
|
pub use discord_rpc::*;
|
||||||
|
pub use webrtc_p2p::*;
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
340
composeApp/src/commonMain/rust/webrtc_p2p.rs
Normal file
340
composeApp/src/commonMain/rust/webrtc_p2p.rs
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use rtc::ice::mdns::MulticastDnsMode;
|
||||||
|
use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors;
|
||||||
|
use rtc::peer_connection::configuration::setting_engine::SettingEngine;
|
||||||
|
use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit};
|
||||||
|
use webrtc::peer_connection::{
|
||||||
|
MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler,
|
||||||
|
RTCConfigurationBuilder, RTCIceGatheringState, RTCIceServer, RTCPeerConnectionIceEvent,
|
||||||
|
RTCPeerConnectionState, RTCSessionDescription, Registry,
|
||||||
|
};
|
||||||
|
use webrtc::runtime::channel;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||||
|
pub enum WebrtcError {
|
||||||
|
#[error("SDP error: {reason}")]
|
||||||
|
SdpError { reason: String },
|
||||||
|
#[error("Connection error: {reason}")]
|
||||||
|
ConnectionError { reason: String },
|
||||||
|
#[error("Data channel error: {reason}")]
|
||||||
|
DataChannelError { reason: String },
|
||||||
|
#[error("Invalid state: {reason}")]
|
||||||
|
InvalidState { reason: String },
|
||||||
|
#[error("Internal error: {reason}")]
|
||||||
|
Internal { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<webrtc::error::Error> for WebrtcError {
|
||||||
|
fn from(e: webrtc::error::Error) -> Self {
|
||||||
|
WebrtcError::Internal {
|
||||||
|
reason: format!("{e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Record)]
|
||||||
|
pub struct IceServerConfig {
|
||||||
|
pub urls: Vec<String>,
|
||||||
|
pub username: String,
|
||||||
|
pub credential: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export(callback_interface)]
|
||||||
|
pub trait WebrtcEventHandler: Send + Sync + 'static {
|
||||||
|
fn on_ice_candidate(&self, candidate: String);
|
||||||
|
fn on_ice_gathering_state_change(&self, state: String);
|
||||||
|
fn on_connection_state_change(&self, state: String);
|
||||||
|
fn on_data_channel_open(&self, label: String);
|
||||||
|
fn on_data_channel_message(&self, label: String, data: String);
|
||||||
|
fn on_data_channel_close(&self, label: String);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DataChannelEntry {
|
||||||
|
dc: Arc<dyn DataChannel>,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(uniffi::Object)]
|
||||||
|
pub struct WebrtcPeerConnection {
|
||||||
|
pc: Arc<dyn PeerConnection>,
|
||||||
|
handler: Arc<dyn WebrtcEventHandler>,
|
||||||
|
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
|
||||||
|
gather_rx: Mutex<webrtc::runtime::Receiver<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
|
pub async fn create_webrtc_peer_connection(
|
||||||
|
ice_servers: Vec<IceServerConfig>,
|
||||||
|
handler: Box<dyn WebrtcEventHandler>,
|
||||||
|
) -> Result<Arc<WebrtcPeerConnection>, WebrtcError> {
|
||||||
|
let handler: Arc<dyn WebrtcEventHandler> = Arc::from(handler);
|
||||||
|
|
||||||
|
let mut media_engine = MediaEngine::default();
|
||||||
|
media_engine
|
||||||
|
.register_default_codecs()
|
||||||
|
.map_err(|e| WebrtcError::Internal {
|
||||||
|
reason: format!("media_engine: {e:?}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let registry = register_default_interceptors(Registry::new(), &mut media_engine)
|
||||||
|
.map_err(|e| WebrtcError::Internal {
|
||||||
|
reason: format!("interceptor_registry: {e:?}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let config = RTCConfigurationBuilder::new()
|
||||||
|
.with_ice_servers(
|
||||||
|
ice_servers
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| RTCIceServer {
|
||||||
|
urls: s.urls,
|
||||||
|
username: s.username,
|
||||||
|
credential: s.credential,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// mDNS adds a multicast UDP socket per peer connection. On some platforms
|
||||||
|
// (notably Android) that socket can stall and ICE gathering then never
|
||||||
|
// completes. Real-IP host candidates (no mDNS) work fine alongside STUN/TURN,
|
||||||
|
// so mDNS is disabled.
|
||||||
|
let mut setting_engine = SettingEngine::default();
|
||||||
|
setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled);
|
||||||
|
|
||||||
|
let (gather_tx, gather_rx) = channel::<()>(1);
|
||||||
|
let channels = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let pc_handler = Arc::new(PeerHandlerBridge {
|
||||||
|
handler: Arc::clone(&handler),
|
||||||
|
gather_tx,
|
||||||
|
channels: Arc::clone(&channels),
|
||||||
|
});
|
||||||
|
|
||||||
|
let pc = PeerConnectionBuilder::new()
|
||||||
|
.with_configuration(config)
|
||||||
|
.with_setting_engine(setting_engine)
|
||||||
|
.with_media_engine(media_engine)
|
||||||
|
.with_interceptor_registry(registry)
|
||||||
|
.with_handler(pc_handler)
|
||||||
|
.with_udp_addrs(vec!["0.0.0.0:0"])
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Arc::new(WebrtcPeerConnection {
|
||||||
|
pc: Arc::new(pc) as Arc<dyn PeerConnection>,
|
||||||
|
handler,
|
||||||
|
channels,
|
||||||
|
gather_rx: Mutex::new(gather_rx),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebrtcPeerConnection {
|
||||||
|
/// Waits for ICE gathering to reach `Complete` so the local SDP includes all
|
||||||
|
/// candidates (non-trickle exchange). Must be called after `set_local_description`,
|
||||||
|
/// which is what starts gathering.
|
||||||
|
///
|
||||||
|
/// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at
|
||||||
|
/// least one candidate has landed in the local description, a short grace period
|
||||||
|
/// is enough — the SDP must never leave candidate-less. Hard cap at 5s.
|
||||||
|
async fn wait_for_ice_gathering(&self) {
|
||||||
|
let mut gather_rx = self.gather_rx.lock().clone();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
if elapsed >= Duration::from_secs(5) {
|
||||||
|
log::warn!(
|
||||||
|
"ICE gathering did not complete within 5s; using the candidates gathered so far"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await {
|
||||||
|
Ok(Some(())) => return, // gathering complete
|
||||||
|
Ok(None) => return, // handler dropped
|
||||||
|
Err(_) => {} // timed out, keep waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace period once candidates are present, so the SDP always carries them.
|
||||||
|
if elapsed >= Duration::from_secs(1) {
|
||||||
|
let sdp = self.pc.local_description().await.map(|d| d.sdp);
|
||||||
|
if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl WebrtcPeerConnection {
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn create_offer(&self) -> Result<String, WebrtcError> {
|
||||||
|
let offer = self.pc.create_offer(None).await?;
|
||||||
|
self.pc.set_local_description(offer.clone()).await?;
|
||||||
|
self.wait_for_ice_gathering().await;
|
||||||
|
Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(offer.sdp))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn create_answer(&self) -> Result<String, WebrtcError> {
|
||||||
|
let answer = self.pc.create_answer(None).await?;
|
||||||
|
self.pc.set_local_description(answer.clone()).await?;
|
||||||
|
self.wait_for_ice_gathering().await;
|
||||||
|
Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(answer.sdp))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn set_remote_offer(&self, sdp: String) -> Result<(), WebrtcError> {
|
||||||
|
let desc = RTCSessionDescription::offer(sdp)
|
||||||
|
.map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?;
|
||||||
|
self.pc.set_remote_description(desc).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn set_remote_answer(&self, sdp: String) -> Result<(), WebrtcError> {
|
||||||
|
let desc = RTCSessionDescription::answer(sdp)
|
||||||
|
.map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?;
|
||||||
|
self.pc.set_remote_description(desc).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn local_description(&self) -> Option<String> {
|
||||||
|
self.pc.local_description().await.map(|d| d.sdp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn create_data_channel(&self, label: String) -> Result<(), WebrtcError> {
|
||||||
|
let dc = self
|
||||||
|
.pc
|
||||||
|
.create_data_channel(&label, None::<RTCDataChannelInit>)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
spawn_data_channel_poll_loop(Arc::clone(&dc), Arc::clone(&self.handler));
|
||||||
|
|
||||||
|
self.channels.lock().push(DataChannelEntry { dc, label });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn send_data(&self, label: String, data: String) -> Result<(), WebrtcError> {
|
||||||
|
let dc = {
|
||||||
|
let channels = self.channels.lock();
|
||||||
|
channels
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.label == label)
|
||||||
|
.map(|c| Arc::clone(&c.dc))
|
||||||
|
};
|
||||||
|
let dc = dc.ok_or_else(|| WebrtcError::InvalidState {
|
||||||
|
reason: format!("No data channel with label '{label}'"),
|
||||||
|
})?;
|
||||||
|
dc.send_text(&data).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn shutdown(&self) -> Result<(), WebrtcError> {
|
||||||
|
let channels: Vec<Arc<dyn DataChannel>> = {
|
||||||
|
let channels = self.channels.lock();
|
||||||
|
channels.iter().map(|c| Arc::clone(&c.dc)).collect()
|
||||||
|
};
|
||||||
|
for dc in channels.iter() {
|
||||||
|
let _ = dc.close().await;
|
||||||
|
}
|
||||||
|
self.pc.close().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PeerHandlerBridge {
|
||||||
|
handler: Arc<dyn WebrtcEventHandler>,
|
||||||
|
gather_tx: webrtc::runtime::Sender<()>,
|
||||||
|
channels: Arc<Mutex<Vec<DataChannelEntry>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl PeerConnectionEventHandler for PeerHandlerBridge {
|
||||||
|
async fn on_ice_candidate(&self, event: RTCPeerConnectionIceEvent) {
|
||||||
|
self.handler.on_ice_candidate(event.candidate.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) {
|
||||||
|
let s = state.to_string();
|
||||||
|
if matches!(state, RTCIceGatheringState::Complete) {
|
||||||
|
let _ = self.gather_tx.try_send(());
|
||||||
|
}
|
||||||
|
self.handler.on_ice_gathering_state_change(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_connection_state_change(&self, state: RTCPeerConnectionState) {
|
||||||
|
self.handler.on_connection_state_change(state.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_data_channel(&self, dc: Arc<dyn DataChannel>) {
|
||||||
|
// Register in-band (remote-initiated) channels so send_data() can find
|
||||||
|
// them — without this, the answering peer can never send anything.
|
||||||
|
let label = match dc.label().await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
self.channels
|
||||||
|
.lock()
|
||||||
|
.push(DataChannelEntry { dc: Arc::clone(&dc), label });
|
||||||
|
spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_data_channel_poll_loop(
|
||||||
|
dc: Arc<dyn DataChannel>,
|
||||||
|
handler: Arc<dyn WebrtcEventHandler>,
|
||||||
|
) {
|
||||||
|
::tokio::spawn(async move {
|
||||||
|
let label = match dc.label().await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
while let Some(event) = dc.poll().await {
|
||||||
|
match event {
|
||||||
|
DataChannelEvent::OnOpen => {
|
||||||
|
handler.on_data_channel_open(label.clone());
|
||||||
|
}
|
||||||
|
DataChannelEvent::OnMessage(msg) => {
|
||||||
|
let text = if msg.is_string {
|
||||||
|
String::from_utf8_lossy(&msg.data).into_owned()
|
||||||
|
} else {
|
||||||
|
format!("[binary:{}bytes]", msg.data.len())
|
||||||
|
};
|
||||||
|
handler.on_data_channel_message(label.clone(), text);
|
||||||
|
}
|
||||||
|
DataChannelEvent::OnClose | DataChannelEvent::OnClosing => {
|
||||||
|
handler.on_data_channel_close(label.clone());
|
||||||
|
if matches!(event, DataChannelEvent::OnClose) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -44,8 +44,6 @@ kotlinx-io = "0.9.1"
|
|||||||
material3 = "1.10.0-alpha05"
|
material3 = "1.10.0-alpha05"
|
||||||
kotlinx-serialization-json = "1.11.0"
|
kotlinx-serialization-json = "1.11.0"
|
||||||
materialKolor = "4.1.1"
|
materialKolor = "4.1.1"
|
||||||
mqttClient = "2.1.1"
|
|
||||||
mqttBuffer = "6.30.8"
|
|
||||||
murmurhash = "0.4.2"
|
murmurhash = "0.4.2"
|
||||||
newpipeextractor = "v0.26.2"
|
newpipeextractor = "v0.26.2"
|
||||||
newpipeExtractorKmp = "1.3.0"
|
newpipeExtractorKmp = "1.3.0"
|
||||||
@ -124,10 +122,6 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
|||||||
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
||||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
||||||
mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" }
|
|
||||||
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
|
|
||||||
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
|
|
||||||
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
|
|
||||||
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
||||||
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
||||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user