Compare commits

...

29 Commits

Author SHA1 Message Date
androidkotlindev
0261e398fd
Merge 729bdd43b9 into 3a85ad0896 2026-09-26 08:34:08 +08:00
Kingkor Roy Tirtho
3a85ad0896 feat(update): implement update checker with dialog for available updates 2026-09-25 22:39:33 +06:00
Kingkor Roy Tirtho
046c17d3df feat(autocomplete): simplify focus management and remove unused key event handling 2026-09-25 21:39:08 +06:00
Kingkor Roy Tirtho
2713e63902 feat(search): enhance LazyRow with drag scrolling functionality 2026-09-25 21:14:34 +06:00
Kingkor Roy Tirtho
86ec117cfd feat(navigation): refactor sidebar and bottombar navigation structure with new tab and group models 2026-09-25 21:07:19 +06:00
Kingkor Roy Tirtho
78168d269c feat: implement shuffle play functionality for albums, playlists, and saved tracks 2026-09-25 18:47:29 +06:00
Kingkor Roy Tirtho
50c3b04eb1 feat: refactor track source management to support plugin-specific caching and retrieval 2026-09-25 18:29:08 +06:00
Kingkor Roy Tirtho
0c6479570c
Merge pull request #3126 from team-spotube/rtc-integration
feat: add remote control and jam session
2026-09-25 17:09:36 +06:00
Kingkor Roy Tirtho
a4d157e9c2 feat(jam-settings): implement JamSettings screen and view model for broker configuration 2026-09-25 16:44:48 +06:00
Kingkor Roy Tirtho
df3dd2ad88 feat(player-options): enhance options menu with localized strings and icons 2026-09-25 15:03:24 +06:00
Kingkor Roy Tirtho
1e6aee142b feat(player-options): add PlayerOptionsViewModel and enhance player UI with options menu 2026-09-25 14:53:10 +06:00
Kingkor Roy Tirtho
df9403f2d3 feat(release): add support for Linux ARM64 builds and artifact uploads 2026-09-12 10:09:03 +06:00
Kingkor Roy Tirtho
6fabc876a5 chore: remove residual plans 2026-09-12 00:04:10 +06:00
Kingkor Roy Tirtho
4c724a333b feat(jam-session): enhance guest functionality with track addition and participant moderation options 2026-09-12 00:01:34 +06:00
Kingkor Roy Tirtho
ad994f3ca3 feat(jam-session): implement track addition to jam session and enhance UI feedback for jam state 2026-09-11 22:04:59 +06:00
Kingkor Roy Tirtho
a2b9f4178c feat(jam-session): integrate MQTT broker configuration and enhance jam session settings 2026-09-11 19:35:54 +06:00
Kingkor Roy Tirtho
571aea8d38 feat(remote-control): refactor service registration and cleanup logic for improved reliability 2026-09-05 09:52:06 +06:00
Kingkor Roy Tirtho
9ec7704d7b feat(jam-session): enhance jam session functionality with improved playback synchronization and error handling 2026-09-05 09:38:44 +06:00
Kingkor Roy Tirtho
60e38f4868 feat(jam-session): add connection state management and update UI feedback for session status 2026-09-04 15:46:56 +06:00
Kingkor Roy Tirtho
0eb07ef9b6 feat(webrtc): update ICE server configuration and enhance logging for peer connection events 2026-09-04 14:17:37 +06:00
Kingkor Roy Tirtho
7bf17afc42 feat(remote-control): enhance remote playback functionality with new commands and UI integration 2026-09-04 12:02:32 +06:00
Kingkor Roy Tirtho
1c169d007a feat(remote-control): enhance queue management and player state synchronization 2026-09-04 09:24:15 +06:00
Kingkor Roy Tirtho
c5d35bfc93 fix(remote-control): update connection handling and improve logging for remote control events 2026-09-04 09:07:08 +06:00
Kingkor Roy Tirtho
df3a6dcbd2 feat(remote-control): implement remote playback functionality and UI components 2026-08-28 18:28:24 +06:00
Kingkor Roy Tirtho
1aeee1db79 fix(sidebar): add spacer to AppSidebar for improved layout 2026-08-28 13:18:04 +06:00
Kingkor Roy Tirtho
f21442b1c2 feat(deeplinks): implement deep link handling for jam sessions 2026-08-28 09:33:39 +06:00
Kingkor Roy Tirtho
d3a8548661 feat(devices): add device discovery and remote control features 2026-08-21 19:50:09 +06:00
Kingkor Roy Tirtho
729bdd43b9
Merge branch 'dev' into libjsoncpp26-support 2025-09-08 16:15:47 +06:00
neonItem
21afee3cfb
Fix dependencies on Debian Testing/Unstable (rolling release) systems 2025-03-22 23:54:28 +02:00
109 changed files with 9803 additions and 828 deletions

View File

@ -221,6 +221,67 @@ jobs:
with:
limit-access-to-actor: true
build-linux-arm64:
name: Linux Desktop (aarch64)
needs: prepare-deps
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Download maven local
uses: actions/download-artifact@v4
with:
name: maven-local
path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Make gradlew executable
run: chmod +x gradlew
- name: Package Linux
run: ./gradlew :composeApp:packageReleaseDeb :composeApp:packageReleaseRpm
- name: Rename Linux artifacts
run: |
DEB_FILE=$(find composeApp/build -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build -name "*.rpm" -type f | head -1)
cp "$DEB_FILE" Spotube-linux-aarch64.deb
cp "$RPM_FILE" Spotube-linux-aarch64.rpm
- name: Upload DEB
uses: actions/upload-artifact@v4
with:
name: linux-deb-arm64
path: Spotube-linux-aarch64.deb
- name: Upload RPM
uses: actions/upload-artifact@v4
with:
name: linux-rpm-arm64
path: Spotube-linux-aarch64.rpm
- name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
build-windows:
name: Windows Desktop
needs: prepare-deps
@ -321,6 +382,7 @@ jobs:
needs:
- build-android
- build-linux
- build-linux-arm64
- build-windows
- build-macos
runs-on: ubuntu-latest
@ -352,7 +414,7 @@ jobs:
run: |
echo "## Downloads" > release-notes.md
echo "" >> release-notes.md
for dir in android-apk android-aab linux-deb linux-rpm linux-appimage windows-msi windows-exe macos-dmg; do
for dir in android-apk android-aab linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 windows-msi windows-exe macos-dmg; do
if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md
for f in "$dir"/*; do
@ -376,6 +438,8 @@ jobs:
linux-deb/*.deb
linux-rpm/*.rpm
linux-appimage/*.AppImage
linux-deb-arm64/*.deb
linux-rpm-arm64/*.rpm
windows-msi/*.msi
windows-exe/*.exe
macos-dmg/*.dmg

View File

@ -3,6 +3,7 @@
"mcp": {
"compose-hot-reload": {
"enabled": true,
"disabled": false,
"type": "local",
"command": [
"./gradlew",
@ -11,6 +12,20 @@
"--console=plain",
"hotMcpServer"
]
},
"android-studio-mcp": {
"type": "local",
"environment": {
"IJ_MCP_SERVER_PORT": "64342"
},
"enabled": true,
"disabled": false,
"command": [
"/opt/android-studio/jbr/bin/java",
"-classpath",
"/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/mcpserver.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.modelcontextprotocol.kotlin.sdk.jar:/home/krtirtho/.local/share/Google/AndroidStudio2026.1.4/mcpserver/lib/io.github.oshai.kotlin.logging.jvm.jar:/opt/android-studio/lib/util-8.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.coroutines.core.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.cio.jar:/opt/android-studio/lib/intellij.libraries.ktor.client.jar:/opt/android-studio/lib/intellij.libraries.ktor.network.tls.jar:/opt/android-studio/lib/intellij.libraries.ktor.io.jar:/opt/android-studio/lib/intellij.libraries.ktor.utils.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.io.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.core.jar:/opt/android-studio/lib/intellij.libraries.kotlinx.serialization.json.jar",
"com.intellij.mcpserver.stdio.McpStdioRunnerKt"
]
}
}
}

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"gradle.autoDetect": "off",
"java.gradle.buildServer.enabled": "off"
}

View File

@ -56,4 +56,19 @@
- **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release.
- **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease.
- **Android signing**: decodes `secrets.KEYSTORE` (base64) → `composeApp/upload-keystore.jks`, writes signing config into `local.properties` from secrets.
- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached.
- `create-release` job (depends on all builds) uses `softprops/action-gh-release@v2` to create the GitHub release with all artifacts attached.
## OpenCode Agent MCP Execution Directives
### 1. Code Discovery & Context Search
- Use `android-studio-mcp` search/symbol tools to locate declarations before making edits across `:composeApp`, `:plugin_interfaces`, or `:js_plugin_example`[cite: 1, 2].
- Keep `gradle/libs.versions.toml` as the single source of truth for dependencies and version updates.
### 2. UI Architecture Constraints
- **Strict ViewModel Separation:** UI composables in `commonMain` must only collect `StateFlow<UiState>` and emit event callbacks. Do NOT write business logic, filtering, or `mutableStateListOf` directly inside `@Composable` functions.
- **Adaptive Components:** Use `AdaptiveDropdownBottomSheet` or `AdaptiveDialogBottomSheet` inside `commonMain` for adaptive layouts[cite: 2]. Do NOT create platform-specific `expect/actual` variants unless platform-native APIs (like windowing or native scrollbars) are explicitly required[cite: 2].
### 3. Verification & Diagnostic Loops
- **Post-Edit Verification:** After editing Kotlin code, validate compilation by running `./gradlew :composeApp:check` or `./gradlew :composeApp:assembleDebug` via MCP[cite: 2].
- **Dispatcher Integrity:** Never remove or bypass `SwingMainDispatcherFactory` in `composeApp/src/jvmMain/.../core/coroutines/`[cite: 2]. It is required to prevent `compose-webview` from hijacking `Dispatchers.Main` away from the Swing EDT[cite: 2].
- **Live Preview Iteration:** Use `compose-hot-reload` to evaluate UI layout changes headlessly without forcing full Gradle rebuilds.

110
composeApp/Cargo.lock generated
View File

@ -56,6 +56,19 @@ dependencies = [
"winnow",
]
[[package]]
name = "async-compat"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590"
dependencies = [
"futures-core",
"futures-io",
"once_cell",
"pin-project-lite",
"tokio",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@ -133,6 +146,10 @@ version = "0.1.0"
dependencies = [
"discord-rich-presence",
"lofty",
"log",
"parking_lot",
"thiserror",
"tokio",
"uniffi",
]
@ -207,6 +224,18 @@ dependencies = [
"autocfg",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "getrandom"
version = "0.2.17"
@ -286,6 +315,15 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.24.0"
@ -365,6 +403,29 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -377,6 +438,12 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plain"
version = "0.2.3"
@ -407,6 +474,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@ -426,6 +502,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scroll"
version = "0.12.0"
@ -522,6 +604,12 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "smawk"
version = "0.3.3"
@ -598,6 +686,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
]
[[package]]
name = "toml"
version = "0.5.11"
@ -660,6 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
dependencies = [
"anyhow",
"async-compat",
"bytes",
"once_cell",
"static_assertions",

View File

@ -4,9 +4,13 @@ version = "0.1.0"
edition = "2024"
[dependencies]
uniffi = "0.29.4"
uniffi = { version = "0.29.4", features = ["tokio"] }
lofty = "0.24.0"
discord-rich-presence = "1.1.0"
thiserror = "2.0"
parking_lot = "0.12"
log = "0.4"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
[lib]
crate-type = ["cdylib", "staticlib"]

View File

@ -20,6 +20,7 @@ import gobley.gradle.cargo.dsl.jvm
import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.gradle.api.tasks.Copy
import java.io.FileInputStream
import java.util.Properties
@ -39,11 +40,53 @@ plugins {
kotlin("plugin.atomicfu") version libs.versions.kotlin
}
val bundleId = "dev.krtirtho.spotube"
val version = "6.0.0"
val buildNumber = 46
val targetName = "Spotube"
val homePageUrl = "https://spotube.cc"
val repositoryUrl = "https://github.com/team-spotube/spotube"
val appVersionName = version
val appBuildNumber = buildNumber
val appRepositoryUrl = repositoryUrl
val githubRepositoryPath = repositoryUrl.removePrefix("https://github.com/")
val latestReleaseApiUrl = "https://api.github.com/repos/$githubRepositoryPath/releases/latest"
val generatedAppInfoDir = layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin")
val generateAppBuildInfo by tasks.registering(Copy::class) {
description = "Generate AppBuildInfo.kt"
from(layout.projectDirectory.file("src/commonMain/templates/AppBuildInfo.kt.template"))
into(layout.buildDirectory.dir("generated/appBuildInfo/commonMain/kotlin/dev/krtirtho/spotube"))
rename("AppBuildInfo.kt.template", "AppBuildInfo.kt")
expand(
mapOf(
"appVersion" to appVersionName,
"appBuildNumber" to appBuildNumber,
"repositoryUrl" to appRepositoryUrl,
"latestReleaseApiUrl" to latestReleaseApiUrl,
)
)
inputs.properties(
mapOf(
"appVersion" to appVersionName,
"appBuildNumber" to appBuildNumber,
"repositoryUrl" to appRepositoryUrl,
"latestReleaseApiUrl" to latestReleaseApiUrl,
)
)
}
tasks.configureEach {
if (name.startsWith("compile") && name.contains("Kotlin")) {
dependsOn(generateAppBuildInfo)
}
}
vlcjBundler {
packageName = "dev.krtirtho.spotube.core.generated" // choose a different package
packageName = "$bundleId.core.generated" // choose a different package
objectName = "VLCBundleLoaderGenerated" // or rename the object
}
kotlin {
// Note: For Android application modules, androidTarget() is still required as of AGP 8.x.
// The deprecation warning is expected. For libraries, use the androidKotlinMultiplatformLibrary plugin instead.
@ -68,6 +111,7 @@ kotlin {
sourceSets {
val commonMain by getting {
kotlin.srcDir(generatedAppInfoDir)
dependencies {
// Project dependencies
implementation(project(":plugin_interfaces"))
@ -86,6 +130,7 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.dns.sd.kt)
// Navigation
implementation(libs.jetbrains.navigation3.ui)
@ -104,8 +149,10 @@ kotlin {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.websockets)
// Zipline
api(libs.zipline.core)
implementation(libs.zipline.loader)
@ -160,6 +207,19 @@ kotlin {
// Shimmer effect
implementation(libs.compose.shimmer)
implementation(libs.compose.placeholder.material3)
// DLNA
implementation(libs.dns.sd.kt)
// mqtt client for jam-session
implementation(libs.mqtt.client)
implementation(libs.mqtt.x.models)
implementation(libs.mqtt.buffer)
implementation(libs.mqtt.buffer.codec)
// Markdown renderer
implementation(libs.multiplatform.markdown.renderer)
implementation(libs.multiplatform.markdown.renderer.m3)
}
}
commonTest.dependencies {
@ -237,11 +297,11 @@ android {
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "dev.krtirtho.spotube"
applicationId = bundleId
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = project.findProperty("versionName") as String? ?: "1.0"
versionCode = buildNumber
versionName = version
}
packaging {
resources {
@ -300,8 +360,8 @@ nucleus.application {
)
nativeDistributions {
packageName = "dev.krtirtho.spotube"
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
packageName = bundleId
packageVersion = version
licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
@ -317,7 +377,7 @@ nucleus.application {
modules("jdk.unsupported")
homepage = "https://spotube.cc"
homepage = homePageUrl
linux {
modules("jdk.security.auth")
@ -359,4 +419,4 @@ cargo {
builds.jvm {
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
}
}
}

View File

@ -16,13 +16,19 @@
~ 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.FOREGROUND_SERVICE" />
<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.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
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />

View File

@ -17,12 +17,16 @@
package dev.krtirtho.spotube
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
import dev.krtirtho.spotube.core.paths.Paths
import dev.krtirtho.spotube.media.PlaybackService
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.init
@ -31,9 +35,24 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
FileKit.init(this)
NewPipeDownloader.init(Paths(this))
NewPipeDownloader.init(Paths())
// Start PlaybackService from Activity context (allowed on Android 12+)
val serviceIntent = Intent(this, PlaybackService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
intent?.dataString?.let(ExternalUriHandler::onNewUri)
setContent {
App()
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.dataString?.let(ExternalUriHandler::onNewUri)
}
}

View File

@ -18,10 +18,8 @@
package dev.krtirtho.spotube
import android.app.Application
import android.content.Intent
import android.os.Build
import dev.krtirtho.spotube.core.di.initKoin
import dev.krtirtho.spotube.media.PlaybackService
import dev.krtirtho.spotube.core.paths.Paths
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent {
override fun onCreate() {
super.onCreate()
Paths.init(this)
initKoin {
androidContext(this@MyApplication)
}
val intent = Intent(this, PlaybackService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
override fun onTerminate() {

View File

@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.audioplayer
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
@ -51,11 +52,19 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val appContext: Context = (context as Context).applicationContext
private fun ensureServiceStarted() {
val intent = Intent(appContext, PlaybackService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
// On Android 12+ starting a foreground service from the background throws
// (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or
// remote control applies playback while the app is backgrounded. Never let
// that crash the app; playback itself runs in-process without the service.
try {
val intent = Intent(appContext, PlaybackService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
} catch (e: Exception) {
Log.w("AudioPlayer", "Failed to start playback service", e)
}
}
@ -226,7 +235,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
actual override suspend fun seekTo(position: Duration) {
withContext(Dispatchers.Main) {
val targetMs = position.inWholeMilliseconds.coerceIn(0, exoPlayer.duration)
val duration = exoPlayer.duration
val targetMs = if (duration > 0) {
position.inWholeMilliseconds.coerceIn(0, duration)
} else {
position.inWholeMilliseconds.coerceAtLeast(0)
}
exoPlayer.seekTo(targetMs)
_position.tryEmit(exoPlayer.currentPosition.milliseconds)
}

View File

@ -29,7 +29,7 @@ import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscove
import org.koin.dsl.module
actual val platformModules = module {
single { Paths(get()) }
single { Paths() }
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
single<ShareService> { AndroidShareService(get()) }

View File

@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.discovery
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
/**
* On Android 13+ (and especially 16+ where it became a runtime permission),
* mDNS/NSD discovery requires `NEARBY_WIFI_DEVICES`. Requests it when the
* returned lambda is invoked; the caller decides when (e.g. first visit to the
* Devices screen).
*/
@Composable
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* result is picked up by discovery/advertising retry loops */ }
return {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.NEARBY_WIFI_DEVICES,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
}
}
}

View File

@ -20,9 +20,10 @@ package dev.krtirtho.spotube.core.paths
import android.content.Context
import android.os.Environment
actual class Paths(
val context: Context
) {
actual class Paths {
private val context: Context
get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" }
actual fun getApplicationCacheDirPath(): String {
return context.cacheDir.absolutePath
}
@ -38,4 +39,13 @@ actual class Paths(
actual fun getMusicCacheDirPath(): String {
return context.cacheDir.absolutePath + "/music_cache"
}
companion object {
@Volatile
private var appContext: Context? = null
fun init(context: Context) {
appContext = context.applicationContext
}
}
}

View File

@ -86,7 +86,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
// On Android 15+ a service created from the background (e.g. by a jam
// sync or queue restoration) hits this in startForeground instead of at
// the startForegroundService call site. Don't crash: playback keeps
// running in-process, just without the notification until the app is
// foregrounded and the service can start properly.
try {
startForeground(NOTIFICATION_ID, notification)
} catch (e: Exception) {
Log.w(TAG, "startForeground not allowed; continuing without notification", e)
}
librarySession =
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())

View File

@ -96,6 +96,13 @@
<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_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_description">Choose a port between 1 and 65535.</string>
<string name="settings_playback_port_placeholder">14769</string>
@ -166,5 +173,29 @@
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
</resources>
<string name="settings_section_jam">Group Jam</string>
<string name="settings_jam_broker_title">MQTT Broker</string>
<string name="settings_jam_broker_host">Broker host</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Broker port</string>
<string name="settings_jam_broker_tls">Use TLS</string>
<string name="settings_jam_broker_username">Username (optional)</string>
<string name="settings_jam_broker_password">Password (optional)</string>
<string name="settings_jam_broker_client_id">Client ID prefix</string>
<string name="settings_jam_broker_client_id_subtitle">Used to identify this Spotube instance</string>
<string name="settings_jam_broker_tls_subtitle">Encrypt the broker connection</string>
<string name="settings_jam_broker_diagnostics_subtitle">Check the current broker settings before starting a room.</string>
<string name="settings_jam_broker_test">Test connection</string>
<string name="settings_jam_broker_testing">Testing…</string>
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Failed: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
<string name="settings_jam_section_connection">Connection</string>
<string name="settings_jam_section_credentials">Credentials</string>
<string name="settings_jam_section_identity">Identity</string>
<string name="settings_jam_section_diagnostics">Diagnostics</string>
<string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string>
<string name="player_track_details">Track details</string>
</resources>

View File

@ -22,73 +22,114 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay
import com.mikepenz.markdown.m3.Markdown
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.shell.AppShell
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.webview.WebViewScreen
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxHome
import dev.krtirtho.spotube.resources.iconsax.IconsaxHomeBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirrorScreenRegular
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicDashboard
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibraryOutline
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSettingTwotone
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
import dev.krtirtho.spotube.resources.iconsax.User
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.koin.compose.koinInject
import org.koin.compose.navigation3.koinEntryProvider
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI
data class TabItem(
val title: String,
val icon: ImageVector,
val activeIcon: ImageVector,
val route: Routes
interface NavigationItem {
data class Tab<T : Any>(
val title: String, val icon: ImageVector, val route: Routes,
val data: T? = null
) : NavigationItem
data class Group<T : Any>(
val title: String, val items: List<Tab<T>>
) : NavigationItem
}
private val tabs = listOf<NavigationItem>(
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search),
)
val tabs = listOf(
TabItem(
"Home",
Iconsax.IconsaxHomeBroken,
Iconsax.IconsaxHome,
Routes.Home
),
TabItem(
"Search",
Iconsax.IconsaxSearchBroken,
Iconsax.IconsaxSearch,
Routes.Search
),
TabItem(
val sidebarTabs = tabs + listOf(
NavigationItem.Group(
"Library",
Iconsax.IconsaxMusicLibraryOutline,
Iconsax.IconsaxMusicLibrary,
Routes.Library
items = listOf(
NavigationItem.Tab(
"Playlists",
Iconsax.IconsaxMusicDashboard,
Routes.Library,
LibraryTab.Playlists
),
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists),
)
),
TabItem(
"Settings",
Iconsax.IconsaxSettingTwotone,
Iconsax.IconsaxSetting2,
Routes.Settings
)
NavigationItem.Group(
"On Device",
items = listOf(
NavigationItem.Tab(
"Local Tracks",
Iconsax.IconsaxMusicLibrary,
Routes.Library,
LibraryTab.LocalTracks
),
NavigationItem.Tab(
"Downloads",
Iconsax.IconsaxDirectboxReceive,
Routes.Library,
LibraryTab.Downloads
),
)
),
NavigationItem.Group(
"Connect",
items = listOf(
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam),
),
),
)
val bottombarTabs = tabs + listOf(
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
)
@OptIn(
@ -102,10 +143,11 @@ fun App(
) {
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
val navigationState = rememberNavigationState(
startRoute = Routes.Home,
topLevelRoutes = TOP_LEVEL_ROUTES
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
)
val navigator = remember {
Navigator(navigationState)
@ -140,6 +182,43 @@ fun App(
}
}
content()
if (availableUpdate != null) {
val update = availableUpdate!!
ThemedDialog(
onDismissRequest = updateCheckerViewModel::dismissUpdate,
title = {
Text(
text = "Spotube ${update.tag} is available",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
},
actions = {
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
Text("Ignore")
}
PrimaryButton(
onClick = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
updateCheckerViewModel.dismissUpdate()
},
) {
Text("Update")
}
},
) {
if (update.releaseNotesMarkdown.isNotBlank()) {
SelectionContainer {
Markdown(
content = update.releaseNotesMarkdown,
modifier = Modifier.padding(bottom = 8.dp),
)
}
} else {
Text("See what's new in this release on the Spotube downloads page.")
}
}
}
}
}
}

View File

@ -17,40 +17,10 @@
package dev.krtirtho.spotube.core.audioplayer
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
data class MediaItem(
val title: String,
val artist: String,
val album: String,
val duration: Duration,
val coverURL: String,
val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
) {
fun toDebugString(): String {
return "MediaItem(title='$title', artist='$artist', album='$album', duration=$duration, coverURL='$coverURL', url='$url', protocol=$protocol)"
}
}
enum class LoopState {
NONE, ONE, ALL;
fun next(): LoopState {
return when (this) {
NONE -> ONE
ONE -> ALL
ALL -> NONE
}
}
}
enum class PlayerState {
IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED
}
interface AudioPlayerInterface {
// // Playback

View File

@ -21,10 +21,38 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Duration
data class MediaItem(
val title: String,
val artist: String,
val album: String,
val duration: Duration,
val coverURL: String,
val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
)
enum class LoopState {
NONE, ONE, ALL;
fun next(): LoopState {
return when (this) {
NONE -> ONE
ONE -> ALL
ALL -> NONE
}
}
}
enum class PlayerState {
IDLE, BUFFERING, READY, PLAYING, PAUSED, COMPLETED
}
@Serializable
sealed interface QueueEntry {
val url: String
val addedBy: String
@Serializable
@SerialName("streaming")
@ -32,6 +60,7 @@ sealed interface QueueEntry {
val track: MetadataTrack,
override val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
override val addedBy: String = "",
) : QueueEntry
@Serializable
@ -42,7 +71,8 @@ sealed interface QueueEntry {
val duration: Long,
val album: String?,
val coverBytes: ByteArray?,
override val url: String
override val url: String,
override val addedBy: String = "",
) : QueueEntry {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@ -56,6 +86,7 @@ sealed interface QueueEntry {
if (album != other.album) return false
if (!coverBytes.contentEquals(other.coverBytes)) return false
if (url != other.url) return false
if (addedBy != other.addedBy) return false
return true
}
@ -67,6 +98,7 @@ sealed interface QueueEntry {
result = 31 * result + (album?.hashCode() ?: 0)
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
result = 31 * result + url.hashCode()
result = 31 * result + addedBy.hashCode()
return result
}
}

View File

@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.deeplink
/**
* Cross-platform receiver for URIs handed to the app by the operating system
* (deep links). Platform entry points (Android activity intents, desktop command
* line / open-URI handler, iOS `onOpenURL`) call [onNewUri]; the main composable
* installs a [listener] once composition starts.
*
* Follows the Compose Multiplatform deep linking docs pattern: URIs arriving
* before a listener is installed are cached and delivered as soon as one is set.
*/
object ExternalUriHandler {
private var cached: String? = null
var listener: ((uri: String) -> Unit)? = null
set(value) {
field = value
if (value != null) {
cached?.let(value::invoke)
cached = null
}
}
fun onNewUri(uri: String) {
if (uri.isBlank()) return
val currentListener = listener
if (currentListener != null) {
currentListener(uri)
} else {
cached = uri
}
}
}

View File

@ -23,13 +23,21 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
import dev.krtirtho.spotube.core.db.Database
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discord.DiscordRpcService
import dev.krtirtho.spotube.core.jam.JamRoomClient
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.navigationModule
import dev.krtirtho.spotube.core.remote.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
import dev.krtirtho.spotube.core.remote.RemoteControlService
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.LocalServer
import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.TrackSourceRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.modules.album.AlbumRepository
@ -38,6 +46,9 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
import dev.krtirtho.spotube.modules.devices.DevicesViewModel
import dev.krtirtho.spotube.modules.devices.RemoteControlViewModel
import dev.krtirtho.spotube.modules.jam.JamViewModel
import dev.krtirtho.spotube.modules.downloads.DownloadManager
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
@ -54,6 +65,7 @@ import dev.krtirtho.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import dev.krtirtho.spotube.modules.lyrics.LyricsViewModel
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistViewModel
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.PluginProvider
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
@ -61,10 +73,14 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.modules.search.SearchRepository
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import dev.krtirtho.spotube.modules.settings.JamSettingsViewModel
import dev.krtirtho.spotube.modules.shell.AppShellViewModel
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
import org.koin.core.module.Module
@ -86,6 +102,7 @@ val sharedModules = module {
// Shell
viewModelOf(::AppShellViewModel)
viewModelOf(::PlayerOptionsViewModel)
viewModelOf(::PlayerQueueContentViewModel)
viewModelOf(::AlternativeTrackContentViewModel)
@ -109,12 +126,17 @@ val sharedModules = module {
viewModelOf(::LibraryLocalTracksViewModel)
// Plugin system
singleOf(::PluginManager) { bind<PluginProvider>() }
singleOf(::PluginManager) {
bind<PluginProvider>()
bind<AudioPluginSource>()
}
viewModelOf(::PluginViewModel)
// Settings
singleOf(::SettingsRepository)
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
viewModelOf(::UpdateCheckerViewModel)
// Downloads
singleOf(::DownloadManager)
@ -133,6 +155,7 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
@ -147,6 +170,7 @@ val sharedModules = module {
libraryRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
@ -162,12 +186,22 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
// Blacklist
singleOf(::BlacklistRepository)
viewModelOf(::BlacklistViewModel)
viewModel { DevicesViewModel(get()) }
viewModelOf(::RemoteControlViewModel)
viewModel {
JamViewModel(
jamRoomService = get(),
shareService = get(),
settingsProvider = get(),
)
}
// Album
singleOf(::AlbumRepository)
@ -182,6 +216,7 @@ val sharedModules = module {
blacklistRepository = get(),
shareService = get(),
downloadManager = get(),
remotePlaybackController = get(),
)
}
@ -194,17 +229,34 @@ val sharedModules = module {
albumRepository = get(),
playlistRepository = get(),
savedTracksRepository = get(),
artistRepository = get(),
audioPlayerQueue = get(),
blacklistRepository = get(),
)
}
singleOf(::MatchedTracksRepository)
singleOf(::StreamingUrlRepository)
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
single {
StreamingUrlRepository(
pluginManager = get(),
audioPlayerQueue = get(),
matchedTracksRepository = get(),
settingsRepository = get(),
)
}
singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository)
singleOf(::LocalServer) withOptions {
createdAtStart()
}
single { RemoteControlHandler(get(), get(), get(), get()) }
single { RemoteControlClient() }
singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart()
}
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
singleOf(::JamRoomClient)
single { JamRoomService(get(), get(), get(), get()) }
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

View File

@ -0,0 +1,108 @@
/*
* 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
import kotlin.text.decodeToString
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]?.decodeToString().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]?.decodeToString().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,
registerTimeoutMs: Long = 5_000,
): NetService {
val service = createService(name, port, deviceId)
service.register(timeoutInMs = registerTimeoutMs)
return service
}
fun createService(
name: String,
port: Int,
deviceId: String,
): NetService = createNetService(
type = SERVICE_TYPE,
name = name,
port = port,
txt = mapOf(TXT_DEVICE_ID to deviceId),
)
}

View File

@ -0,0 +1,28 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.discovery
import androidx.compose.runtime.Composable
/**
* Returns a lambda that requests the OS permission needed for local network
* discovery (mDNS/NSD). No-op on platforms where such a permission doesn't
* exist or is granted implicitly.
*/
@Composable
expect fun rememberLocalNetworkPermissionRequester(): () -> Unit

View File

@ -0,0 +1,198 @@
/*
* 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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.MediaItem
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Jam messages exchanged over MQTT.
*
* - `state` topic: [QueueState] (retained, host -> everyone)
* - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist]
* (anyone -> host, except Kick which is host -> guest)
* - `presence/{clientId}` topic: [JamPresence] (retained, one per participant)
*/
@Serializable
sealed class JamMessage {
@Serializable
@SerialName("queueState")
data class QueueState(
val items: List<JamMediaItem>,
val currentIndex: Int,
val shuffleEnabled: Boolean = false,
/**
* Whether guests should follow the host's current index. True when the
* host manually skipped/jumped or loaded a queue; false when the host
* merely auto-advanced because its song ended (guests stay put).
*/
val follow: Boolean = false,
/** Host's live play state — late-joining guests start with it. */
val isPlaying: Boolean = false,
) : JamMessage()
@Serializable
@SerialName("playbackCommand")
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
@Serializable
@SerialName("suggestTrack")
data class SuggestTrack(
val mediaItem: JamMediaItem,
val addedBy: String = "",
) : JamMessage()
@Serializable
@SerialName("suggestPlaylist")
data class SuggestPlaylist(
val tracks: List<JamMediaItem>,
val addedBy: String = "",
) : JamMessage()
@Serializable
@SerialName("kick")
data class Kick(
val participantId: String,
val reason: String = "kicked",
) : JamMessage()
@Serializable
@SerialName("leave")
data class Leave(val reason: String = "user_left") : JamMessage()
}
/**
* Playback commands. Only queue navigation is global — play/pause, seek,
* volume, shuffle and loop are local to each participant.
*/
@Serializable
sealed class PlaybackCmd {
@Serializable
@SerialName("skipNext")
data object SkipNext : PlaybackCmd()
@Serializable
@SerialName("skipPrevious")
data object SkipPrevious : PlaybackCmd()
@Serializable
@SerialName("jumpTo")
data class JumpTo(val index: Int) : PlaybackCmd()
}
/** Retained per-participant presence entry (with an MQTT Last Will for leave). */
@Serializable
data class JamPresence(
val clientId: String,
val displayName: String,
val isHost: Boolean,
val left: Boolean = false,
)
@Serializable
data class JamMediaItem(
val url: String,
val trackId: String = "",
val title: String,
val artist: String,
val album: String,
val durationMs: Long,
val coverUrl: String,
val protocol: String,
val addedBy: String = "",
) {
companion object {
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
is QueueEntry.StreamingTrack -> JamMediaItem(
url = "",
trackId = entry.track.id,
title = entry.track.title,
artist = entry.track.artists.joinToString(", ") { it.name },
album = entry.track.album?.title.orEmpty(),
durationMs = entry.track.durationMs,
coverUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(),
protocol = entry.protocol.name,
addedBy = entry.addedBy,
)
is QueueEntry.LocalTrack -> JamMediaItem(
url = entry.url,
trackId = "",
title = entry.name,
artist = entry.artists.joinToString(", "),
album = entry.album.orEmpty(),
durationMs = entry.duration,
coverUrl = "",
protocol = "PROGRESSIVE",
addedBy = entry.addedBy,
)
}
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
url = "",
trackId = track.id,
title = track.title,
artist = track.artists.joinToString(", ") { it.name },
album = track.album?.title.orEmpty(),
durationMs = track.durationMs,
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(),
protocol = "PROGRESSIVE",
)
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
url = item.url,
title = item.title,
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.ifBlank { "PROGRESSIVE" }),
)
}
}
@Serializable
data class JamParticipant(
val id: String,
val displayName: String,
val isHost: Boolean,
)
@Serializable
enum class JamRole {
Host,
Guest,
}

