mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 14:44:00 +00:00
feat(devices): add device discovery and remote control features
This commit is contained in:
parent
94ec831a96
commit
d3a8548661
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)
|
||||||
1832
composeApp/Cargo.lock
generated
1832
composeApp/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,9 +4,16 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
uniffi = "0.29.4"
|
uniffi = { version = "0.29.4", features = ["tokio"] }
|
||||||
lofty = "0.24.0"
|
lofty = "0.24.0"
|
||||||
discord-rich-presence = "1.1.0"
|
discord-rich-presence = "1.1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
webrtc = { path = "../build/webrtc-rs" }
|
||||||
|
rtc = { path = "../build/webrtc-rs/rtc" }
|
||||||
|
async-trait = "0.1"
|
||||||
|
bytes = "1"
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "staticlib"]
|
crate-type = ["cdylib", "staticlib"]
|
||||||
|
|||||||
@ -86,6 +86,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation(libs.dns.sd.kt)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
@ -104,8 +105,10 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.websockets)
|
||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
|
implementation(libs.ktor.server.websockets)
|
||||||
// Zipline
|
// Zipline
|
||||||
api(libs.zipline.core)
|
api(libs.zipline.core)
|
||||||
implementation(libs.zipline.loader)
|
implementation(libs.zipline.loader)
|
||||||
@ -160,6 +163,9 @@ kotlin {
|
|||||||
// Shimmer effect
|
// Shimmer effect
|
||||||
implementation(libs.compose.shimmer)
|
implementation(libs.compose.shimmer)
|
||||||
implementation(libs.compose.placeholder.material3)
|
implementation(libs.compose.placeholder.material3)
|
||||||
|
|
||||||
|
// DLNA
|
||||||
|
implementation(libs.dns.sd.kt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
|
|||||||
@ -16,13 +16,19 @@
|
|||||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation"
|
||||||
|
tools:targetApi="33" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
|||||||
@ -96,6 +96,13 @@
|
|||||||
<string name="settings_enable_connect_title">Enable Connect</string>
|
<string name="settings_enable_connect_title">Enable Connect</string>
|
||||||
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
|
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
|
||||||
<string name="settings_playback_port_title">Playback proxy server port</string>
|
<string name="settings_playback_port_title">Playback proxy server port</string>
|
||||||
|
<string name="settings_allow_remote_control_title">Allow remote control</string>
|
||||||
|
<string name="settings_allow_remote_control_subtitle">Let other devices on the same network control playback when they connect.</string>
|
||||||
|
<string name="settings_remote_device_name_title">Remote control device name</string>
|
||||||
|
<string name="settings_remote_device_name_subtitle">The name other devices see when discovering this one. Current: %1$s</string>
|
||||||
|
<string name="settings_remote_device_name_description">Choose a friendly name to show to other devices on your network when they discover this Spotube instance.</string>
|
||||||
|
<string name="settings_remote_device_name_placeholder">e.g. Living Room Spotube</string>
|
||||||
|
<string name="settings_remote_device_name_default">device hostname</string>
|
||||||
<string name="settings_playback_port_subtitle_current">Port used by the playback proxy server. Current: %1$d</string>
|
<string name="settings_playback_port_subtitle_current">Port used by the playback proxy server. Current: %1$d</string>
|
||||||
<string name="settings_playback_port_description">Choose a port between 1 and 65535.</string>
|
<string name="settings_playback_port_description">Choose a port between 1 and 65535.</string>
|
||||||
<string name="settings_playback_port_placeholder">14769</string>
|
<string name="settings_playback_port_placeholder">14769</string>
|
||||||
|
|||||||
@ -23,8 +23,12 @@ 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.discovery.DeviceDiscoveryService
|
||||||
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
import dev.krtirtho.spotube.core.navigation.navigationModule
|
import dev.krtirtho.spotube.core.navigation.navigationModule
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemoteControlService
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||||
import dev.krtirtho.spotube.core.server.CacheManager
|
import dev.krtirtho.spotube.core.server.CacheManager
|
||||||
@ -38,6 +42,8 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
|||||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.devices.DevicesViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.jam.JamViewModel
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
||||||
@ -168,6 +174,8 @@ val sharedModules = module {
|
|||||||
// Blacklist
|
// Blacklist
|
||||||
singleOf(::BlacklistRepository)
|
singleOf(::BlacklistRepository)
|
||||||
viewModelOf(::BlacklistViewModel)
|
viewModelOf(::BlacklistViewModel)
|
||||||
|
viewModelOf(::DevicesViewModel)
|
||||||
|
viewModelOf(::JamViewModel)
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
singleOf(::AlbumRepository)
|
singleOf(::AlbumRepository)
|
||||||
@ -205,6 +213,12 @@ val sharedModules = module {
|
|||||||
singleOf(::LocalServer) withOptions {
|
singleOf(::LocalServer) withOptions {
|
||||||
createdAtStart()
|
createdAtStart()
|
||||||
}
|
}
|
||||||
|
single { RemoteControlHandler(get(), get(), get()) }
|
||||||
|
singleOf(::DeviceDiscoveryService)
|
||||||
|
single { RemoteControlService(get(), get(), get()) } withOptions {
|
||||||
|
createdAtStart()
|
||||||
|
}
|
||||||
|
single { JamSessionService(get(), get()) }
|
||||||
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,100 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.discovery
|
||||||
|
|
||||||
|
import com.appstractive.dnssd.DiscoveryEvent
|
||||||
|
import com.appstractive.dnssd.NetService
|
||||||
|
import com.appstractive.dnssd.createNetService
|
||||||
|
import com.appstractive.dnssd.discoverServices
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
data class DiscoveredDevice(
|
||||||
|
val name: String,
|
||||||
|
val type: String,
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val deviceId: String,
|
||||||
|
) {
|
||||||
|
val key: String get() = "$name$type".replace(".", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface DiscoveryState {
|
||||||
|
data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState
|
||||||
|
data class Resolved(val device: DiscoveredDevice) : DiscoveryState
|
||||||
|
data class Removed(val device: DiscoveredDevice) : DiscoveryState
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceDiscoveryService {
|
||||||
|
companion object {
|
||||||
|
const val SERVICE_TYPE = "_spotube-ctrl._tcp"
|
||||||
|
const val TXT_DEVICE_ID = "deviceId"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun discover(): Flow<DiscoveryState> = discoverServices(SERVICE_TYPE).map { event ->
|
||||||
|
when (event) {
|
||||||
|
is DiscoveryEvent.Discovered -> {
|
||||||
|
val device = DiscoveredDevice(
|
||||||
|
name = event.service.name,
|
||||||
|
type = event.service.type,
|
||||||
|
host = event.service.host,
|
||||||
|
port = event.service.port,
|
||||||
|
deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(),
|
||||||
|
)
|
||||||
|
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DiscoveryEvent.Resolved -> {
|
||||||
|
val device = DiscoveredDevice(
|
||||||
|
name = event.service.name,
|
||||||
|
type = event.service.type,
|
||||||
|
host = event.service.host,
|
||||||
|
port = event.service.port,
|
||||||
|
deviceId = event.service.txt[TXT_DEVICE_ID]?.let { String(it) }.orEmpty(),
|
||||||
|
)
|
||||||
|
DiscoveryState.Resolved(device = device)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DiscoveryEvent.Removed -> {
|
||||||
|
val device = DiscoveredDevice(
|
||||||
|
name = event.service.name,
|
||||||
|
type = event.service.type,
|
||||||
|
host = event.service.host,
|
||||||
|
port = event.service.port,
|
||||||
|
deviceId = "",
|
||||||
|
)
|
||||||
|
DiscoveryState.Removed(device = device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun advertise(
|
||||||
|
name: String,
|
||||||
|
port: Int,
|
||||||
|
deviceId: String,
|
||||||
|
): NetService {
|
||||||
|
val service = createNetService(
|
||||||
|
type = SERVICE_TYPE,
|
||||||
|
name = name,
|
||||||
|
port = port,
|
||||||
|
txt = mapOf(TXT_DEVICE_ID to deviceId),
|
||||||
|
)
|
||||||
|
service.register()
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.MediaItem
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class JamMessage {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("hello")
|
||||||
|
data class Hello(
|
||||||
|
val displayName: String,
|
||||||
|
val deviceId: String,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("welcome")
|
||||||
|
data class Welcome(
|
||||||
|
val hostName: String,
|
||||||
|
val participantId: String,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("queueState")
|
||||||
|
data class QueueState(
|
||||||
|
val items: List<JamMediaItem>,
|
||||||
|
val currentIndex: Int,
|
||||||
|
val isPlaying: Boolean,
|
||||||
|
val positionMs: Long,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("playbackCommand")
|
||||||
|
data class PlaybackCommand(
|
||||||
|
val command: PlaybackCmd,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("suggestTrack")
|
||||||
|
data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("suggestPlaylist")
|
||||||
|
data class SuggestPlaylist(val tracks: List<JamMediaItem>) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("chat")
|
||||||
|
data class Chat(
|
||||||
|
val fromName: String,
|
||||||
|
val text: String,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("participantList")
|
||||||
|
data class ParticipantList(val participants: List<JamParticipant>) : JamMessage()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("leave")
|
||||||
|
data class Leave(val reason: String = "user_left") : JamMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class PlaybackCmd {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("play")
|
||||||
|
data object Play : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pause")
|
||||||
|
data object Pause : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("toggle")
|
||||||
|
data object Toggle : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("seek")
|
||||||
|
data class Seek(val positionMs: Long) : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("skipNext")
|
||||||
|
data object SkipNext : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("skipPrevious")
|
||||||
|
data object SkipPrevious : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setVolume")
|
||||||
|
data class SetVolume(val volume: Float) : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setLoop")
|
||||||
|
data class SetLoop(val loop: String) : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setShuffle")
|
||||||
|
data class SetShuffle(val enabled: Boolean) : PlaybackCmd()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("jumpTo")
|
||||||
|
data class JumpTo(val index: Int) : PlaybackCmd()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class JamMediaItem(
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val artist: String,
|
||||||
|
val album: String,
|
||||||
|
val durationMs: Long,
|
||||||
|
val coverUrl: String,
|
||||||
|
val protocol: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
||||||
|
url = item.url,
|
||||||
|
title = item.title,
|
||||||
|
artist = item.artist,
|
||||||
|
album = item.album,
|
||||||
|
durationMs = item.duration.inWholeMilliseconds,
|
||||||
|
coverUrl = item.coverURL,
|
||||||
|
protocol = item.protocol.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
||||||
|
title = item.title,
|
||||||
|
artist = item.artist,
|
||||||
|
album = item.album,
|
||||||
|
duration = kotlin.time.Duration.parse("${item.durationMs}ms"),
|
||||||
|
coverURL = item.coverUrl,
|
||||||
|
url = item.url,
|
||||||
|
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
|
.valueOf(item.protocol),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class JamParticipant(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val isHost: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class JamRole {
|
||||||
|
Host,
|
||||||
|
Guest,
|
||||||
|
}
|
||||||
|
|
||||||
|
object JamLoopMapping {
|
||||||
|
fun toString(state: LoopState): String = state.name.lowercase()
|
||||||
|
fun fromString(value: String): LoopState = when (value.lowercase()) {
|
||||||
|
"none" -> LoopState.NONE
|
||||||
|
"one" -> LoopState.ONE
|
||||||
|
"all" -> LoopState.ALL
|
||||||
|
else -> LoopState.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import uniffi.compose_app.IceServerConfig
|
||||||
|
import uniffi.compose_app.WebrtcEventHandler
|
||||||
|
import uniffi.compose_app.WebrtcPeerConnection
|
||||||
|
import uniffi.compose_app.createWebrtcPeerConnection
|
||||||
|
|
||||||
|
class JamSessionService(
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val settingsProvider: SettingsProvider,
|
||||||
|
) : KoinComponent {
|
||||||
|
val logger by injectLogger<JamSessionService>()
|
||||||
|
private val log = Logger.withTag("JamSessionService")
|
||||||
|
|
||||||
|
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 _incomingMessages = MutableSharedFlow<JamMessage>(extraBufferCapacity = 64)
|
||||||
|
val incomingMessages = _incomingMessages.asSharedFlow()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
private val _incomingSuggestions = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
|
||||||
|
val incomingSuggestions = _incomingSuggestions.asSharedFlow()
|
||||||
|
|
||||||
|
private var hostConnection: WebrtcPeerConnection? = null
|
||||||
|
private val guestConnections = mutableMapOf<String, WebrtcPeerConnection>()
|
||||||
|
private val guestLabels = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
private val eventHandler = object : WebrtcEventHandler {
|
||||||
|
override fun onIceCandidate(candidate: String) {
|
||||||
|
// No-op in non-trickle mode: candidates are bundled into SDP
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIceGatheringStateChange(state: String) {
|
||||||
|
log.d { "ICE gathering state: $state" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnectionStateChange(state: String) {
|
||||||
|
log.i { "Connection state: $state" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelOpen(label: String) {
|
||||||
|
log.i { "Data channel '$label' open" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelMessage(label: String, data: String) {
|
||||||
|
handleIncomingMessage(label, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDataChannelClose(label: String) {
|
||||||
|
log.i { "Data channel '$label' closed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createSession(): String {
|
||||||
|
log.i { "Creating jam session" }
|
||||||
|
val settings = settingsProvider.settingsState.first()
|
||||||
|
val participantName = settings?.jamParticipantName?.ifBlank {
|
||||||
|
"Host-${randomShortId()}"
|
||||||
|
} ?: "Host"
|
||||||
|
|
||||||
|
val pc = createWebrtcPeerConnection(
|
||||||
|
iceServers = listOf(
|
||||||
|
IceServerConfig(
|
||||||
|
urls = listOf("stun:stun.l.google.com:19302"),
|
||||||
|
username = "",
|
||||||
|
credential = "",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
handler = eventHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
hostConnection = pc
|
||||||
|
_role.value = JamRole.Host
|
||||||
|
_localParticipantId.value = "host"
|
||||||
|
_participants.value = listOf(
|
||||||
|
JamParticipant(
|
||||||
|
id = "host",
|
||||||
|
displayName = participantName,
|
||||||
|
isHost = true,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_isActive.value = true
|
||||||
|
|
||||||
|
pc.createDataChannel("jam")
|
||||||
|
val offer = pc.createOffer()
|
||||||
|
log.i { "Generated SDP offer (length=${offer.length})" }
|
||||||
|
return offer
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun acceptGuestAnswer(guestId: String, answer: String) {
|
||||||
|
val pc = guestConnections[guestId] ?: run {
|
||||||
|
log.w { "acceptGuestAnswer: no connection for $guestId" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pc.setRemoteAnswer(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun joinSession(offer: String): String {
|
||||||
|
log.i { "Joining jam session" }
|
||||||
|
val settings = settingsProvider.settingsState.first()
|
||||||
|
val participantName = settings?.jamParticipantName?.ifBlank {
|
||||||
|
"Guest-${randomShortId()}"
|
||||||
|
} ?: "Guest"
|
||||||
|
|
||||||
|
val pc = createWebrtcPeerConnection(
|
||||||
|
iceServers = listOf(
|
||||||
|
IceServerConfig(
|
||||||
|
urls = listOf("stun:stun.l.google.com:19302"),
|
||||||
|
username = "",
|
||||||
|
credential = "",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
handler = eventHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
hostConnection = pc
|
||||||
|
_role.value = JamRole.Guest
|
||||||
|
_localParticipantId.value = "guest"
|
||||||
|
_isActive.value = true
|
||||||
|
|
||||||
|
pc.setRemoteOffer(offer)
|
||||||
|
pc.createDataChannel("jam")
|
||||||
|
val answer = pc.createAnswer()
|
||||||
|
log.i { "Generated SDP answer (length=${answer.length})" }
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun hostAdmitGuest(guestOffer: String): String {
|
||||||
|
if (_role.value != JamRole.Host) {
|
||||||
|
error("hostAdmitGuest can only be called by the host")
|
||||||
|
}
|
||||||
|
val guestId = "guest-${guestConnections.size + 1}"
|
||||||
|
log.i { "Admitting guest $guestId" }
|
||||||
|
|
||||||
|
val handler = object : WebrtcEventHandler {
|
||||||
|
override fun onIceCandidate(candidate: String) {}
|
||||||
|
override fun onIceGatheringStateChange(state: String) {}
|
||||||
|
override fun onConnectionStateChange(state: String) {}
|
||||||
|
override fun onDataChannelOpen(label: String) {}
|
||||||
|
override fun onDataChannelMessage(label: String, data: String) {
|
||||||
|
handleIncomingMessage(label, data, guestId)
|
||||||
|
}
|
||||||
|
override fun onDataChannelClose(label: String) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
val pc = createWebrtcPeerConnection(
|
||||||
|
iceServers = listOf(
|
||||||
|
IceServerConfig(
|
||||||
|
urls = listOf("stun:stun.l.google.com:19302"),
|
||||||
|
username = "",
|
||||||
|
credential = "",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
handler = handler,
|
||||||
|
)
|
||||||
|
guestConnections[guestId] = pc
|
||||||
|
guestLabels[guestId] = "jam-$guestId"
|
||||||
|
|
||||||
|
pc.setRemoteOffer(guestOffer)
|
||||||
|
pc.createDataChannel("jam-${guestId}")
|
||||||
|
val answer = pc.createAnswer()
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(message: JamMessage, guestId: String? = null) {
|
||||||
|
val json = json.encodeToString(JamMessage.serializer(), message)
|
||||||
|
when (_role.value) {
|
||||||
|
JamRole.Host -> {
|
||||||
|
if (guestId != null) {
|
||||||
|
guestConnections[guestId]?.sendData("jam-$guestId", json)
|
||||||
|
} else {
|
||||||
|
guestConnections.forEach { (id, pc) ->
|
||||||
|
pc.sendData("jam-$id", json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JamRole.Guest -> {
|
||||||
|
hostConnection?.sendData("jam", json)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> log.w { "sendMessage called while no session is active" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun leave() {
|
||||||
|
log.i { "Leaving jam session" }
|
||||||
|
runCatching { sendMessage(JamMessage.Leave()) }
|
||||||
|
hostConnection?.shutdown()
|
||||||
|
guestConnections.values.forEach { runCatching { it.shutdown() } }
|
||||||
|
hostConnection = null
|
||||||
|
guestConnections.clear()
|
||||||
|
guestLabels.clear()
|
||||||
|
_role.value = null
|
||||||
|
_participants.value = emptyList()
|
||||||
|
_isActive.value = false
|
||||||
|
_localParticipantId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIncomingMessage(label: String, data: String, fromGuestId: String? = null) {
|
||||||
|
try {
|
||||||
|
val message = json.decodeFromString(JamMessage.serializer(), data)
|
||||||
|
_incomingMessages.tryEmit(message)
|
||||||
|
when (message) {
|
||||||
|
is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> {
|
||||||
|
_incomingSuggestions.tryEmit(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Leave -> {
|
||||||
|
if (_role.value == JamRole.Host && fromGuestId != null) {
|
||||||
|
val leavingPc = guestConnections.remove(fromGuestId)
|
||||||
|
guestLabels.remove(fromGuestId)
|
||||||
|
scope.launch {
|
||||||
|
runCatching { leavingPc?.shutdown() }
|
||||||
|
}
|
||||||
|
_participants.update { current ->
|
||||||
|
current.filterNot { it.id == fromGuestId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.w(e) { "Failed to parse jam message on $label" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun broadcastPlaybackCommand(command: PlaybackCmd) {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
sendMessage(JamMessage.PlaybackCommand(command))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun broadcastQueueState(
|
||||||
|
items: List<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,162 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <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.PlayerState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages queue synchronization between the host and the jam session.
|
||||||
|
*
|
||||||
|
* On the host: observes local playback state and broadcasts queue updates to guests.
|
||||||
|
* On the guest: receives queue updates and applies them to local playback.
|
||||||
|
*
|
||||||
|
* Conflict resolution: the host has authority. When a guest receives a queue state,
|
||||||
|
* it replaces the local queue. (and (The guest's local queue is essentially read-only
|
||||||
|
* during a jam session.)
|
||||||
|
*/
|
||||||
|
class QueueSyncManager(
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val jamSession: JamSessionService,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val log = Logger.withTag("QueueSyncManager")
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
classDiscriminator = "type"
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _isSyncing = MutableStateFlow(false)
|
||||||
|
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
|
||||||
|
|
||||||
|
private var hostBroadcastJob: Job? = null
|
||||||
|
private var guestApplyJob: Job? = null
|
||||||
|
private var guestCommandJob: Job? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (_isSyncing.value) return
|
||||||
|
_isSyncing.value = true
|
||||||
|
|
||||||
|
when (jamSession.role.value) {
|
||||||
|
JamRole.Host -> startHostSync()
|
||||||
|
JamRole.Guest -> startGuestSync()
|
||||||
|
null -> {
|
||||||
|
_isSyncing.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
_isSyncing.value = false
|
||||||
|
hostBroadcastJob?.cancel()
|
||||||
|
guestApplyJob?.cancel()
|
||||||
|
guestCommandJob?.cancel()
|
||||||
|
hostBroadcastJob = null
|
||||||
|
guestApplyJob = null
|
||||||
|
guestCommandJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startHostSync() {
|
||||||
|
hostBroadcastJob = scope.launch {
|
||||||
|
jamSession.role.first { it != null }
|
||||||
|
if (jamSession.role.value != JamRole.Host) return@launch
|
||||||
|
|
||||||
|
jamSession.broadcastQueueState(
|
||||||
|
items = audioPlayer.playlistFlow.value.map(JamMediaItem::fromMediaItem),
|
||||||
|
currentIndex = audioPlayer.playlistFlow.value.indexOf(
|
||||||
|
audioPlayer.currentMediaItemFlow.value
|
||||||
|
).coerceAtLeast(0),
|
||||||
|
isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
|
||||||
|
positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
audioPlayer.playlistFlow.collect { playlist ->
|
||||||
|
audioPlayer.playerStateFlow.value.let { state ->
|
||||||
|
audioPlayer.positionFlow.value.let { position ->
|
||||||
|
jamSession.broadcastQueueState(
|
||||||
|
items = playlist.map(JamMediaItem::fromMediaItem),
|
||||||
|
currentIndex = playlist.indexOf(audioPlayer.currentMediaItemFlow.value)
|
||||||
|
.coerceAtLeast(0),
|
||||||
|
isPlaying = state == PlayerState.PLAYING,
|
||||||
|
positionMs = position.inWholeMilliseconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startGuestSync() {
|
||||||
|
guestApplyJob = scope.launch {
|
||||||
|
jamSession.incomingMessages.collect { message ->
|
||||||
|
if (message !is JamMessage.QueueState) return@collect
|
||||||
|
applyQueueState(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guestCommandJob = scope.launch {
|
||||||
|
jamSession.incomingMessages.collect { message ->
|
||||||
|
if (message !is JamMessage.PlaybackCommand) return@collect
|
||||||
|
applyPlaybackCommand(message.command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyQueueState(state: JamMessage.QueueState) {
|
||||||
|
log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" }
|
||||||
|
val mediaItems = state.items.map(JamMediaItem::toMediaItem)
|
||||||
|
audioPlayer.load(
|
||||||
|
playlist = mediaItems,
|
||||||
|
autoPlay = state.isPlaying,
|
||||||
|
startPosition = state.currentIndex.coerceAtLeast(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyPlaybackCommand(command: PlaybackCmd) {
|
||||||
|
log.d { "Applying playback command: $command" }
|
||||||
|
when (command) {
|
||||||
|
PlaybackCmd.Play -> audioPlayer.play()
|
||||||
|
PlaybackCmd.Pause -> audioPlayer.pause()
|
||||||
|
PlaybackCmd.Toggle -> {
|
||||||
|
if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
||||||
|
PlaybackCmd.SkipNext -> audioPlayer.skipToNext()
|
||||||
|
PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious()
|
||||||
|
is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume)
|
||||||
|
is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop))
|
||||||
|
is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled)
|
||||||
|
is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,8 @@ import androidx.navigation3.runtime.NavKey
|
|||||||
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
||||||
|
import dev.krtirtho.spotube.modules.devices.DevicesScreen
|
||||||
|
import dev.krtirtho.spotube.modules.jam.JamScreen
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreen
|
import dev.krtirtho.spotube.modules.home.HomeScreen
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||||
@ -75,6 +77,12 @@ sealed interface Routes : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Blacklist : Routes
|
data object Blacklist : Routes
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Devices : Routes
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Jam : Routes
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(KoinExperimentalAPI::class)
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
@ -148,5 +156,11 @@ val navigationModule = module {
|
|||||||
navigation<Routes.Blacklist> {
|
navigation<Routes.Blacklist> {
|
||||||
BlacklistScreen()
|
BlacklistScreen()
|
||||||
}
|
}
|
||||||
|
navigation<Routes.Devices> {
|
||||||
|
DevicesScreen(navigationCommands = get())
|
||||||
|
}
|
||||||
|
navigation<Routes.Jam> {
|
||||||
|
JamScreen(navigationCommands = get())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,8 @@ val serializersConfig = SavedStateConfiguration {
|
|||||||
subclass(Routes.Artist::class, Routes.Artist.serializer())
|
subclass(Routes.Artist::class, Routes.Artist.serializer())
|
||||||
subclass(Routes.Album::class, Routes.Album.serializer())
|
subclass(Routes.Album::class, Routes.Album.serializer())
|
||||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||||
|
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
||||||
|
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConnectionRequestDialogHost() {
|
||||||
|
val handler: RemoteControlHandler = koinInject()
|
||||||
|
var pendingRequest by remember { mutableStateOf<ConnectionRequest?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(handler) {
|
||||||
|
handler.incomingConnectionRequests.collect { request ->
|
||||||
|
pendingRequest = request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRequest?.let { request ->
|
||||||
|
AdaptiveDialogBottomSheet(
|
||||||
|
onDismiss = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Remote Control Request",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Deny")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Allow")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Allow Always")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,262 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState as AudioPlayerState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
|
import io.ktor.server.websocket.WebSocketServerSession
|
||||||
|
import io.ktor.websocket.CloseReason
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.close
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
|
class RemoteControlHandler(
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
) : KoinComponent {
|
||||||
|
val logger by injectLogger<RemoteControlHandler>()
|
||||||
|
|
||||||
|
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
||||||
|
extraBufferCapacity = 16,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
classDiscriminator = "type"
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val pendingRequestResolutions = mutableMapOf<String, (ConnectionRequestResponse) -> Unit>()
|
||||||
|
|
||||||
|
suspend fun handleConnection(session: WebSocketServerSession) {
|
||||||
|
val settings = settingsRepository.userSettings.first()
|
||||||
|
if (!settings.allowRemoteControl) {
|
||||||
|
logger.w { "Rejecting remote control connection: remote control is disabled" }
|
||||||
|
session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Remote control is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceId = session.call.request.headers["X-Device-Id"]
|
||||||
|
val deviceName = session.call.request.headers["X-Device-Name"] ?: "Unknown"
|
||||||
|
|
||||||
|
val isAllowed = deviceId != null && deviceId in settings.allowedRemoteDevices
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
val request = ConnectionRequest(
|
||||||
|
deviceId = deviceId ?: "unknown",
|
||||||
|
deviceName = deviceName,
|
||||||
|
sessionId = session.call.request.headers["X-Request-Id"]
|
||||||
|
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}",
|
||||||
|
)
|
||||||
|
incomingConnectionRequests.emit(request)
|
||||||
|
val response = waitForRequestResolution(request.sessionId)
|
||||||
|
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
||||||
|
session.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Connection denied"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response == ConnectionRequestResponse.AllowAlways && deviceId != null) {
|
||||||
|
settingsRepository.updateSettings(
|
||||||
|
settings.copy(
|
||||||
|
allowedRemoteDevices = (settings.allowedRemoteDevices + deviceId).distinct()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.i { "Device $deviceId added to always-allowed devices" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.i { "Remote control connection established from $deviceName ($deviceId)" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
handleControlLoop(session)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.w(e) { "Error in remote control session" }
|
||||||
|
} finally {
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveConnectionRequest(sessionId: String, response: ConnectionRequestResponse) {
|
||||||
|
pendingRequestResolutions.remove(sessionId)?.invoke(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun waitForRequestResolution(sessionId: String): ConnectionRequestResponse {
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
pendingRequestResolutions[sessionId] = { response ->
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resume(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
pendingRequestResolutions.remove(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleControlLoop(session: WebSocketServerSession) {
|
||||||
|
for (frame in session.incoming) {
|
||||||
|
if (frame is Frame.Text) {
|
||||||
|
val text = frame.readText()
|
||||||
|
try {
|
||||||
|
val envelope = json.decodeFromString(CommandEnvelope.serializer(), text)
|
||||||
|
handleCommand(session, envelope)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.w(e) { "Failed to parse remote control command" }
|
||||||
|
sendError(session, "Invalid command: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
|
||||||
|
when (val command = envelope.command) {
|
||||||
|
is RemoteControlCommand.Play -> {
|
||||||
|
logger.d { "Remote play request: ${command.source} (playback source not yet implemented)" }
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.Pause -> {
|
||||||
|
audioPlayer.pause()
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.TogglePlayPause -> {
|
||||||
|
if (audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.Seek -> {
|
||||||
|
audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms"))
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.SetVolume -> {
|
||||||
|
audioPlayer.setVolume(command.volume)
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.SkipNext -> {
|
||||||
|
audioPlayer.skipToNext()
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.SkipPrevious -> {
|
||||||
|
audioPlayer.skipToPrevious()
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.SetShuffle -> {
|
||||||
|
audioPlayer.shuffle(command.enabled)
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.SetLoopMode -> {
|
||||||
|
val loopState = when (command.mode) {
|
||||||
|
"none" -> LoopState.NONE
|
||||||
|
"one" -> LoopState.ONE
|
||||||
|
"all" -> LoopState.ALL
|
||||||
|
else -> {
|
||||||
|
sendError(session, "Invalid loop mode: ${command.mode}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
audioPlayer.loop(loopState)
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.AddToQueue -> {
|
||||||
|
logger.d { "Remote add to queue: ${command.source} (source parsing not yet implemented)" }
|
||||||
|
}
|
||||||
|
is RemoteControlCommand.RemoveFromQueue -> {
|
||||||
|
audioPlayerQueue.removeFromQueueByMediaUrl(command.mediaUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendAck(session, envelope.commandId)
|
||||||
|
broadcastState(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendAck(session: WebSocketServerSession, commandId: String) {
|
||||||
|
val text = json.encodeToString(RemoteControlEvent.Ack.serializer(), RemoteControlEvent.Ack(commandId))
|
||||||
|
session.send(Frame.Text(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendError(session: WebSocketServerSession, message: String) {
|
||||||
|
val text = json.encodeToString(RemoteControlEvent.Error.serializer(), RemoteControlEvent.Error(message))
|
||||||
|
session.send(Frame.Text(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun broadcastState(session: WebSocketServerSession) {
|
||||||
|
val current = audioPlayerQueue.currentQueueEntryFlow.value
|
||||||
|
val state = RemoteControlEvent.PlayerState(
|
||||||
|
isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING,
|
||||||
|
positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds,
|
||||||
|
durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds,
|
||||||
|
volume = audioPlayer.volumeFlow.value,
|
||||||
|
shuffleEnabled = audioPlayer.shuffleModeFlow.value,
|
||||||
|
loopMode = audioPlayer.loopStateFlow.value.name.lowercase(),
|
||||||
|
currentTrackId = current?.mediaKey(),
|
||||||
|
currentTrackTitle = current?.titleOrNull(),
|
||||||
|
currentTrackArtists = current?.artistsOrNull(),
|
||||||
|
currentTrackAlbum = current?.albumOrNull(),
|
||||||
|
currentTrackCoverUrl = current?.coverUrlOrNull(),
|
||||||
|
)
|
||||||
|
val text = json.encodeToString(RemoteControlEvent.PlayerState.serializer(), state)
|
||||||
|
session.send(Frame.Text(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.mediaKey(): String = when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> track.id
|
||||||
|
is QueueEntry.LocalTrack -> url
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.titleOrNull(): String = when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> track.title
|
||||||
|
is QueueEntry.LocalTrack -> name
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.artistsOrNull(): String = when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> track.artists.joinToString(", ") { artist -> artist.name }
|
||||||
|
is QueueEntry.LocalTrack -> artists.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.albumOrNull(): String? = when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> track.album?.title
|
||||||
|
is QueueEntry.LocalTrack -> album
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.coverUrlOrNull(): String? = when (this) {
|
||||||
|
is QueueEntry.StreamingTrack -> track.thumbnails?.firstOrNull()?.url
|
||||||
|
is QueueEntry.LocalTrack -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CommandEnvelope(
|
||||||
|
val commandId: String,
|
||||||
|
val command: RemoteControlCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ConnectionRequest(
|
||||||
|
val deviceId: String,
|
||||||
|
val deviceName: String,
|
||||||
|
val sessionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ConnectionRequestResponse {
|
||||||
|
Allow,
|
||||||
|
AllowAlways,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class RemoteControlCommand {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("play")
|
||||||
|
data class Play(val source: String) : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("pause")
|
||||||
|
data object Pause : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("togglePlayPause")
|
||||||
|
data object TogglePlayPause : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("seek")
|
||||||
|
data class Seek(val positionMs: Long) : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setVolume")
|
||||||
|
data class SetVolume(val volume: Float) : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("skipNext")
|
||||||
|
data object SkipNext : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("skipPrevious")
|
||||||
|
data object SkipPrevious : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setShuffle")
|
||||||
|
data class SetShuffle(val enabled: Boolean) : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("setLoopMode")
|
||||||
|
data class SetLoopMode(val mode: String) : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("addToQueue")
|
||||||
|
data class AddToQueue(val source: String) : RemoteControlCommand()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("removeFromQueue")
|
||||||
|
data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class RemoteControlEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("playerState")
|
||||||
|
data class PlayerState(
|
||||||
|
val isPlaying: Boolean,
|
||||||
|
val positionMs: Long,
|
||||||
|
val durationMs: Long,
|
||||||
|
val volume: Float,
|
||||||
|
val shuffleEnabled: Boolean,
|
||||||
|
val loopMode: String,
|
||||||
|
val currentTrackId: String?,
|
||||||
|
val currentTrackTitle: String?,
|
||||||
|
val currentTrackArtists: String?,
|
||||||
|
val currentTrackAlbum: String?,
|
||||||
|
val currentTrackCoverUrl: String?,
|
||||||
|
) : RemoteControlEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("queueUpdated")
|
||||||
|
data class QueueUpdated(
|
||||||
|
val entries: List<RemoteQueueEntry>,
|
||||||
|
val currentIndex: Int,
|
||||||
|
) : RemoteControlEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("ack")
|
||||||
|
data class Ack(val commandId: String) : RemoteControlEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("error")
|
||||||
|
data class Error(val message: String) : RemoteControlEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RemoteQueueEntry(
|
||||||
|
val mediaUrl: String,
|
||||||
|
val trackId: String,
|
||||||
|
val title: String,
|
||||||
|
val artists: String,
|
||||||
|
val album: String?,
|
||||||
|
val coverUrl: String?,
|
||||||
|
val durationMs: Long,
|
||||||
|
)
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.appstractive.dnssd.NetService
|
||||||
|
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
|
||||||
|
import dev.krtirtho.spotube.core.server.LocalServer
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advertises this device on the local network via DNS-SD so that other Spotube
|
||||||
|
* instances can discover and control it. Advertises only while the
|
||||||
|
* "Allow remote control" setting is enabled and the local playback server is
|
||||||
|
* listening on the LAN (0.0.0.0).
|
||||||
|
*/
|
||||||
|
class RemoteControlService(
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val discoveryService: DeviceDiscoveryService,
|
||||||
|
private val localServer: LocalServer,
|
||||||
|
) {
|
||||||
|
private val log = Logger.withTag("RemoteControlService")
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
private var advertisedService: NetService? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
settingsRepository.userSettings,
|
||||||
|
localServer.port,
|
||||||
|
) { settings, port -> settings to port }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { (settings, port) ->
|
||||||
|
if (settings.allowRemoteControl && port != null) {
|
||||||
|
ensureAdvertised(settings.remoteControlDeviceName, port)
|
||||||
|
} else {
|
||||||
|
stopAdvertising()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureAdvertised(name: String, port: Int) {
|
||||||
|
val deviceId = resolveDeviceId()
|
||||||
|
val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" }
|
||||||
|
if (advertisedService == null) {
|
||||||
|
try {
|
||||||
|
advertisedService = discoveryService.advertise(
|
||||||
|
name = serviceName,
|
||||||
|
port = port,
|
||||||
|
deviceId = deviceId,
|
||||||
|
)
|
||||||
|
log.i { "Advertising remote control service '$serviceName' on port $port" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.w(e) { "Failed to advertise remote control service" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun stopAdvertising() {
|
||||||
|
if (advertisedService != null) {
|
||||||
|
runCatching { advertisedService?.unregister() }
|
||||||
|
advertisedService = null
|
||||||
|
log.i { "Stopped advertising remote control service" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveDeviceId(): String {
|
||||||
|
val settings = settingsRepository.userSettings.first()
|
||||||
|
if (settings.remoteControlDeviceId.isNotBlank()) {
|
||||||
|
return settings.remoteControlDeviceId
|
||||||
|
}
|
||||||
|
val generated = buildString(16) {
|
||||||
|
val chars = "0123456789abcdef"
|
||||||
|
repeat(16) { append(chars[Random.nextInt(chars.length)]) }
|
||||||
|
}
|
||||||
|
settingsRepository.updateSettings(settings.copy(remoteControlDeviceId = generated))
|
||||||
|
return generated
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,10 +19,12 @@ package dev.krtirtho.spotube.core.server
|
|||||||
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.http.HttpMethod
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.install
|
||||||
import io.ktor.server.cio.CIO
|
import io.ktor.server.cio.CIO
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
import io.ktor.server.engine.EmbeddedServer
|
||||||
import io.ktor.server.engine.embeddedServer
|
import io.ktor.server.engine.embeddedServer
|
||||||
@ -30,6 +32,8 @@ import io.ktor.server.response.respondText
|
|||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.head
|
import io.ktor.server.routing.head
|
||||||
import io.ktor.server.routing.routing
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
@ -55,6 +59,7 @@ class LocalServer(
|
|||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
|
private val remoteControlHandler: RemoteControlHandler,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
val logger by injectLogger<LocalServer>()
|
val logger by injectLogger<LocalServer>()
|
||||||
@ -70,8 +75,10 @@ class LocalServer(
|
|||||||
private val activePort = MutableStateFlow<Int?>(null)
|
private val activePort = MutableStateFlow<Int?>(null)
|
||||||
val port = activePort.asStateFlow()
|
val port = activePort.asStateFlow()
|
||||||
|
|
||||||
|
private val activeHost = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
val baseUrl = activePort.map { port ->
|
val baseUrl = activePort.map { port ->
|
||||||
port?.let { "http://$HOST:$it" }
|
port?.let { "http://$HOST_LOCAL:$it" }
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
private val cachedCacheEnabled = MutableStateFlow(false)
|
private val cachedCacheEnabled = MutableStateFlow(false)
|
||||||
@ -89,18 +96,19 @@ class LocalServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val HOST = "127.0.0.1"
|
private const val HOST_LOCAL = "127.0.0.1"
|
||||||
|
private const val HOST_LAN = "0.0.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
logger.d { "Starting playback proxy port watcher" }
|
logger.d { "Starting playback proxy port watcher" }
|
||||||
portWatcher = scope.launch {
|
portWatcher = scope.launch {
|
||||||
settingsViewModel.settingsState
|
settingsViewModel.settingsState
|
||||||
.mapNotNull { it?.playbackProxyServerPort }
|
.mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collectLatest { port ->
|
.collectLatest { (port, allowRemoteControl) ->
|
||||||
logger.d { "Observed playback proxy port change to $port" }
|
logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" }
|
||||||
restartServer(port)
|
restartServer(port, allowRemoteControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -129,26 +137,28 @@ class LocalServer(
|
|||||||
logger.d { "Playback proxy server stopped" }
|
logger.d { "Playback proxy server stopped" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restartServer(port: Int) {
|
private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) {
|
||||||
|
val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL
|
||||||
serverMutex.withLock {
|
serverMutex.withLock {
|
||||||
if (serverState.value != null && activePort.value == port) {
|
if (serverState.value != null && activePort.value == port && activeHost.value == host) {
|
||||||
logger.v { "Playback proxy server already running on port $port; skipping restart" }
|
logger.v { "Playback proxy server already running on $host:$port; skipping restart" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.d { "Restarting playback proxy server on port $port" }
|
logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" }
|
||||||
stopServerLocked()
|
stopServerLocked()
|
||||||
|
|
||||||
serverState.value = embeddedServer(
|
serverState.value = embeddedServer(
|
||||||
factory = CIO,
|
factory = CIO,
|
||||||
host = HOST,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
module = { configureRoutes() }
|
module = { configureRoutes() }
|
||||||
).also { engine ->
|
).also { engine ->
|
||||||
engine.start(wait = false)
|
engine.start(wait = false)
|
||||||
}
|
}
|
||||||
activePort.value = port
|
activePort.value = port
|
||||||
logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$HOST:$port"}" }
|
activeHost.value = host
|
||||||
|
logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:$port"}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,9 +175,16 @@ class LocalServer(
|
|||||||
}
|
}
|
||||||
serverState.value = null
|
serverState.value = null
|
||||||
activePort.value = null
|
activePort.value = null
|
||||||
|
activeHost.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Application.configureRoutes() {
|
private fun Application.configureRoutes() {
|
||||||
|
install(WebSockets) {
|
||||||
|
pingPeriodMillis = 30_000L
|
||||||
|
timeoutMillis = 60_000L
|
||||||
|
maxFrameSize = 10L * 1024 * 1024
|
||||||
|
masking = false
|
||||||
|
}
|
||||||
routing {
|
routing {
|
||||||
get("/health") {
|
get("/health") {
|
||||||
call.respondText("ok")
|
call.respondText("ok")
|
||||||
@ -188,6 +205,10 @@ class LocalServer(
|
|||||||
get("/segment/{trackId}") {
|
get("/segment/{trackId}") {
|
||||||
streamProxy.handleSegmentRequest(call)
|
streamProxy.handleSegmentRequest(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webSocket("/control") {
|
||||||
|
remoteControlHandler.handleConnection(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,181 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.modules.devices
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import dev.krtirtho.spotube.core.discovery.DiscoveredDevice
|
||||||
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxRefreshRight
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DevicesScreen(
|
||||||
|
navigationCommands: NavigationCommands,
|
||||||
|
) {
|
||||||
|
val viewModel = koinViewModel<DevicesViewModel>()
|
||||||
|
val devices by viewModel.devices.collectAsStateWithLifecycle()
|
||||||
|
val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
viewModel.startDiscovery()
|
||||||
|
onDispose { viewModel.stopDiscovery() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
ApplicationMainBar(
|
||||||
|
backButton = true,
|
||||||
|
title = { Text("Devices") },
|
||||||
|
actions = {
|
||||||
|
if (isDiscovering) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.padding(end = 8.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxRefreshRight,
|
||||||
|
contentDescription = "Refresh",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clickable { viewModel.startDiscovery() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
|
|
||||||
|
if (devices.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(bottom = shellBottomInset),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = if (isDiscovering) {
|
||||||
|
"Searching for devices on the network..."
|
||||||
|
} else {
|
||||||
|
"No devices found"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (!isDiscovering) {
|
||||||
|
Text(
|
||||||
|
text = "Make sure the other device has \"Allow remote control\" enabled in settings.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||||
|
horizontal = 16.dp,
|
||||||
|
vertical = 8.dp,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
items(devices.values.toList(), key = { it.key }) { device ->
|
||||||
|
DeviceRow(
|
||||||
|
device = device,
|
||||||
|
onClick = { viewModel.connectToDevice(device) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.padding(bottom = shellBottomInset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceRow(
|
||||||
|
device: DiscoveredDevice,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = device.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${device.host}:${device.port}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.modules.devices
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.appstractive.dnssd.NetService
|
||||||
|
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
|
||||||
|
import dev.krtirtho.spotube.core.discovery.DiscoveredDevice
|
||||||
|
import dev.krtirtho.spotube.core.discovery.DiscoveryState
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
class DevicesViewModel : ViewModel(), KoinComponent {
|
||||||
|
private val logger = Logger.withTag("DevicesViewModel")
|
||||||
|
private val discoveryService: DeviceDiscoveryService by inject()
|
||||||
|
|
||||||
|
private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap())
|
||||||
|
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
|
||||||
|
|
||||||
|
private val _isDiscovering = MutableStateFlow(false)
|
||||||
|
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
|
||||||
|
|
||||||
|
private var discoveryJob: Job? = null
|
||||||
|
private var advertisedService: NetService? = null
|
||||||
|
|
||||||
|
fun startDiscovery() {
|
||||||
|
if (discoveryJob?.isActive == true) return
|
||||||
|
_isDiscovering.value = true
|
||||||
|
discoveryJob = viewModelScope.launch {
|
||||||
|
discoveryService.discover().collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is DiscoveryState.Discovered -> {
|
||||||
|
event.resolve()
|
||||||
|
_devices.update { it + (event.device.key to event.device.copy()) }
|
||||||
|
}
|
||||||
|
is DiscoveryState.Resolved -> {
|
||||||
|
_devices.update { it + (event.device.key to event.device) }
|
||||||
|
}
|
||||||
|
is DiscoveryState.Removed -> {
|
||||||
|
_devices.update { it - event.device.key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopDiscovery() {
|
||||||
|
discoveryJob?.cancel()
|
||||||
|
discoveryJob = null
|
||||||
|
_isDiscovering.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectToDevice(device: DiscoveredDevice) {
|
||||||
|
logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,8 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||||||
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.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@ -66,8 +68,14 @@ import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
|
|||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
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.IconsaxMirroringScreen
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
@ -75,6 +83,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(viewModel: HomeScreenViewModel) {
|
fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||||
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
val platform = getPlatform()
|
val platform = getPlatform()
|
||||||
val isDesktop = platform.type == PlatformType.Windows ||
|
val isDesktop = platform.type == PlatformType.Windows ||
|
||||||
platform.type == PlatformType.Linux ||
|
platform.type == PlatformType.Linux ||
|
||||||
@ -117,7 +126,21 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
|||||||
backButton = false,
|
backButton = false,
|
||||||
title = {
|
title = {
|
||||||
Text("Browse")
|
Text("Browse")
|
||||||
}
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||||
|
contentDescription = "Devices",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.User,
|
||||||
|
contentDescription = "Group Jam",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
|
|||||||
@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.modules.jam
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun JamScreen(
|
||||||
|
navigationCommands: NavigationCommands,
|
||||||
|
) {
|
||||||
|
val viewModel = koinViewModel<JamViewModel>()
|
||||||
|
val isActive by viewModel.isActive.collectAsStateWithLifecycle()
|
||||||
|
val pendingOffer by viewModel.pendingHostOffer.collectAsStateWithLifecycle()
|
||||||
|
val pendingAnswer by viewModel.pendingGuestAnswer.collectAsStateWithLifecycle()
|
||||||
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(isActive) {
|
||||||
|
if (isActive && navigationCommands != null) {
|
||||||
|
// navigationCommands doesn't navigate here automatically;
|
||||||
|
// the session screen is the same screen so we just stay.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
ApplicationMainBar(
|
||||||
|
backButton = true,
|
||||||
|
title = { Text("Group Jam") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
if (error != null) {
|
||||||
|
Text(
|
||||||
|
text = error ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingOffer == null && pendingAnswer == null) {
|
||||||
|
CreateOrJoinView(
|
||||||
|
onCreate = { viewModel.createSession() },
|
||||||
|
onJoin = { offer -> viewModel.joinSession(offer) },
|
||||||
|
)
|
||||||
|
} else if (pendingOffer != null) {
|
||||||
|
HostOfferView(
|
||||||
|
offer = pendingOffer!!,
|
||||||
|
onLeave = { viewModel.leave() },
|
||||||
|
)
|
||||||
|
} else if (pendingAnswer != null) {
|
||||||
|
GuestAnswerView(
|
||||||
|
answer = pendingAnswer!!,
|
||||||
|
onLeave = { viewModel.leave() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CreateOrJoinView(
|
||||||
|
onCreate: () -> Unit,
|
||||||
|
onJoin: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var tab by remember { mutableIntStateOf(0) }
|
||||||
|
var offer by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Listen Together with friends",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
SegmentedButton(
|
||||||
|
selected = tab == 0,
|
||||||
|
onClick = { tab = 0 },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(0, 2),
|
||||||
|
) { Text("Create") }
|
||||||
|
SegmentedButton(
|
||||||
|
selected = tab == 1,
|
||||||
|
onClick = { tab = 1 },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(1, 2),
|
||||||
|
) { Text("Join") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab == 0) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Create a new jam session. You'll be the host and can control playback. Share the SDP offer with your friends so they can join.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Button(onClick = onCreate) {
|
||||||
|
Text("Create Session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Paste the SDP offer from the host below. You'll get an SDP answer to send back.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = offer,
|
||||||
|
onValueChange = { offer = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("Host's SDP offer") },
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 6,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { onJoin(offer.trim()) },
|
||||||
|
enabled = offer.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text("Generate Answer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HostOfferView(
|
||||||
|
offer: String,
|
||||||
|
onLeave: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Session created. Send this SDP offer to your friends:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
SelectionContainer {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = offer,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("SDP Offer (copy and send to guests)") },
|
||||||
|
minLines = 4,
|
||||||
|
maxLines = 10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "When a guest responds with an SDP answer, use the JamSessionScreen to add them.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Button(onClick = onLeave) {
|
||||||
|
Text("Leave Session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GuestAnswerView(
|
||||||
|
answer: String,
|
||||||
|
onLeave: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "You've joined the session. Send this SDP answer back to the host:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
SelectionContainer {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = answer,
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("SDP Answer (copy and send to host)") },
|
||||||
|
minLines = 4,
|
||||||
|
maxLines = 10,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(onClick = onLeave) {
|
||||||
|
Text("Leave Session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.modules.jam
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamMessage
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamSessionService
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
class JamViewModel : ViewModel(), KoinComponent {
|
||||||
|
private val jamSession: JamSessionService by inject()
|
||||||
|
private val audioPlayer: AudioPlayerInterface by inject()
|
||||||
|
|
||||||
|
val role: StateFlow<JamRole?> = jamSession.role
|
||||||
|
val participants: StateFlow<List<JamParticipant>> = jamSession.participants
|
||||||
|
val isActive: StateFlow<Boolean> = jamSession.isActive
|
||||||
|
|
||||||
|
private val _pendingHostOffer = MutableStateFlow<String?>(null)
|
||||||
|
val pendingHostOffer: StateFlow<String?> = _pendingHostOffer.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingGuestAnswer = MutableStateFlow<String?>(null)
|
||||||
|
val pendingGuestAnswer: StateFlow<String?> = _pendingGuestAnswer.asStateFlow()
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
fun createSession() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val offer = jamSession.createSession()
|
||||||
|
_pendingHostOffer.value = offer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Failed to create session: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun joinSession(offer: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val answer = jamSession.joinSession(offer)
|
||||||
|
_pendingGuestAnswer.value = answer
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Failed to join session: ${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun leave() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
jamSession.leave()
|
||||||
|
_pendingHostOffer.value = null
|
||||||
|
_pendingGuestAnswer.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.modules.jam
|
||||||
|
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog shown when the user tries to play a collection while a jam session is active.
|
||||||
|
* The user picks between playing locally on their device or suggesting it to the jam session.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PlayDestinationPicker(
|
||||||
|
visible: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onPlayLocally: () -> Unit,
|
||||||
|
onSuggestToJam: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
ThemedDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text("Play Where?", style = MaterialTheme.typography.titleLarge)
|
||||||
|
},
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = "You have an active jam session. Choose where to play this collection.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
TextButton(onClick = onPlayLocally) {
|
||||||
|
Text("Play here")
|
||||||
|
}
|
||||||
|
Button(onClick = onSuggestToJam) {
|
||||||
|
Text("Suggest to Jam")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -55,6 +55,15 @@ data class UserSettings(
|
|||||||
val enableConnect: Boolean = false,
|
val enableConnect: Boolean = false,
|
||||||
val playbackProxyServerPort: Int = 14769,
|
val playbackProxyServerPort: Int = 14769,
|
||||||
|
|
||||||
|
// Remote Control (LAN)
|
||||||
|
val allowRemoteControl: Boolean = false,
|
||||||
|
val allowedRemoteDevices: List<String> = emptyList(),
|
||||||
|
val remoteControlDeviceName: String = "",
|
||||||
|
val remoteControlDeviceId: String = "",
|
||||||
|
|
||||||
|
// Group Jam (P2P)
|
||||||
|
val jamParticipantName: String = "",
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
||||||
val localMediaFolders: List<String> = emptyList(),
|
val localMediaFolders: List<String> = emptyList(),
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
|||||||
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
||||||
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
||||||
@ -135,6 +136,46 @@ internal fun LazyListScope.playbackSection(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
SwitchSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_allow_remote_control_title),
|
||||||
|
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(
|
||||||
|
Iconsax.IconsaxMirroringScreen,
|
||||||
|
stringResource(Res.string.settings_allow_remote_control_title)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
checked = settings.allowRemoteControl,
|
||||||
|
onCheckedChange = { enabled ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(allowRemoteControl = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_remote_device_name_title),
|
||||||
|
subtitle = stringResource(
|
||||||
|
Res.string.settings_remote_device_name_subtitle,
|
||||||
|
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
|
||||||
|
),
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
|
||||||
|
},
|
||||||
|
value = settings.remoteControlDeviceName,
|
||||||
|
dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
|
||||||
|
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
|
||||||
|
normalize = { it.trim() },
|
||||||
|
validate = { _ -> null },
|
||||||
|
onValueSaved = { value ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(remoteControlDeviceName = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||||
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
||||||
|
|||||||
@ -69,6 +69,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
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.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
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||||
@ -116,6 +117,8 @@ fun AppShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ConnectionRequestDialogHost()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val useSidebar = viewModel.useSidebar()
|
val useSidebar = viewModel.useSidebar()
|
||||||
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
||||||
|
|||||||
@ -68,8 +68,10 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
|||||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
import dev.krtirtho.spotube.tabs
|
import dev.krtirtho.spotube.tabs
|
||||||
import org.jetbrains.compose.resources.Font
|
import org.jetbrains.compose.resources.Font
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
@ -103,6 +105,7 @@ fun AppSidebar(
|
|||||||
},
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -175,6 +178,22 @@ fun AppSidebar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SidebarItem(
|
||||||
|
label = "Devices",
|
||||||
|
activeIcon = Iconsax.IconsaxMirroringScreen,
|
||||||
|
onClick = { navigator.navigate(Routes.Devices) },
|
||||||
|
selected = false,
|
||||||
|
expanded = expanded,
|
||||||
|
)
|
||||||
|
SidebarItem(
|
||||||
|
label = "Group Jam",
|
||||||
|
activeIcon = Iconsax.User,
|
||||||
|
onClick = { navigator.navigate(Routes.Jam) },
|
||||||
|
selected = false,
|
||||||
|
expanded = expanded,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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!();
|
||||||
289
composeApp/src/commonMain/rust/webrtc_p2p.rs
Normal file
289
composeApp/src/commonMain/rust/webrtc_p2p.rs
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors;
|
||||||
|
use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit};
|
||||||
|
use webrtc::peer_connection::{
|
||||||
|
MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler,
|
||||||
|
RTCConfigurationBuilder, RTCIceGatheringState, RTCIceServer, RTCPeerConnectionIceEvent,
|
||||||
|
RTCPeerConnectionState, RTCSessionDescription, Registry,
|
||||||
|
};
|
||||||
|
use webrtc::runtime::channel;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, uniffi::Error)]
|
||||||
|
pub enum WebrtcError {
|
||||||
|
#[error("SDP error: {reason}")]
|
||||||
|
SdpError { reason: String },
|
||||||
|
#[error("Connection error: {reason}")]
|
||||||
|
ConnectionError { reason: String },
|
||||||
|
#[error("Data channel error: {reason}")]
|
||||||
|
DataChannelError { reason: String },
|
||||||
|
#[error("Invalid state: {reason}")]
|
||||||
|
InvalidState { reason: String },
|
||||||
|
#[error("Internal error: {reason}")]
|
||||||
|
Internal { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<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: 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();
|
||||||
|
|
||||||
|
let (gather_tx, gather_rx) = channel::<()>(1);
|
||||||
|
let pc_handler = Arc::new(PeerHandlerBridge {
|
||||||
|
handler: Arc::clone(&handler),
|
||||||
|
gather_tx,
|
||||||
|
});
|
||||||
|
|
||||||
|
let pc = PeerConnectionBuilder::new()
|
||||||
|
.with_configuration(config)
|
||||||
|
.with_media_engine(media_engine)
|
||||||
|
.with_interceptor_registry(registry)
|
||||||
|
.with_handler(pc_handler)
|
||||||
|
.with_udp_addrs(vec!["0.0.0.0:0"])
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Arc::new(WebrtcPeerConnection {
|
||||||
|
pc: Arc::new(pc) as Arc<dyn PeerConnection>,
|
||||||
|
handler,
|
||||||
|
channels: Mutex::new(Vec::new()),
|
||||||
|
gather_rx: Mutex::new(gather_rx),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebrtcPeerConnection {
|
||||||
|
/// Waits for ICE gathering to reach `Complete` so the local SDP includes all
|
||||||
|
/// candidates (non-trickle exchange). Must be called after `set_local_description`,
|
||||||
|
/// which is what starts gathering.
|
||||||
|
async fn wait_for_ice_gathering(&self) {
|
||||||
|
let mut gather_rx = self.gather_rx.lock().clone();
|
||||||
|
let _ = gather_rx.recv().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl WebrtcPeerConnection {
|
||||||
|
#[uniffi::method(async_runtime = "tokio")]
|
||||||
|
pub async fn create_offer(&self) -> Result<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<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -53,6 +53,7 @@ vlcj = "4.12.1"
|
|||||||
vlcjNative = "4.12.0"
|
vlcjNative = "4.12.0"
|
||||||
ziplineVersion = "1.27.0"
|
ziplineVersion = "1.27.0"
|
||||||
ktor = "3.5.1"
|
ktor = "3.5.1"
|
||||||
|
dnssdkt = "1.1.0"
|
||||||
kotlinStdlib = "2.4.10"
|
kotlinStdlib = "2.4.10"
|
||||||
runner = "1.7.0"
|
runner = "1.7.0"
|
||||||
core = "1.7.0"
|
core = "1.7.0"
|
||||||
@ -85,6 +86,7 @@ androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
|||||||
compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" }
|
compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" }
|
||||||
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
|
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
||||||
haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" }
|
haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" }
|
||||||
@ -117,6 +119,8 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s
|
|||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
||||||
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
||||||
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||||
|
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
||||||
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
||||||
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" }
|
||||||
|
|||||||
@ -4,5 +4,12 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Required to discover local network devices</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_http._tcp</string>
|
||||||
|
<string>_spotube-ctrl._tcp</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user