View File

@ -0,0 +1,323 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.jam
import co.touchlab.kermit.Logger
import com.ditchoom.buffer.Charset
import com.ditchoom.buffer.codec.asReadBuffer
import com.ditchoom.buffer.toReadBuffer
import com.ditchoom.mqtt.client.ConnectionState
import com.ditchoom.mqtt.client.MqttClient
import com.ditchoom.mqtt.connection.MqttConnectionOptions
import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec
import com.ditchoom.mqtt.controlpacket.QualityOfService
import com.ditchoom.mqtt.controlpacket.TopicName
import com.ditchoom.mqtt.controlpacket.WillConfig
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
import dev.krtirtho.spotube.modules.settings.JamBroker
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
/**
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
*
* Topics (room code `C`):
* - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone)
* - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest)
* - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will
* (`left = true`) so a dropped client disappears from the room automatically.
*
* The library keeps the connection alive (auto-reconnect + backoff); this class only
* re-establishes the room subscription and re-publishes presence after a reconnect.
*/
class JamRoomClient : KoinComponent {
private val log = Logger.withTag("JamRoomClient")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val json = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
encodeDefaults = true
}
private var client: MqttClient? = null
private var roomCode: String? = null
private var localPresence: JamPresence? = null
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8)
val state: SharedFlow<JamMessage.QueueState> = _state.asSharedFlow()
private val _commands = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
val commands: SharedFlow<JamMessage> = _commands.asSharedFlow()
private val _presence = MutableStateFlow<Map<String, JamPresence>>(emptyMap())
val presence: StateFlow<Map<String, JamPresence>> = _presence.asStateFlow()
// ---------- Public API ----------
/** One-off connection check used by the settings screen. Returns latency description. */
suspend fun testConnection(broker: JamBroker): Result<String> {
if (broker.host.isBlank()) return Result.failure(IllegalArgumentException("Broker host is empty"))
val started = kotlin.time.TimeSource.Monotonic.markNow()
return runCatching {
val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test")
try {
withTimeout(broker.connectionTimeoutSeconds.seconds) {
client.awaitConnectivity()
}
"Connected in ${started.elapsedNow().inWholeMilliseconds} ms"
} finally {
runCatching { client.shutdown(sendDisconnect = true, drain = false) }
}
}
}
/** Connects to [code] on [broker] and starts routing room messages. */
suspend fun connect(
broker: JamBroker,
code: String,
clientId: String,
displayName: String,
isHost: Boolean,
): Result<Unit> {
disconnect()
if (broker.host.isBlank()) {
return Result.failure(IllegalArgumentException("No jam broker configured"))
}
return runCatching {
roomCode = code
localPresence = JamPresence(
clientId = clientId,
displayName = displayName,
isHost = isHost,
left = false,
)
val mqtt = startClient(broker, clientId)
client = mqtt
withTimeout(broker.connectionTimeoutSeconds.seconds) {
mqtt.awaitConnectivity()
}
_connectionError.value = null
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
mqtt.connectionState
.onEach { onConnectionStateChanged(it) }
.launchIn(scope)
Unit
}.onFailure { e ->
log.w(e) { "Failed to connect to jam broker" }
_connectionError.value = e.message ?: "Connection failed"
_isConnected.value = false
runCatching { client?.shutdown(sendDisconnect = true, drain = false) }
client = null
}
}
suspend fun publishState(state: JamMessage.QueueState) {
val code = roomCode ?: return
publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true)
}
suspend fun publishCommand(message: JamMessage) {
val code = roomCode ?: return
publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false)
}
/** Publishes our own (retained) presence. Re-published after every reconnect. */
suspend fun publishPresence() {
val code = roomCode ?: return
val presence = localPresence ?: return
publishJson(
presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence),
retain = true,
)
}
/** Marks us as the host in presence (host takeover). */
suspend fun claimHost() {
val presence = localPresence ?: return
localPresence = presence.copy(isHost = true)
publishPresence()
}
/** Graceful leave: publish `left = true` before disconnecting. */
suspend fun leavePresence() {
val presence = localPresence ?: return
val code = roomCode ?: return
runCatching {
publishJson(
presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
retain = true,
)
}
}
suspend fun disconnect() {
roomCode = null
localPresence = null
_isConnected.value = false
_presence.value = emptyMap()
val current = client
client = null
runCatching { current?.shutdown(sendDisconnect = true, drain = false) }
}
// ---------- Internals ----------
private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient {
val connection = MqttConnectionOptions.SocketConnection(
host = broker.host,
port = broker.port,
tlsEnabled = broker.useTls,
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
)
val code = roomCode ?: "unset"
val will = WillConfig.Enabled(
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
payload = json.encodeToString(
JamPresence.serializer(),
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
).toReadBuffer(Charset.UTF8),
qos = QualityOfService.AT_LEAST_ONCE,
retain = true,
)
val request = ConnectionRequest(
clientId = clientId,
keepAliveSeconds = broker.keepAliveSeconds,
cleanStart = true,
userName = broker.username,
password = broker.password,
will = will,
)
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
val brokerRef = persistence.addBroker(connection, request)
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
}
private fun onConnectionStateChanged(state: ConnectionState) {
when (state) {
is ConnectionState.Connected -> {
_isConnected.value = true
_connectionError.value = null
scope.launch {
// Every connection (initial + reconnects) must re-establish the
// broker-side subscription (clean session) and re-publish our
// retained presence to clear any Last Will. Re-subscribing with
// the same filter replaces the previous dispatcher handler.
subscribeRoom()
publishPresence()
}
}
ConnectionState.Disconnected, ConnectionState.Handshaking -> {
_isConnected.value = false
}
else -> {
_isConnected.value = false
_connectionError.value = "Connection lost"
}
}
}
private suspend fun subscribeRoom() {
val mqtt = client ?: return
val code = roomCode ?: return
val operation = mqtt.subscribe(
roomFilter(code),
OpaquePublishPayloadCodec,
QualityOfService.AT_LEAST_ONCE,
) { publish, payload ->
route(publish.topic.toString(), payload)
}
runCatching { operation.subAck.await() }
.onFailure { log.w(it) { "Subscribe ack failed for room $code" } }
}
private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) {
val text = runCatching {
val buffer = payload.handle.asReadBuffer()
buffer.readString(buffer.remaining(), Charset.UTF8)
}.getOrElse { e ->
log.w(e) { "Failed to read jam payload on $topic" }
return
}
runCatching {
when {
topic.endsWith("/state") -> {
_state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text))
}
topic.endsWith("/cmd") -> {
_commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text))
}
topic.contains("/presence/") -> {
val presence = json.decodeFromString(JamPresence.serializer(), text)
_presence.value = _presence.value + (presence.clientId to presence)
}
}
}.onFailure { e ->
log.w(e) { "Failed to decode jam message on $topic: $text" }
}
}
private suspend fun publishJson(topic: String, payload: String, retain: Boolean) {
val mqtt = client ?: return
runCatching {
mqtt.publish(
topicName = topic,
qos = QualityOfService.AT_LEAST_ONCE,
payload = payload.toReadBuffer(Charset.UTF8),
retain = retain,
)
}.onFailure { e ->
log.w(e) { "Failed to publish to $topic" }
}
}
private fun stateTopic(code: String) = "spotube/jam/$code/state"
private fun cmdTopic(code: String) = "spotube/jam/$code/cmd"
private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId"
private fun roomFilter(code: String) = "spotube/jam/$code/#"
}

View File

@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.jam
import kotlin.random.Random
/**
* Six-character room codes shared verbally / by text. Codes are opaque keys used
* to namespace the MQTT topics of a jam room — they carry no connection details.
*
* The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to
* read aloud and retype.
*/
object JamRoomCode {
const val LENGTH = 6
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
fun generate(): String = buildString(LENGTH) {
repeat(LENGTH) {
append(ALPHABET[Random.nextInt(ALPHABET.length)])
}
}
/** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */
fun normalize(input: String): String = input
.uppercase()
.filter { it.isLetterOrDigit() }
.take(LENGTH)
fun isValid(code: String): Boolean =
code.length == LENGTH && code.all { it in ALPHABET }
}

View File

@ -0,0 +1,573 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.jam
import co.touchlab.kermit.Logger
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlin.random.Random
import kotlin.time.Clock
/**
* A jam session over MQTT (star topology, host-authoritative queue).
*
* Sync rules:
* - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo]
* from anyone are applied by the host, then broadcast via the retained state topic.
* - Play/pause, seek, volume and loop are local to each device — never broadcast.
* When the queue moves on, a paused participant stays paused; a playing one
* keeps playing the new current item.
* - Shuffle is host-only; guests mirror the host's shuffle setting.
* - Guests can only add to the queue (suggest); the host applies suggestions.
* - If the host leaves, the participant with the lowest client id takes over.
*/
private data class PlaybackBroadcast(
val queue: List<QueueEntry>,
val current: QueueEntry?,
val shuffle: Boolean,
val playerState: PlayerState,
)
class JamRoomService(
private val jamClient: JamRoomClient,
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository,
) {
private val log = Logger.withTag("JamRoomService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _role = MutableStateFlow<JamRole?>(null)
val role: StateFlow<JamRole?> = _role.asStateFlow()
private val _participants = MutableStateFlow<List<JamParticipant>>(emptyList())
val participants: StateFlow<List<JamParticipant>> = _participants.asStateFlow()
private val _roomCode = MutableStateFlow<String?>(null)
val roomCode: StateFlow<String?> = _roomCode.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _shuffleEnabled = MutableStateFlow(false)
val shuffleEnabled: StateFlow<Boolean> = _shuffleEnabled.asStateFlow()
private var localClientId: String = ""
private var localDisplayName: String = ""
/** Display name of the local participant. */
val participantDisplayName: String
get() = localDisplayName
/** Stable id of the local participant (stamped on items this device adds). */
val participantClientId: String
get() = localClientId
private var hostBroadcastJob: Job? = null
/** Guest side: last queue snapshot applied to the local player. */
private var lastAppliedItems: List<JamMediaItem> = emptyList()
private var lastAppliedIndex = -1
/**
* Host side: a song completed, so index changes within this window are
* auto-advance (guests must not follow). The completion event, the player
* state change and the media transition arrive as separate flow emissions,
* so the window covers the whole sequence instead of a single flag.
*/
private var autoAdvanceDeadlineMs = 0L
/** Guest side: playback has started at least once (local control is the guest's own). */
private var hasStartedPlayback = false
/** Host side: client ids banned for this session. */
private val bannedClientIds = mutableSetOf<String>()
private var leaving = false
init {
jamClient.isConnected
.onEach { _isConnected.value = it }
.launchIn(scope)
jamClient.connectionError
.onEach { _connectionError.value = it }
.launchIn(scope)
jamClient.state
.onEach { onRemoteState(it) }
.launchIn(scope)
jamClient.commands
.onEach { onCommand(it) }
.launchIn(scope)
jamClient.presence
.onEach { onPresence(it) }
.launchIn(scope)
// A naturally-completed song means the host's next index change is an
// auto-advance — guests must NOT follow those, only manual skips.
audioPlayer.completionFlow
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
.launchIn(scope)
// Once a guest has played on its own (or was started by the host), its
// play/pause is its own — the host's play state only starts fresh joiners.
audioPlayer.playerStateFlow
.onEach { state ->
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) {
hasStartedPlayback = true
}
}
.launchIn(scope)
}
// ---------- Session lifecycle ----------
suspend fun createRoom(): Result<String> {
val broker = settingsRepository.userSettings.value.jamBroker
if (broker.host.isBlank()) {
return Result.failure(IllegalStateException("No jam broker configured"))
}
val code = JamRoomCode.generate()
localClientId = newClientId(broker)
val name = participantName("Host")
localDisplayName = name
return jamClient.connect(
broker = broker,
code = code,
clientId = localClientId,
displayName = name,
isHost = true,
).map {
_role.value = JamRole.Host
_roomCode.value = code
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
lastAppliedItems = emptyList()
lastAppliedIndex = -1
bannedClientIds.clear()
leaving = false
autoAdvanceDeadlineMs = 0L
hasStartedPlayback = false
startHostBroadcast()
persistLastCode(code)
code
}
}
suspend fun joinRoom(code: String): Result<Unit> {
val broker = settingsRepository.userSettings.value.jamBroker
if (broker.host.isBlank()) {
return Result.failure(IllegalStateException("No jam broker configured"))
}
val normalized = JamRoomCode.normalize(code)
if (!JamRoomCode.isValid(normalized)) {
return Result.failure(IllegalArgumentException("Invalid room code"))
}
localClientId = newClientId(broker)
val name = participantName("Guest")
localDisplayName = name
return jamClient.connect(
broker = broker,
code = normalized,
clientId = localClientId,
displayName = name,
isHost = false,
).map {
_role.value = JamRole.Guest
_roomCode.value = normalized
_participants.value = emptyList()
lastAppliedItems = emptyList()
lastAppliedIndex = -1
leaving = false
hasStartedPlayback = false
persistLastCode(normalized)
}
}
suspend fun leaveRoom() {
leaving = true
stopHostBroadcast()
runCatching { jamClient.leavePresence() }
jamClient.disconnect()
_role.value = null
_roomCode.value = null
_participants.value = emptyList()
_shuffleEnabled.value = false
_isConnected.value = false
lastAppliedItems = emptyList()
lastAppliedIndex = -1
bannedClientIds.clear()
hasStartedPlayback = false
}
// ---------- Controls (called from the UI) ----------
fun skipNext() {
publishCommand(PlaybackCmd.SkipNext)
}
fun skipPrevious() {
publishCommand(PlaybackCmd.SkipPrevious)
}
fun jumpTo(index: Int) {
publishCommand(PlaybackCmd.JumpTo(index))
}
/** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */
fun toggleShuffle() {
if (_role.value != JamRole.Host) return
scope.launch {
runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) }
}
}
suspend fun suggestTrack(track: MetadataTrack) {
jamClient.publishCommand(
JamMessage.SuggestTrack(
mediaItem = JamMediaItem.fromTrack(track),
addedBy = localClientId,
)
)
}
suspend fun suggestPlaylist(tracks: List<MetadataTrack>) {
if (tracks.isEmpty()) return
jamClient.publishCommand(
JamMessage.SuggestPlaylist(
tracks = tracks.map(JamMediaItem::fromTrack),
addedBy = localClientId,
)
)
}
suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") {
if (_role.value != JamRole.Host) return
jamClient.publishCommand(JamMessage.Kick(participantId, reason))
}
suspend fun banParticipant(participantId: String) {
if (_role.value != JamRole.Host) return
bannedClientIds += participantId
kickParticipant(participantId, "banned by host")
}
// ---------- Host: broadcast ----------
private fun startHostBroadcast() {
if (hostBroadcastJob?.isActive == true) return
hostBroadcastJob = scope.launch {
combine(
audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.shuffleModeFlow,
audioPlayer.playerStateFlow,
) { queue, current, shuffle, playerState ->
PlaybackBroadcast(queue, current, shuffle, playerState)
}
.onEach { broadcast ->
if (_role.value != JamRole.Host) return@onEach
val index = if (broadcast.current != null) {
broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) }
} else {
-1
}
_shuffleEnabled.value = broadcast.shuffle
jamClient.publishState(
JamMessage.QueueState(
items = broadcast.queue.map(JamMediaItem::fromQueueEntry),
currentIndex = index.coerceAtLeast(0),
shuffleEnabled = broadcast.shuffle,
follow = now() > autoAdvanceDeadlineMs,
isPlaying = broadcast.playerState == PlayerState.PLAYING,
)
)
}
.launchIn(this)
}
}
private fun stopHostBroadcast() {
hostBroadcastJob?.cancel()
hostBroadcastJob = null
}
// ---------- Guest: apply remote state ----------
private suspend fun onRemoteState(state: JamMessage.QueueState) {
if (_role.value != JamRole.Guest) return
if (leaving) return
_shuffleEnabled.value = state.shuffleEnabled
runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
// Late joiner: the host is already playing, so start immediately. Once
// this guest has played on its own, the host's play state is ignored.
if (state.isPlaying && !hasStartedPlayback) {
runCatching { audioPlayer.play() }
.onFailure { log.w(it) { "Failed to start playback on host play state" } }
}
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
if (items != lastAppliedItems) {
val previous = lastAppliedItems
lastAppliedItems = items
val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value
val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) }
// The host only appended items (e.g. accepted suggestions): merge
// them in without resetting playback or the guest's position.
if (previous.isNotEmpty() && items.size > previous.size &&
items.take(previous.size) == previous && guestIndex >= 0
) {
lastAppliedIndex = guestIndex
val appended = items.drop(previous.size)
runCatching {
audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() })
}.onFailure { log.w(it) { "Failed to append jam queue items" } }
return
}
// Full re-sync. Keep the guest's current track when it still exists
// in the synced queue; otherwise take the host's position.
val startIndex = if (guestIndex >= 0) guestIndex
else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))
lastAppliedIndex = startIndex
runCatching {
audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying,
startPosition = startIndex,
)
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
return
}
// Same queue content: only follow the host when it moved manually
// (skip/jump). Natural auto-advance keeps everyone where they are.
if (state.follow && state.currentIndex != lastAppliedIndex) {
lastAppliedIndex = state.currentIndex
runCatching {
audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false)
}.onFailure { log.w(it) { "Failed to follow jam queue index" } }
}
}
// ---------- Incoming commands ----------
private suspend fun onCommand(message: JamMessage) {
when (message) {
is JamMessage.PlaybackCommand -> {
if (_role.value != JamRole.Host) return
applyCommand(message.command)
}
is JamMessage.SuggestTrack -> {
if (_role.value == JamRole.Host) {
acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy)))
}
}
is JamMessage.SuggestPlaylist -> {
if (_role.value == JamRole.Host) {
acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) })
}
}
is JamMessage.Kick -> {
if (_role.value == JamRole.Guest && message.participantId == localClientId) {
log.i { "Kicked from jam room: ${message.reason}" }
leaveRoom()
}
}
else -> Unit
}
}
private suspend fun applyCommand(command: PlaybackCmd) {
when (command) {
PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() }
PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() }
is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) }
}
}
private suspend fun acceptSuggestion(items: List<JamMediaItem>) {
if (items.isEmpty()) return
log.i { "Accepting ${items.size} suggested item(s) into the jam queue" }
runCatching {
audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() })
}
}
// ---------- Presence & host takeover ----------
private fun onPresence(all: Map<String, JamPresence>) {
if (_role.value == null) return
val live = all.values.filter { !it.left }
_participants.value = live
.sortedBy { it.clientId }
.map { JamParticipant(it.clientId, it.displayName, it.isHost) }
// Host-side: auto-kick banned participants that rejoin.
if (_role.value == JamRole.Host) {
live.filter { it.clientId in bannedClientIds }.forEach { banned ->
scope.launch { kickParticipant(banned.clientId, "banned by host") }
}
return
}
val host = live.firstOrNull { it.isHost }
if (host != null) return
// Host left: the lowest client id takes over (deterministic, clock-free).
val candidate = live.minByOrNull { it.clientId } ?: return
if (candidate.clientId != localClientId) return
scope.launch {
delay(HOST_TAKEOVER_DELAY_MS)
val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost }
if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch
log.i { "Taking over as jam host (previous host left)" }
_role.value = JamRole.Host
jamClient.claimHost()
startHostBroadcast()
}
}
// ---------- Helpers ----------
private fun publishCommand(command: PlaybackCmd) {
scope.launch {
jamClient.publishCommand(JamMessage.PlaybackCommand(command))
}
}
private fun participantName(fallbackPrefix: String): String {
val configured = settingsRepository.userSettings.value.jamParticipantName
return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" }
}
private fun newClientId(broker: dev.krtirtho.spotube.modules.settings.JamBroker): String {
val suffix = buildString(6) {
val chars = "0123456789abcdef"
repeat(6) { append(chars[Random.nextInt(chars.length)]) }
}
return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix"
}
private fun persistLastCode(code: String) {
scope.launch {
runCatching {
val settings = settingsRepository.userSettings.value
if (settings.lastJamCode != code) {
settingsRepository.updateSettings(settings.copy(lastJamCode = code))
}
}
}
}
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
trackId.isNotBlank() -> QueueEntry.StreamingTrack(
track = MetadataTrack(
id = trackId,
title = title,
durationMs = durationMs,
trackNumber = null,
discNumber = null,
artists = listOf(
MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null)
),
album = null,
// Keep the cover art flowing to participants — the wire carries it
// as coverUrl, so mirror it back into the reconstructed thumbnails.
thumbnails = coverUrl.takeIf { it.isNotBlank() }
?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) },
explicit = null,
popularity = null,
isrcCode = null,
externalUri = null,
),
url = "",
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
.getOrDefault(StreamProtocol.PROGRESSIVE),
addedBy = addedBy,
)
else -> QueueEntry.LocalTrack(
name = title,
artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() },
duration = durationMs,
album = album.ifBlank { null },
coverBytes = null,
url = url,
addedBy = addedBy,
)
}
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
if (entry == null) return false
return when (entry) {
is QueueEntry.StreamingTrack ->
trackId.isNotBlank() && entry.track.id == trackId
is QueueEntry.LocalTrack ->
url.isNotBlank() && entry.url == url && entry.name == title
}
}
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when {
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
this.track.id == other.track.id
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
this.url == other.url && this.name == other.name
else -> false
}
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
companion object {
private const val HOST_TAKEOVER_DELAY_MS = 1_500L
private const val AUTO_ADVANCE_WINDOW_MS = 2_000L
}
}

View File

@ -21,6 +21,9 @@ import androidx.navigation3.runtime.NavKey
import dev.krtirtho.spotube.modules.album.AlbumScreen
import dev.krtirtho.spotube.modules.artist.ArtistScreen
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
import dev.krtirtho.spotube.modules.devices.DevicesScreen
import dev.krtirtho.spotube.modules.devices.RemoteControlScreen
import dev.krtirtho.spotube.modules.jam.JamScreen
import dev.krtirtho.spotube.modules.home.HomeScreen
import dev.krtirtho.spotube.modules.library.LibraryScreen
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
@ -30,6 +33,7 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
import dev.krtirtho.spotube.modules.search.SearchScreen
import dev.krtirtho.spotube.modules.settings.SettingsScreen
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
import kotlinx.serialization.Serializable
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.KoinExperimentalAPI
@ -75,6 +79,18 @@ sealed interface Routes : NavKey {
@Serializable
data object Blacklist : Routes
@Serializable
data object RemoteControl : Routes
@Serializable
data object Devices : Routes
@Serializable
data object Jam : Routes
@Serializable
data object JamSettings : Routes
}
@OptIn(KoinExperimentalAPI::class)
@ -148,5 +164,18 @@ val navigationModule = module {
navigation<Routes.Blacklist> {
BlacklistScreen()
}
navigation<Routes.Devices> {
DevicesScreen(navigationCommands = get())
}
navigation<Routes.RemoteControl> {
RemoteControlScreen(
onDisconnect = { get<NavigationCommands>().pop() }
)
}
navigation<Routes.Jam> {
JamScreen(navigationCommands = get())
}
navigation<Routes.JamSettings> {
JamSettingsScreen(viewModel = koinViewModel())
}
}

View File

@ -61,6 +61,8 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Artist::class, Routes.Artist.serializer())
subclass(Routes.Album::class, Routes.Album.serializer())
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
subclass(Routes.Devices::class, Routes.Devices.serializer())
subclass(Routes.Jam::class, Routes.Jam.serializer())
}
}
}

View File

@ -22,20 +22,23 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.artist.ArtistRepository
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
class CollectionPlaybackHelper(
private val albumRepository: AlbumRepository,
private val playlistRepository: PlaylistRepository,
private val savedTracksRepository: SavedTracksRepository,
private val artistRepository: ArtistRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
) {
suspend fun playAlbum(albumId: String) {
if (audioPlayerQueue.isAlbumPlaying(albumId)) return
val entries = fetchAllAlbumTracks(albumId)
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) {
audioPlayerQueue.load(
entries = entries,
@ -56,6 +59,13 @@ class CollectionPlaybackHelper(
}
}
suspend fun playAlbumNext(albumId: String) {
val entries = fetchAllAlbumTracks(albumId)
if (entries.isNotEmpty()) {
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) {
val entries = fetchAllAlbumTracks(albumId)
if (entries.isEmpty()) return
@ -81,9 +91,9 @@ class CollectionPlaybackHelper(
)
}
suspend fun playPlaylist(playlistId: String) {
if (audioPlayerQueue.isPlaylistPlaying(playlistId)) return
val entries = fetchAllPlaylistTracks(playlistId)
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
if (!shuffle && audioPlayerQueue.isPlaylistPlaying(playlistId)) return
val entries = fetchAllPlaylistTracks(playlistId).let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) {
audioPlayerQueue.load(
entries = entries,
@ -104,6 +114,13 @@ class CollectionPlaybackHelper(
}
}
suspend fun playPlaylistNext(playlistId: String) {
val entries = fetchAllPlaylistTracks(playlistId)
if (entries.isNotEmpty()) {
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) {
val entries = fetchAllPlaylistTracks(playlistId)
if (entries.isEmpty()) return
@ -129,9 +146,9 @@ class CollectionPlaybackHelper(
)
}
suspend fun playSavedTracks() {
if (audioPlayerQueue.isSavedTracksPlaying()) return
val entries = fetchAllSavedTracks()
suspend fun playSavedTracks(shuffle: Boolean = false) {
if (!shuffle && audioPlayerQueue.isSavedTracksPlaying()) return
val entries = fetchAllSavedTracks().let { if (shuffle) it.shuffled() else it }
if (entries.isNotEmpty()) {
audioPlayerQueue.load(
entries = entries,
@ -142,6 +159,32 @@ class CollectionPlaybackHelper(
}
}
suspend fun playArtistTopTracks(artistId: String) {
val entries = fetchArtistTopTracks(artistId)
if (entries.isNotEmpty()) {
audioPlayerQueue.load(
entries = entries,
autoPlay = true,
startPosition = 0,
collectionEntry = null,
)
}
}
suspend fun addArtistTopTracksToQueue(artistId: String) {
val entries = fetchArtistTopTracks(artistId)
if (entries.isNotEmpty()) {
audioPlayerQueue.addAllToQueue(entries)
}
}
suspend fun playArtistTopTracksNext(artistId: String) {
val entries = fetchArtistTopTracks(artistId)
if (entries.isNotEmpty()) {
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
suspend fun addSavedTracksToQueue() {
val entries = fetchAllSavedTracks()
if (entries.isNotEmpty()) {
@ -222,6 +265,21 @@ class CollectionPlaybackHelper(
}
}
/**
* Resolves the tracks of a collection without loading them into the local
* queue — used to suggest a collection into a jam session from a guest.
*/
suspend fun resolveCollectionTracks(type: RemoteCollectionType, id: String): List<MetadataTrack> =
when (type) {
RemoteCollectionType.Playlist -> fetchAllPlaylistTracks(id).asTracks()
RemoteCollectionType.Album -> fetchAllAlbumTracks(id).asTracks()
RemoteCollectionType.ArtistTopTracks -> fetchArtistTopTracks(id).asTracks()
RemoteCollectionType.SavedTracks -> fetchAllSavedTracks().asTracks()
}
private fun List<QueueEntry>.asTracks(): List<MetadataTrack> =
mapNotNull { (it as? QueueEntry.StreamingTrack)?.track }
private suspend fun fetchAllSavedTracks(): List<QueueEntry> {
val allTracks = mutableListOf<MetadataTrack>()
var pagination = savedTracksRepository.getSavedTracks()
@ -243,6 +301,15 @@ class CollectionPlaybackHelper(
}
}
private suspend fun fetchArtistTopTracks(artistId: String): List<QueueEntry> {
val tracks = artistRepository.topTracks(artistId).orEmpty()
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
return tracks
.filter { track -> !isTrackBlacklisted(track, blacklistedTrackIds, blacklistedArtistIds) }
.map { track -> QueueEntry.StreamingTrack(track = track, url = "") }
}
private fun isTrackBlacklisted(
track: MetadataTrack,
blacklistedTrackIds: Set<String>,

View File

@ -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")
}
},
)
}
}

View File

@ -0,0 +1,200 @@
/*
* 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 io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
/**
* WebSocket client for controlling a remote Spotube instance.
* Connects to the remote device's `/control` endpoint and sends commands.
*/
class RemoteControlClient {
private val logger = Logger.withTag("RemoteControlClient")
private val json = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
encodeDefaults = true
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val httpClient = HttpClient {
install(WebSockets)
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 30_000
}
}
private var session: WebSocketSession? = null
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _latestPlayerState = MutableStateFlow<RemoteControlEvent.PlayerState?>(null)
val latestPlayerState: StateFlow<RemoteControlEvent.PlayerState?> = _latestPlayerState.asStateFlow()
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow()
suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
if (_connectionState.value is ConnectionState.Connected) {
logger.w { "Already connected" }
return
}
_connectionState.value = ConnectionState.Connecting
try {
session = httpClient.webSocketSession {
url("ws://$host:$port/control")
header("X-Device-Id", deviceId)
header("X-Device-Name", deviceName)
}
logger.i { "WebSocket connected to $host:$port, waiting for authorization..." }
// Start receiving messages in a separate coroutine
scope.launch {
receiveLoop(host, port)
}
} catch (e: Exception) {
logger.e(e) { "Failed to connect to $host:$port" }
_connectionState.value = ConnectionState.Error(e.message ?: "Connection failed")
disconnect()
}
}
private suspend fun receiveLoop(host: String, port: Int) {
val currentSession = session ?: return
logger.d { "Starting receive loop for $host:$port" }
try {
for (frame in currentSession.incoming) {
when (frame) {
is Frame.Text -> {
val text = frame.readText()
logger.d { "Received frame: $text" }
try {
val event = json.decodeFromString(RemoteControlEvent.serializer(), text)
logger.d { "Parsed event: $event" }
when (event) {
is RemoteControlEvent.Connected -> {
logger.i { "Connection authorized by server" }
_connectionState.value = ConnectionState.Connected(host, port)
}
is RemoteControlEvent.WaitingForPermission -> {
logger.i { "Waiting for permission: ${event.message}" }
// Keep showing connecting state
}
else -> {
// Only store state updates after connection is established
if (_connectionState.value is ConnectionState.Connected) {
when (event) {
is RemoteControlEvent.PlayerState -> {
_latestPlayerState.value = event
}
is RemoteControlEvent.QueueUpdated -> {
_latestQueue.value = event
}
else -> {}
}
}
}
}
} catch (e: Exception) {
logger.w(e) { "Failed to parse message: $text" }
}
}
is Frame.Close -> {
logger.i { "WebSocket closed by server" }
_connectionState.value = ConnectionState.Disconnected
break
}
else -> {}
}
}
logger.d { "Receive loop exited normally" }
} catch (e: Exception) {
logger.e(e) { "Error in receive loop" }
_connectionState.value = ConnectionState.Error(e.message ?: "Connection lost")
}
}
suspend fun sendCommand(command: RemoteControlCommand) {
val currentSession = session ?: run {
logger.w { "Not connected" }
return
}
val envelope = CommandEnvelope(
commandId = randomShortId(),
command = command,
)
try {
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
currentSession.send(Frame.Text(text))
logger.d { "Sent command: $command" }
} catch (e: Exception) {
logger.e(e) { "Failed to send command" }
_connectionState.value = ConnectionState.Error(e.message ?: "Send failed")
}
}
suspend fun disconnect() {
session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting"))
session = null
_connectionState.value = ConnectionState.Disconnected
logger.i { "Disconnected" }
}
private fun randomShortId(): String {
val chars = "0123456789abcdef"
return buildString(8) {
repeat(8) {
append(chars[kotlin.random.Random.nextInt(chars.length)])
}
}
}
}
sealed interface ConnectionState {
data object Disconnected : ConnectionState
data object Connecting : ConnectionState
data class Connected(val host: String, val port: Int) : ConnectionState
data class Error(val message: String) : ConnectionState
}

View File

@ -0,0 +1,474 @@
/*
* 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.core.playback.CollectionPlaybackHelper
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 kotlin.time.TimeSource
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
class RemoteControlHandler(
private val settingsRepository: SettingsRepository,
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val collectionPlaybackHelper: CollectionPlaybackHelper,
) : 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) {
// Send waiting for permission message
val waitingMessage = RemoteControlEvent.WaitingForPermission(
"Waiting for permission from $deviceName..."
)
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), waitingMessage)))
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" }
}
}
// Send connected message
session.send(Frame.Text(json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Connected)))
logger.i { "Remote control connection established from $deviceName ($deviceId)" }
// Broadcast initial player state so the controller shows current track info
broadcastState(session)
broadcastQueue(session)
try {
// Periodically push player state so the controller's progress bar
// stays in sync even when no commands are being sent.
coroutineScope {
launch {
while (isActive) {
delay(1_000)
broadcastState(session)
}
}
handleControlLoop(session)
}
} catch (e: Exception) {
logger.w(e) { "Error in remote control session" }
} finally {
logger.d { "Closing session in finally block" }
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) {
logger.d { "Starting control loop for session" }
for (frame in session.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
logger.d { "Received command: $text" }
try {
val envelope = json.decodeFromString(CommandEnvelope.serializer(), text)
handleCommand(session, envelope)
} catch (e: Exception) {
logger.w(e) { "Failed to parse remote control command" }
sendError(session, "Invalid command: ${e.message}")
}
}
}
logger.d { "Control loop exited normally" }
}
private suspend fun handleCommand(session: WebSocketServerSession, envelope: CommandEnvelope) {
when (val command = envelope.command) {
is RemoteControlCommand.Play -> {
handleCollectionSource(command.source, RemoteCollectionAction.Play, command.shuffle)
}
is RemoteControlCommand.Pause -> {
audioPlayer.pause()
}
is RemoteControlCommand.TogglePlayPause -> {
val isPlaying = audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING
if (isPlaying) {
audioPlayer.pause()
waitForPlaybackState(expectPlaying = false)
} else {
audioPlayer.play()
waitForPlaybackState(expectPlaying = true)
}
}
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 -> {
handleCollectionSource(command.source, RemoteCollectionAction.AddToQueue)
}
is RemoteControlCommand.PlayNext -> {
handleCollectionSource(command.source, RemoteCollectionAction.PlayNext)
}
is RemoteControlCommand.PlayTrack -> {
audioPlayerQueue.load(
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
autoPlay = true,
startPosition = 0,
collectionEntry = null,
)
}
is RemoteControlCommand.AddTrackToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
}
is RemoteControlCommand.PlayTrackNext -> {
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = command.track, url = "")))
}
is RemoteControlCommand.AddTracksToQueue -> {
val entries = command.tracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
audioPlayerQueue.addAllToQueue(entries)
}
is RemoteControlCommand.PlayTracksNext -> {
val entries = command.tracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
audioPlayerQueue.addAllAfterCurrent(entries)
}
is RemoteControlCommand.PlayIndex -> {
audioPlayerQueue.jumpTo(command.index)
}
is RemoteControlCommand.RemoveFromQueue -> {
val queue = audioPlayerQueue.queueFlow.value
val entry = queue.firstOrNull { candidate ->
when (candidate) {
is QueueEntry.StreamingTrack -> candidate.track.id == command.mediaUrl
is QueueEntry.LocalTrack -> candidate.url == command.mediaUrl
}
}
if (entry != null) {
audioPlayerQueue.removeFromQueue(entry)
} else {
logger.w { "Remote remove: no matching queue entry for ${command.mediaUrl}" }
}
}
}
sendAck(session, envelope.commandId)
broadcastState(session)
broadcastQueue(session)
}
private suspend fun sendAck(session: WebSocketServerSession, commandId: String) {
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Ack(commandId))
session.send(Frame.Text(text))
}
private suspend fun sendError(session: WebSocketServerSession, message: String) {
val text = json.encodeToString(RemoteControlEvent.serializer(), RemoteControlEvent.Error(message))
session.send(Frame.Text(text))
}
/**
* Player state changes are applied asynchronously (e.g. ExoPlayer listener
* callbacks posted to the main looper), so after play/pause we poll until
* [playerStateFlow] reflects the expected state before broadcasting it back
* to the controller. Otherwise the client would see a stale (inverted) icon.
*/
private suspend fun waitForPlaybackState(expectPlaying: Boolean, timeoutMs: Long = 1_000) {
val timeoutAt = TimeSource.Monotonic.markNow() + timeoutMs.milliseconds
while (timeoutAt.hasNotPassedNow()) {
if ((audioPlayer.playerStateFlow.value == AudioPlayerState.PLAYING) == expectPlaying) return
delay(25)
}
}
/**
* Resolves a `spotube://` collection source URI (playlist/album/artist top
* tracks/saved tracks) and applies the requested action on the remote queue.
*/
private suspend fun handleCollectionSource(
source: String,
action: RemoteCollectionAction,
shuffle: Boolean = false,
) {
logger.d { "Remote collection $action for source: $source" }
when {
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
val id = source.removePrefix(COLLECTION_PLAYLIST_PREFIX)
when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playPlaylist(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(id)
}
}
source.startsWith(COLLECTION_ALBUM_PREFIX) -> {
val id = source.removePrefix(COLLECTION_ALBUM_PREFIX)
when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playAlbum(id, shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(id)
}
}
source.startsWith(COLLECTION_ARTIST_TOP_PREFIX) -> {
val id = source.removePrefix(COLLECTION_ARTIST_TOP_PREFIX)
when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playArtistTopTracks(id)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(id)
RemoteCollectionAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(id)
}
}
source == COLLECTION_SAVED_TRACKS -> {
when (action) {
RemoteCollectionAction.Play -> collectionPlaybackHelper.playSavedTracks(shuffle)
RemoteCollectionAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
RemoteCollectionAction.PlayNext -> {
// Saved tracks "play next" is not supported; add to queue instead
collectionPlaybackHelper.addSavedTracksToQueue()
}
}
}
else -> logger.w { "Unknown remote collection source: $source" }
}
}
private suspend fun broadcastState(session: WebSocketServerSession) {
val current = audioPlayerQueue.currentQueueEntryFlow.value
val state = RemoteControlEvent.PlayerState(
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.serializer(), state)
session.send(Frame.Text(text))
}
private suspend fun broadcastQueue(session: WebSocketServerSession) {
val queue = audioPlayerQueue.queueFlow.value
val current = audioPlayerQueue.currentQueueEntryFlow.value
val currentIndex = if (current != null) {
queue.indexOfFirst { it.matchesCurrent(current) }
} else {
-1
}
val event = RemoteControlEvent.QueueUpdated(
entries = queue.map { it.toRemoteQueueEntry() },
currentIndex = currentIndex,
)
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
session.send(Frame.Text(text))
}
private fun QueueEntry.matchesCurrent(current: QueueEntry): Boolean {
return when {
this is QueueEntry.StreamingTrack && current is QueueEntry.StreamingTrack -> {
this.track.id == current.track.id
}
this is QueueEntry.LocalTrack && current is QueueEntry.LocalTrack -> {
this.url == current.url && this.name == current.name
}
else -> false
}
}
private fun QueueEntry.toRemoteQueueEntry(): RemoteQueueEntry = when (this) {
is QueueEntry.StreamingTrack -> RemoteQueueEntry(
mediaUrl = track.id,
trackId = track.id,
title = track.title,
artists = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title,
coverUrl = coverUrlOrNull(),
durationMs = track.durationMs,
)
is QueueEntry.LocalTrack -> RemoteQueueEntry(
mediaUrl = url,
trackId = url,
title = name,
artists = artists.joinToString(", "),
album = album,
coverUrl = null,
durationMs = duration,
)
}
private fun QueueEntry.mediaKey(): String = when (this) {
is QueueEntry.StreamingTrack -> track.id
is QueueEntry.LocalTrack -> url
}
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?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
is QueueEntry.LocalTrack -> null
}
}
@Serializable
data class CommandEnvelope(
val commandId: String,
val command: RemoteControlCommand,
)
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
private const val COLLECTION_ARTIST_TOP_PREFIX = "spotube://artist/"
private const val COLLECTION_SAVED_TRACKS = "spotube://saved_tracks"
enum class RemoteCollectionAction {
Play,
AddToQueue,
PlayNext,
}
data class ConnectionRequest(
val deviceId: String,
val deviceName: String,
val sessionId: String,
)
enum class ConnectionRequestResponse {
Allow,
AllowAlways,
Deny,
}

View File

@ -0,0 +1,150 @@
/*
* 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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class RemoteControlCommand {
@Serializable
@SerialName("play")
data class Play(val source: String, val shuffle: Boolean = false) : 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("playNext")
data class PlayNext(val source: String) : RemoteControlCommand()
@Serializable
@SerialName("playTrack")
data class PlayTrack(val track: MetadataTrack) : RemoteControlCommand()
@Serializable
@SerialName("addTrackToQueue")
data class AddTrackToQueue(val track: MetadataTrack) : RemoteControlCommand()
@Serializable
@SerialName("playTrackNext")
data class PlayTrackNext(val track: MetadataTrack) : RemoteControlCommand()
@Serializable
@SerialName("addTracksToQueue")
data class AddTracksToQueue(val tracks: List<MetadataTrack>) : RemoteControlCommand()
@Serializable
@SerialName("playTracksNext")
data class PlayTracksNext(val tracks: List<MetadataTrack>) : RemoteControlCommand()
@Serializable
@SerialName("playIndex")
data class PlayIndex(val index: Int) : RemoteControlCommand()
@Serializable
@SerialName("removeFromQueue")
data class RemoveFromQueue(val mediaUrl: String) : RemoteControlCommand()
}
@Serializable
sealed class RemoteControlEvent {
@Serializable
@SerialName("connected")
data object Connected : RemoteControlEvent()
@Serializable
@SerialName("waitingForPermission")
data class WaitingForPermission(val message: String) : RemoteControlEvent()
@Serializable
@SerialName("playerState")
data class PlayerState(
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,
)

View File

@ -0,0 +1,221 @@
/*
* 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.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.random.Random
/**
* 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).
*
* Registration is retried with backoff: NsdManager is flaky right after a cold
* start, and a single registration attempt is bounded by a short timeout so a
* stalled platform callback can't wedge a dispatcher thread for long.
*/
class RemoteControlService(
private val settingsRepository: SettingsRepository,
private val discoveryService: DeviceDiscoveryService,
private val localServer: LocalServer,
) {
private val log = Logger.withTag("RemoteControlService")
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _localDeviceId = MutableStateFlow("")
val localDeviceId: StateFlow<String> = _localDeviceId.asStateFlow()
private var advertisedService: NetService? = null
/** A registration attempt that may have been left pending by the platform. */
private var pendingService: NetService? = null
private var registerJob: Job? = null
private var cleanupJob: Job? = null
init {
// Ensure a stable device id exists and is persisted up front, so discovery
// can reliably filter out this device's own advertisement.
scope.launch {
_localDeviceId.value = resolveDeviceId()
}
scope.launch {
combine(
settingsRepository.userSettings,
localServer.port,
) { settings, port -> settings to port }
.distinctUntilChanged()
.collect { (settings, port) ->
if (settings.allowRemoteControl && port != null) {
if (registerJob?.isActive != true) {
registerJob = scope.launch {
registerLoop(settings.remoteControlDeviceName, port)
}
}
} else {
registerJob?.cancel()
registerJob = null
stopAdvertising()
}
}
}
}
/**
* The service name this device advertises under, derived deterministically
* from settings so discovery can match it against the local advertisement.
*/
fun advertisedName(): String {
val deviceId = _localDeviceId.value.ifBlank {
settingsRepository.userSettings.value.remoteControlDeviceId
}
val configured = settingsRepository.userSettings.value.remoteControlDeviceName
return configured.ifBlank { "Spotube-${deviceId.take(6)}" }
}
/**
* Kicks off (or restarts) the advertising loop. Used when the local-network
* permission is granted at runtime after earlier attempts failed.
*/
fun retryAdvertising() {
val settings = settingsRepository.userSettings.value
val port = localServer.port.value
if (!settings.allowRemoteControl || port == null) return
registerJob?.cancel()
// Clean up any in-flight registration the cancelled job may have leaked.
scheduleCleanup(pendingService)
pendingService = null
registerJob = scope.launch {
registerLoop(settings.remoteControlDeviceName, port)
}
}
private suspend fun registerLoop(name: String, port: Int) {
val deviceId = resolveDeviceId()
_localDeviceId.value = deviceId
val serviceName = name.ifBlank { "Spotube-${deviceId.take(6)}" }
var attempt = 0
while (advertisedService == null && coroutineContext.isActive) {
attempt++
// The user may have toggled the setting off during backoff.
if (!settingsRepository.userSettings.value.allowRemoteControl) return
val service = discoveryService.createService(
name = serviceName,
port = port,
deviceId = deviceId,
)
pendingService = service
try {
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
pendingService = null
advertisedService = service
log.i { "Advertising remote control service '$serviceName' on port $port (attempt $attempt)" }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
pendingService = null
log.w(e) { "Failed to advertise remote control service (attempt $attempt); retrying in ${retryDelayMs(attempt)}ms" }
// The library leaks the platform registration on timeout. Once the
// platform eventually completes it (success), isRegistered flips
// and unregister() will actually remove it — keep trying until then.
scheduleCleanup(service)
delay(retryDelayMs(attempt))
}
}
}
/**
* Repeatedly tries to unregister a service whose registration attempt failed.
* The library's `unregister()` is a no-op while the platform hasn't completed
* the registration, so poll until it has (or give up after a while).
*/
private fun scheduleCleanup(service: NetService?) {
if (service == null) return
cleanupJob?.cancel()
cleanupJob = scope.launch {
repeat(REGISTER_CLEANUP_TRIES) {
delay(1_000)
runCatching { service.unregister() }
}
}
}
private suspend fun stopAdvertising() {
registerJob?.cancel()
registerJob = null
// Clean up any in-flight registration the cancelled job may have leaked.
scheduleCleanup(pendingService)
pendingService = null
if (advertisedService != null) {
runCatching { advertisedService?.unregister() }
advertisedService = null
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
}
private fun retryDelayMs(attempt: Int): Long = when {
attempt >= 6 -> 5 * 60_000L
attempt >= 3 -> 30_000L
else -> 5_000L
}
companion object {
// Generous enough that the library's timeout (which leaks the platform
// registration) rarely fires on a working system — registration callbacks
// normally arrive within a second.
private const val REGISTER_TIMEOUT_MS = 10_000L
// How long to keep polling unregister() on a failed service, in seconds.
private const val REGISTER_CLEANUP_TRIES = 15
}
}

View File

@ -0,0 +1,390 @@
/*
* 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 dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
enum class PlaybackDestinationAction {
Play,
AddToQueue,
PlayNext,
}
enum class RemoteCollectionType {
Playlist,
Album,
ArtistTopTracks,
SavedTracks,
}
/**
* A playback request awaiting a destination choice (local device vs a connected
* remote device). [title] is the content label shown in the picker dialog.
*/
sealed interface PlaybackDestinationRequest {
val title: String
val action: PlaybackDestinationAction
data class Collection(
override val title: String,
override val action: PlaybackDestinationAction,
val type: RemoteCollectionType,
val id: String,
val startTrack: MetadataTrack? = null,
val shuffle: Boolean = false,
) : PlaybackDestinationRequest
data class Track(
override val title: String,
override val action: PlaybackDestinationAction,
val track: MetadataTrack,
) : PlaybackDestinationRequest
data class Tracks(
override val title: String,
override val action: PlaybackDestinationAction,
val tracks: List<MetadataTrack>,
) : PlaybackDestinationRequest
}
/**
* Routes playback actions (play / add to queue / play next) to either the local
* device or a connected remote device. When a remote device is connected the
* user is shown a destination picker; otherwise the action runs locally.
*/
class RemotePlaybackController(
private val remoteControlClient: RemoteControlClient,
private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository,
private val jamRoomService: JamRoomService,
) : KoinComponent {
private val logger = Logger.withTag("RemotePlaybackController")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _pendingRequest = MutableStateFlow<PlaybackDestinationRequest?>(null)
val pendingRequest: StateFlow<PlaybackDestinationRequest?> = _pendingRequest.asStateFlow()
/** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
val events: SharedFlow<String> = _events.asSharedFlow()
fun isRemoteConnected(): Boolean {
return remoteControlClient.connectionState.value is ConnectionState.Connected
}
// ---------- Collection actions ----------
fun requestCollectionPlay(
type: RemoteCollectionType,
id: String,
title: String,
startTrack: MetadataTrack? = null,
shuffle: Boolean = false,
) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
}
fun requestCollectionAddToQueue(type: RemoteCollectionType, id: String, title: String) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.AddToQueue, type, id))
}
fun requestCollectionPlayNext(type: RemoteCollectionType, id: String, title: String) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.PlayNext, type, id))
}
// ---------- Single track actions ----------
fun requestTrackAddToQueue(track: MetadataTrack) {
request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.AddToQueue, track))
}
fun requestTrackPlayNext(track: MetadataTrack) {
request(PlaybackDestinationRequest.Track(track.title, PlaybackDestinationAction.PlayNext, track))
}
// ---------- Bulk track actions ----------
fun requestTracksAddToQueue(tracks: List<MetadataTrack>, title: String) {
if (tracks.isEmpty()) return
request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.AddToQueue, tracks))
}
fun requestTracksPlayNext(tracks: List<MetadataTrack>, title: String) {
if (tracks.isEmpty()) return
request(PlaybackDestinationRequest.Tracks(title, PlaybackDestinationAction.PlayNext, tracks))
}
// ---------- Picker resolution ----------
fun playLocally() {
val request = _pendingRequest.value ?: return
_pendingRequest.value = null
executeLocally(request)
}
fun playOnRemote() {
val request = _pendingRequest.value ?: return
_pendingRequest.value = null
executeOnRemote(request)
}
fun dismissPicker() {
_pendingRequest.value = null
}
// ---------- Jam actions ----------
/**
* Adds a single track to the active jam queue. The host applies it to the
* local (shared) queue directly; a guest suggests it to the host over MQTT.
*/
fun addTrackToJam(track: MetadataTrack) {
if (jamRoomService.role.value == null) return
scope.launch {
try {
when (jamRoomService.role.value) {
JamRole.Host -> audioPlayerQueue.addToQueue(
QueueEntry.StreamingTrack(track = track, url = "", addedBy = jamRoomService.participantClientId)
)
JamRole.Guest -> jamRoomService.suggestTrack(track)
null -> return@launch
}
_events.emit("Added to the jam queue")
} catch (e: Exception) {
logger.e(e) { "Failed to add track to jam session" }
}
}
}
/**
* Adds multiple tracks to the active jam queue (host applies locally,
* guest suggests to the host).
*/
fun addTracksToJam(tracks: List<MetadataTrack>) {
if (tracks.isEmpty() || jamRoomService.role.value == null) return
scope.launch {
try {
when (jamRoomService.role.value) {
JamRole.Host -> audioPlayerQueue.addAllToQueue(
tracks.map { track ->
QueueEntry.StreamingTrack(
track = track,
url = "",
addedBy = jamRoomService.participantClientId,
)
}
)
JamRole.Guest -> jamRoomService.suggestPlaylist(tracks)
null -> return@launch
}
_events.emit("Added ${tracks.size} to the jam queue")
} catch (e: Exception) {
logger.e(e) { "Failed to add tracks to jam session" }
}
}
}
// ---------- Internals ----------
private fun request(request: PlaybackDestinationRequest) {
if (isRemoteConnected()) {
_pendingRequest.value = request
} else {
executeLocally(request)
}
}
private fun executeLocally(request: PlaybackDestinationRequest) {
scope.launch {
when (request) {
is PlaybackDestinationRequest.Collection -> {
val startTrack = request.startTrack
when (request.type) {
RemoteCollectionType.Playlist -> when (request.action) {
PlaybackDestinationAction.Play -> {
if (startTrack != null) {
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
} else {
collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
}
}
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id)
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id)
}
RemoteCollectionType.Album -> when (request.action) {
PlaybackDestinationAction.Play -> {
if (startTrack != null) {
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
} else {
collectionPlaybackHelper.playAlbum(request.id, request.shuffle)
}
}
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id)
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id)
}
RemoteCollectionType.ArtistTopTracks -> when (request.action) {
PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id)
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id)
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id)
}
RemoteCollectionType.SavedTracks -> when (request.action) {
PlaybackDestinationAction.Play -> {
if (startTrack != null) {
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
} else {
collectionPlaybackHelper.playSavedTracks(request.shuffle)
}
}
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue()
}
}
}
is PlaybackDestinationRequest.Track -> {
val entry = QueueEntry.StreamingTrack(track = request.track, url = "")
when (request.action) {
PlaybackDestinationAction.Play -> {
audioPlayerQueue.load(
entries = listOf(entry),
autoPlay = true,
startPosition = 0,
collectionEntry = null,
)
}
PlaybackDestinationAction.AddToQueue -> {
audioPlayerQueue.addToQueue(entry)
}
PlaybackDestinationAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { candidate ->
(candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(entry))
}
}
}
is PlaybackDestinationRequest.Tracks -> {
val entries = request.tracks
.filter { track -> !isTrackBlacklisted(track) }
.map { track -> QueueEntry.StreamingTrack(track = track, url = "") }
when (request.action) {
PlaybackDestinationAction.Play -> {
audioPlayerQueue.load(
entries = entries,
autoPlay = true,
startPosition = 0,
collectionEntry = null,
)
}
PlaybackDestinationAction.AddToQueue -> {
audioPlayerQueue.addAllToQueue(entries)
}
PlaybackDestinationAction.PlayNext -> {
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
}
}
}
}
private fun executeOnRemote(request: PlaybackDestinationRequest) {
scope.launch {
try {
val command = when (request) {
is PlaybackDestinationRequest.Collection -> {
val source = when (request.type) {
RemoteCollectionType.Playlist -> "spotube://playlist/${request.id}"
RemoteCollectionType.Album -> "spotube://album/${request.id}"
RemoteCollectionType.ArtistTopTracks -> "spotube://artist/${request.id}"
RemoteCollectionType.SavedTracks -> "spotube://saved_tracks"
}
when (request.action) {
PlaybackDestinationAction.Play -> RemoteControlCommand.Play(source, request.shuffle)
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddToQueue(source)
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayNext(source)
}
}
is PlaybackDestinationRequest.Track -> when (request.action) {
PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTrack(request.track)
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTrackToQueue(request.track)
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTrackNext(request.track)
}
is PlaybackDestinationRequest.Tracks -> when (request.action) {
PlaybackDestinationAction.Play -> RemoteControlCommand.PlayTracksNext(request.tracks)
PlaybackDestinationAction.AddToQueue -> RemoteControlCommand.AddTracksToQueue(request.tracks)
PlaybackDestinationAction.PlayNext -> RemoteControlCommand.PlayTracksNext(request.tracks)
}
}
remoteControlClient.sendCommand(command)
logger.i { "Sent remote ${request.action} for ${request.title}" }
} catch (e: Exception) {
logger.e(e) { "Failed to send remote playback command" }
}
}
}
private suspend fun isTrackBlacklisted(track: MetadataTrack): Boolean {
val trackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val artistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
return track.id in trackIds || track.artists.any { it.id in artistIds }
}
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id
return title == other.title &&
durationMs == other.durationMs &&
album?.id == other.album?.id &&
artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } }
}
}

View File

@ -21,12 +21,12 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import org.koin.core.component.KoinComponent
class AlternativeTracksRepository(
private val pluginManager: PluginManager,
private val matchedTracksRepository: MatchedTracksRepository,
private val pluginManager: AudioPluginSource,
private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
@ -36,10 +36,6 @@ class AlternativeTracksRepository(
suspend fun resolveAlternatives(track: MetadataTrack): List<AudioSource> {
val trackId = track.id
streamingUrlRepository.getCachedAlternatives(trackId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
@ -47,6 +43,11 @@ class AlternativeTracksRepository(
return emptyList()
}
streamingUrlRepository.getCachedAlternatives(trackId, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached alternatives for track $trackId" }
return cached
}
val sources = runCatching {
audioPlugin.use { audioAPI.getStreamsByTrack(track) }
}.getOrElse { throwable ->
@ -59,24 +60,30 @@ class AlternativeTracksRepository(
return emptyList()
}
streamingUrlRepository.cacheAlternatives(trackId, sources)
streamingUrlRepository.cacheAlternatives(trackId, sources, audioPlugin.pluginId)
logger.d { "Resolved ${sources.size} alternative sources for track $trackId" }
return sources
}
suspend fun getActiveSourceId(track: MetadataTrack): String? {
return matchedTracksRepository.getTrackSource(track)?.id
val audioPlugin = pluginManager.selectedAudioPlugin.value ?: return null
return matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)?.id
}
suspend fun selectAlternative(track: MetadataTrack, source: AudioSource) {
val trackId = track.id
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
logger.w { "No audio plugin selected while selecting alternative for track $trackId" }
return
}
val basic = when (source) {
is AudioSource.Streamed -> source.toBasic()
is AudioSource.Basic -> source
}
logger.i { "Selecting alternative source for track $trackId: ${basic.id} (${basic.title})" }
matchedTracksRepository.saveTrackSource(track, basic)
matchedTracksRepository.saveTrackSource(track, basic, audioPlugin.pluginId)
streamingUrlRepository.invalidateCachedStreamUrl(trackId)
streamingUrlRepository.invalidateCachedAlternatives(trackId)
cacheManager.invalidateCacheEntry(trackId)

View File

@ -19,10 +19,13 @@ package dev.krtirtho.spotube.core.server
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.http.HttpMethod
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
@ -30,6 +33,8 @@ import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.head
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
@ -55,10 +60,18 @@ class LocalServer(
private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager,
private val remoteControlHandler: RemoteControlHandler,
) : KoinComponent {
val logger by injectLogger<LocalServer>()
private val httpClient = HttpClient()
private val httpClient = HttpClient {
// A stalled upstream connection must not wedge the CIO dispatcher thread
// forever. Only the connect phase is bounded — the proxy streams long
// audio bodies, so request/socket timeouts would cut playback short.
install(HttpTimeout) {
connectTimeoutMillis = 10_000
}
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val serverMutex = Mutex()
@ -70,8 +83,10 @@ class LocalServer(
private val activePort = MutableStateFlow<Int?>(null)
val port = activePort.asStateFlow()
private val activeHost = MutableStateFlow<String?>(null)
val baseUrl = activePort.map { port ->
port?.let { "http://$HOST:$it" }
port?.let { "http://$HOST_LOCAL:$it" }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
private val cachedCacheEnabled = MutableStateFlow(false)
@ -89,18 +104,19 @@ class LocalServer(
}
companion object {
private const val HOST = "127.0.0.1"
private const val HOST_LOCAL = "127.0.0.1"
private const val HOST_LAN = "0.0.0.0"
}
init {
logger.d { "Starting playback proxy port watcher" }
portWatcher = scope.launch {
settingsViewModel.settingsState
.mapNotNull { it?.playbackProxyServerPort }
.mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } }
.distinctUntilChanged()
.collectLatest { port ->
logger.d { "Observed playback proxy port change to $port" }
restartServer(port)
.collectLatest { (port, allowRemoteControl) ->
logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" }
restartServer(port, allowRemoteControl)
}
}
scope.launch {
@ -129,26 +145,28 @@ class LocalServer(
logger.d { "Playback proxy server stopped" }
}
private suspend fun restartServer(port: Int) {
private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) {
val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL
serverMutex.withLock {
if (serverState.value != null && activePort.value == port) {
logger.v { "Playback proxy server already running on port $port; skipping restart" }
if (serverState.value != null && activePort.value == port && activeHost.value == host) {
logger.v { "Playback proxy server already running on $host:$port; skipping restart" }
return
}
logger.d { "Restarting playback proxy server on port $port" }
logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" }
stopServerLocked()
serverState.value = embeddedServer(
factory = CIO,
host = HOST,
host = host,
port = port,
module = { configureRoutes() }
).also { engine ->
engine.start(wait = false)
}
activePort.value = port
logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$HOST:$port"}" }
activeHost.value = host
logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:$port"}" }
}
}
@ -165,9 +183,16 @@ class LocalServer(
}
serverState.value = null
activePort.value = null
activeHost.value = null
}
private fun Application.configureRoutes() {
install(WebSockets) {
pingPeriodMillis = 30_000L
timeoutMillis = 60_000L
maxFrameSize = 10L * 1024 * 1024
masking = false
}
routing {
get("/health") {
call.respondText("ok")
@ -188,6 +213,10 @@ class LocalServer(
get("/segment/{trackId}") {
streamProxy.handleSegmentRequest(call)
}
webSocket("/control") {
remoteControlHandler.handleConnection(this)
}
}
}
}

View File

@ -24,23 +24,53 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.db.Database
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
class MatchedTracksRepository(private val database: Database) {
suspend fun getTrackSource(track: MetadataTrack): AudioSource.Basic? {
/**
* A matched [AudioSource.Basic] tagged with the id of the audio plugin that produced it.
* The [pluginId] tag lets [MatchedTracksRepository] detect and discard matches that were
* made by a plugin the user is no longer using, since a source id from one plugin (e.g. a
* YouTube video id) is meaningless - or worse, silently wrong - when handed to a different
* plugin's `getStreamsOfAudioSource`.
*/
@Serializable
data class PluginTaggedAudioSource(
val pluginId: String,
val source: AudioSource.Basic,
)
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
interface TrackSourceRepository {
suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic?
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String)
}
class MatchedTracksRepository(private val database: Database) : TrackSourceRepository {
/**
* Returns the previously matched source for [track], but only if it was produced by
* [pluginId]. A match saved by a different (or since-removed) plugin is treated as a
* cache miss rather than being handed to the wrong plugin.
*/
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
return database.matchedTracksDataStore.data.map { prefs ->
val json = prefs[stringPreferencesKey(track.id)]
if (json != null) {
Json.decodeFromString<AudioSource.Basic>(json as String)
} else {
val json = prefs[stringPreferencesKey(track.id)] ?: return@map null
val tagged = try {
Json.decodeFromString<PluginTaggedAudioSource>(json)
} catch (_: Exception) {
// Legacy entries stored before plugin-tagging was introduced; treat as
// unverifiable and force a fresh match instead of risking a wrong plugin.
return@map null
}
if (tagged.pluginId != pluginId) return@map null
tagged.source
}.first()
}
suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic) {
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
database.matchedTracksDataStore.edit { prefs ->
prefs[stringPreferencesKey(track.id)] = Json.encodeToString(source)
prefs[stringPreferencesKey(track.id)] =
Json.encodeToString(PluginTaggedAudioSource(pluginId, source))
}
}
}

View File

@ -53,6 +53,7 @@ import okio.SYSTEM
import okio.buffer
import okio.use
import org.koin.core.component.KoinComponent
import kotlin.time.Duration.Companion.milliseconds
internal class StreamProxy(
private val httpClient: HttpClient,
@ -114,7 +115,7 @@ internal class StreamProxy(
logger.d { "Track $trackId is being cached, waiting for cache" }
var waited = 0
while (waited < 30) {
delay(200)
delay(200.milliseconds)
waited++
val entry = cacheManager.findCachedEntry(trackId)
if (entry != null) {
@ -245,7 +246,7 @@ internal class StreamProxy(
call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = HttpStatusCode.OK
override val contentType: ContentType = contentType
override val contentLength: Long? = rewrittenManifest.encodeToByteArray().size.toLong()
override val contentLength: Long = rewrittenManifest.encodeToByteArray().size.toLong()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
channel.writeFully(rewrittenManifest.encodeToByteArray())
@ -305,7 +306,7 @@ internal class StreamProxy(
var currentUrl = streamUrl
var attemptedRefresh = false
var upstream: HttpResponse? = null
var upstream: HttpResponse?
while (true) {
val response = runCatching {

View File

@ -18,15 +18,20 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.component.KoinComponent
import kotlin.math.abs
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
@ -36,6 +41,19 @@ data class CachedStreamUrl(
val expiresAtMs: Long,
val container: String,
val codec: String,
val source: AudioSource.Streamed? = null,
val selectedStream: AudioStream? = null,
val preferredFormat: AudioFormat,
val preferredQuality: AudioQuality,
// The id of the audio plugin that produced this entry. Guards against a stale entry
// (e.g. a YouTube video URL) being served after the user switches to a different
// audio plugin, which would otherwise play back completely unrelated content.
val pluginId: String,
)
private data class CachedAlternatives(
val pluginId: String,
val sources: List<AudioSource>,
)
data class StreamInfo(
@ -45,31 +63,19 @@ data class StreamInfo(
val container: String,
)
class StreamingUrlRepository(
private val pluginManager: PluginManager,
private val pluginManager: AudioPluginSource,
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: MatchedTracksRepository,
private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
) : KoinComponent {
companion object {
private val STREAM_URL_CACHE_TTL_MS = 30.seconds.inWholeMilliseconds
}
private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex()
private val streamUrlCache = mutableMapOf<String, CachedStreamUrl>()
private val alternativesCacheMutex = Mutex()
private val alternativesCache = mutableMapOf<String, List<AudioSource>>()
private fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
if (url.startsWith("http")) return url
val base = when (protocol) {
StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com"
StreamProtocol.PROGRESSIVE -> return url
}
return "$base$url"
}
private val alternativesCache = mutableMapOf<String, CachedAlternatives>()
suspend fun resolveStreamInfo(
trackId: String,
@ -91,12 +97,9 @@ class StreamingUrlRepository(
forceRefresh: Boolean = false
): StreamInfo? {
val trackId = track.id
if (!forceRefresh) {
getCachedStreamUrl(trackId)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" }
return cached
}
}
val settings = settingsRepository.userSettings.value
val preferredFormat = settings.streamingMusicFormat
val preferredQuality = settings.streamingMusicQuality
val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) {
@ -104,7 +107,14 @@ class StreamingUrlRepository(
return null
}
val source = matchedTracksRepository.getTrackSource(track)
if (!forceRefresh) {
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" }
return cached
}
}
val source = matchedTracksRepository.getTrackSource(track, audioPlugin.pluginId)
if (source != null) {
logger.d { "Attempting stream resolution for track $trackId using cached source" }
@ -120,16 +130,14 @@ class StreamingUrlRepository(
if (stream != null) {
logger.d { "Resolved stream for track $trackId using cached source" }
val resolvedStream = stream.streams.firstOrNull()
val resolvedStream = selectPreferredAudioStream(
stream.streams,
preferredFormat,
preferredQuality,
)
if (resolvedStream != null) {
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol)
val info = StreamInfo(
url,
resolvedStream.protocol,
resolvedStream.codec,
resolvedStream.container
)
cacheStreamInfo(trackId, info)
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info
}
}
@ -152,19 +160,19 @@ class StreamingUrlRepository(
return null
}
cacheAlternatives(trackId, sources)
cacheAlternatives(trackId, sources, audioPlugin.pluginId)
val stream = runCatching {
audioPlugin.use {
sources.firstNotNullOfOrNull { src ->
when (src) {
is AudioSource.Streamed -> {
matchedTracksRepository.saveTrackSource(track, src.toBasic())
matchedTracksRepository.saveTrackSource(track, src.toBasic(), audioPlugin.pluginId)
src
}
is AudioSource.Basic -> {
matchedTracksRepository.saveTrackSource(track, src)
matchedTracksRepository.saveTrackSource(track, src, audioPlugin.pluginId)
audioAPI.getStreamsOfAudioSource(src)
.firstOrNull()
}
@ -182,45 +190,73 @@ class StreamingUrlRepository(
}
logger.d { "Resolved stream for track $trackId via track lookup" }
val resolvedStream = stream.streams.firstOrNull()
val resolvedStream = selectPreferredAudioStream(
stream.streams,
preferredFormat,
preferredQuality,
)
if (resolvedStream != null) {
val url = normalizeManifestUrl(resolvedStream.url, resolvedStream.protocol)
val info = StreamInfo(
url,
resolvedStream.protocol,
resolvedStream.codec,
resolvedStream.container
)
cacheStreamInfo(trackId, info)
val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
return info
}
return null
}
suspend fun getCachedStreamUrl(trackId: String): StreamInfo? {
private fun AudioStream.toStreamInfo() = StreamInfo(
url = normalizeManifestUrl(url, protocol),
protocol = protocol,
codec = codec,
container = container,
)
private suspend fun getCachedStreamUrl(
trackId: String,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
pluginId: String,
): StreamInfo? {
val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock {
val cached = streamUrlCache[trackId] ?: return@withLock null
if (cached.pluginId != pluginId) {
logger.v { "Cached stream URL for track $trackId belongs to a different plugin; ignoring" }
return@withLock null
}
if (cached.preferredFormat != preferredFormat || cached.preferredQuality != preferredQuality) {
return@withLock null
}
if (cached.expiresAtMs <= now) {
streamUrlCache.remove(trackId)
logger.v { "Cached stream URL expired for track $trackId" }
return@withLock null
}
StreamInfo(cached.url, cached.protocol, cached.container, cached.codec)
StreamInfo(cached.url, cached.protocol, cached.codec, cached.container)
}
}
suspend fun cacheStreamInfo(trackId: String, info: StreamInfo) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + STREAM_URL_CACHE_TTL_MS
private suspend fun cacheStreamInfo(
trackId: String,
info: StreamInfo,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
source: AudioSource.Streamed,
selectedStream: AudioStream,
pluginId: String,
) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
streamUrlCacheMutex.withLock {
streamUrlCache[trackId] =
CachedStreamUrl(
url = info.url,
protocol = info.protocol,
expiresAtMs = expiresAtMs,
container = info.container,
codec = info.codec
)
streamUrlCache[trackId] = CachedStreamUrl(
url = info.url,
protocol = info.protocol,
expiresAtMs = expiresAtMs,
container = info.container,
codec = info.codec,
source = source,
selectedStream = selectedStream,
preferredFormat = preferredFormat,
preferredQuality = preferredQuality,
pluginId = pluginId,
)
}
}
@ -232,24 +268,99 @@ class StreamingUrlRepository(
logger.v { "Invalidated cached stream URL for track $trackId" }
}
}
// Stream metadata is part of the same URL-cache entry and is removed with it.
}
suspend fun getCachedAlternatives(trackId: String): List<AudioSource>? {
/** Returns cached alternatives only if they were produced by [pluginId]. */
suspend fun getCachedAlternatives(trackId: String, pluginId: String): List<AudioSource>? {
return alternativesCacheMutex.withLock {
alternativesCache[trackId]
val cached = alternativesCache[trackId] ?: return@withLock null
if (cached.pluginId != pluginId) {
logger.v { "Cached alternatives for track $trackId belong to a different plugin; ignoring" }
return@withLock null
}
cached.sources
}
}
suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>) {
suspend fun cacheAlternatives(trackId: String, sources: List<AudioSource>, pluginId: String) {
alternativesCacheMutex.withLock {
alternativesCache[trackId] = sources
alternativesCache[trackId] = CachedAlternatives(pluginId, sources)
logger.v { "Cached ${sources.size} alternative sources for track $trackId" }
}
}
/** Returns stream details from the existing playback URL cache; never fetches them. */
suspend fun getCachedStreamUrlEntry(trackId: String): CachedStreamUrl? {
val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock {
val entry = streamUrlCache[trackId] ?: return@withLock null
if (entry.expiresAtMs <= now) {
logger.v { "Returning expired stream details for track $trackId" }
}
entry
}
}
suspend fun invalidateCachedAlternatives(trackId: String) {
alternativesCacheMutex.withLock {
alternativesCache.remove(trackId)
}
}
}
}
/**
* Resolves a stream URL to an absolute URL. Relative manifest paths (DASH/HLS) are
* resolved against the YouTube origin; progressive URLs are returned unchanged.
*/
internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String {
if (url.startsWith("http")) return url
return when (protocol) {
StreamProtocol.DASH, StreamProtocol.HLS -> "https://www.youtube.com$url"
StreamProtocol.PROGRESSIVE -> url
}
}
/** Selects the available stream closest to the user's preferred format and quality. */
internal fun selectPreferredAudioStream(
streams: List<AudioStream>,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream ->
formatMismatchCount(stream, preferredFormat)
}.thenBy { stream ->
if (stream.matchesQualityType(preferredQuality)) 0 else 1
}.thenBy { stream ->
stream.qualityDistance(preferredQuality)
}.thenBy { stream ->
stream.channelDistance(preferredQuality)
}
)
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +
(if (stream.container.equals(preferredFormat.container, ignoreCase = true)) 0 else 1)
private fun AudioStream.matchesQualityType(preferredQuality: AudioQuality): Boolean = when (this) {
is AudioStream.Lossy -> preferredQuality is AudioQuality.Lossy
is AudioStream.Lossless -> preferredQuality is AudioQuality.Lossless
}
private fun AudioStream.qualityDistance(preferredQuality: AudioQuality): Long = when {
this is AudioStream.Lossy && preferredQuality is AudioQuality.Lossy ->
abs(bitrate.toLong() - preferredQuality.bitrate.toLong())
this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless ->
abs(sampleRate.toLong() - preferredQuality.sampleRate.toLong())
else -> Long.MAX_VALUE
}
private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = when {
this is AudioStream.Lossless && preferredQuality is AudioQuality.Lossless ->
abs(channels - preferredQuality.channels)
else -> 0
}

View File

@ -28,7 +28,6 @@ import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -70,7 +69,6 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
@ -173,11 +171,9 @@ fun <T> AutocompleteTextField(
val isHovered by interactionSource.collectIsHoveredAsState()
var isMenuOpen by remember { mutableStateOf(false) }
var isMovingFocusToPopup by remember { mutableStateOf(false) }
var selectedIndex by remember { mutableIntStateOf(-1) }
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
val menuListState = rememberLazyListState()
val popupFocusRequester = remember { FocusRequester() }
val textFieldFocusRequester = remember { FocusRequester() }
val menuExpanded = isMenuOpen && items.isNotEmpty()
@ -185,7 +181,6 @@ fun <T> AutocompleteTextField(
LaunchedEffect(isFocused) {
if (isFocused) {
isMenuOpen = true
isMovingFocusToPopup = false
}
}
@ -291,7 +286,7 @@ fun <T> AutocompleteTextField(
)
.focusRequester(textFieldFocusRequester)
.onFocusChanged { state ->
if (!state.isFocused && !isMovingFocusToPopup) {
if (!state.isFocused) {
isMenuOpen = false
selectedIndex = -1
}
@ -303,8 +298,6 @@ fun <T> AutocompleteTextField(
if (!menuExpanded) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionDown -> {
isMovingFocusToPopup = true
popupFocusRequester.requestFocus()
navigate(1)
true
}
@ -374,7 +367,7 @@ fun <T> AutocompleteTextField(
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
properties = PopupProperties(focusable = true),
properties = PopupProperties(focusable = false),
onDismissRequest = {
selectedIndex = -1
isMenuOpen = false
@ -386,45 +379,7 @@ fun <T> AutocompleteTextField(
.heightIn(max = menuMaxHeight)
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
.clip(resolvedMenuTheme.shape)
.focusRequester(popupFocusRequester)
.focusable()
.onFocusChanged { state ->
if (state.isFocused) {
isMovingFocusToPopup = false
}
}
.onKeyEvent { event ->
when (event.key) {
Key.DirectionDown -> {
navigate(1); true
}
Key.DirectionUp -> {
navigate(-1); true
}
Key.Enter -> {
if (selectedIndex in items.indices) {
onItemSelected(items[selectedIndex])
selectedIndex = -1
isMenuOpen = false
textFieldFocusRequester.requestFocus()
return@onKeyEvent true
}
false
}
Key.Escape -> {
selectedIndex = -1
isMenuOpen = false
textFieldFocusRequester.requestFocus()
true
}
else -> false
}
},
.clip(resolvedMenuTheme.shape),
) {
LazyColumn(
state = menuListState,

View File

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

View File

@ -20,7 +20,6 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -29,8 +28,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@Composable
@ -39,9 +39,9 @@ fun AlbumCard(
modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject()
navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject(),
) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
PlayableCard(
@ -54,10 +54,18 @@ fun AlbumCard(
},
onPlay = {
if (currentCollectionEntry?.id == album.id) return@PlayableCard
scope.launch { playbackHelper.playAlbum(album.id) }
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Album,
album.id,
album.title,
)
},
onAddToQueue = {
scope.launch { playbackHelper.addAlbumToQueue(album.id) }
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Album,
album.id,
album.title,
)
},
modifier = modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}",

View File

@ -78,6 +78,9 @@ fun CollectionDetails(
onShufflePlay: () -> Unit,
onAddToQueue: () -> Unit,
isPlaying: Boolean = false,
/** Guest in a jam session: play/shuffle are replaced by Add to Jam. */
isJamGuest: Boolean = false,
onAddToJam: (() -> Unit)? = null,
isFollowing: Boolean = false,
onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true,
@ -91,39 +94,61 @@ fun CollectionDetails(
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val modifier = if (isCompact) {
Modifier.weight(1f)
} else {
Modifier
if (isJamGuest) {
if (onAddToJam != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
PrimaryButton(
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
onClick = onAddToJam!!,
) {
Icon(
imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to Jam",
)
TextWithShimmer(
text = "Add to Jam",
modifier = Modifier.padding(start = 6.dp),
)
}
}
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
val modifier = if (isCompact) {
Modifier.weight(1f)
} else {
Modifier
}
PrimaryButton(
modifier = modifier,
onClick = onPlay,
) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
TextWithShimmer(
text = if (isPlaying) "Pause" else "Play",
modifier = Modifier.padding(start = 6.dp),
)
}
OutlineButton(
modifier = modifier,
onClick = onShufflePlay
) {
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
TextWithShimmer(
text = "Shuffle",
modifier = Modifier.padding(start = 6.dp),
)
PrimaryButton(
modifier = modifier,
onClick = onPlay,
) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
TextWithShimmer(
text = if (isPlaying) "Pause" else "Play",
modifier = Modifier.padding(start = 6.dp),
)
}
OutlineButton(
modifier = modifier,
onClick = onShufflePlay
) {
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
TextWithShimmer(
text = "Shuffle",
modifier = Modifier.padding(start = 6.dp),
)
}
}
}
}

View File

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

View File

@ -27,6 +27,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@ -37,7 +39,8 @@ fun PlaylistCard(
modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject()
navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject(),
) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -52,10 +55,18 @@ fun PlaylistCard(
},
onPlay = {
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
scope.launch { playbackHelper.playPlaylist(playlist.id) }
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Playlist,
playlist.id,
playlist.title,
)
},
onAddToQueue = {
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Playlist,
playlist.id,
playlist.title,
)
},
modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}",

View File

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

View File

@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
sealed interface TrackOptionsAction {
data object AddToJam : TrackOptionsAction
data object StartRadio : TrackOptionsAction
data object PlayNext : TrackOptionsAction
data object AddToQueue : TrackOptionsAction
@ -98,6 +99,8 @@ fun TrackOptions(
onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit,
modifier: Modifier = Modifier,
isInJam: Boolean = false,
isJamGuest: Boolean = false,
) {
AdaptiveDropdownBottomSheet(
items = buildTrackMenuItems(
@ -105,6 +108,8 @@ fun TrackOptions(
state = state,
onAction = onAction,
onAlbumClick = onAlbumClick,
isInJam = isInJam,
isJamGuest = isJamGuest,
),
trigger = { onClick ->
GhostIconButton(onClick = onClick) {
@ -129,6 +134,8 @@ fun TrackOptionsBottomSheet(
onDismiss: () -> Unit,
onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit,
isInJam: Boolean = false,
isJamGuest: Boolean = false,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.fillMaxWidth()) {
@ -151,6 +158,8 @@ fun TrackOptionsBottomSheet(
onAlbumClick()
onDismiss()
},
isInJam = isInJam,
isJamGuest = isJamGuest,
).forEach { item ->
Row(
modifier = Modifier
@ -241,7 +250,19 @@ private fun buildTrackMenuItems(
state: TrackOptionsState,
onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit,
isInJam: Boolean = false,
isJamGuest: Boolean = false,
): List<AdaptiveMenuItem> = buildList {
if (isInJam) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = "Add to Jam",
onClick = { onAction(TrackOptionsAction.AddToJam) },
),
)
}
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicCircle,
@ -250,40 +271,44 @@ private fun buildTrackMenuItems(
),
)
if (!state.isInQueue && !state.isCurrentlyPlaying) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = "Play next",
onClick = { onAction(TrackOptionsAction.PlayNext) },
),
)
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = "Move to next",
onClick = { onAction(TrackOptionsAction.PlayNext) },
),
)
}
// A guest's queue is the shared jam queue — mutating it locally is not
// allowed, so queue actions are replaced by "Add to Jam".
if (!isJamGuest) {
if (!state.isInQueue && !state.isCurrentlyPlaying) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = "Play next",
onClick = { onAction(TrackOptionsAction.PlayNext) },
),
)
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = "Move to next",
onClick = { onAction(TrackOptionsAction.PlayNext) },
),
)
}
if (!state.isInQueue) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = "Add to queue",
onClick = { onAction(TrackOptionsAction.AddToQueue) },
),
)
} else {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemove,
label = "Remove from queue",
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
),
)
if (!state.isInQueue) {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = "Add to queue",
onClick = { onAction(TrackOptionsAction.AddToQueue) },
),
)
} else {
add(
AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemove,
label = "Remove from queue",
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
),
)
}
}
add(

View File

@ -41,6 +41,7 @@ class BuiltInPluginService(
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id
private val loggedInStateFlow = MutableStateFlow(false)
override val loggedInFlow = loggedInStateFlow.asStateFlow()
val servicesRegistry = mutableMapOf<KClass<*>, ZiplineService>()

View File

@ -52,6 +52,7 @@ class PluginServiceScope(private val registry: Map<KClass<*>, ZiplineService>) {
}
interface PluginService {
val pluginId: String
val loggedInFlow: StateFlow<Boolean>
suspend fun start()

View File

@ -105,6 +105,8 @@ open class ZiplinePluginService(
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id
// QuickJS compile() uses deep C-level recursion on the native thread stack.
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.

View File

@ -20,10 +20,14 @@ package dev.krtirtho.spotube.modules.album
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -37,6 +41,11 @@ fun AlbumScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
@ -69,7 +78,7 @@ fun AlbumScreen(
}
},
onPlay = viewModel::playAlbum,
onShufflePlay = {},
onShufflePlay = viewModel::shufflePlayAlbum,
onAddToQueue = viewModel::addAlbumToQueue,
isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId),
@ -92,6 +101,10 @@ fun AlbumScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,

View File

@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -97,6 +99,7 @@ class AlbumViewModel(
private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>()
@ -217,15 +220,33 @@ class AlbumViewModel(
}
fun playAlbum() {
viewModelScope.launch { playbackHelper.playAlbum(albumId) }
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
}
fun shufflePlayAlbum() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title, shuffle = true)
}
fun addAlbumToQueue() {
viewModelScope.launch { playbackHelper.addAlbumToQueue(albumId) }
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Album, albumId, title)
}
fun playAlbumNext() {
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Album, albumId, title)
}
fun playAlbumFromTrack(track: MetadataTrack) {
viewModelScope.launch { playbackHelper.playAlbumFromTrack(albumId, track) }
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.Album,
id = albumId,
title = title,
startTrack = track,
)
}
fun refresh() {
@ -241,14 +262,24 @@ class AlbumViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
if (existing != null) {
// Already in the local queue: move it to the next position
audioPlayerQueue.removeFromQueue(existing)
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
} else {
remotePlaybackController.requestTrackPlayNext(track)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -311,34 +342,18 @@ class AlbumViewModel(
tracks.forEach { track -> downloadManager.enqueue(track) }
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestTracksAddToQueue(tracks, title)
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
remotePlaybackController.requestTracksPlayNext(tracks, title)
}
fun isTrackBlacklisted(track: MetadataTrack): Boolean {

View File

@ -38,10 +38,16 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -63,8 +69,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
@ -78,6 +87,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@ -95,6 +105,11 @@ fun ArtistScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
@ -126,7 +141,8 @@ fun ArtistScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
.padding(innerPadding)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
@ -179,6 +195,9 @@ fun ArtistScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
)
}
@ -484,6 +503,7 @@ private fun ArtistMeta(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ArtistHeaderActions(
isSaved: Boolean,
@ -497,7 +517,12 @@ private fun ArtistHeaderActions(
) {
if (isSaved) {
SecondaryButton(onClick = onFollowClick) {
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
Text(
"Following",
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
PrimaryButton(onClick = onFollowClick) {
@ -505,20 +530,30 @@ private fun ArtistHeaderActions(
}
}
if (isBlacklisted) {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Remove from blacklist",
tint = MaterialTheme.colorScheme.error,
)
}
} else {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Add to blacklist",
)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist")
}
},
state = rememberTooltipState()
) {
if (isBlacklisted) {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Remove from blacklist",
tint = MaterialTheme.colorScheme.error,
)
}
} else {
SecondaryIconButton(onClick = onBlacklistClick) {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Add to blacklist",
)
}
}
}
}

View File

@ -27,6 +27,8 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -73,6 +75,7 @@ class ArtistViewModel(
private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>()
@ -247,80 +250,44 @@ class ArtistViewModel(
}
fun addTopTracksToQueue() {
viewModelScope.launch {
val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch
audioPlayerQueue.addAllToQueue(entries)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.ArtistTopTracks,
artistId,
artistName,
)
}
fun playTopTracks() {
viewModelScope.launch {
val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch
audioPlayerQueue.load(
entries = entries,
autoPlay = true,
startPosition = 0,
collectionEntry = null,
)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.ArtistTopTracks,
artistId,
artistName,
)
}
fun playTopTracksFromTrack(track: MetadataTrack) {
viewModelScope.launch {
val queue = audioPlayerQueue.getQueue()
val queueIndex = queue.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}
if (queueIndex >= 0) {
audioPlayerQueue.jumpTo(queueIndex)
return@launch
}
val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch
val startPosition = entries.indexOfFirst { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}.coerceAtLeast(0)
audioPlayerQueue.load(
entries = entries,
autoPlay = true,
startPosition = startPosition,
collectionEntry = null,
)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.ArtistTopTracks,
id = artistId,
title = artistName,
startTrack = track,
)
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
remotePlaybackController.requestTracksPlayNext(tracks, artistName)
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
@ -329,13 +296,23 @@ class ArtistViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
if (existing != null) {
// Already in the local queue: move it to the next position
audioPlayerQueue.removeFromQueue(existing)
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
} else {
remotePlaybackController.requestTrackPlayNext(track)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
val queue = audioPlayerQueue.getQueue()

View File

@ -0,0 +1,296 @@
/*
* 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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.discovery.rememberLocalNetworkPermissionRequester
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
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()
val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
DisposableEffect(Unit) {
// Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works.
requestLocalNetworkPermission()
viewModel.startDiscovery()
onDispose {
viewModel.stopDiscovery()
// Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
// The RemoteControlViewModel will manage the connection lifecycle
}
}
Scaffold(
topBar = {
ApplicationMainBar(
backButton = true,
title = { Text("Devices") },
actions = {
if (isDiscovering && connectingToDevice == null) {
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(enabled = connectingToDevice == null) {
viewModel.startDiscovery()
},
)
}
},
)
},
) { innerPadding ->
val shellBottomInset = LocalAppShellBottomInset.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// Error banner
error?.let { errorMessage ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Column {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(
onClick = { viewModel.clearError() },
modifier = Modifier.padding(top = 8.dp),
) {
Text("Dismiss")
}
}
}
}
// Connection status
when (val state = connectionState) {
is ConnectionState.Connected -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Column {
Text(
text = "Connected to ${state.host}:${state.port}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
OutlinedButton(
onClick = { viewModel.disconnect() },
modifier = Modifier.padding(top = 8.dp),
) {
Text("Disconnect")
}
}
}
}
is ConnectionState.Connecting -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.CenterStart,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
Text(
text = "Connecting...",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
else -> {}
}
// Device list
if (devices.isEmpty() && connectingToDevice == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (isDiscovering) {
"Searching for devices on the network..."
} else {
"No devices found"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!isDiscovering) {
Text(
text = "Make sure the other device has \"Allow remote control\" enabled in settings.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp),
)
}
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp,
vertical = 8.dp,
),
) {
items(devices.values.toList(), key = { it.key }) { device ->
DeviceRow(
device = device,
isConnecting = connectingToDevice?.key == device.key,
onClick = { viewModel.connectToDevice(device) },
)
}
item {
Box(modifier = Modifier.padding(bottom = shellBottomInset))
}
}
}
}
}
}
@Composable
private fun DeviceRow(
device: DiscoveredDevice,
isConnecting: Boolean,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick, enabled = !isConnecting)
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
imageVector = Iconsax.IconsaxMirroringScreen,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = device.name.ifBlank { "Unknown Device" },
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (device.host.isNotBlank() && device.port > 0) {
"${device.host}:${device.port}"
} else {
"Resolving..."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (device.deviceId.isNotBlank()) {
Text(
text = "ID: ${device.deviceId.take(8)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View File

@ -0,0 +1,213 @@
/*
* 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 dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
import dev.krtirtho.spotube.core.discovery.DiscoveredDevice
import dev.krtirtho.spotube.core.discovery.DiscoveryState
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemoteControlService
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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(
private val navigationCommands: NavigationCommands,
) : ViewModel(), KoinComponent {
private val logger = Logger.withTag("DevicesViewModel")
private val discoveryService: DeviceDiscoveryService by inject()
private val remoteControlClient: RemoteControlClient by inject()
private val remoteControlService: RemoteControlService by inject()
private val settingsProvider: SettingsProvider by inject()
private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap())
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
private val _isDiscovering = MutableStateFlow(false)
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
val connectingToDevice: StateFlow<DiscoveredDevice?> = _connectingToDevice.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private var discoveryJob: Job? = null
init {
// Observe connection state from the client
viewModelScope.launch {
remoteControlClient.connectionState.collect { state ->
_connectionState.value = state
if (state is ConnectionState.Error) {
_error.value = state.message
_connectingToDevice.value = null
} else if (state is ConnectionState.Disconnected) {
_connectingToDevice.value = null
} else if (state is ConnectionState.Connected) {
// Navigate to remote control screen after successful connection
_connectingToDevice.value = null
navigationCommands.navigateTo(Routes.RemoteControl)
}
}
}
// Whenever the local device id is resolved, drop any of our own
// advertisements that may have been picked up before we knew our id.
viewModelScope.launch {
remoteControlService.localDeviceId.collect { id ->
if (id.isNotBlank()) {
removeSelf()
}
}
}
}
fun startDiscovery() {
if (discoveryJob?.isActive == true) return
_isDiscovering.value = true
_error.value = null
logger.i { "Starting device discovery" }
// Advertising may have failed before the local-network permission was
// granted; give it another chance now that discovery is being used.
remoteControlService.retryAdvertising()
discoveryJob = viewModelScope.launch {
try {
discoveryService.discover().collect { event ->
logger.d { "Discovery event: $event" }
when (event) {
is DiscoveryState.Discovered -> {
event.resolve()
if (!isSelf(event.device)) {
_devices.update { it + (event.device.key to event.device.copy()) }
}
}
is DiscoveryState.Resolved -> {
if (isSelf(event.device)) {
// Resolved now carries our deviceId in TXT; drop self.
_devices.update { it - event.device.key }
} else {
_devices.update { it + (event.device.key to event.device) }
}
}
is DiscoveryState.Removed -> {
_devices.update { it - event.device.key }
}
}
}
} catch (e: Exception) {
logger.e(e) { "Discovery failed" }
_error.value = "Discovery failed: ${e.message}"
_isDiscovering.value = false
}
}
}
/**
* True when [device] is this device's own advertisement. On the initial
* `Discovered` event dns-sd hasn't resolved the TXT record yet (deviceId is
* empty), so we match by the name we advertise; once resolved we also have
* the authoritative deviceId.
*/
private fun isSelf(device: DiscoveredDevice): Boolean {
val localId = remoteControlService.localDeviceId.value.ifBlank {
settingsProvider.settingsState.value?.remoteControlDeviceId ?: ""
}
if (localId.isNotBlank() && device.deviceId == localId) return true
// Match by the deterministic advertised name as a fallback for the
// pre-resolution event where deviceId isn't available yet.
return device.name.isNotBlank() && device.name == remoteControlService.advertisedName()
}
private fun removeSelf() {
val localId = remoteControlService.localDeviceId.value
if (localId.isBlank()) return
_devices.update { map ->
map.filterNot { (_, device) -> device.deviceId == localId }
}
}
fun stopDiscovery() {
discoveryJob?.cancel()
discoveryJob = null
_isDiscovering.value = false
}
fun connectToDevice(device: DiscoveredDevice) {
if (_connectingToDevice.value != null) {
logger.w { "Already connecting to a device" }
return
}
if (isSelf(device)) {
logger.w { "Refusing to connect to self: ${device.name}" }
return
}
_connectingToDevice.value = device
_error.value = null
logger.i { "Connecting to device ${device.name} at ${device.host}:${device.port}" }
viewModelScope.launch {
try {
val settings = settingsProvider.settingsState.value
val deviceId = settings?.remoteControlDeviceId ?: ""
val deviceName = settings?.remoteControlDeviceName?.ifBlank { "Spotube Controller" }
?: "Spotube Controller"
remoteControlClient.connect(
host = device.host,
port = device.port,
deviceId = deviceId,
deviceName = deviceName,
)
// Clear connecting state after connection attempt
// The connectionState flow will show the actual connection status
_connectingToDevice.value = null
} catch (e: Exception) {
logger.e(e) { "Failed to connect to device" }
_error.value = "Failed to connect: ${e.message}"
_connectingToDevice.value = null
}
}
}
fun disconnect() {
viewModelScope.launch {
remoteControlClient.disconnect()
}
}
fun clearError() {
_error.value = null
}
}

View File

@ -0,0 +1,148 @@
/*
* 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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction
import dev.krtirtho.spotube.core.remote.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import org.koin.compose.koinInject
/**
* Globally hosted dialog shown when the user tries to play / add to queue /
* play next and there is more than one place it could go (a connected remote
* device and/or an active jam session). Lets the user choose the destination.
*/
@Composable
fun PlayDestinationPickerHost() {
val controller = koinInject<RemotePlaybackController>()
val remoteControlClient = koinInject<RemoteControlClient>()
val request by controller.pendingRequest.collectAsStateWithLifecycle()
val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle()
val pendingRequest = request ?: return
val remoteDeviceName = when (val state = connectionState) {
is ConnectionState.Connected -> "Remote Device (${state.host})"
else -> "Remote Device"
}
val actionLabel = when (pendingRequest.action) {
PlaybackDestinationAction.Play -> "Play"
PlaybackDestinationAction.AddToQueue -> "Add to queue"
PlaybackDestinationAction.PlayNext -> "Play next"
}
ThemedDialog(
onDismissRequest = controller::dismissPicker,
title = {
Text(
text = "Where to $actionLabel?",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
},
content = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "$actionLabel \"${pendingRequest.title}\" on:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ListRowTile(
onClick = controller::playLocally,
modifier = Modifier.fillMaxWidth(),
leading = {
Icon(
imageVector = Iconsax.IconsaxCd,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = {
Text(
text = "This Device",
style = MaterialTheme.typography.bodyLarge,
)
},
subtitle = {
Text(
text = "$actionLabel here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
if (connectionState is ConnectionState.Connected) {
ListRowTile(
onClick = controller::playOnRemote,
modifier = Modifier.fillMaxWidth(),
leading = {
Icon(
imageVector = Iconsax.IconsaxMirroringScreen,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
title = {
Text(
text = remoteDeviceName,
style = MaterialTheme.typography.bodyLarge,
)
},
subtitle = {
Text(
text = "$actionLabel on the connected device",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}
},
actions = {
TextButton(onClick = controller::dismissPicker) {
Text("Cancel")
}
},
)
}

View File

@ -0,0 +1,619 @@
/*
* 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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.RemoteQueueEntry
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.Slider
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.modules.shell.player_queue.QueueSheet
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious
import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
import org.koin.compose.viewmodel.koinViewModel
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemoteControlScreen(
onDisconnect: () -> Unit,
) {
val viewModel = koinViewModel<RemoteControlViewModel>()
val playerState by viewModel.playerState.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val queueState by viewModel.queueState.collectAsStateWithLifecycle()
val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
// Center the mobile-inspired layout and limit its width so it doesn't
// stretch awkwardly on large screens.
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Scaffold(
modifier = Modifier
.fillMaxHeight(),
topBar = {
ApplicationMainBar(
title = { Text("Remote Control") },
backButton = true,
actions = {
GhostIconButton(
onClick = viewModel::toggleQueueVisibility,
) {
Icon(
imageVector = Iconsax.IconsaxMusicFilter,
contentDescription = "Queue",
tint = if (isQueueVisible) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
GhostIconButton(
onClick = {
viewModel.disconnect()
onDisconnect()
}
) {
Icon(
imageVector = Iconsax.IconsaxCloseSquare,
contentDescription = "Disconnect",
)
}
}
)
}
) { padding ->
when (connectionState) {
is ConnectionState.Connected -> {
RemoteControlContent(
playerState = playerState,
onTogglePlayPause = viewModel::togglePlayPause,
onSkipNext = viewModel::skipNext,
onSkipPrevious = viewModel::skipPrevious,
onSeek = viewModel::seek,
onSetVolume = viewModel::setVolume,
onToggleShuffle = viewModel::toggleShuffle,
onCycleLoopMode = viewModel::cycleLoopMode,
modifier = Modifier.padding(padding).padding(bottom = shellBottomInset)
)
}
is ConnectionState.Connecting -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Connecting...")
}
}
is ConnectionState.Disconnected -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Disconnected")
}
}
is ConnectionState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center
) {
Text("Connection error: ${(connectionState as ConnectionState.Error).message}")
}
}
}
}
}
// Click-outside scrim for the sliding queue sheet on large screens.
// (The ModalBottomSheet variant has its own built-in scrim.)
if (isQueueVisible) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { viewModel.toggleQueueVisibility() },
)
)
}
// Keep the sliding sheet above the AppLargePlayer on large screens.
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = shellBottomInset),
) {
QueueSheet(
isVisible = isQueueVisible,
onDismiss = { viewModel.toggleQueueVisibility() },
modifier = Modifier.fillMaxSize(),
) {
RemoteQueueSection(
queueState = queueState,
onPlayQueueItem = viewModel::playQueueItem,
onRemoveQueueItem = viewModel::removeQueueItem,
)
}
}
}
@Composable
private fun RemoteControlContent(
playerState: RemotePlayerState,
onTogglePlayPause: () -> Unit,
onSkipNext: () -> Unit,
onSkipPrevious: () -> Unit,
onSeek: (Long) -> Unit,
onSetVolume: (Float) -> Unit,
onToggleShuffle: () -> Unit,
onCycleLoopMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Box (modifier = Modifier.fillMaxSize()) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.widthIn(max = 480.dp)
.align(Alignment.TopCenter),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
// Album art
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (playerState.currentTrackCoverUrl != null) {
AsyncImage(
model = playerState.currentTrackCoverUrl,
contentDescription = "Album cover",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Iconsax.IconsaxMusicFilter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Track info
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = playerState.currentTrackTitle ?: "Unknown Track",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = playerState.currentTrackArtists ?: "Unknown Artist",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (playerState.currentTrackAlbum != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = playerState.currentTrackAlbum!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Seek bar
Column(
modifier = Modifier.fillMaxWidth(),
) {
Slider(
value = playerState.positionMs.toFloat(),
onValueChange = { onSeek(it.toLong()) },
valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f),
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = formatDuration(playerState.positionMs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = formatDuration(playerState.durationMs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Playback controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// Shuffle
IconButton(
onClick = onToggleShuffle,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Iconsax.IconsaxShuffle,
contentDescription = "Shuffle",
tint = if (playerState.shuffleEnabled) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
// Skip previous
IconButton(
onClick = onSkipPrevious,
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = Iconsax.IconsaxPrevious,
contentDescription = "Previous",
modifier = Modifier.size(32.dp),
)
}
// Play/Pause
val baseTheme = LocalBaseUITheme.current
val circlePrimaryIconTheme = remember(baseTheme) {
baseTheme.iconButtons.primary.copy(
shape = BaseUITheme.InteractionState.fromSingleValue(CircleShape)
)
}
PrimaryIconButton(
onClick = onTogglePlayPause,
modifier = Modifier.size(72.dp),
theme = circlePrimaryIconTheme,
) {
Icon(
imageVector = if (playerState.isPlaying) {
Iconsax.IconsaxPause
} else {
Iconsax.IconsaxPlay
},
contentDescription = if (playerState.isPlaying) "Pause" else "Play",
modifier = Modifier.size(40.dp),
)
}
// Skip next
IconButton(
onClick = onSkipNext,
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = Iconsax.IconsaxNext,
contentDescription = "Next",
modifier = Modifier.size(32.dp),
)
}
// Loop mode
IconButton(
onClick = onCycleLoopMode,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Iconsax.IconsaxRepeateMusic,
contentDescription = "Loop mode",
tint = if (playerState.loopMode != "none") {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
Spacer(modifier = Modifier.height(32.dp))
// Volume control
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Iconsax.IconsaxVolumeHigh,
contentDescription = "Volume",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Slider(
value = playerState.volume,
onValueChange = onSetVolume,
valueRange = 0f..1f,
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@Composable
private fun RemoteQueueSection(
queueState: RemoteQueueState,
onPlayQueueItem: (Int) -> Unit,
onRemoveQueueItem: (String) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "Queue",
style = MaterialTheme.typography.titleLarge,
)
if (queueState.entries.isEmpty()) {
Text(
text = "No queue entries",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(
items = queueState.entries,
key = { index, entry -> "${entry.mediaUrl}@$index" },
) { index, entry ->
RemoteQueueItemRow(
entry = entry,
index = index,
isCurrent = index == queueState.currentIndex,
onPlayClick = { onPlayQueueItem(index) },
onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) },
)
}
}
}
}
}
@Composable
private fun RemoteQueueItemRow(
entry: RemoteQueueEntry,
index: Int,
isCurrent: Boolean,
onPlayClick: () -> Unit,
onRemoveClick: () -> Unit,
) {
var showMenu by remember { mutableStateOf(false) }
ListRowTile(
onClick = onPlayClick,
selected = isCurrent,
modifier = Modifier,
leading = {
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (entry.coverUrl != null) {
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = "${index + 1}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
title = {
Text(
text = entry.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (isCurrent) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
},
subtitle = {
Text(
text = entry.artists,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
trailing = {
Text(
text = formatDuration(entry.durationMs),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
GhostIconButton(
onClick = { showMenu = true },
modifier = Modifier.size(36.dp),
) {
Icon(
Iconsax.Iconsax3DotsMore,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Remove from queue") },
onClick = {
onRemoveClick()
showMenu = false
},
leadingIcon = {
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
},
)
}
}
}
)
}
private fun formatDuration(ms: Long): String {
val duration = ms.milliseconds
val minutes = duration.inWholeMinutes
val seconds = duration.inWholeSeconds % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}

View File

@ -0,0 +1,191 @@
/*
* 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 dev.krtirtho.spotube.core.remote.ConnectionState
import dev.krtirtho.spotube.core.remote.RemoteControlClient
import dev.krtirtho.spotube.core.remote.RemoteControlCommand
import dev.krtirtho.spotube.core.remote.RemoteControlEvent
import dev.krtirtho.spotube.core.remote.RemoteQueueEntry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
data class RemotePlayerState(
val isPlaying: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
val volume: Float = 1.0f,
val shuffleEnabled: Boolean = false,
val loopMode: String = "none",
val currentTrackId: String? = null,
val currentTrackTitle: String? = null,
val currentTrackArtists: String? = null,
val currentTrackAlbum: String? = null,
val currentTrackCoverUrl: String? = null,
)
data class RemoteQueueState(
val entries: List<RemoteQueueEntry> = emptyList(),
val currentIndex: Int = -1,
)
class RemoteControlViewModel : ViewModel(), KoinComponent {
private val logger = Logger.withTag("RemoteControlViewModel")
private val remoteControlClient: RemoteControlClient by inject()
private val _playerState = MutableStateFlow(RemotePlayerState())
val playerState: StateFlow<RemotePlayerState> = _playerState.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _queueState = MutableStateFlow(RemoteQueueState())
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow()
private val _isQueueVisible = MutableStateFlow(false)
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow()
init {
viewModelScope.launch {
remoteControlClient.connectionState.collect { state ->
_connectionState.value = state
}
}
viewModelScope.launch {
remoteControlClient.latestPlayerState.collect { event ->
if (event != null) {
handlePlayerState(event)
}
}
}
viewModelScope.launch {
remoteControlClient.latestQueue.collect { event ->
if (event != null) {
handleQueueUpdated(event)
}
}
}
}
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) {
_playerState.update {
it.copy(
isPlaying = event.isPlaying,
positionMs = event.positionMs,
durationMs = event.durationMs,
volume = event.volume,
shuffleEnabled = event.shuffleEnabled,
loopMode = event.loopMode,
currentTrackId = event.currentTrackId,
currentTrackTitle = event.currentTrackTitle,
currentTrackArtists = event.currentTrackArtists,
currentTrackAlbum = event.currentTrackAlbum,
currentTrackCoverUrl = event.currentTrackCoverUrl,
)
}
}
private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) {
_queueState.value = RemoteQueueState(
entries = event.entries,
currentIndex = event.currentIndex,
)
}
fun togglePlayPause() {
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause)
}
}
fun skipNext() {
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.SkipNext)
}
}
fun skipPrevious() {
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious)
}
}
fun seek(positionMs: Long) {
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs))
}
}
fun setVolume(volume: Float) {
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
}
}
fun toggleShuffle() {
viewModelScope.launch {
val newState = !_playerState.value.shuffleEnabled
remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState))
}
}
fun cycleLoopMode() {
viewModelScope.launch {
val currentMode = _playerState.value.loopMode
val newMode = when (currentMode) {
"none" -> "one"
"one" -> "all"
else -> "none"
}
remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode))
}
}
fun playQueueItem(index: Int) {
if (index < 0) return
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index))
}
}
fun removeQueueItem(mediaUrl: String) {
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl))
}
}
fun toggleQueueVisibility() {
_isQueueVisible.update { !it }
}
fun disconnect() {
viewModelScope.launch {
remoteControlClient.disconnect()
}
}
}

View File

@ -31,6 +31,8 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -66,8 +68,14 @@ import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.User
import org.koin.compose.koinInject
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@ -75,6 +83,7 @@ import kotlinx.coroutines.flow.map
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(viewModel: HomeScreenViewModel) {
val navigationCommands = koinInject<NavigationCommands>()
val platform = getPlatform()
val isDesktop = platform.type == PlatformType.Windows ||
platform.type == PlatformType.Linux ||
@ -117,7 +126,23 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
backButton = false,
title = {
Text("Browse")
}
},
actions = {
if (!isDesktop) {
IconButton(onClick = { navigationCommands.navigateTo(Routes.Devices) }) {
Icon(
imageVector = Iconsax.IconsaxMirroringScreen,
contentDescription = "Devices",
)
}
IconButton(onClick = { navigationCommands.navigateTo(Routes.Jam) }) {
Icon(
imageVector = Iconsax.User,
contentDescription = "Group Jam",
)
}
}
},
)
},
) { innerPadding ->
@ -332,7 +357,7 @@ private fun HomeSection(
if (!subtitle.isNullOrEmpty()) Text(
text = subtitle,
style = MaterialTheme.typography.labelMedium.copy(
color = MaterialTheme.colorScheme.secondary,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Medium,
),
modifier = Modifier.padding(horizontal = 16.dp),

View File

@ -0,0 +1,347 @@
/*
* 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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomCode
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import org.koin.compose.viewmodel.koinViewModel
/**
* Group Jam session screen. Playback controls and the queue live in the app's
* regular player / queue sheet (the shared jam queue is the local queue), so
* this screen only covers participation and session management.
*/
@Composable
fun JamScreen(
navigationCommands: NavigationCommands,
) {
val viewModel = koinViewModel<JamViewModel>()
val state by viewModel.uiState.collectAsStateWithLifecycle()
val shellBottomInset = LocalAppShellBottomInset.current
Scaffold(
topBar = {
ApplicationMainBar(
backButton = true,
title = { Text("Group Jam") },
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp)
.padding(bottom = shellBottomInset)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
state.error?.let { error ->
ErrorBanner(text = error, onDismiss = viewModel::clearError)
}
when {
!state.isActive -> CreateOrJoinView(
state = state,
onCreate = viewModel::createSession,
onJoin = viewModel::joinWithCode,
)
else -> SessionView(
state = state,
onShareCode = viewModel::shareRoomCode,
onLeave = viewModel::leave,
onKick = viewModel::kickParticipant,
onBan = viewModel::banParticipant,
)
}
}
}
}
@Composable
private fun ErrorBanner(text: String, onDismiss: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = text,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = onDismiss) {
Text("Dismiss")
}
}
}
@Composable
private fun CreateOrJoinView(
state: JamUiState,
onCreate: () -> Unit,
onJoin: (String) -> Unit,
) {
var tab by rememberSaveable { mutableIntStateOf(0) }
var pasted by rememberSaveable { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.",
style = MaterialTheme.typography.titleMedium,
)
if (!state.brokerConfigured) {
Text(
text = "No jam broker configured — set one up in Settings to host or join a session.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
} else {
Text(
text = "Broker: ${state.brokerHost}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
SegmentedButton(
selected = tab == 0,
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 = "Start a session as the host. You'll get a 6-character room code to " +
"share with friends; you control the queue.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onCreate,
enabled = state.brokerConfigured,
) {
Text("Create Session")
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Enter the 6-character room code the host shared with you.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = pasted,
onValueChange = { pasted = JamRoomCode.normalize(it) },
modifier = Modifier.fillMaxWidth(),
label = { Text("Room code") },
placeholder = { Text("ABC123") },
singleLine = true,
)
Button(
onClick = { onJoin(pasted) },
enabled = state.brokerConfigured && JamRoomCode.isValid(pasted),
) {
Text("Join Session")
}
}
}
}
}
@Composable
private fun SessionView(
state: JamUiState,
onShareCode: () -> Unit,
onLeave: () -> Unit,
onKick: (String) -> Unit,
onBan: (String) -> Unit,
) {
val isHost = state.role == JamRole.Host
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (!state.isConnected) {
Text(
text = "Connecting to the session…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ParticipantsSection(
participants = state.participants,
isHost = isHost,
onKick = onKick,
onBan = onBan,
)
if (isHost) {
HorizontalDivider()
Text(
text = "Invite someone",
style = MaterialTheme.typography.titleSmall,
)
RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode)
}
HorizontalDivider()
Text(
text = "The queue and playback controls are in the player at the bottom of the app — " +
"the jam queue is shared with every participant.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LeaveButton(onLeave)
}
}
@Composable
private fun ParticipantsSection(
participants: List<dev.krtirtho.spotube.core.jam.JamParticipant>,
isHost: Boolean,
onKick: (String) -> Unit,
onBan: (String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Participants (${participants.size})",
style = MaterialTheme.typography.titleSmall,
)
participants.forEach { participant ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = participant.displayName,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (participant.isHost) {
Text(
text = "Host",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
if (isHost && !participant.isHost) {
OutlinedButton(
onClick = { onKick(participant.id) },
modifier = Modifier.height(32.dp),
) {
Text("Kick", style = MaterialTheme.typography.labelSmall)
}
OutlinedButton(
onClick = { onBan(participant.id) },
modifier = Modifier.height(32.dp),
) {
Text("Ban", style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
}
@Composable
private fun RoomCodeBox(
code: String,
onShare: () -> Unit,
) {
val clipboard = LocalClipboardManager.current
val viewModel: JamViewModel = koinViewModel()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SelectionContainer {
Text(
text = code,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 8.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { clipboard.setText(AnnotatedString(code)) }) {
Text("Copy")
}
if (viewModel.supportsNativeShare) {
OutlinedButton(onClick = onShare) {
Text("Share")
}
}
}
}
}
@Composable
private fun LeaveButton(onLeave: () -> Unit) {
OutlinedButton(onClick = onLeave) {
Text("Leave Session")
}
}

View File

@ -0,0 +1,143 @@
/*
* 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.PlatformType
import dev.krtirtho.spotube.core.jam.JamParticipant
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomCode
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class JamUiState(
val isActive: Boolean = false,
val isConnected: Boolean = false,
val role: JamRole? = null,
val participants: List<JamParticipant> = emptyList(),
val roomCode: String? = null,
val brokerHost: String = "",
val brokerConfigured: Boolean = false,
val error: String? = null,
)
class JamViewModel(
private val jamRoomService: JamRoomService,
private val shareService: ShareService,
private val settingsProvider: SettingsProvider,
) : ViewModel() {
private val _localError = MutableStateFlow<String?>(null)
val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
val uiState: StateFlow<JamUiState> = combine(
jamRoomService.role,
jamRoomService.participants,
jamRoomService.isConnected,
jamRoomService.roomCode,
jamRoomService.connectionError,
settingsProvider.settingsState,
_localError,
) { values ->
@Suppress("UNCHECKED_CAST")
val role = values[0] as JamRole?
@Suppress("UNCHECKED_CAST")
val participants = values[1] as List<JamParticipant>
val isConnected = values[2] as Boolean
val roomCode = values[3] as String?
val connectionError = values[4] as String?
val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings
val localError = values[6] as String?
JamUiState(
isActive = role != null,
isConnected = isConnected,
role = role,
participants = participants,
roomCode = roomCode,
brokerHost = settings?.jamBroker?.host.orEmpty(),
brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(),
error = localError ?: connectionError,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState())
// ---------- Session lifecycle ----------
fun createSession() {
viewModelScope.launch {
jamRoomService.createRoom()
.onFailure { e ->
_localError.value = e.message ?: "Failed to create jam session"
}
.onSuccess { _localError.value = null }
}
}
fun joinWithCode(input: String) {
val code = JamRoomCode.normalize(input)
if (!JamRoomCode.isValid(code)) {
_localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)"
return
}
viewModelScope.launch {
jamRoomService.joinRoom(code)
.onFailure { e ->
_localError.value = e.message ?: "Failed to join jam session"
}
.onSuccess { _localError.value = null }
}
}
fun shareRoomCode() {
val code = uiState.value.roomCode ?: return
shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam")
}
fun leave() {
viewModelScope.launch {
jamRoomService.leaveRoom()
_localError.value = null
}
}
fun clearError() {
_localError.value = null
}
// ---------- Host moderation ----------
fun kickParticipant(participantId: String) {
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
}
fun banParticipant(participantId: String) {
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
}
}

View File

@ -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")
}
},
)
}

View File

@ -54,6 +54,8 @@ import androidx.compose.ui.unit.sp
import compose.icons.FeatherIcons
import compose.icons.feathericons.X
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsLine
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
import kotlinx.coroutines.launch
@ -70,6 +72,47 @@ fun LyricsScreen(
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
ApplicationMainBar(
backButton = onClose == null,
title = {
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
if (onClose != null) {
IconButton(
onClick = onClose,
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = "Close"
)
}
}
Row(
modifier = Modifier
.align(Alignment.Center)
.clip(RoundedCornerShape(50.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(10.dp, 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
LyricsModeTab(
label = "Synced",
isSelected = uiState.mode == LyricType.SYNCED,
onClick = { viewModel.setMode(LyricType.SYNCED) }
)
LyricsModeTab(
label = "Plain",
isSelected = uiState.mode == LyricType.STATIC,
onClick = { viewModel.setMode(LyricType.STATIC) }
)
}
}
}
)
},
modifier = modifier,
) { innerPadding ->
Column(
@ -77,46 +120,9 @@ fun LyricsScreen(
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier.fillMaxWidth(),
) {
if (onClose != null) {
IconButton(
onClick = onClose,
modifier = Modifier.align(Alignment.CenterStart)
) {
Icon(
imageVector = Iconsax.IconsaxArrowDown4,
contentDescription = "Close"
)
}
}
Row(
modifier = Modifier
.align(Alignment.Center)
.clip(RoundedCornerShape(50.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(10.dp, 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
LyricsModeTab(
label = "Synced",
isSelected = uiState.mode == LyricType.SYNCED,
onClick = { viewModel.setMode(LyricType.SYNCED) }
)
LyricsModeTab(
label = "Plain",
isSelected = uiState.mode == LyricType.STATIC,
onClick = { viewModel.setMode(LyricType.STATIC) }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
when {
uiState.isLoading -> {
Box(

View File

@ -31,10 +31,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.CollectionView
@ -53,6 +57,11 @@ fun PlaylistScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
@ -102,7 +111,7 @@ fun PlaylistScreen(
ownerImageURL = ownerImageURL,
onOwnerClick = {},
onPlay = viewModel::playPlaylist,
onShufflePlay = {},
onShufflePlay = viewModel::shufflePlayPlaylist,
onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId),
@ -127,6 +136,10 @@ fun PlaylistScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
footerContent = footerContent,
trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist

View File

@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -98,6 +100,7 @@ class PlaylistViewModel(
private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<PlaylistViewModel>()
@ -223,15 +226,33 @@ class PlaylistViewModel(
}
fun playPlaylist() {
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
}
fun shufflePlayPlaylist() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title, shuffle = true)
}
fun addPlaylistToQueue() {
viewModelScope.launch { playbackHelper.addPlaylistToQueue(playlistId) }
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.Playlist, playlistId, title)
}
fun playPlaylistNext() {
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlayNext(RemoteCollectionType.Playlist, playlistId, title)
}
fun playPlaylistFromTrack(track: MetadataTrack) {
viewModelScope.launch { playbackHelper.playPlaylistFromTrack(playlistId, track) }
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.Playlist,
id = playlistId,
title = title,
startTrack = track,
)
}
fun refresh() {
@ -274,14 +295,24 @@ class PlaylistViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
if (existing != null) {
// Already in the local queue: move it to the next position
audioPlayerQueue.removeFromQueue(existing)
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
} else {
remotePlaybackController.requestTrackPlayNext(track)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -356,34 +387,18 @@ class PlaylistViewModel(
}
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestTracksAddToQueue(tracks, title)
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
remotePlaybackController.requestTracksPlayNext(tracks, title)
}
val savedTrackIds

View File

@ -69,11 +69,18 @@ interface PluginProvider {
val selectedMetadataPlugin: StateFlow<PluginService?>
}
/** Narrow read-only view of the active audio plugin, used by consumers that only need to
* key caches or make calls against the currently selected audio plugin without depending
* on the full [PluginManager] (which also owns plugin installation/lifecycle machinery). */
interface AudioPluginSource {
val selectedAudioPlugin: StateFlow<PluginService?>
}
class PluginManager(
val database: Database,
val paths: Paths,
) : KoinComponent, PluginProvider {
) : KoinComponent, PluginProvider, AudioPluginSource {
private val logger by injectLogger<PluginManager>()
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." }
@ -245,7 +252,7 @@ class PluginManager(
override val selectedMetadataPlugin =
filterSelectedPluginByType(PluginAbility.METADATA)
val selectedAudioPlugin =
override val selectedAudioPlugin =
filterSelectedPluginByType(PluginAbility.AUDIO)
val selectedLyricsPlugin =
filterSelectedPluginByType(PluginAbility.LYRICS)

View File

@ -20,11 +20,15 @@ package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.PlayerState
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.component.CollectionView
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
@ -39,6 +43,11 @@ fun SavedTracksScreen(
navigationCommands: NavigationCommands
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
@ -61,7 +70,7 @@ fun SavedTracksScreen(
ownerImageURL = null,
onOwnerClick = {},
onPlay = viewModel::playSavedTracks,
onShufflePlay = {},
onShufflePlay = viewModel::shufflePlaySavedTracks,
onAddToQueue = viewModel::addSavedTracksToQueue,
isPlaying = isPlaying,
isFollowing = false,
@ -85,6 +94,10 @@ fun SavedTracksScreen(
onBulkAddToQueue = viewModel::addTracksToQueue,
onBulkPlayNext = viewModel::playTracksNext,
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onAddToJam = viewModel::addTracksToJam,
trailingContent = {
AddToPlaylistPicker(
visible = showAddToPlaylistPicker,

View File

@ -34,6 +34,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
@ -97,6 +99,7 @@ class SavedTracksViewModel(
private val libraryRepository: LibraryRepository,
private val shareService: ShareService,
private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<SavedTracksViewModel>()
@ -200,15 +203,29 @@ class SavedTracksViewModel(
}
fun playSavedTracks() {
viewModelScope.launch { playbackHelper.playSavedTracks() }
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
}
fun shufflePlaySavedTracks() {
remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.SavedTracks,
SAVED_TRACKS_COLLECTION_ID,
"Saved Tracks",
shuffle = true,
)
}
fun addSavedTracksToQueue() {
viewModelScope.launch { playbackHelper.addSavedTracksToQueue() }
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
}
fun playSavedTracksFromTrack(track: MetadataTrack) {
viewModelScope.launch { playbackHelper.playSavedTracksFromTrack(track) }
remotePlaybackController.requestCollectionPlay(
type = RemoteCollectionType.SavedTracks,
id = SAVED_TRACKS_COLLECTION_ID,
title = "Saved Tracks",
startTrack = track,
)
}
fun refresh() {
@ -224,14 +241,24 @@ class SavedTracksViewModel(
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
if (existing != null) {
// Already in the local queue: move it to the next position
audioPlayerQueue.removeFromQueue(existing)
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
} else {
remotePlaybackController.requestTrackPlayNext(track)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -298,34 +325,16 @@ class SavedTracksViewModel(
}
}
fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks")
}
suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> {
@ -377,4 +386,4 @@ fun rememberIsSavedTracks(
}
return state
}
}

View File

@ -39,18 +39,19 @@ 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.material3.LinearProgressIndicator
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -84,8 +85,11 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
import dev.krtirtho.spotube.core.ui.base.ChipTab
@ -99,6 +103,7 @@ import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.component.UserCard
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
@ -121,11 +126,17 @@ private val GridMinCellSize = 180.dp
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val audioPlayerQueue: AudioPlayerQueue = koinInject()
val shareService: ShareService = koinInject()
val remotePlaybackController: RemotePlaybackController = koinInject()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val libraryRepository: LibraryRepository = koinInject()
val blacklistRepository: BlacklistRepository = koinInject()
val navigationCommands: NavigationCommands = koinInject()
val state by viewModel.state.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false)
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val selectedType = state.selectedSearchType
val scope = rememberCoroutineScope()
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
@ -171,14 +182,23 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
is TrackOptionsAction.StartRadio -> {}
is TrackOptionsAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue()
queue.find { entry ->
val existing = queue.find { entry ->
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
}?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
}
if (existing != null) {
// Already in the local queue: move it to the next position
audioPlayerQueue.removeFromQueue(existing)
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
} else {
remotePlaybackController.requestTrackPlayNext(track)
}
}
is TrackOptionsAction.AddToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
remotePlaybackController.requestTrackAddToQueue(track)
}
is TrackOptionsAction.AddToJam -> {
remotePlaybackController.addTrackToJam(track)
}
is TrackOptionsAction.RemoveFromQueue -> {
@ -237,33 +257,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
scope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
}
fun bulkAddToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks)
}
fun bulkPlayNext(tracks: List<MetadataTrack>) {
scope.launch {
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
val blacklistedArtistIds = blacklistRepository.getArtistsSnapshot().map { it.id }.toSet()
val filteredTracks = tracks.filter { track ->
track.id !in blacklistedTrackIds &&
track.artists.none { it.id in blacklistedArtistIds }
}
val entries = filteredTracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
}
Scaffold(
@ -341,6 +343,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
@ -370,6 +375,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
tracksToAddToPlaylist = tracks
showAddToPlaylistPicker = true
},
onBulkAddToJam = ::bulkAddToJam,
isInJam = jamActive,
isJamGuest = isJamGuest,
onArtistClick = { artist ->
navigationCommands.navigateTo(Routes.Artist(artist.id))
},
@ -617,8 +625,10 @@ private fun SearchTabs(
selectedType: MetadataSupportedSearchType?,
onTabSelected: (MetadataSupportedSearchType) -> Unit,
) {
val listState = rememberLazyListState()
LazyRow(
modifier = Modifier.fillMaxWidth(),
state = listState,
modifier = Modifier.fillMaxWidth().dragScrollable(listState),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
@ -651,6 +661,9 @@ private fun SearchAllTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -708,6 +721,9 @@ private fun SearchAllTab(
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,
@ -795,6 +811,9 @@ private fun SearchTracksTab(
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
isInJam: Boolean,
isJamGuest: Boolean,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: (MetadataTrack) -> Unit,
@ -824,6 +843,9 @@ private fun SearchTracksTab(
onBulkAddToQueue = onBulkAddToQueue,
onBulkPlayNext = onBulkPlayNext,
onBulkAddToPlaylist = onBulkAddToPlaylist,
onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam,
isJamGuest = isJamGuest,
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = onArtistsOverflowClick,

View File

@ -0,0 +1,274 @@
package dev.krtirtho.spotube.modules.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.modules.settings.sections.SettingsItemIcon
import dev.krtirtho.spotube.modules.settings.sections.settingsSectionCard
import dev.krtirtho.spotube.modules.settings.sections.settingsSectionHeader
import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxEye
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxTag
import dev.krtirtho.spotube.resources.iconsax.IconsaxWifiSquare
import dev.krtirtho.spotube.resources.iconsax.User
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_error_port_range
import spotube.composeapp.generated.resources.settings_error_whole_number
import spotube.composeapp.generated.resources.settings_jam_broker_client_id
import spotube.composeapp.generated.resources.settings_jam_broker_client_id_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_diagnostics_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_host
import spotube.composeapp.generated.resources.settings_jam_broker_password
import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note
import spotube.composeapp.generated.resources.settings_jam_broker_port
import spotube.composeapp.generated.resources.settings_jam_broker_test
import spotube.composeapp.generated.resources.settings_jam_broker_testing
import spotube.composeapp.generated.resources.settings_jam_broker_tls
import spotube.composeapp.generated.resources.settings_jam_broker_tls_subtitle
import spotube.composeapp.generated.resources.settings_jam_section_connection
import spotube.composeapp.generated.resources.settings_jam_section_credentials
import spotube.composeapp.generated.resources.settings_jam_section_diagnostics
import spotube.composeapp.generated.resources.settings_jam_section_identity
import spotube.composeapp.generated.resources.settings_jam_broker_username
import spotube.composeapp.generated.resources.settings_section_jam
@Composable
fun JamSettingsScreen(
viewModel: JamSettingsViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val broker = uiState.settings.jamBroker
val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
}
Scaffold(
topBar = {
ApplicationMainBar(
title = { Text(stringResource(Res.string.settings_section_jam)) },
)
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
LazyColumn(
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter),
contentPadding = contentPadding,
) {
settingsSectionHeader(Res.string.settings_jam_section_connection)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_host),
value = broker.host,
placeholder = "broker.example.com",
icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") },
onValueSaved = { host ->
viewModel.update {
copy(
jamBroker = jamBroker.copy(
host = host
)
)
}
},
)
},
{
val errorWholeNumber =
stringResource(Res.string.settings_error_whole_number)
val errorPortRange =
stringResource(Res.string.settings_error_port_range)
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_port),
value = broker.port.toString(),
placeholder = "1883",
normalize = { it.filter(Char::isDigit).take(5) },
validate = { value ->
val port = value.toIntOrNull()
when {
port == null -> errorWholeNumber
port !in 1..65535 -> errorPortRange
else -> null
}
},
onValueSaved = { port ->
viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
},
icon = {
SettingsItemIcon(
Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_port)
)
},
)
},
{
SwitchSettingCard(
title = stringResource(Res.string.settings_jam_broker_tls),
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
checked = broker.useTls,
onCheckedChange = { tls ->
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxWifiSquare,
stringResource(Res.string.settings_jam_broker_tls)
)
},
)
},
),
)
settingsSectionHeader(Res.string.settings_jam_section_credentials)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_username),
value = broker.username.orEmpty(),
placeholder = "anonymous",
onValueSaved = { username ->
viewModel.update {
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
}
},
icon = {
SettingsItemIcon(
Iconsax.User,
stringResource(Res.string.settings_jam_broker_username)
)
},
)
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_password),
value = broker.password.orEmpty(),
placeholder = "••••••••",
onValueSaved = { password ->
viewModel.update {
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
}
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxEye,
stringResource(Res.string.settings_jam_broker_password)
)
},
)
},
),
)
settingsSectionHeader(Res.string.settings_jam_section_identity)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_client_id),
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
value = broker.clientIdPrefix,
placeholder = "spotube",
onValueSaved = { prefix ->
viewModel.update {
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
}
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxTag,
stringResource(Res.string.settings_jam_broker_client_id)
)
},
)
},
),
)
settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
settingsSectionCard(
items = listOf(
{
val canTest = broker.host.isNotBlank() && !uiState.isTesting
val resultMessage = uiState.testResult?.fold(
onSuccess = { it },
onFailure = { "Failed: ${it.message ?: "Connection failed"}" },
)
SettingCardItem(
enabled = canTest,
title = stringResource(Res.string.settings_jam_broker_test),
subtitle = when {
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
resultMessage != null -> resultMessage
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxInformation,
stringResource(Res.string.settings_jam_broker_test),
)
},
trailingContent = {
PrimaryButton(
onClick = viewModel::testConnection,
enabled = canTest,
) {
Text(
if (uiState.isTesting) {
stringResource(Res.string.settings_jam_broker_testing)
} else {
stringResource(Res.string.settings_jam_broker_test)
},
)
}
},
onClick = viewModel::testConnection,
)
},
),
)
item {
Text(
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
}
}
}

View File

@ -0,0 +1,51 @@
package dev.krtirtho.spotube.modules.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.spotube.core.jam.JamRoomClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class JamSettingsUiState(
val settings: UserSettings = UserSettings(),
val isTesting: Boolean = false,
val testResult: Result<String>? = null,
)
class JamSettingsViewModel(
private val settingsRepository: SettingsRepository,
private val jamRoomClient: JamRoomClient,
) : ViewModel() {
private val _uiState = MutableStateFlow(JamSettingsUiState())
val uiState: StateFlow<JamSettingsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.userSettings.collect { settings ->
_uiState.update { it.copy(settings = settings) }
}
}
}
fun update(transform: UserSettings.() -> UserSettings) {
viewModelScope.launch {
settingsRepository.updateSettings(uiState.value.settings.transform())
}
}
fun testConnection() {
if (_uiState.value.isTesting) return
_uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch {
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker)
_uiState.update { it.copy(isTesting = false, testResult = result) }
}
}
fun clearTestResult() {
_uiState.update { it.copy(testResult = null) }
}
}

View File

@ -55,6 +55,17 @@ data class UserSettings(
val enableConnect: Boolean = false,
val playbackProxyServerPort: Int = 14769,
// Remote Control (LAN)
val allowRemoteControl: Boolean = false,
val allowedRemoteDevices: List<String> = emptyList(),
val remoteControlDeviceName: String = "",
val remoteControlDeviceId: String = "",
// Group Jam (MQTT)
val jamParticipantName: String = "",
val jamBroker: JamBroker = JamBroker(),
val lastJamCode: String = "",
// Downloads
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
val localMediaFolders: List<String> = emptyList(),
@ -76,4 +87,22 @@ data class UserSettings(
// Updates
val autoCheckForUpdates: Boolean = true,
val ignoredUpdateVersion: String? = null,
)
/**
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder
* until a real broker is configured; users can self-host and point the app at it.
*/
@Serializable
data class JamBroker(
val name: String = "",
val host: String = "test.mosquitto.org",
val port: Int = 1883,
val useTls: Boolean = false,
val username: String? = null,
val password: String? = null,
val clientIdPrefix: String = "spotube",
val keepAliveSeconds: Int = 30,
val connectionTimeoutSeconds: Int = 10,
)

View File

@ -27,14 +27,21 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json
class SettingsRepository(private val database: Database) {
/** Narrow read-only view over the current [UserSettings], for consumers (like
* [dev.krtirtho.spotube.core.server.StreamingUrlRepository]) that only need to read the
* user's streaming preferences without depending on the full [SettingsRepository]. */
interface UserSettingsSource {
val userSettings: StateFlow<UserSettings>
}
class SettingsRepository(private val database: Database) : UserSettingsSource {
companion object {
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
override val userSettings: StateFlow<UserSettings> = database.settingsDataStore.data.map { prefs ->
val json = prefs[SETTINGS_KEY]
if (json != null) {
Json.decodeFromString<UserSettings>(json as String)
@ -52,4 +59,10 @@ class SettingsRepository(private val database: Database) {
prefs[SETTINGS_KEY] = Json.encodeToString(newSettings)
}
}
}
suspend fun getCurrentSettings(): UserSettings {
val preferences = database.settingsDataStore.data.first()
val settingsJson = preferences[SETTINGS_KEY] ?: return UserSettings()
return Json.decodeFromString<UserSettings>(settingsJson)
}
}

View File

@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.getPlatform
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import spotube.composeapp.generated.resources.*
@ -42,6 +43,7 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
import dev.krtirtho.spotube.modules.settings.sections.cacheSection
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
import dev.krtirtho.spotube.modules.settings.sections.jamSection
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
@ -62,6 +64,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
platformType == PlatformType.MacOS
val shellBottomInset = LocalAppShellBottomInset.current
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
}
@ -106,6 +109,12 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
settings = settingsState!!,
settingsViewModel = settingsViewModel,
navigatorCommands = navigatorCommands,
requestLocalNetworkPermission = requestLocalNetworkPermission,
)
if (settingsState != null)
jamSection(
settings = settingsState!!,
navigationCommands = navigatorCommands,
)
if (settingsState != null)
cacheSection(
@ -133,4 +142,3 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
}
}
}

View File

@ -0,0 +1,62 @@
/*
* 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.
*/
package dev.krtirtho.spotube.modules.settings.sections
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_title
import spotube.composeapp.generated.resources.settings_section_jam
internal fun LazyListScope.jamSection(
settings: UserSettings,
navigationCommands: NavigationCommands,
) {
settingsSectionHeader(Res.string.settings_section_jam)
settingsSectionCard(
items = listOf(
{
SettingCardItem(
title = stringResource(Res.string.settings_jam_broker_title),
subtitle = stringResource(
Res.string.settings_jam_broker_host_subtitle,
settings.jamBroker.host.ifBlank { "—" },
settings.jamBroker.port,
),
icon = {
SettingsItemIcon(
Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_title),
)
},
trailingContent = {
Icon(
imageVector = FeatherIcons.ChevronRight,
contentDescription = stringResource(Res.string.settings_jam_broker_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { navigationCommands.navigateTo(Routes.JamSettings) },
)
},
)
)
}

View File

@ -38,6 +38,7 @@ import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
import dev.krtirtho.spotube.resources.iconsax.CustomServer
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
@ -49,6 +50,7 @@ internal fun LazyListScope.playbackSection(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
navigatorCommands: NavigationCommands,
requestLocalNetworkPermission: () -> Unit,
) {
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
val streamingQualities = availableAudioQualities(
@ -135,6 +137,51 @@ 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)
}
// Request the local network permission when enabling remote control
// so that DNS-SD registration can succeed on Android 16+
if (enabled) {
requestLocalNetworkPermission()
}
}
)
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_remote_device_name_title),
subtitle = stringResource(
Res.string.settings_remote_device_name_subtitle,
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
),
icon = {
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
},
value = settings.remoteControlDeviceName,
dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
normalize = { it.trim() },
validate = { _ -> null },
onValueSaved = { value ->
settingsViewModel.updateSettings {
copy(remoteControlDeviceName = value)
}
}
)
},
{
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
val error_port_range = stringResource(Res.string.settings_error_port_range)

View File

@ -146,7 +146,7 @@ internal fun availableAudioQualities(format: AudioFormat, current: AudioQuality)
}
internal fun AudioFormat.displayLabel(): String {
return "${codec.uppercase()} in ${container.uppercase()} • ${preferredQuality().displayLabel()}"
return "${codec.uppercase()} in ${container.uppercase()}"
}
internal fun AudioFormat.preferredQuality(): AudioQuality {

View File

@ -42,11 +42,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.NavigationItem
import dev.krtirtho.spotube.bottombarTabs
import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.tabs
@Composable
fun AppBottombar(
@ -68,46 +69,47 @@ fun AppBottombar(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEach { (label, icon, activeIcon, screen) ->
val selected = navigationState.topLevelRoute == screen
val labelColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Column(
modifier = Modifier
.clip(CircleShape)
.clickable { navigator.navigate(screen) }
.padding(8.dp)
.size(56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box {
Icon(
imageVector = activeIcon,
contentDescription = label,
tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
if (screen == Routes.Library) {
DownloadBadgeIndicator(
modifier = Modifier.align(Alignment.TopEnd)
)
}
bottombarTabs.filterIsInstance<NavigationItem.Tab<*>>()
.forEach { tab ->
val selected = navigationState.topLevelRoute == tab.route
val labelColor = if (selected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Column(
modifier = Modifier
.clip(CircleShape)
.clickable { navigator.navigate(tab.route) }
.padding(8.dp)
.size(56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box {
Icon(
imageVector = tab.icon,
contentDescription = tab.title,
tint = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
if (tab.route == Routes.Library) {
DownloadBadgeIndicator(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = tab.title,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
color = labelColor,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal,
color = labelColor,
)
}
}
}
}
}

View File

@ -66,9 +66,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
@ -77,14 +74,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
@ -117,6 +114,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@ -152,16 +150,22 @@ fun AppExpandedPlayer(
onSleepTimer: () -> Unit = {},
audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(),
playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() }
),
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val jamRoomService: JamRoomService = koinInject()
val isJamGuest by jamRoomService.role
.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val scope = rememberCoroutineScope()
val downloadsViewModel: DownloadsViewModel = koinViewModel()
val navigationCommands: NavigationCommands = koinInject()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle()
val currentTrack = remember(currentEntry) {
(currentEntry as? QueueEntry.StreamingTrack)?.track
}
@ -177,9 +181,10 @@ fun AppExpandedPlayer(
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
var showMoreOptionsSheet by remember { mutableStateOf(false) }
val moreOptionsSheetState = rememberModalBottomSheetState()
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
LaunchedEffect(playerUiState.progress, isSeeking) {
if (!isSeeking) {
@ -198,18 +203,22 @@ fun AppExpandedPlayer(
}
fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() }
}
fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() }
}
fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
}
fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
}
@ -230,10 +239,10 @@ fun AppExpandedPlayer(
modifier = modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { innerPadding ->
if (showMoreOptionsSheet) {
if (playerOptionsUiState.isMoreOptionsSheetOpen) {
ModalBottomSheet(
onDismissRequest = {
showMoreOptionsSheet = false
playerOptionsViewModel.dismissMoreOptionsSheet()
},
sheetState = moreOptionsSheetState
) {
@ -250,7 +259,7 @@ fun AppExpandedPlayer(
icon = Iconsax.SwapHorizontal2,
label = "Alternative Source",
onClick = {
showMoreOptionsSheet = false
playerOptionsViewModel.dismissMoreOptionsSheet()
onAlternativeSource()
},
)
@ -276,7 +285,7 @@ fun AppExpandedPlayer(
modifier = Modifier
.aspectRatio(1f)
.clickable {
showMoreOptionsSheet = false
playerOptionsViewModel.dismissMoreOptionsSheet()
when (status) {
is DownloadStatus.Completed -> {
scope.launch {
@ -364,7 +373,8 @@ fun AppExpandedPlayer(
icon = Iconsax.InconsaxClock,
label = "Sleep Timer",
onClick = {
showMoreOptionsSheet = false
playerOptionsViewModel.dismissMoreOptionsSheet()
playerOptionsViewModel.showSleepTimerDialog()
onSleepTimer()
},
)
@ -374,7 +384,7 @@ fun AppExpandedPlayer(
icon = Iconsax.IconsaxCd,
label = "Go to Album",
onClick = {
showMoreOptionsSheet = false
playerOptionsViewModel.dismissMoreOptionsSheet()
val albumId = currentTrack?.album?.id
if (albumId != null) {
navigationCommands.navigateTo(Routes.Album(albumId))
@ -383,6 +393,16 @@ fun AppExpandedPlayer(
},
)
}
item {
OptionTile(
icon = Iconsax.Iconsax3DotsMore,
label = "Track Details",
onClick = {
playerOptionsViewModel.dismissMoreOptionsSheet()
playerOptionsViewModel.showTrackDetails()
},
)
}
}
}
}
@ -411,7 +431,7 @@ fun AppExpandedPlayer(
textAlign = TextAlign.Center,
maxLines = 1,
)
GhostIconButton(onClick = { showMoreOptionsSheet = true }) {
GhostIconButton(onClick = playerOptionsViewModel::showMoreOptionsSheet) {
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "Player options")
}
}
@ -517,7 +537,7 @@ fun AppExpandedPlayer(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
GhostIconButton(onClick = ::onShuffleToggle) {
GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) {
Icon(
Iconsax.IconsaxShuffle,
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
@ -528,7 +548,7 @@ fun AppExpandedPlayer(
}
)
}
GhostIconButton(onClick = ::onSkipPrevious) {
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
}
IconButton(
@ -543,10 +563,10 @@ fun AppExpandedPlayer(
modifier = Modifier.size(30.dp),
)
}
GhostIconButton(onClick = ::onSkipNext) {
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
}
GhostIconButton(onClick = ::onLoopToggle) {
GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) {
Icon(
imageVector = when (playerUiState.loopState) {
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
@ -733,4 +753,4 @@ private fun OptionTile(
)
}
}
}
}

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.modules.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -34,6 +33,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -54,17 +55,20 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.LoopState
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.jam.JamRole
import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.Slider
@ -78,7 +82,9 @@ import dev.krtirtho.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicFilter
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
@ -92,11 +98,18 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.player_go_to_album
import spotube.composeapp.generated.resources.player_sleep_timer
import spotube.composeapp.generated.resources.player_track_details
import kotlin.time.Duration.Companion.milliseconds
@ -119,19 +132,29 @@ fun AppLargePlayer(
audioPlayer: AudioPlayerInterface = koinInject(),
audioPlayerQueue: AudioPlayerQueue = koinInject(),
downloadsViewModel: DownloadsViewModel = koinViewModel(),
playerOptionsViewModel: PlayerOptionsViewModel = koinViewModel(),
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
key = SAVED_TRACKS_COLLECTION_ID,
parameters = { parametersOf() }
),
) {
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
val jamRoomService: JamRoomService = koinInject()
val isJamGuest by jamRoomService.role
.map { it == JamRole.Guest }
.collectAsStateWithLifecycle(initialValue = false)
val scope = rememberCoroutineScope()
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track
val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle()
val navigationCommands: NavigationCommands = koinInject()
var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
LaunchedEffect(playerUiState.progress, isSeeking) {
if (!isSeeking) {
seekProgress = playerUiState.progress
@ -155,18 +178,22 @@ fun AppLargePlayer(
}
fun onSkipPrevious() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToPrevious() }
}
fun onSkipNext() {
if (isJamGuest) return
scope.launch { audioPlayer.skipToNext() }
}
fun onShuffleToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
}
fun onLoopToggle() {
if (isJamGuest) return
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
}
@ -294,6 +321,7 @@ fun AppLargePlayer(
) {
VariableIconButton(
onClick = ::onShuffleToggle,
enabled = !isJamGuest,
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
) {
Icon(
@ -306,7 +334,7 @@ fun AppLargePlayer(
}
)
}
GhostIconButton(onClick = ::onSkipPrevious) {
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
}
IconButton(
@ -320,11 +348,12 @@ fun AppLargePlayer(
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
)
}
GhostIconButton(onClick = ::onSkipNext) {
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
}
VariableIconButton(
onClick = ::onLoopToggle,
enabled = !isJamGuest,
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
) {
Icon(
@ -370,8 +399,63 @@ fun AppLargePlayer(
GhostIconButton(onClick = onLyrics) {
Icon(Iconsax.IconsaxMusic, contentDescription = "Lyrics")
}
GhostIconButton(onClick = onMoreOptions) {
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
Box {
GhostIconButton(
onClick = {
playerOptionsViewModel.showMoreOptionsMenu()
onMoreOptions()
}
) {
Icon(Iconsax.Iconsax3DotsMore, contentDescription = "More options")
}
DropdownMenu(
expanded = playerOptionsUiState.isMoreOptionsMenuOpen,
onDismissRequest = playerOptionsViewModel::dismissMoreOptionsMenu,
) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.player_go_to_album)) },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxCd,
contentDescription = null,
)
},
enabled = currentTrack?.album?.id != null,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
currentTrack?.album?.id?.let { albumId ->
navigationCommands.navigateTo(Routes.Album(albumId))
}
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.player_sleep_timer)) },
leadingIcon = {
Icon(
imageVector = Iconsax.InconsaxClock,
contentDescription = null,
)
},
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showSleepTimerDialog()
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.player_track_details)) },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxInformation,
contentDescription = null,
)
},
enabled = currentTrack != null,
onClick = {
playerOptionsViewModel.dismissMoreOptionsMenu()
playerOptionsViewModel.showTrackDetails()
},
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {

View File

@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberBottomSheetScaffoldState
@ -69,6 +72,9 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
@ -93,6 +99,13 @@ fun AppShell(
content: @Composable () -> Unit,
) {
val navigatorCommands: NavigationCommands = koinInject()
val remotePlaybackController: RemotePlaybackController = koinInject()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(remotePlaybackController) {
remotePlaybackController.events.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
@ -116,6 +129,9 @@ fun AppShell(
}
}
ConnectionRequestDialogHost()
PlayDestinationPickerHost()
Box(modifier = Modifier.fillMaxSize()) {
val useSidebar = viewModel.useSidebar()
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
@ -258,6 +274,15 @@ fun AppShell(
}
}
}
// Drawn last so it floats above the players/sheets, just above the
// bottom overlay (large player or compact player + bottombar).
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = bottomOverlayInset + 12.dp),
)
}
}

View File

@ -29,12 +29,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
@ -55,6 +59,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.hazeEffect
import dev.krtirtho.spotube.NavigationItem
import dev.krtirtho.spotube.core.navigation.NavigationState
import dev.krtirtho.spotube.core.navigation.Navigator
import dev.krtirtho.spotube.core.navigation.Routes
@ -64,13 +69,15 @@ import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.copyPadding
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
import dev.krtirtho.spotube.modules.library.LibraryState
import dev.krtirtho.spotube.modules.library.LibraryTab
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
import dev.krtirtho.spotube.tabs
import dev.krtirtho.spotube.sidebarTabs
import org.jetbrains.compose.resources.Font
import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res
@ -88,6 +95,7 @@ fun AppSidebar(
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
val currentLibraryTab by libraryState.currentTab.collectAsState()
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
val listState = rememberLazyListState()
Column(
modifier = modifier
@ -103,78 +111,109 @@ fun AppSidebar(
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text(
text = "Spotube",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily(
listOf(
Font(Res.font.cookie_regular, weight = FontWeight.Normal)
)
)
),
color = MaterialTheme.colorScheme.onSurface
)
}
GhostIconButton(
onClick = { expanded = !expanded }
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = if (expanded) Arrangement.SpaceBetween else Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken,
contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
tabs.forEach { (label, icon, activeIcon, screen) ->
val selected = navigationState.topLevelRoute == screen
if (screen == Routes.Library) {
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text(
text = "LIBRARY",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
text = "Spotube",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily(
listOf(
Font(Res.font.cookie_regular, weight = FontWeight.Normal)
)
)
),
color = MaterialTheme.colorScheme.onSurface
)
}
LibraryTab.entries.forEach { tab ->
val isSubSelected = selected && currentLibraryTab == tab
SidebarItem(
label = tab.title,
activeIcon = tab.icon,
onClick = {
libraryState.currentTab.value = tab
navigator.navigate(screen)
},
selected = isSubSelected,
expanded = expanded,
showDownloadBadge = tab == LibraryTab.Downloads,
GhostIconButton(
onClick = { expanded = !expanded }
) {
Icon(
imageVector = if (expanded) Iconsax.IconsaxSidebarLeftBroken else Iconsax.IconsaxSidebarRightBroken,
contentDescription = if (expanded) "Collapse sidebar" else "Expand sidebar",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
SidebarItem(
label = label,
activeIcon = activeIcon,
onClick = { navigator.navigate(screen) },
selected = selected,
expanded = expanded,
}
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
items(sidebarTabs.size) { index ->
when (val tab = sidebarTabs[index]) {
is NavigationItem.Group<*> -> {
androidx.compose.animation.AnimatedVisibility(visible = expanded) {
Text(
text = tab.title.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp)
)
}
tab.items.forEach {
val selected = navigationState.topLevelRoute == it.route
SidebarItem(
label = it.title,
activeIcon = it.icon,
onClick = {
if (it.route == Routes.Library) {
libraryState.currentTab.value =
it.data as LibraryTab
}
navigator.navigate(it.route)
},
selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected,
expanded = expanded,
showDownloadBadge = it.route == Routes.Library
&& it.data == LibraryTab.Downloads
)
}
}
is NavigationItem.Tab<*> -> {
val selected = navigationState.topLevelRoute == tab.route
SidebarItem(
label = tab.title,
activeIcon = tab.icon,
onClick = {
navigator.navigate(tab.route)
},
selected = selected,
expanded = expanded,
)
}
}
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
)
}
}
HorizontalDivider()
SidebarItem(
label = "Settings",
activeIcon = Iconsax.IconsaxSetting2,
onClick = {
navigator.navigate(Routes.Settings)
},
selected = navigationState.topLevelRoute == Routes.Settings,
expanded = expanded,
)
Spacer(modifier = Modifier.height(120.dp))
}
}
@ -188,7 +227,7 @@ fun SidebarItem(
showDownloadBadge: Boolean = false,
) {
val itemContent: @Composable RowScope.() -> Unit = {
Box(modifier = Modifier.size(20.dp)) {
Box(modifier = Modifier.size(16.dp)) {
Icon(
imageVector = activeIcon,
contentDescription = label,
@ -210,7 +249,7 @@ fun SidebarItem(
maxLines = 1,
softWrap = false,
style = LocalTextStyle.current.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize,
fontSize = MaterialTheme.typography.labelMedium.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
@ -225,8 +264,8 @@ fun SidebarItem(
val buttonModifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp)
val contentPadding = PaddingValues(horizontal = 7.dp, vertical = 6.dp)
.padding(horizontal = 7.dp, vertical = 2.dp)
val contentPadding = PaddingValues(horizontal = 5.dp)
if (selected) {
SecondaryButton(
@ -234,7 +273,7 @@ fun SidebarItem(
modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.secondary
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
.copyShape(RoundedCornerShape(5.dp)),
content = itemContent,
)
} else {
@ -243,7 +282,7 @@ fun SidebarItem(
modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.outline
.copyPadding(contentPadding)
.copyShape(RoundedCornerShape(8.dp)),
.copyShape(RoundedCornerShape(5.dp)),
hoverOnly = true,
content = itemContent,
)

View File

@ -0,0 +1,401 @@
/*
* 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.shell
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
import dev.krtirtho.spotube.core.server.CacheEntry
@Composable
fun PlayerOptionDialogs(viewModel: PlayerOptionsViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
if (uiState.isSleepTimerDialogOpen) {
SleepTimerDialog(
minutes = uiState.sleepTimerMinutes,
remainingMs = uiState.sleepTimerRemainingMs,
onMinutesChange = viewModel::updateSleepTimerMinutes,
onStart = viewModel::startSleepTimer,
onCancelTimer = viewModel::cancelSleepTimer,
onDismiss = viewModel::dismissSleepTimerDialog,
)
}
uiState.trackDetails?.let { details ->
TrackDetailsDialog(
details = details,
onDismiss = viewModel::dismissTrackDetails,
)
}
}
@Composable
private fun SleepTimerDialog(
minutes: String,
remainingMs: Long?,
onMinutesChange: (String) -> Unit,
onStart: () -> Unit,
onCancelTimer: () -> Unit,
onDismiss: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Text("Sleep timer", style = MaterialTheme.typography.titleLarge)
},
actions = {
if (remainingMs != null) {
SecondaryButton(onClick = onCancelTimer) { Text("Cancel timer") }
}
SecondaryButton(onClick = onDismiss) { Text("Close") }
PrimaryButton(
onClick = onStart,
enabled = minutes.toLongOrNull()?.let { it in 1L..600L } == true,
) {
Text("Start")
}
},
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (remainingMs != null) {
Text(
text = "Playback will pause in ${formatRemainingTime(remainingMs)}.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = "Pause playback after the selected number of minutes.",
style = MaterialTheme.typography.bodyMedium,
)
}
OutlinedTextField(
value = minutes,
onValueChange = onMinutesChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("Minutes") },
supportingText = { Text("Choose between 1 and 600 minutes") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
}
}
}
@Composable
private fun TrackDetailsDialog(
details: PlayerTrackDetails,
onDismiss: () -> Unit,
) {
ThemedDialog(
onDismissRequest = onDismiss,
title = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Track details", style = MaterialTheme.typography.titleLarge)
Text(
details.track.title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
actions = {
SecondaryButton(onClick = onDismiss) { Text("Close") }
},
) {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
if (details.isLoading) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
if (details.localCacheEntry != null) {
LocalCacheBanner(details.localCacheEntry)
}
if (details.localCacheEntry == null && details.activeStreamMatchesPreference == false) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Text(
"The playback URL was resolved with different streaming settings. The highlighted stream matches your current preference; the active URL refreshes on the next stream request.",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
TrackMetadataSection(details.track)
Text(
"Matched audio source",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
val source = details.matchedSource
if (details.isLoading) {
Text("Loading matched source…", style = MaterialTheme.typography.bodySmall)
} else if (source == null) {
Text(
details.error ?: if (details.localCacheEntry != null) {
"A matched remote source is not needed while playing the local cached file."
} else {
"No matched source is available."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
DetailRow("Title", source.title)
DetailRow("ID", source.id)
DetailRow("Artist", source.artist)
DetailRow("Album", source.album)
DetailRow("Confidence", "${(source.confidence * 100).toInt()}%")
DetailRow("External URI", source.externalUri)
}
Text(
"Available streams",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
when {
details.localCacheEntry != null && details.streams.isEmpty() -> Text(
"This track is being served from the local cache. Stream variants are not used for this playback.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
details.isLoading -> Text("Loading streams…", style = MaterialTheme.typography.bodySmall)
details.streams.isEmpty() -> Text(
details.error ?: "No streams are available for this source.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> details.streams.forEach { stream ->
val isSelected = stream == details.preferredStream
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = streamFormatAndQuality(stream),
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
if (isSelected) {
Icon(
imageVector = Iconsax.IconsaxCheckCircle,
contentDescription = "Currently selected stream",
tint = MaterialTheme.colorScheme.primary,
)
}
}
Text(
"${stream.protocol} • ${stream.codec} • ${stream.container}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stream.url,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (isSelected) {
Text(
"Matches your streaming format & quality preference",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
details.activeStreamUrl?.let { activeUrl ->
DetailRow("Active playback URL", activeUrl)
}
}
}
}
@Composable
private fun LocalCacheBanner(entry: CacheEntry) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Iconsax.IconsaxDirectboxReceive,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
"Served from local cache",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
"${entry.filename} • ${formatFileSize(entry.sizeBytes)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
}
@Composable
private fun TrackMetadataSection(track: MetadataTrack) {
Text(
"Metadata track",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
DetailRow("ID", track.id)
DetailRow("Title", track.title)
DetailRow("Artists", track.artists.joinToString { it.name })
DetailRow("Album", track.album?.title)
DetailRow("Album ID", track.album?.id)
DetailRow("Album type", track.album?.albumType?.name)
DetailRow("Album release date", track.album?.releaseDate)
DetailRow("Album track count", track.album?.trackCount?.toString())
DetailRow("Album genres", track.album?.genres?.joinToString())
DetailRow("Duration", formatRemainingTime(track.durationMs))
DetailRow("Track number", track.trackNumber?.toString())
DetailRow("Disc number", track.discNumber?.toString())
DetailRow("Explicit", track.explicit?.toString())
DetailRow("Popularity", track.popularity?.toString())
DetailRow("ISRC", track.isrcCode)
DetailRow("External URI", track.externalUri)
DetailRow("Thumbnail URLs", track.thumbnails?.joinToString { it.url })
}
@Composable
private fun DetailRow(label: String, value: String?) {
if (value.isNullOrBlank()) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
label,
modifier = Modifier.weight(0.38f),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
value,
modifier = Modifier.weight(0.62f),
style = MaterialTheme.typography.bodySmall,
)
}
}
private fun streamFormatAndQuality(stream: AudioStream): String = when (stream) {
is AudioStream.Lossy -> "${stream.bitrate / 1_000} kbps"
is AudioStream.Lossless -> {
val rate = if (stream.sampleRate % 1_000 == 0) {
"${stream.sampleRate / 1_000}"
} else {
"${stream.sampleRate / 1_000.0}"
}
"$rate kHz • ${stream.channels} ch"
}
}
private fun formatRemainingTime(milliseconds: Long): String {
val totalSeconds = (milliseconds / 1_000).coerceAtLeast(0)
val hours = totalSeconds / 3_600
val minutes = (totalSeconds % 3_600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) {
"$hours:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}"
} else {
"$minutes:${seconds.toString().padStart(2, '0')}"
}
}
private fun formatFileSize(sizeBytes: Long): String = when {
sizeBytes >= 1024L * 1024L -> "${sizeBytes / (1024L * 1024L)} MB"
sizeBytes >= 1024L -> "${sizeBytes / 1024L} KB"
else -> "$sizeBytes B"
}

View File

@ -0,0 +1,244 @@
/*
* 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.shell
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.server.CacheEntry
import dev.krtirtho.spotube.core.server.CacheManager
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.server.TrackSourceRepository
import dev.krtirtho.spotube.core.server.normalizeManifestUrl
import dev.krtirtho.spotube.core.server.selectPreferredAudioStream
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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 kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds
data class PlayerTrackDetails(
val track: MetadataTrack,
val matchedSource: AudioSource.Basic? = null,
val streams: List<AudioStream> = emptyList(),
/** Stream picked by the current streaming format/quality preference. */
val preferredStream: AudioStream? = null,
/** URL currently used for playback, read from the playback URL cache. */
val activeStreamUrl: String? = null,
/** Whether the active URL matches what the current preferences select. */
val activeStreamMatchesPreference: Boolean? = null,
val localCacheEntry: CacheEntry? = null,
val isLoading: Boolean = false,
val error: String? = null,
)
data class PlayerOptionsUiState(
val isMoreOptionsMenuOpen: Boolean = false,
val isMoreOptionsSheetOpen: Boolean = false,
val isSleepTimerDialogOpen: Boolean = false,
val sleepTimerMinutes: String = "30",
val sleepTimerRemainingMs: Long? = null,
val trackDetails: PlayerTrackDetails? = null,
)
class PlayerOptionsViewModel(
private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository,
private val cacheManager: CacheManager,
private val settingsRepository: SettingsRepository,
private val pluginManager: AudioPluginSource,
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerOptionsUiState())
val uiState: StateFlow<PlayerOptionsUiState> = _uiState.asStateFlow()
private var sleepTimerJob: Job? = null
private var trackDetailsJob: Job? = null
fun showMoreOptionsMenu() {
_uiState.update {
it.copy(isMoreOptionsMenuOpen = true, isMoreOptionsSheetOpen = false)
}
}
fun dismissMoreOptionsMenu() {
_uiState.update { it.copy(isMoreOptionsMenuOpen = false) }
}
fun showMoreOptionsSheet() {
_uiState.update {
it.copy(isMoreOptionsSheetOpen = true, isMoreOptionsMenuOpen = false)
}
}
fun dismissMoreOptionsSheet() {
_uiState.update { it.copy(isMoreOptionsSheetOpen = false) }
}
fun showSleepTimerDialog() {
_uiState.update { it.copy(isSleepTimerDialogOpen = true) }
}
fun dismissSleepTimerDialog() {
_uiState.update { it.copy(isSleepTimerDialogOpen = false) }
}
fun updateSleepTimerMinutes(minutes: String) {
if (minutes.all(Char::isDigit)) {
_uiState.update { it.copy(sleepTimerMinutes = minutes) }
}
}
fun startSleepTimer() {
val minutes = _uiState.value.sleepTimerMinutes.toLongOrNull()
?.takeIf { it in 1..600 } ?: return
val durationMs = minutes * MILLIS_PER_MINUTE
sleepTimerJob?.cancel()
sleepTimerJob = viewModelScope.launch {
var remainingMs = durationMs
while (remainingMs > 0L) {
_uiState.update { it.copy(sleepTimerRemainingMs = remainingMs) }
val tickMs = minOf(remainingMs, MILLIS_PER_SECOND)
delay(tickMs.milliseconds)
remainingMs -= tickMs
}
runCatching { audioPlayer.pause() }
_uiState.update { it.copy(sleepTimerRemainingMs = null, isSleepTimerDialogOpen = false) }
}
}
fun cancelSleepTimer() {
sleepTimerJob?.cancel()
sleepTimerJob = null
_uiState.update { it.copy(sleepTimerRemainingMs = null) }
}
fun showTrackDetails() {
val track = (audioPlayerQueue.currentQueueEntryFlow.value as? QueueEntry.StreamingTrack)
?.track ?: return
trackDetailsJob?.cancel()
_uiState.update {
it.copy(
trackDetails = PlayerTrackDetails(track = track, isLoading = true),
)
}
trackDetailsJob = viewModelScope.launch {
runCatching {
val settings = settingsRepository.userSettings.value
withContext(Dispatchers.IO) {
val pluginId = pluginManager.selectedAudioPlugin.value?.pluginId
val matchedSource = pluginId?.let { matchedTracksRepository.getTrackSource(track, it) }
val cachedStream = streamingUrlRepository.getCachedStreamUrlEntry(track.id)
val localCacheEntry = if (settings.enableMusicCaching) {
cacheManager.findCachedEntry(track.id)?.second
} else {
null
}
val cachedStreamSource = cachedStream?.source?.takeIf {
matchedSource == null || it.id == matchedSource.id
}
val source = matchedSource ?: cachedStreamSource?.toBasic()
val streams = cachedStreamSource?.streams.orEmpty()
val preferredStream = selectPreferredAudioStream(
streams = streams,
preferredFormat = settings.streamingMusicFormat,
preferredQuality = settings.streamingMusicQuality,
)
val activeStreamUrl = if (cachedStreamSource != null) cachedStream.url else null
val activeStreamMatchesPreference = when {
activeStreamUrl == null || preferredStream == null -> null
else -> activeStreamUrl == normalizeManifestUrl(
url = preferredStream.url,
protocol = preferredStream.protocol,
)
}
PlayerTrackDetails(
track = track,
matchedSource = source,
streams = streams,
preferredStream = preferredStream,
activeStreamUrl = activeStreamUrl,
activeStreamMatchesPreference = activeStreamMatchesPreference,
localCacheEntry = localCacheEntry,
error = when {
source == null && localCacheEntry == null ->
"No matched audio source is cached for this track."
streams.isEmpty() && localCacheEntry == null ->
"Stream details are not cached yet. Play the track first to load them."
else -> null
},
)
}
}.onSuccess { details ->
_uiState.update { current ->
if (current.trackDetails?.track?.id == track.id) {
current.copy(trackDetails = details)
} else current
}
}.onFailure { error ->
if (error is CancellationException) throw error
_uiState.update { current ->
if (current.trackDetails?.track?.id == track.id) {
current.copy(
trackDetails = PlayerTrackDetails(
track = track,
isLoading = false,
error = error.message ?: "Failed to load track details.",
),
)
} else current
}
}
}
}
fun dismissTrackDetails() {
trackDetailsJob?.cancel()
trackDetailsJob = null
_uiState.update { it.copy(trackDetails = null) }
}
override fun onCleared() {
sleepTimerJob?.cancel()
trackDetailsJob?.cancel()
super.onCleared()
}
companion object {
private const val MILLIS_PER_SECOND = 1_000L
private const val MILLIS_PER_MINUTE = 60_000L
}
}

View File

@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.jam.JamParticipant
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
@ -59,12 +65,15 @@ import dev.krtirtho.spotube.core.ui.base.ListRowTile
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.copyShape
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
import org.koin.compose.viewmodel.koinViewModel
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@ -78,12 +87,14 @@ fun PlayerQueueContent(
val displayItems = state.displayItems
val filterQuery = state.filterQuery
val isFiltered = state.isFiltered
val isReadOnly = state.isReadOnly
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState,
onMove = { from, to ->
if (isFiltered) return@rememberReorderableLazyListState
if (isFiltered || isReadOnly) return@rememberReorderableLazyListState
viewModel.onMove(from.index, to.index)
},
)
@ -118,11 +129,13 @@ fun PlayerQueueContent(
singleLine = true,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = viewModel::clearQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
if (!isReadOnly) {
IconButton(
onClick = viewModel::clearQueue,
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
) {
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
}
}
}
@ -144,21 +157,141 @@ fun PlayerQueueContent(
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
QueueItemRow(
item = item,
reorderScope = if (isFiltered) null else this,
reorderScope = if (isFiltered || isReadOnly) null else this,
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
onDragStarted = { viewModel.onDragStart() },
onDragStopped = { viewModel.onDragStop() },
showOptions = !isReadOnly,
enabled = !isReadOnly,
onParticipantClick = { selectedParticipant = it },
)
}
}
}
}
}
selectedParticipant?.let { participant ->
ParticipantDialog(
participant = participant,
isJamHost = state.isJamHost,
onDismiss = { selectedParticipant = null },
onKick = { viewModel.kickParticipant(participant.id) },
onBan = { viewModel.banParticipant(participant.id) },
onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) },
)
}
}
}
}
@Composable
private fun ParticipantDialog(
participant: JamParticipant,
isJamHost: Boolean,
onDismiss: () -> Unit,
onKick: () -> Unit,
onBan: () -> Unit,
onRemoveSuggestions: () -> Unit,
) {
AdaptiveDialogBottomSheet(
onDismiss = onDismiss,
title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) },
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(vertical = 8.dp),
) {
ParticipantAvatar(participant, size = 40)
Text(
text = participant.displayName,
style = MaterialTheme.typography.bodyLarge,
)
if (participant.isHost) {
Text(
text = "Host",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (isJamHost && !participant.isHost) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
ListRowTile(
onClick = {
onKick()
onDismiss()
},
leading = {
Icon(
imageVector = Iconsax.IconsaxCloseSquare,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = { Text("Kick") },
subtitle = { Text("Remove them from the session") },
)
ListRowTile(
onClick = {
onBan()
onDismiss()
},
leading = {
Icon(
imageVector = Iconsax.IconsaxUserRemove,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
title = { Text("Ban") },
subtitle = { Text("Kick and prevent them from rejoining") },
)
ListRowTile(
onClick = {
onRemoveSuggestions()
onDismiss()
},
leading = {
Icon(
imageVector = Iconsax.IconsaxMusicSquareRemove,
contentDescription = null,
)
},
title = { Text("Remove suggestions") },
subtitle = { Text("Remove every track they added to the queue") },
)
}
}
}
}
@Composable
private fun ParticipantAvatar(participant: JamParticipant, size: Int) {
Box(
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
@Composable
private fun QueueItemRow(
item: QueueItemUi,
@ -167,11 +300,15 @@ private fun QueueItemRow(
onRemoveClick: () -> Unit,
onDragStarted: () -> Unit,
onDragStopped: () -> Unit,
showOptions: Boolean = true,
enabled: Boolean = true,
onParticipantClick: (JamParticipant) -> Unit = {},
) {
var showMenu by remember { mutableStateOf(false) }
ListRowTile(
onClick = onPlayClick,
enabled = enabled,
selected = item.isCurrent,
modifier = Modifier,
leading = {
@ -255,31 +392,51 @@ private fun QueueItemRow(
Spacer(modifier = Modifier.width(4.dp))
Box {
GhostIconButton(
onClick = { showMenu = true },
modifier = Modifier.size(36.dp),
item.addedByParticipant?.let { participant ->
Box(
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { onParticipantClick(participant) },
contentAlignment = Alignment.Center,
) {
Icon(
Iconsax.Iconsax3DotsMore,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
Text(
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Remove from queue") },
onClick = {
onRemoveClick()
showMenu = false
},
leadingIcon = {
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
},
)
Spacer(modifier = Modifier.width(4.dp))
}
if (showOptions) {
Box {
GhostIconButton(
onClick = { showMenu = true },
modifier = Modifier.size(36.dp),
) {
Icon(
Iconsax.Iconsax3DotsMore,
contentDescription = "More options",
modifier = Modifier.size(18.dp),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Remove from queue") },
onClick = {
onRemoveClick()
showMenu = false
},
leadingIcon = {
Icon(Iconsax.IconsaxMusicSquareRemove, contentDescription = null)
},
)
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,124 @@
/*
* 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.update
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dev.krtirtho.spotube.AppBuildInfo
import dev.krtirtho.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer
data class AvailableUpdate(
val tag: String,
val releaseNotesMarkdown: String,
)
class UpdateCheckerViewModel(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
private val logger = Logger.withTag("UpdateChecker")
private val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
private val _availableUpdate = MutableStateFlow<AvailableUpdate?>(null)
val availableUpdate: StateFlow<AvailableUpdate?> = _availableUpdate.asStateFlow()
init {
viewModelScope.launch {
checkForUpdate()
}
}
private suspend fun checkForUpdate() {
try {
// Read directly from DataStore so the check honors persisted preferences,
// rather than the repository StateFlow's default value during startup.
val settings = settingsRepository.getCurrentSettings()
if (!settings.autoCheckForUpdates) return
val release = httpClient.get(AppBuildInfo.LATEST_RELEASE_API_URL) {
header(HttpHeaders.Accept, "application/vnd.github+json")
header(HttpHeaders.UserAgent, "Spotube/${AppBuildInfo.VERSION}")
}.body<GitHubRelease>()
val latestVersion = parseVersion(release.tagName.removePrefix("v"))
?: return logger.w { "Ignoring release with invalid SemVer tag: ${release.tagName}" }
val currentVersion = parseVersion(AppBuildInfo.VERSION)
?: return logger.w { "Ignoring update check for invalid app version: ${AppBuildInfo.VERSION}" }
if (latestVersion > currentVersion && release.tagName != settings.ignoredUpdateVersion) {
_availableUpdate.value = AvailableUpdate(
tag = release.tagName,
releaseNotesMarkdown = release.body.orEmpty(),
)
}
} catch (e: Exception) {
logger.w(e) { "Failed to check for app updates" }
}
}
fun ignoreUpdate() {
val update = _availableUpdate.value ?: return
_availableUpdate.value = null
viewModelScope.launch {
runCatching {
val settings = settingsRepository.getCurrentSettings()
settingsRepository.updateSettings(settings.copy(ignoredUpdateVersion = update.tag))
}.onFailure { e ->
logger.e(e) { "Failed to persist ignored app version" }
}
}
}
fun dismissUpdate() {
_availableUpdate.value = null
}
private fun parseVersion(version: String): SemVer? = runCatching {
SemVer.parse(version)
}.getOrNull()
override fun onCleared() {
httpClient.close()
}
}
@Serializable
private data class GitHubRelease(
@SerialName("tag_name") val tagName: String,
val body: String? = null,
)

View File

@ -0,0 +1,89 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxMirrorScreenRegular: ImageVector
get() {
if (_IconsaxMirrorScreenRegular != null) {
return _IconsaxMirrorScreenRegular!!
}
_IconsaxMirrorScreenRegular = ImageVector.Builder(
name = "IconsaxMirrorScreenRegular",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(
stroke = SolidColor(Color.White),
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(2f, 9f)
verticalLineTo(8f)
curveTo(2f, 5f, 4f, 3f, 7f, 3f)
horizontalLineTo(17f)
curveTo(20f, 3f, 22f, 5f, 22f, 8f)
verticalLineTo(16f)
curveTo(22f, 19f, 20f, 21f, 17f, 21f)
horizontalLineTo(16f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(3.69f, 11.711f)
curveTo(8.31f, 12.301f, 11.7f, 15.701f, 12.3f, 20.321f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(2.62f, 15.07f)
curveTo(6.01f, 15.5f, 8.5f, 18f, 8.94f, 21.39f)
}
path(
fillAlpha = 0.4f,
stroke = SolidColor(Color.White),
strokeAlpha = 0.4f,
strokeLineWidth = 1.5f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
) {
moveTo(1.98f, 18.859f)
curveTo(3.67f, 19.079f, 4.92f, 20.319f, 5.14f, 22.019f)
}
}
}.build()
return _IconsaxMirrorScreenRegular!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxMirrorScreenRegular: ImageVector? = null

View File

@ -1,20 +1,3 @@
/*
* 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.resources.iconsax
import androidx.compose.ui.graphics.Color
@ -56,11 +39,7 @@ val Iconsax.IconsaxSound: ImageVector
curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
path(fill = SolidColor(Color.White)) {
moveTo(7.5f, 19f)
curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f)
verticalLineTo(5.75f)
@ -80,11 +59,7 @@ val Iconsax.IconsaxSound: ImageVector
curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
path(fill = SolidColor(Color.White)) {
moveTo(16.5f, 19f)
curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f)
verticalLineTo(5.75f)

View File

@ -0,0 +1,97 @@
package dev.krtirtho.spotube.resources.iconsax
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.group
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val Iconsax.IconsaxSoundTwotone: ImageVector
get() {
if (_IconsaxSoundTwotone != null) {
return _IconsaxSoundTwotone!!
}
_IconsaxSoundTwotone = ImageVector.Builder(
name = "IconsaxSoundTwotone",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
group(
clipPathData = PathData {
moveTo(0f, 0f)
horizontalLineToRelative(24f)
verticalLineToRelative(24f)
horizontalLineToRelative(-24f)
close()
}
) {
path(fill = SolidColor(Color.White)) {
moveTo(3f, 16.5f)
curveTo(2.59f, 16.5f, 2.25f, 16.16f, 2.25f, 15.75f)
verticalLineTo(8.25f)
curveTo(2.25f, 7.84f, 2.59f, 7.5f, 3f, 7.5f)
curveTo(3.41f, 7.5f, 3.75f, 7.84f, 3.75f, 8.25f)
verticalLineTo(15.75f)
curveTo(3.75f, 16.16f, 3.41f, 16.5f, 3f, 16.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(7.5f, 19f)
curveTo(7.09f, 19f, 6.75f, 18.66f, 6.75f, 18.25f)
verticalLineTo(5.75f)
curveTo(6.75f, 5.34f, 7.09f, 5f, 7.5f, 5f)
curveTo(7.91f, 5f, 8.25f, 5.34f, 8.25f, 5.75f)
verticalLineTo(18.25f)
curveTo(8.25f, 18.66f, 7.91f, 19f, 7.5f, 19f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(12f, 21.5f)
curveTo(11.59f, 21.5f, 11.25f, 21.16f, 11.25f, 20.75f)
verticalLineTo(3.25f)
curveTo(11.25f, 2.84f, 11.59f, 2.5f, 12f, 2.5f)
curveTo(12.41f, 2.5f, 12.75f, 2.84f, 12.75f, 3.25f)
verticalLineTo(20.75f)
curveTo(12.75f, 21.16f, 12.41f, 21.5f, 12f, 21.5f)
close()
}
path(
fill = SolidColor(Color.White),
fillAlpha = 0.4f,
strokeAlpha = 0.4f
) {
moveTo(16.5f, 19f)
curveTo(16.09f, 19f, 15.75f, 18.66f, 15.75f, 18.25f)
verticalLineTo(5.75f)
curveTo(15.75f, 5.34f, 16.09f, 5f, 16.5f, 5f)
curveTo(16.91f, 5f, 17.25f, 5.34f, 17.25f, 5.75f)
verticalLineTo(18.25f)
curveTo(17.25f, 18.66f, 16.91f, 19f, 16.5f, 19f)
close()
}
path(fill = SolidColor(Color.White)) {
moveTo(21f, 16.5f)
curveTo(20.59f, 16.5f, 20.25f, 16.16f, 20.25f, 15.75f)
verticalLineTo(8.25f)
curveTo(20.25f, 7.84f, 20.59f, 7.5f, 21f, 7.5f)
curveTo(21.41f, 7.5f, 21.75f, 7.84f, 21.75f, 8.25f)
verticalLineTo(15.75f)
curveTo(21.75f, 16.16f, 21.41f, 16.5f, 21f, 16.5f)
close()
}
}
}.build()
return _IconsaxSoundTwotone!!
}
@Suppress("ObjectPropertyName")
private var _IconsaxSoundTwotone: ImageVector? = null

View File

@ -4,4 +4,4 @@ mod discord_rpc;
pub use metadata::*;
pub use discord_rpc::*;
uniffi::setup_scaffolding!();
uniffi::setup_scaffolding!();

View File

@ -17,10 +17,9 @@
package dev.krtirtho.spotube
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}
internal object AppBuildInfo {
const val VERSION = "${appVersion}"
const val BUILD_NUMBER = ${appBuildNumber}
const val REPOSITORY_URL = "${repositoryUrl}"
const val LATEST_RELEASE_API_URL = "${latestReleaseApiUrl}"
}

View File

@ -33,7 +33,8 @@ class FakeMetadataTrackAPI(
}
class FakePluginService(
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI()
private val metadataTrackAPI: MetadataTrackAPI = FakeMetadataTrackAPI(),
override val pluginId: String = "fake-plugin",
) : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)

View File

@ -0,0 +1,556 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.modules.settings.UserSettings
import co.touchlab.kermit.Logger
import kotlinx.coroutines.test.runTest
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Behavioral / caching tests for [StreamingUrlRepository]: the in-memory stream-url cache,
* the matched-source lookup, and the interaction between the two when the active audio
* plugin changes.
*
* These specifically target the "wrong track plays" class of bug: a stale match or cached
* URL from one plugin (or one track) being silently reused for a different plugin/track.
*/
class StreamingUrlRepositoryBehaviorTest {
@BeforeTest
fun setup() {
startKoin {
modules(module {
factory { (tag: String?) -> Logger.withTag(tag ?: "test") }
})
}
}
@AfterTest
fun teardown() {
stopKoin()
}
private fun track(id: String, title: String = "Track $id") = MetadataTrack(
id = id,
title = title,
durationMs = 200_000,
trackNumber = 1,
discNumber = 1,
artists = emptyList(),
album = null,
thumbnails = null,
explicit = false,
popularity = null,
isrcCode = null,
externalUri = null,
)
private fun basicSource(id: String, confidence: Float = 1f) = AudioSource.Basic(
id = id,
title = "source-$id",
artist = null,
album = null,
thumbnails = emptyList(),
externalUri = null,
confidence = confidence,
)
private fun streamedSource(
sourceId: String,
streams: List<AudioStream>,
confidence: Float = 1f,
) = AudioSource.Streamed(
id = sourceId,
title = "source-$sourceId",
artist = null,
album = null,
thumbnails = emptyList(),
externalUri = null,
confidence = confidence,
streams = streams,
)
private fun stream(url: String, bitrate: Int = 256_000) = AudioStream.Lossy(
url = url,
codec = "opus",
container = "webm",
protocol = StreamProtocol.PROGRESSIVE,
bitrate = bitrate,
)
private class Harness(cacheTtlMs: Long = 30_000) {
val queue = FakeStreamingAudioPlayerQueue()
val matchedTracksRepository = FakeTrackSourceRepository()
val settings = FakeUserSettingsSource()
val pluginSource = FakeAudioPluginSource()
val repository = StreamingUrlRepository(
pluginManager = pluginSource,
audioPlayerQueue = queue,
matchedTracksRepository = matchedTracksRepository,
settingsRepository = settings,
streamUrlCacheTtlMs = cacheTtlMs,
)
fun selectPlugin(pluginId: String, audioAPI: FakeAudioAPI = FakeAudioAPI()): FakeAudioPluginService {
val service = FakeAudioPluginService(pluginId, audioAPI)
pluginSource.setPlugin(service)
return service
}
}
// ---------- basic resolution ----------
@Test
fun `resolves and returns a stream via track lookup when nothing is cached`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertNotNull(info)
assertEquals("https://example.com/a.opus", info.url)
}
@Test
fun `returns null when no audio plugin is selected`() = runTest {
val h = Harness()
val info = h.repository.resolveStreamInfo(track("t1"))
assertNull(info)
}
@Test
fun `returns null when the plugin yields no sources`() = runTest {
val h = Harness()
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { emptyList() }
assertNull(h.repository.resolveStreamInfo(track("t1")))
}
@Test
fun `resolveStreamInfo by trackId looks up the matching queue entry`() = runTest {
val h = Harness()
val t = track("t1")
h.queue.setQueue(listOf(QueueEntry.StreamingTrack(track = t, url = "")))
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
val info = h.repository.resolveStreamInfo(trackId = "t1")
assertNotNull(info)
assertEquals("https://example.com/a.opus", info.url)
}
@Test
fun `resolveStreamInfo by trackId returns null when track is not queued`() = runTest {
val h = Harness()
h.selectPlugin("pluginA")
assertNull(h.repository.resolveStreamInfo(trackId = "not-queued"))
}
// ---------- in-memory URL cache ----------
@Test
fun `second resolution for the same track reuses the cache instead of calling the plugin again`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.resolveStreamInfo(t)
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `forceRefresh bypasses the cache and re-resolves`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
var counter = 0
plugin.audioAPI.onGetStreamsByTrack = {
counter++
listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus"))))
}
val first = h.repository.resolveStreamInfo(t)
val second = h.repository.resolveStreamInfo(t, forceRefresh = true)
assertEquals("https://example.com/1.opus", first?.url)
assertEquals("https://example.com/2.opus", second?.url)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `changing the preferred format invalidates the cached url`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(
streamedSource(
"src1",
listOf(
stream("https://example.com/opus.webm"),
AudioStream.Lossy(
url = "https://example.com/aac.mp4",
codec = "aac",
container = "mp4",
bitrate = 256_000,
),
)
)
)
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/opus.webm", first?.url)
h.settings.update {
copy(streamingMusicFormat = streamingMusicFormat.copy(codec = "aac", container = "mp4"))
}
val second = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/aac.mp4", second?.url)
// Preference change forced a second real lookup rather than serving the stale URL.
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `changing the preferred quality invalidates the cached url`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(
streamedSource(
"src1",
listOf(stream("https://example.com/128.opus", 128_000), stream("https://example.com/320.opus", 320_000))
)
)
}
h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(128_000)) }
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/128.opus", first?.url)
h.settings.update { copy(streamingMusicQuality = AudioQuality.Lossy(320_000)) }
val second = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/320.opus", second?.url)
}
@Test
fun `cached url expires after the configured ttl`() = kotlinx.coroutines.runBlocking {
val h = Harness(cacheTtlMs = 10)
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
var counter = 0
plugin.audioAPI.onGetStreamsByTrack = {
counter++
listOf(streamedSource("src$counter", listOf(stream("https://example.com/$counter.opus"))))
}
h.repository.resolveStreamInfo(t)
kotlinx.coroutines.delay(50)
h.repository.resolveStreamInfo(t)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `invalidateCachedStreamUrl removes the cached entry unconditionally when no url is given`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.invalidateCachedStreamUrl(t.id)
h.repository.resolveStreamInfo(t)
assertEquals(2, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `invalidateCachedStreamUrl with a mismatching url is a no-op`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
h.repository.invalidateCachedStreamUrl(t.id, url = "https://not-the-cached-url")
h.repository.resolveStreamInfo(t)
// Cache entry survived because the supplied url didn't match; plugin isn't hit again.
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
// ---------- matched-source fast path ----------
@Test
fun `a previously matched source from the same plugin is reused via getStreamsOfAudioSource`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/matched.opus"))))
}
plugin.audioAPI.onGetStreamsByTrack = {
error("should not fall back to track lookup when a matched source resolves")
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/matched.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
assertEquals(0, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `THE BUG - a source matched by a previous plugin is never handed to a newly selected plugin`() = runTest {
val h = Harness()
val t = track("t1")
// The user matched this track while "pluginA" (e.g. a YouTube-backed plugin) was active.
// "yt-video-123" is meaningful *only* to pluginA.
h.matchedTracksRepository.seed(t, basicSource("yt-video-123"), pluginId = "pluginA")
// The user then switches the active audio plugin to "pluginB" (a different backend).
val pluginB = h.selectPlugin("pluginB")
pluginB.audioAPI.onGetStreamsOfAudioSource = {
error(
"pluginB must never receive a source id matched by a different plugin " +
"(id=${it.id}); this is exactly how playback of the wrong track happens."
)
}
pluginB.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("pluginB-native-source", listOf(stream("https://example.com/correct.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
// pluginB never saw the stale "yt-video-123" id...
assertEquals(0, pluginB.audioAPI.getStreamsOfAudioSourceCallCount)
// ...and instead correctly fell back to a fresh, pluginB-native track lookup.
assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount)
assertEquals("https://example.com/correct.opus", info?.url)
}
@Test
fun `a cached stream url from a previous plugin is not served after switching plugins`() = runTest {
val h = Harness()
val t = track("t1")
val pluginA = h.selectPlugin("pluginA")
pluginA.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("srcA", listOf(stream("https://example.com/pluginA.opus"))))
}
val first = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/pluginA.opus", first?.url)
val pluginB = h.selectPlugin("pluginB")
pluginB.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("srcB", listOf(stream("https://example.com/pluginB.opus"))))
}
val second = h.repository.resolveStreamInfo(t)
// Must re-resolve via pluginB rather than replaying pluginA's cached URL.
assertEquals("https://example.com/pluginB.opus", second?.url)
assertEquals(1, pluginB.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `matched source is saved and tagged with the resolving plugin id`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
val saved = h.matchedTracksRepository.rawEntryFor(t.id)
assertNotNull(saved)
assertEquals("pluginA", saved.pluginId)
assertEquals("src1", saved.source.id)
}
@Test
fun `matched source is looked up but ignored when the format preference has no matching stream`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
// Matched source resolves to a stream, so preferred-format mismatch is irrelevant here;
// this documents that selection still happens on top of the matched-source stream list.
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/only.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/only.opus", info?.url)
}
@Test
fun `falls back to track lookup when the matched source resolves no streams`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
h.matchedTracksRepository.seed(t, basicSource("matched-1"), pluginId = "pluginA")
plugin.audioAPI.onGetStreamsOfAudioSource = { emptyList() }
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("fresh", listOf(stream("https://example.com/fresh.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/fresh.opus", info?.url)
assertEquals(1, plugin.audioAPI.getStreamsOfAudioSourceCallCount)
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
@Test
fun `track lookup persists a Basic source by resolving its streams before caching`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { listOf(basicSource("basic-1")) }
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/resolved.opus"))))
}
val info = h.repository.resolveStreamInfo(t)
assertEquals("https://example.com/resolved.opus", info?.url)
assertEquals("basic-1", h.matchedTracksRepository.rawEntryFor(t.id)?.source?.id)
}
@Test
fun `distinct tracks never share cache entries even with concurrent resolution`() = runTest {
val h = Harness()
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = { t ->
listOf(streamedSource("src-${t.id}", listOf(stream("https://example.com/${t.id}.opus"))))
}
val t1 = track("t1")
val t2 = track("t2")
val info1 = h.repository.resolveStreamInfo(t1)
val info2 = h.repository.resolveStreamInfo(t2)
assertEquals("https://example.com/t1.opus", info1?.url)
assertEquals("https://example.com/t2.opus", info2?.url)
assertTrue(info1?.url != info2?.url)
}
// ---------- alternatives cache ----------
@Test
fun `getCachedAlternatives returns null before anything is cached`() = runTest {
val h = Harness()
assertNull(h.repository.getCachedAlternatives("t1", "pluginA"))
}
@Test
fun `alternatives resolved via track lookup are cached and retrievable`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
val candidates = listOf(basicSource("alt-1"), basicSource("alt-2"))
plugin.audioAPI.onGetStreamsByTrack = { candidates }
plugin.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/${it.id}.opus"))))
}
h.repository.resolveStreamInfo(t)
val cached = h.repository.getCachedAlternatives(t.id, "pluginA")
assertNotNull(cached)
assertEquals(2, cached.size)
}
@Test
fun `alternatives cached by one plugin are not visible to a different plugin`() = runTest {
val h = Harness()
val t = track("t1")
val pluginA = h.selectPlugin("pluginA")
pluginA.audioAPI.onGetStreamsByTrack = { listOf(basicSource("alt-1")) }
pluginA.audioAPI.onGetStreamsOfAudioSource = {
listOf(streamedSource(it.id, listOf(stream("https://example.com/a.opus"))))
}
h.repository.resolveStreamInfo(t)
assertNotNull(h.repository.getCachedAlternatives(t.id, "pluginA"))
assertNull(h.repository.getCachedAlternatives(t.id, "pluginB"))
}
@Test
fun `cacheAlternatives followed by invalidateCachedAlternatives clears the entry`() = runTest {
val h = Harness()
h.repository.cacheAlternatives("t1", listOf(basicSource("alt-1")), "pluginA")
assertNotNull(h.repository.getCachedAlternatives("t1", "pluginA"))
h.repository.invalidateCachedAlternatives("t1")
assertNull(h.repository.getCachedAlternatives("t1", "pluginA"))
}
// ---------- getCachedStreamUrlEntry ----------
@Test
fun `getCachedStreamUrlEntry returns null when nothing has been cached`() = runTest {
val h = Harness()
assertNull(h.repository.getCachedStreamUrlEntry("t1"))
}
@Test
fun `getCachedStreamUrlEntry never fetches - it only reflects the existing cache`() = runTest {
val h = Harness()
val t = track("t1")
val plugin = h.selectPlugin("pluginA")
plugin.audioAPI.onGetStreamsByTrack = {
listOf(streamedSource("src1", listOf(stream("https://example.com/a.opus"))))
}
assertNull(h.repository.getCachedStreamUrlEntry(t.id))
h.repository.resolveStreamInfo(t)
val entry = h.repository.getCachedStreamUrlEntry(t.id)
assertNotNull(entry)
assertEquals("https://example.com/a.opus", entry.url)
assertEquals("pluginA", entry.pluginId)
// Fetching the entry doesn't call the plugin again.
assertEquals(1, plugin.audioAPI.getStreamsByTrackCallCount)
}
}

View File

@ -0,0 +1,137 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioSource
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.zipline.PluginService
import dev.krtirtho.spotube.core.zipline.PluginServiceScope
import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
import dev.krtirtho.spotube.modules.settings.UserSettings
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** In-memory [AudioAPI] whose behaviour for each call is fully scripted by the test. */
class FakeAudioAPI(
override val supportedQualities: List<AudioFormat> = emptyList(),
var onGetStreamsByTrack: suspend (MetadataTrack) -> List<AudioSource> = { emptyList() },
var onGetStreamsOfAudioSource: suspend (AudioSource.Basic) -> List<AudioSource.Streamed> = { emptyList() },
) : AudioAPI {
var getStreamsByTrackCallCount = 0
var getStreamsOfAudioSourceCallCount = 0
val getStreamsOfAudioSourceCalls = mutableListOf<AudioSource.Basic>()
override suspend fun getStreamsByTrack(track: MetadataTrack): List<AudioSource> {
getStreamsByTrackCallCount++
return onGetStreamsByTrack(track)
}
override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {
getStreamsOfAudioSourceCallCount++
getStreamsOfAudioSourceCalls.add(source)
return onGetStreamsOfAudioSource(source)
}
}
/** Minimal [PluginService] that only ever exposes an [AudioAPI], identified by [pluginId]. */
class FakeAudioPluginService(
override val pluginId: String,
val audioAPI: FakeAudioAPI = FakeAudioAPI(),
) : PluginService {
override val loggedInFlow: StateFlow<Boolean> = MutableStateFlow(true)
override suspend fun start() = Unit
override suspend fun stop() = Unit
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
val scope = PluginServiceScope(mapOf(AudioAPI::class to audioAPI))
return scope.block()
}
}
class FakeAudioPluginSource(
initialPlugin: PluginService? = null
) : AudioPluginSource {
private val _selectedAudioPlugin = MutableStateFlow(initialPlugin)
override val selectedAudioPlugin: StateFlow<PluginService?> = _selectedAudioPlugin
fun setPlugin(service: PluginService?) {
_selectedAudioPlugin.value = service
}
}
/** In-memory [TrackSourceRepository], mirroring the plugin-scoped persistence contract of
* the real [MatchedTracksRepository] without touching disk. */
class FakeTrackSourceRepository : TrackSourceRepository {
private val store = mutableMapOf<String, PluginTaggedAudioSource>()
var getCallCount = 0
var saveCallCount = 0
override suspend fun getTrackSource(track: MetadataTrack, pluginId: String): AudioSource.Basic? {
getCallCount++
val tagged = store[track.id] ?: return null
if (tagged.pluginId != pluginId) return null
return tagged.source
}
override suspend fun saveTrackSource(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
saveCallCount++
store[track.id] = PluginTaggedAudioSource(pluginId, source)
}
fun rawEntryFor(trackId: String): PluginTaggedAudioSource? = store[trackId]
fun seed(track: MetadataTrack, source: AudioSource.Basic, pluginId: String) {
store[track.id] = PluginTaggedAudioSource(pluginId, source)
}
}
class FakeUserSettingsSource(initial: UserSettings = UserSettings()) : UserSettingsSource {
private val _userSettings = MutableStateFlow(initial)
override val userSettings: StateFlow<UserSettings> = _userSettings
fun update(transform: UserSettings.() -> UserSettings) {
_userSettings.value = _userSettings.value.transform()
}
}
/** Bare-bones [AudioPlayerQueue] that only backs [queueFlow]; every other member is unused
* by [StreamingUrlRepository] and throws if accidentally exercised. */
class FakeStreamingAudioPlayerQueue : AudioPlayerQueue {
private val _queueFlow = MutableStateFlow<List<QueueEntry>>(emptyList())
override val queueFlow: StateFlow<List<QueueEntry>> = _queueFlow
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = MutableStateFlow(null)
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> = MutableStateFlow(null)
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> = MutableStateFlow(emptyList())
fun setQueue(entries: List<QueueEntry>) {
_queueFlow.value = entries
}
override suspend fun load(
entries: List<QueueEntry>,
autoPlay: Boolean,
startPosition: Int,
collectionEntry: QueueCollectionEntry?,
) = error("not used")
override suspend fun addToQueue(entry: QueueEntry) = error("not used")
override suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry?) =
error("not used")
override suspend fun addAllAfterCurrent(entries: List<QueueEntry>) = error("not used")
override suspend fun removeFromQueue(entry: QueueEntry) = error("not used")
override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = error("not used")
override suspend fun move(fromIndex: Int, toIndex: Int) = error("not used")
override suspend fun jumpTo(index: Int, autoPlay: Boolean) = error("not used")
override suspend fun reloadCurrent() = error("not used")
override suspend fun clear() = error("not used")
override suspend fun getQueue(): List<QueueEntry> = _queueFlow.value
override suspend fun getCurrentQueueEntry(): QueueEntry? = null
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
}

View File

@ -0,0 +1,434 @@
package dev.krtirtho.spotube.core.server
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioStream
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
/**
* Tests for the stream preference selection used by [StreamingUrlRepository].
*
* Ordering contract (best first):
* 1. Fewest codec/container mismatches against the preferred format.
* 2. Matching quality family (lossy vs lossless).
* 3. Closest bitrate / sample rate.
* 4. Closest channel count (lossless only).
* 5. First occurrence wins remaining ties.
*/
class StreamingUrlRepositoryTest {
// ---------- helpers ----------
private fun opusWebm() = AudioFormat(codec = "opus", container = "webm", qualities = emptyList())
private fun flac() = AudioFormat(codec = "flac", container = "flac", qualities = emptyList())
private fun lossy(
url: String,
bitrate: Int,
codec: String = "opus",
container: String = "webm",
protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
) = AudioStream.Lossy(
url = url,
codec = codec,
container = container,
protocol = protocol,
bitrate = bitrate,
)
private fun lossless(
url: String,
sampleRate: Int,
channels: Int,
codec: String = "flac",
container: String = "flac",
) = AudioStream.Lossless(
url = url,
codec = codec,
container = container,
sampleRate = sampleRate,
channels = channels,
)
private fun select(
streams: List<AudioStream>,
format: AudioFormat = opusWebm(),
quality: AudioQuality = AudioQuality.Lossy(256_000),
) = selectPreferredAudioStream(streams, format, quality)
// ---------- empty / single ----------
@Test
fun `returns null when no streams are available`() {
assertNull(select(emptyList()))
}
@Test
fun `returns the only stream when exactly one is available`() {
val only = lossy("opus-128", 128_000)
assertSame(only, select(listOf(only)))
}
@Test
fun `does not mutate the input list`() {
val streams = mutableListOf(
lossy("opus-44", 44_000),
lossy("opus-256", 256_000),
)
val snapshot = streams.toList()
select(streams)
assertEquals(snapshot, streams)
}
// ---------- format priority ----------
@Test
fun `prefers an exact codec and container match over a closer quality in another format`() {
val otherFormat = lossy("aac-256", 256_000, codec = "aac", container = "mp4")
val preferredFormat = lossy("opus-128", 128_000)
val result = select(
streams = listOf(otherFormat, preferredFormat),
quality = AudioQuality.Lossy(256_000),
)
assertSame(preferredFormat, result)
}
@Test
fun `format matching ignores codec and container case`() {
val exact = lossy("opus-128", 128_000, codec = "OpUs", container = "WeBm")
val mismatch = lossy("aac-128", 128_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(mismatch, exact),
format = AudioFormat(codec = "OPUS", container = "WEBM", qualities = emptyList()),
)
assertSame(exact, result)
}
@Test
fun `a partial format match beats a full format mismatch regardless of bitrate`() {
val partial = lossy("opus-mp4-320", 320_000, container = "mp4")
val fullMismatch = lossy("aac-mp4-44", 44_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(fullMismatch, partial),
quality = AudioQuality.Lossy(44_000),
)
assertSame(partial, result)
}
@Test
fun `a one-field match beats a two-field mismatch`() {
val oneField = lossy("opus-ogg", 128_000, container = "ogg")
val twoFields = lossy("aac-mp4", 128_000, codec = "aac", container = "mp4")
val result = select(streams = listOf(twoFields, oneField))
assertSame(oneField, result)
}
@Test
fun `codec-only and container-only matches tie on format and are resolved by quality`() {
val codecOnly = lossy("opus-mp4-320", 320_000, container = "mp4")
val containerOnly = lossy("aac-webm-44", 44_000, codec = "aac", container = "webm")
val result = select(
streams = listOf(codecOnly, containerOnly),
quality = AudioQuality.Lossy(44_000),
)
assertSame(containerOnly, result)
}
@Test
fun `when nothing matches the format the closest quality is chosen`() {
val low = lossy("aac-128", 128_000, codec = "aac", container = "mp4")
val target = lossy("aac-256", 256_000, codec = "aac", container = "mp4")
val high = lossy("aac-320", 320_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(low, target, high),
quality = AudioQuality.Lossy(256_000),
)
assertSame(target, result)
}
@Test
fun `format match is prioritized over matching the quality family`() {
// Same format but wrong family vs. wrong format but correct family.
val sameFormatWrongFamily = lossless("opus-webm-flac", 48_000, 2, codec = "opus", container = "webm")
val wrongFormatRightFamily = lossy("aac-44", 44_000, codec = "aac", container = "mp4")
val result = select(
streams = listOf(wrongFormatRightFamily, sameFormatWrongFamily),
quality = AudioQuality.Lossy(44_000),
)
assertSame(sameFormatWrongFamily, result)
}
// ---------- lossy quality ----------
@Test
fun `exact lossy bitrate wins`() {
val low = lossy("opus-44", 44_000)
val exact = lossy("opus-128", 128_000)
val high = lossy("opus-256", 256_000)
val result = select(
streams = listOf(low, exact, high),
quality = AudioQuality.Lossy(128_000),
)
assertSame(exact, result)
}
@Test
fun `closest lower lossy bitrate wins`() {
val lower = lossy("opus-128", 128_000)
val higher = lossy("opus-320", 320_000)
val result = select(
streams = listOf(higher, lower),
quality = AudioQuality.Lossy(200_000),
)
assertSame(lower, result)
}
@Test
fun `closest higher lossy bitrate wins`() {
val lower = lossy("opus-44", 44_000)
val higher = lossy("opus-128", 128_000)
val result = select(
streams = listOf(lower, higher),
quality = AudioQuality.Lossy(100_000),
)
assertSame(higher, result)
}
@Test
fun `equidistant lossy bitrates pick the first occurrence`() {
val below = lossy("opus-96", 96_000)
val above = lossy("opus-104", 104_000)
assertSame(below, select(listOf(below, above), quality = AudioQuality.Lossy(100_000)))
assertSame(above, select(listOf(above, below), quality = AudioQuality.Lossy(100_000)))
}
@Test
fun `lossy preference prefers a lossy stream over lossless in the same format`() {
val losslessStream = lossless("opus-webm-lossless", 48_000, 2, codec = "opus", container = "webm")
val lossyStream = lossy("opus-256", 256_000)
val result = select(
streams = listOf(losslessStream, lossyStream),
quality = AudioQuality.Lossy(128_000),
)
assertSame(lossyStream, result)
}
@Test
fun `lossy distance does not overflow for extreme bitrates`() {
val min = lossy("opus-min", Int.MIN_VALUE)
val max = lossy("opus-max", Int.MAX_VALUE)
val result = select(
streams = listOf(min, max),
quality = AudioQuality.Lossy(Int.MAX_VALUE),
)
assertSame(max, result)
}
// ---------- lossless quality ----------
@Test
fun `exact lossless sample rate and channels wins`() {
val otherRate = lossless("flac-44", 44_100, 2)
val mono = lossless("flac-48-mono", 48_000, 1)
val exact = lossless("flac-48-stereo", 48_000, 2)
val result = select(
streams = listOf(otherRate, mono, exact),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(exact, result)
}
@Test
fun `closest lossless sample rate wins`() {
val near = lossless("flac-44", 44_100, 2)
val far = lossless("flac-96", 96_000, 2)
val result = select(
streams = listOf(far, near),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(near, result)
}
@Test
fun `lossless sample rate tie is broken by channel count`() {
val mono = lossless("flac-44-mono", 44_100, 1)
val stereo = lossless("flac-44-stereo", 44_100, 2)
val result = select(
streams = listOf(mono, stereo),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(stereo, result)
}
@Test
fun `lossless preference prefers a lossless stream over lossy in the same format`() {
val lossyStream = lossy("flac-flac-lossy", 128_000, codec = "flac", container = "flac")
val losslessStream = lossless("flac-44", 44_100, 2)
val result = select(
streams = listOf(lossyStream, losslessStream),
format = flac(),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(losslessStream, result)
}
// ---------- quality-family fallbacks ----------
@Test
fun `lossy preference with only lossless streams still returns the first stream`() {
val first = lossless("flac-44", 44_100, 2)
val second = lossless("flac-48", 48_000, 2)
val result = select(
streams = listOf(first, second),
format = flac(),
quality = AudioQuality.Lossy(256_000),
)
assertSame(first, result)
}
@Test
fun `lossless preference with only lossy streams still returns the first stream`() {
val first = lossy("opus-128", 128_000)
val second = lossy("opus-320", 320_000)
val result = select(
streams = listOf(first, second),
quality = AudioQuality.Lossless(sampleRate = 48_000, channels = 2),
)
assertSame(first, result)
}
// ---------- degenerate / orthogonal inputs ----------
@Test
fun `empty preferred codec and container fall back to quality`() {
val low = lossy("opus-44", 44_000)
val target = lossy("opus-256", 256_000)
val result = select(
streams = listOf(low, target),
format = AudioFormat(codec = "", container = "", qualities = emptyList()),
quality = AudioQuality.Lossy(256_000),
)
assertSame(target, result)
}
@Test
fun `protocol does not influence selection`() {
val progressive = lossy("a", 128_000, protocol = StreamProtocol.PROGRESSIVE)
val dash = lossy("b", 128_000, protocol = StreamProtocol.DASH)
val hls = lossy("c", 128_000, protocol = StreamProtocol.HLS)
val result = select(streams = listOf(progressive, dash, hls))
assertSame(progressive, result)
}
@Test
fun `selection is stable across repeated calls`() {
val streams = listOf(
lossy("opus-44", 44_000),
lossy("opus-128", 128_000),
lossy("opus-256", 256_000),
)
val first = select(streams, quality = AudioQuality.Lossy(100_000))
val second = select(streams, quality = AudioQuality.Lossy(100_000))
assertSame(first, second)
}
// ---------- normalizeManifestUrl ----------
@Test
fun `absolute http urls are returned unchanged`() {
val url = "http://example.com/audio.mp3"
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE))
}
@Test
fun `absolute https urls are returned unchanged for every protocol`() {
val url = "https://example.com/manifest.m3u8"
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.HLS))
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.DASH))
assertEquals(url, normalizeManifestUrl(url, StreamProtocol.PROGRESSIVE))
}
@Test
fun `relative hls manifest paths are resolved against the youtube origin`() {
assertEquals(
"https://www.youtube.com/watch?v=abc",
normalizeManifestUrl("/watch?v=abc", StreamProtocol.HLS),
)
}
@Test
fun `relative dash manifest paths are resolved against the youtube origin`() {
assertEquals(
"https://www.youtube.com/watch?v=abc",
normalizeManifestUrl("/watch?v=abc", StreamProtocol.DASH),
)
}
@Test
fun `relative progressive urls are returned unchanged`() {
assertEquals(
"/watch?v=abc",
normalizeManifestUrl("/watch?v=abc", StreamProtocol.PROGRESSIVE),
)
}
@Test
fun `empty url is handled without throwing`() {
assertEquals("", normalizeManifestUrl("", StreamProtocol.PROGRESSIVE))
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.DASH))
assertEquals("https://www.youtube.com", normalizeManifestUrl("", StreamProtocol.HLS))
}
}

Some files were not shown because too many files have changed in this diff Show More