mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-09-20 06:34:00 +00:00
Merge df9403f2d3 into 94ec831a96
This commit is contained in:
commit
8787dbc420
66
.github/workflows/spotube-release-binary.yml
vendored
66
.github/workflows/spotube-release-binary.yml
vendored
@ -221,6 +221,67 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
limit-access-to-actor: true
|
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:
|
build-windows:
|
||||||
name: Windows Desktop
|
name: Windows Desktop
|
||||||
needs: prepare-deps
|
needs: prepare-deps
|
||||||
@ -321,6 +382,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-android
|
- build-android
|
||||||
- build-linux
|
- build-linux
|
||||||
|
- build-linux-arm64
|
||||||
- build-windows
|
- build-windows
|
||||||
- build-macos
|
- build-macos
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -352,7 +414,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "## Downloads" > release-notes.md
|
echo "## Downloads" > release-notes.md
|
||||||
echo "" >> 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
|
if [ -d "$dir" ]; then
|
||||||
echo "### $dir" >> release-notes.md
|
echo "### $dir" >> release-notes.md
|
||||||
for f in "$dir"/*; do
|
for f in "$dir"/*; do
|
||||||
@ -376,6 +438,8 @@ jobs:
|
|||||||
linux-deb/*.deb
|
linux-deb/*.deb
|
||||||
linux-rpm/*.rpm
|
linux-rpm/*.rpm
|
||||||
linux-appimage/*.AppImage
|
linux-appimage/*.AppImage
|
||||||
|
linux-deb-arm64/*.deb
|
||||||
|
linux-rpm-arm64/*.rpm
|
||||||
windows-msi/*.msi
|
windows-msi/*.msi
|
||||||
windows-exe/*.exe
|
windows-exe/*.exe
|
||||||
macos-dmg/*.dmg
|
macos-dmg/*.dmg
|
||||||
|
|||||||
110
composeApp/Cargo.lock
generated
110
composeApp/Cargo.lock
generated
@ -56,6 +56,19 @@ dependencies = [
|
|||||||
"winnow",
|
"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]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@ -133,6 +146,10 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
"lofty",
|
"lofty",
|
||||||
|
"log",
|
||||||
|
"parking_lot",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
"uniffi",
|
"uniffi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -207,6 +224,18 @@ dependencies = [
|
|||||||
"autocfg",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@ -286,6 +315,15 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
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]]
|
[[package]]
|
||||||
name = "lofty"
|
name = "lofty"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@ -365,6 +403,29 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
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]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@ -377,6 +438,12 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "plain"
|
name = "plain"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@ -407,6 +474,15 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@ -426,6 +502,12 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scopeguard"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scroll"
|
name = "scroll"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@ -522,6 +604,12 @@ version = "0.3.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.15.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smawk"
|
name = "smawk"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@ -598,6 +686,27 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.5.11"
|
version = "0.5.11"
|
||||||
@ -660,6 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
|
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-compat",
|
||||||
"bytes",
|
"bytes",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
|
|||||||
@ -4,9 +4,13 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
uniffi = "0.29.4"
|
uniffi = { version = "0.29.4", features = ["tokio"] }
|
||||||
lofty = "0.24.0"
|
lofty = "0.24.0"
|
||||||
discord-rich-presence = "1.1.0"
|
discord-rich-presence = "1.1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
log = "0.4"
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "staticlib"]
|
crate-type = ["cdylib", "staticlib"]
|
||||||
|
|||||||
@ -86,6 +86,7 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation(libs.dns.sd.kt)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
@ -104,8 +105,10 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.websockets)
|
||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
|
implementation(libs.ktor.server.websockets)
|
||||||
// Zipline
|
// Zipline
|
||||||
api(libs.zipline.core)
|
api(libs.zipline.core)
|
||||||
implementation(libs.zipline.loader)
|
implementation(libs.zipline.loader)
|
||||||
@ -160,6 +163,15 @@ kotlin {
|
|||||||
// Shimmer effect
|
// Shimmer effect
|
||||||
implementation(libs.compose.shimmer)
|
implementation(libs.compose.shimmer)
|
||||||
implementation(libs.compose.placeholder.material3)
|
implementation(libs.compose.placeholder.material3)
|
||||||
|
|
||||||
|
// DLNA
|
||||||
|
implementation(libs.dns.sd.kt)
|
||||||
|
|
||||||
|
// mqtt client for jam-session
|
||||||
|
implementation(libs.mqtt.client)
|
||||||
|
implementation(libs.mqtt.x.models)
|
||||||
|
implementation(libs.mqtt.buffer)
|
||||||
|
implementation(libs.mqtt.buffer.codec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
|
|||||||
@ -16,13 +16,19 @@
|
|||||||
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
|
android:usesPermissionFlags="neverForLocation"
|
||||||
|
tools:targetApi="33" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
|
|||||||
@ -17,12 +17,16 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube
|
package dev.krtirtho.spotube
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
|
||||||
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
|
import dev.krtirtho.spotube.media.PlaybackService
|
||||||
import io.github.vinceglb.filekit.FileKit
|
import io.github.vinceglb.filekit.FileKit
|
||||||
import io.github.vinceglb.filekit.dialogs.init
|
import io.github.vinceglb.filekit.dialogs.init
|
||||||
|
|
||||||
@ -31,9 +35,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
FileKit.init(this)
|
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 {
|
setContent {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent.dataString?.let(ExternalUriHandler::onNewUri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -18,10 +18,8 @@
|
|||||||
package dev.krtirtho.spotube
|
package dev.krtirtho.spotube
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import dev.krtirtho.spotube.core.di.initKoin
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@ -34,15 +32,10 @@ class MyApplication : Application(), KoinComponent {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Paths.init(this)
|
||||||
initKoin {
|
initKoin {
|
||||||
androidContext(this@MyApplication)
|
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() {
|
override fun onTerminate() {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ package dev.krtirtho.spotube.core.audioplayer
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
@ -51,12 +52,20 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
private val appContext: Context = (context as Context).applicationContext
|
private val appContext: Context = (context as Context).applicationContext
|
||||||
|
|
||||||
private fun ensureServiceStarted() {
|
private fun ensureServiceStarted() {
|
||||||
|
// 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)
|
val intent = Intent(appContext, PlaybackService::class.java)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
appContext.startForegroundService(intent)
|
appContext.startForegroundService(intent)
|
||||||
} else {
|
} else {
|
||||||
appContext.startService(intent)
|
appContext.startService(intent)
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("AudioPlayer", "Failed to start playback service", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(appContext)
|
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(appContext)
|
||||||
@ -226,7 +235,12 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
|
|
||||||
actual override suspend fun seekTo(position: Duration) {
|
actual override suspend fun seekTo(position: Duration) {
|
||||||
withContext(Dispatchers.Main) {
|
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)
|
exoPlayer.seekTo(targetMs)
|
||||||
_position.tryEmit(exoPlayer.currentPosition.milliseconds)
|
_position.tryEmit(exoPlayer.currentPosition.milliseconds)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import dev.krtirtho.spotube.modules.library.local_tracks.media.LocalMediaDiscove
|
|||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
actual val platformModules = module {
|
actual val platformModules = module {
|
||||||
single { Paths(get()) }
|
single { Paths() }
|
||||||
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
|
single<AudioPlayerInterface> { AudioPlayer(get<Context>()) }
|
||||||
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
|
single<LocalMediaDiscoveryService> { AndroidLocalMediaDiscoveryService(get()) }
|
||||||
single<ShareService> { AndroidShareService(get()) }
|
single<ShareService> { AndroidShareService(get()) }
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,9 +20,10 @@ package dev.krtirtho.spotube.core.paths
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
|
||||||
actual class Paths(
|
actual class Paths {
|
||||||
val context: Context
|
private val context: Context
|
||||||
) {
|
get() = requireNotNull(appContext) { "Paths.init(context) must be called before use" }
|
||||||
|
|
||||||
actual fun getApplicationCacheDirPath(): String {
|
actual fun getApplicationCacheDirPath(): String {
|
||||||
return context.cacheDir.absolutePath
|
return context.cacheDir.absolutePath
|
||||||
}
|
}
|
||||||
@ -38,4 +39,13 @@ actual class Paths(
|
|||||||
actual fun getMusicCacheDirPath(): String {
|
actual fun getMusicCacheDirPath(): String {
|
||||||
return context.cacheDir.absolutePath + "/music_cache"
|
return context.cacheDir.absolutePath + "/music_cache"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -86,7 +86,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
// 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)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "startForeground not allowed; continuing without notification", e)
|
||||||
|
}
|
||||||
|
|
||||||
librarySession =
|
librarySession =
|
||||||
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
|
||||||
|
|||||||
@ -96,6 +96,13 @@
|
|||||||
<string name="settings_enable_connect_title">Enable Connect</string>
|
<string name="settings_enable_connect_title">Enable Connect</string>
|
||||||
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
|
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
|
||||||
<string name="settings_playback_port_title">Playback proxy server port</string>
|
<string name="settings_playback_port_title">Playback proxy server port</string>
|
||||||
|
<string name="settings_allow_remote_control_title">Allow remote control</string>
|
||||||
|
<string name="settings_allow_remote_control_subtitle">Let other devices on the same network control playback when they connect.</string>
|
||||||
|
<string name="settings_remote_device_name_title">Remote control device name</string>
|
||||||
|
<string name="settings_remote_device_name_subtitle">The name other devices see when discovering this one. Current: %1$s</string>
|
||||||
|
<string name="settings_remote_device_name_description">Choose a friendly name to show to other devices on your network when they discover this Spotube instance.</string>
|
||||||
|
<string name="settings_remote_device_name_placeholder">e.g. Living Room Spotube</string>
|
||||||
|
<string name="settings_remote_device_name_default">device hostname</string>
|
||||||
<string name="settings_playback_port_subtitle_current">Port used by the playback proxy server. Current: %1$d</string>
|
<string name="settings_playback_port_subtitle_current">Port used by the playback proxy server. Current: %1$d</string>
|
||||||
<string name="settings_playback_port_description">Choose a port between 1 and 65535.</string>
|
<string name="settings_playback_port_description">Choose a port between 1 and 65535.</string>
|
||||||
<string name="settings_playback_port_placeholder">14769</string>
|
<string name="settings_playback_port_placeholder">14769</string>
|
||||||
@ -166,5 +173,19 @@
|
|||||||
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
|
<string name="plugin_permissions_capability_network_desc">Send and receive data over the internet</string>
|
||||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||||
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
|
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
|
||||||
|
<string name="settings_section_jam">Group Jam</string>
|
||||||
|
<string name="settings_jam_broker_title">MQTT Broker</string>
|
||||||
|
<string name="settings_jam_broker_host">Broker host</string>
|
||||||
|
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||||
|
<string name="settings_jam_broker_port">Broker port</string>
|
||||||
|
<string name="settings_jam_broker_tls">Use TLS</string>
|
||||||
|
<string name="settings_jam_broker_username">Username (optional)</string>
|
||||||
|
<string name="settings_jam_broker_password">Password (optional)</string>
|
||||||
|
<string name="settings_jam_broker_client_id">Client ID prefix</string>
|
||||||
|
<string name="settings_jam_broker_test">Test connection</string>
|
||||||
|
<string name="settings_jam_broker_testing">Testing…</string>
|
||||||
|
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
|
||||||
|
<string name="settings_jam_broker_test_fail">Failed: %1$s</string>
|
||||||
|
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
sealed interface QueueEntry {
|
sealed interface QueueEntry {
|
||||||
val url: String
|
val url: String
|
||||||
|
val addedBy: String
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("streaming")
|
@SerialName("streaming")
|
||||||
@ -32,6 +33,7 @@ sealed interface QueueEntry {
|
|||||||
val track: MetadataTrack,
|
val track: MetadataTrack,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||||
|
override val addedBy: String = "",
|
||||||
) : QueueEntry
|
) : QueueEntry
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@ -42,7 +44,8 @@ sealed interface QueueEntry {
|
|||||||
val duration: Long,
|
val duration: Long,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
val coverBytes: ByteArray?,
|
val coverBytes: ByteArray?,
|
||||||
override val url: String
|
override val url: String,
|
||||||
|
override val addedBy: String = "",
|
||||||
) : QueueEntry {
|
) : QueueEntry {
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
@ -56,6 +59,7 @@ sealed interface QueueEntry {
|
|||||||
if (album != other.album) return false
|
if (album != other.album) return false
|
||||||
if (!coverBytes.contentEquals(other.coverBytes)) return false
|
if (!coverBytes.contentEquals(other.coverBytes)) return false
|
||||||
if (url != other.url) return false
|
if (url != other.url) return false
|
||||||
|
if (addedBy != other.addedBy) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -67,6 +71,7 @@ sealed interface QueueEntry {
|
|||||||
result = 31 * result + (album?.hashCode() ?: 0)
|
result = 31 * result + (album?.hashCode() ?: 0)
|
||||||
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
|
result = 31 * result + (coverBytes?.contentHashCode() ?: 0)
|
||||||
result = 31 * result + url.hashCode()
|
result = 31 * result + url.hashCode()
|
||||||
|
result = 31 * result + addedBy.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.deeplink
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,8 +23,15 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository
|
||||||
import dev.krtirtho.spotube.core.db.Database
|
import dev.krtirtho.spotube.core.db.Database
|
||||||
|
import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService
|
||||||
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
import dev.krtirtho.spotube.core.discord.DiscordRpcService
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomClient
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import dev.krtirtho.spotube.core.navigation.navigationModule
|
import dev.krtirtho.spotube.core.navigation.navigationModule
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
||||||
|
import dev.krtirtho.spotube.core.remote.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.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||||
import dev.krtirtho.spotube.core.server.CacheManager
|
import dev.krtirtho.spotube.core.server.CacheManager
|
||||||
@ -38,6 +45,9 @@ import dev.krtirtho.spotube.modules.artist.ArtistRepository
|
|||||||
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.devices.DevicesViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.devices.RemoteControlViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.jam.JamViewModel
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
import dev.krtirtho.spotube.modules.downloads.DownloadManager
|
||||||
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
import dev.krtirtho.spotube.modules.home.HomeScreenRepository
|
||||||
@ -133,6 +143,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
|
remotePlaybackController = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,6 +158,7 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
|
remotePlaybackController = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,12 +174,22 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
|
remotePlaybackController = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blacklist
|
// Blacklist
|
||||||
singleOf(::BlacklistRepository)
|
singleOf(::BlacklistRepository)
|
||||||
viewModelOf(::BlacklistViewModel)
|
viewModelOf(::BlacklistViewModel)
|
||||||
|
viewModel { DevicesViewModel(get()) }
|
||||||
|
viewModelOf(::RemoteControlViewModel)
|
||||||
|
viewModel {
|
||||||
|
JamViewModel(
|
||||||
|
jamRoomService = get(),
|
||||||
|
shareService = get(),
|
||||||
|
settingsProvider = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
singleOf(::AlbumRepository)
|
singleOf(::AlbumRepository)
|
||||||
@ -182,6 +204,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
|
remotePlaybackController = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,6 +217,7 @@ val sharedModules = module {
|
|||||||
albumRepository = get(),
|
albumRepository = get(),
|
||||||
playlistRepository = get(),
|
playlistRepository = get(),
|
||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
|
artistRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
)
|
)
|
||||||
@ -205,6 +229,15 @@ val sharedModules = module {
|
|||||||
singleOf(::LocalServer) withOptions {
|
singleOf(::LocalServer) withOptions {
|
||||||
createdAtStart()
|
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>() }
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||||
|
|||||||
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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,
|
||||||
|
}
|
||||||
@ -0,0 +1,323 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.ditchoom.buffer.Charset
|
||||||
|
import com.ditchoom.buffer.codec.asReadBuffer
|
||||||
|
import com.ditchoom.buffer.toReadBuffer
|
||||||
|
import com.ditchoom.mqtt.client.ConnectionState
|
||||||
|
import com.ditchoom.mqtt.client.MqttClient
|
||||||
|
import com.ditchoom.mqtt.connection.MqttConnectionOptions
|
||||||
|
import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec
|
||||||
|
import com.ditchoom.mqtt.controlpacket.QualityOfService
|
||||||
|
import com.ditchoom.mqtt.controlpacket.TopicName
|
||||||
|
import com.ditchoom.mqtt.controlpacket.WillConfig
|
||||||
|
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
||||||
|
import dev.krtirtho.spotube.modules.settings.JamBroker
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
||||||
|
*
|
||||||
|
* Topics (room code `C`):
|
||||||
|
* - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone)
|
||||||
|
* - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest)
|
||||||
|
* - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will
|
||||||
|
* (`left = true`) so a dropped client disappears from the room automatically.
|
||||||
|
*
|
||||||
|
* The library keeps the connection alive (auto-reconnect + backoff); this class only
|
||||||
|
* re-establishes the room subscription and re-publishes presence after a reconnect.
|
||||||
|
*/
|
||||||
|
class JamRoomClient : KoinComponent {
|
||||||
|
private val log = Logger.withTag("JamRoomClient")
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
classDiscriminator = "type"
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var client: MqttClient? = null
|
||||||
|
|
||||||
|
private var roomCode: String? = null
|
||||||
|
private var localPresence: JamPresence? = null
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectionError = MutableStateFlow<String?>(null)
|
||||||
|
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||||
|
|
||||||
|
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8)
|
||||||
|
val state: SharedFlow<JamMessage.QueueState> = _state.asSharedFlow()
|
||||||
|
|
||||||
|
private val _commands = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
|
||||||
|
val commands: SharedFlow<JamMessage> = _commands.asSharedFlow()
|
||||||
|
|
||||||
|
private val _presence = MutableStateFlow<Map<String, JamPresence>>(emptyMap())
|
||||||
|
val presence: StateFlow<Map<String, JamPresence>> = _presence.asStateFlow()
|
||||||
|
|
||||||
|
// ---------- Public API ----------
|
||||||
|
|
||||||
|
/** One-off connection check used by the settings screen. Returns latency description. */
|
||||||
|
suspend fun testConnection(broker: JamBroker): Result<String> {
|
||||||
|
if (broker.host.isBlank()) return Result.failure(IllegalArgumentException("Broker host is empty"))
|
||||||
|
val started = kotlin.time.TimeSource.Monotonic.markNow()
|
||||||
|
return runCatching {
|
||||||
|
val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test")
|
||||||
|
try {
|
||||||
|
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
||||||
|
client.awaitConnectivity()
|
||||||
|
}
|
||||||
|
"Connected in ${started.elapsedNow().inWholeMilliseconds} ms"
|
||||||
|
} finally {
|
||||||
|
runCatching { client.shutdown(sendDisconnect = true, drain = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connects to [code] on [broker] and starts routing room messages. */
|
||||||
|
suspend fun connect(
|
||||||
|
broker: JamBroker,
|
||||||
|
code: String,
|
||||||
|
clientId: String,
|
||||||
|
displayName: String,
|
||||||
|
isHost: Boolean,
|
||||||
|
): Result<Unit> {
|
||||||
|
disconnect()
|
||||||
|
if (broker.host.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
roomCode = code
|
||||||
|
localPresence = JamPresence(
|
||||||
|
clientId = clientId,
|
||||||
|
displayName = displayName,
|
||||||
|
isHost = isHost,
|
||||||
|
left = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
val mqtt = startClient(broker, clientId)
|
||||||
|
client = mqtt
|
||||||
|
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
||||||
|
mqtt.awaitConnectivity()
|
||||||
|
}
|
||||||
|
_connectionError.value = null
|
||||||
|
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
|
||||||
|
|
||||||
|
mqtt.connectionState
|
||||||
|
.onEach { onConnectionStateChanged(it) }
|
||||||
|
.launchIn(scope)
|
||||||
|
Unit
|
||||||
|
}.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to connect to jam broker" }
|
||||||
|
_connectionError.value = e.message ?: "Connection failed"
|
||||||
|
_isConnected.value = false
|
||||||
|
runCatching { client?.shutdown(sendDisconnect = true, drain = false) }
|
||||||
|
client = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun publishState(state: JamMessage.QueueState) {
|
||||||
|
val code = roomCode ?: return
|
||||||
|
publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun publishCommand(message: JamMessage) {
|
||||||
|
val code = roomCode ?: return
|
||||||
|
publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publishes our own (retained) presence. Re-published after every reconnect. */
|
||||||
|
suspend fun publishPresence() {
|
||||||
|
val code = roomCode ?: return
|
||||||
|
val presence = localPresence ?: return
|
||||||
|
publishJson(
|
||||||
|
presenceTopic(code, presence.clientId),
|
||||||
|
json.encodeToString(JamPresence.serializer(), presence),
|
||||||
|
retain = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks us as the host in presence (host takeover). */
|
||||||
|
suspend fun claimHost() {
|
||||||
|
val presence = localPresence ?: return
|
||||||
|
localPresence = presence.copy(isHost = true)
|
||||||
|
publishPresence()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Graceful leave: publish `left = true` before disconnecting. */
|
||||||
|
suspend fun leavePresence() {
|
||||||
|
val presence = localPresence ?: return
|
||||||
|
val code = roomCode ?: return
|
||||||
|
runCatching {
|
||||||
|
publishJson(
|
||||||
|
presenceTopic(code, presence.clientId),
|
||||||
|
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
||||||
|
retain = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
roomCode = null
|
||||||
|
localPresence = null
|
||||||
|
_isConnected.value = false
|
||||||
|
_presence.value = emptyMap()
|
||||||
|
val current = client
|
||||||
|
client = null
|
||||||
|
runCatching { current?.shutdown(sendDisconnect = true, drain = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Internals ----------
|
||||||
|
|
||||||
|
private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient {
|
||||||
|
val connection = MqttConnectionOptions.SocketConnection(
|
||||||
|
host = broker.host,
|
||||||
|
port = broker.port,
|
||||||
|
tlsEnabled = broker.useTls,
|
||||||
|
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
|
||||||
|
)
|
||||||
|
val code = roomCode ?: "unset"
|
||||||
|
val will = WillConfig.Enabled(
|
||||||
|
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
||||||
|
payload = json.encodeToString(
|
||||||
|
JamPresence.serializer(),
|
||||||
|
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
||||||
|
).toReadBuffer(Charset.UTF8),
|
||||||
|
qos = QualityOfService.AT_LEAST_ONCE,
|
||||||
|
retain = true,
|
||||||
|
)
|
||||||
|
val request = ConnectionRequest(
|
||||||
|
clientId = clientId,
|
||||||
|
keepAliveSeconds = broker.keepAliveSeconds,
|
||||||
|
cleanStart = true,
|
||||||
|
userName = broker.username,
|
||||||
|
password = broker.password,
|
||||||
|
will = will,
|
||||||
|
)
|
||||||
|
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
||||||
|
val brokerRef = persistence.addBroker(connection, request)
|
||||||
|
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onConnectionStateChanged(state: ConnectionState) {
|
||||||
|
when (state) {
|
||||||
|
is ConnectionState.Connected -> {
|
||||||
|
_isConnected.value = true
|
||||||
|
_connectionError.value = null
|
||||||
|
scope.launch {
|
||||||
|
// Every connection (initial + reconnects) must re-establish the
|
||||||
|
// broker-side subscription (clean session) and re-publish our
|
||||||
|
// retained presence to clear any Last Will. Re-subscribing with
|
||||||
|
// the same filter replaces the previous dispatcher handler.
|
||||||
|
subscribeRoom()
|
||||||
|
publishPresence()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionState.Disconnected, ConnectionState.Handshaking -> {
|
||||||
|
_isConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
_isConnected.value = false
|
||||||
|
_connectionError.value = "Connection lost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun subscribeRoom() {
|
||||||
|
val mqtt = client ?: return
|
||||||
|
val code = roomCode ?: return
|
||||||
|
val operation = mqtt.subscribe(
|
||||||
|
roomFilter(code),
|
||||||
|
OpaquePublishPayloadCodec,
|
||||||
|
QualityOfService.AT_LEAST_ONCE,
|
||||||
|
) { publish, payload ->
|
||||||
|
route(publish.topic.toString(), payload)
|
||||||
|
}
|
||||||
|
runCatching { operation.subAck.await() }
|
||||||
|
.onFailure { log.w(it) { "Subscribe ack failed for room $code" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) {
|
||||||
|
val text = runCatching {
|
||||||
|
val buffer = payload.handle.asReadBuffer()
|
||||||
|
buffer.readString(buffer.remaining(), Charset.UTF8)
|
||||||
|
}.getOrElse { e ->
|
||||||
|
log.w(e) { "Failed to read jam payload on $topic" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
when {
|
||||||
|
topic.endsWith("/state") -> {
|
||||||
|
_state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text))
|
||||||
|
}
|
||||||
|
|
||||||
|
topic.endsWith("/cmd") -> {
|
||||||
|
_commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text))
|
||||||
|
}
|
||||||
|
|
||||||
|
topic.contains("/presence/") -> {
|
||||||
|
val presence = json.decodeFromString(JamPresence.serializer(), text)
|
||||||
|
_presence.value = _presence.value + (presence.clientId to presence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to decode jam message on $topic: $text" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun publishJson(topic: String, payload: String, retain: Boolean) {
|
||||||
|
val mqtt = client ?: return
|
||||||
|
runCatching {
|
||||||
|
mqtt.publish(
|
||||||
|
topicName = topic,
|
||||||
|
qos = QualityOfService.AT_LEAST_ONCE,
|
||||||
|
payload = payload.toReadBuffer(Charset.UTF8),
|
||||||
|
retain = retain,
|
||||||
|
)
|
||||||
|
}.onFailure { e ->
|
||||||
|
log.w(e) { "Failed to publish to $topic" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stateTopic(code: String) = "spotube/jam/$code/state"
|
||||||
|
private fun cmdTopic(code: String) = "spotube/jam/$code/cmd"
|
||||||
|
private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId"
|
||||||
|
private fun roomFilter(code: String) = "spotube/jam/$code/#"
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Six-character room codes shared verbally / by text. Codes are opaque keys used
|
||||||
|
* to namespace the MQTT topics of a jam room — they carry no connection details.
|
||||||
|
*
|
||||||
|
* The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to
|
||||||
|
* read aloud and retype.
|
||||||
|
*/
|
||||||
|
object JamRoomCode {
|
||||||
|
const val LENGTH = 6
|
||||||
|
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
fun generate(): String = buildString(LENGTH) {
|
||||||
|
repeat(LENGTH) {
|
||||||
|
append(ALPHABET[Random.nextInt(ALPHABET.length)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */
|
||||||
|
fun normalize(input: String): String = input
|
||||||
|
.uppercase()
|
||||||
|
.filter { it.isLetterOrDigit() }
|
||||||
|
.take(LENGTH)
|
||||||
|
|
||||||
|
fun isValid(code: String): Boolean =
|
||||||
|
code.length == LENGTH && code.all { it in ALPHABET }
|
||||||
|
}
|
||||||
@ -0,0 +1,573 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.jam
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||||
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A jam session over MQTT (star topology, host-authoritative queue).
|
||||||
|
*
|
||||||
|
* Sync rules:
|
||||||
|
* - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo]
|
||||||
|
* from anyone are applied by the host, then broadcast via the retained state topic.
|
||||||
|
* - Play/pause, seek, volume and loop are local to each device — never broadcast.
|
||||||
|
* When the queue moves on, a paused participant stays paused; a playing one
|
||||||
|
* keeps playing the new current item.
|
||||||
|
* - Shuffle is host-only; guests mirror the host's shuffle setting.
|
||||||
|
* - Guests can only add to the queue (suggest); the host applies suggestions.
|
||||||
|
* - If the host leaves, the participant with the lowest client id takes over.
|
||||||
|
*/
|
||||||
|
private data class PlaybackBroadcast(
|
||||||
|
val queue: List<QueueEntry>,
|
||||||
|
val current: QueueEntry?,
|
||||||
|
val shuffle: Boolean,
|
||||||
|
val playerState: PlayerState,
|
||||||
|
)
|
||||||
|
|
||||||
|
class JamRoomService(
|
||||||
|
private val jamClient: JamRoomClient,
|
||||||
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
) {
|
||||||
|
private val log = Logger.withTag("JamRoomService")
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
private val _role = MutableStateFlow<JamRole?>(null)
|
||||||
|
val role: StateFlow<JamRole?> = _role.asStateFlow()
|
||||||
|
|
||||||
|
private val _participants = MutableStateFlow<List<JamParticipant>>(emptyList())
|
||||||
|
val participants: StateFlow<List<JamParticipant>> = _participants.asStateFlow()
|
||||||
|
|
||||||
|
private val _roomCode = MutableStateFlow<String?>(null)
|
||||||
|
val roomCode: StateFlow<String?> = _roomCode.asStateFlow()
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectionError = MutableStateFlow<String?>(null)
|
||||||
|
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||||
|
|
||||||
|
private val _shuffleEnabled = MutableStateFlow(false)
|
||||||
|
val shuffleEnabled: StateFlow<Boolean> = _shuffleEnabled.asStateFlow()
|
||||||
|
|
||||||
|
private var localClientId: String = ""
|
||||||
|
private var localDisplayName: String = ""
|
||||||
|
|
||||||
|
/** Display name of the local participant. */
|
||||||
|
val participantDisplayName: String
|
||||||
|
get() = localDisplayName
|
||||||
|
|
||||||
|
/** Stable id of the local participant (stamped on items this device adds). */
|
||||||
|
val participantClientId: String
|
||||||
|
get() = localClientId
|
||||||
|
|
||||||
|
private var hostBroadcastJob: Job? = null
|
||||||
|
|
||||||
|
/** Guest side: last queue snapshot applied to the local player. */
|
||||||
|
private var lastAppliedItems: List<JamMediaItem> = emptyList()
|
||||||
|
private var lastAppliedIndex = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host side: a song completed, so index changes within this window are
|
||||||
|
* auto-advance (guests must not follow). The completion event, the player
|
||||||
|
* state change and the media transition arrive as separate flow emissions,
|
||||||
|
* so the window covers the whole sequence instead of a single flag.
|
||||||
|
*/
|
||||||
|
private var autoAdvanceDeadlineMs = 0L
|
||||||
|
|
||||||
|
/** Guest side: playback has started at least once (local control is the guest's own). */
|
||||||
|
private var hasStartedPlayback = false
|
||||||
|
|
||||||
|
/** Host side: client ids banned for this session. */
|
||||||
|
private val bannedClientIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
private var leaving = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
jamClient.isConnected
|
||||||
|
.onEach { _isConnected.value = it }
|
||||||
|
.launchIn(scope)
|
||||||
|
jamClient.connectionError
|
||||||
|
.onEach { _connectionError.value = it }
|
||||||
|
.launchIn(scope)
|
||||||
|
jamClient.state
|
||||||
|
.onEach { onRemoteState(it) }
|
||||||
|
.launchIn(scope)
|
||||||
|
jamClient.commands
|
||||||
|
.onEach { onCommand(it) }
|
||||||
|
.launchIn(scope)
|
||||||
|
jamClient.presence
|
||||||
|
.onEach { onPresence(it) }
|
||||||
|
.launchIn(scope)
|
||||||
|
// A naturally-completed song means the host's next index change is an
|
||||||
|
// auto-advance — guests must NOT follow those, only manual skips.
|
||||||
|
audioPlayer.completionFlow
|
||||||
|
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
|
||||||
|
.launchIn(scope)
|
||||||
|
// Once a guest has played on its own (or was started by the host), its
|
||||||
|
// play/pause is its own — the host's play state only starts fresh joiners.
|
||||||
|
audioPlayer.playerStateFlow
|
||||||
|
.onEach { state ->
|
||||||
|
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) {
|
||||||
|
hasStartedPlayback = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Session lifecycle ----------
|
||||||
|
|
||||||
|
suspend fun createRoom(): Result<String> {
|
||||||
|
val broker = settingsRepository.userSettings.value.jamBroker
|
||||||
|
if (broker.host.isBlank()) {
|
||||||
|
return Result.failure(IllegalStateException("No jam broker configured"))
|
||||||
|
}
|
||||||
|
val code = JamRoomCode.generate()
|
||||||
|
localClientId = newClientId(broker)
|
||||||
|
val name = participantName("Host")
|
||||||
|
localDisplayName = name
|
||||||
|
|
||||||
|
return jamClient.connect(
|
||||||
|
broker = broker,
|
||||||
|
code = code,
|
||||||
|
clientId = localClientId,
|
||||||
|
displayName = name,
|
||||||
|
isHost = true,
|
||||||
|
).map {
|
||||||
|
_role.value = JamRole.Host
|
||||||
|
_roomCode.value = code
|
||||||
|
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
||||||
|
lastAppliedItems = emptyList()
|
||||||
|
lastAppliedIndex = -1
|
||||||
|
bannedClientIds.clear()
|
||||||
|
leaving = false
|
||||||
|
autoAdvanceDeadlineMs = 0L
|
||||||
|
hasStartedPlayback = false
|
||||||
|
startHostBroadcast()
|
||||||
|
persistLastCode(code)
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun joinRoom(code: String): Result<Unit> {
|
||||||
|
val broker = settingsRepository.userSettings.value.jamBroker
|
||||||
|
if (broker.host.isBlank()) {
|
||||||
|
return Result.failure(IllegalStateException("No jam broker configured"))
|
||||||
|
}
|
||||||
|
val normalized = JamRoomCode.normalize(code)
|
||||||
|
if (!JamRoomCode.isValid(normalized)) {
|
||||||
|
return Result.failure(IllegalArgumentException("Invalid room code"))
|
||||||
|
}
|
||||||
|
localClientId = newClientId(broker)
|
||||||
|
val name = participantName("Guest")
|
||||||
|
localDisplayName = name
|
||||||
|
|
||||||
|
return jamClient.connect(
|
||||||
|
broker = broker,
|
||||||
|
code = normalized,
|
||||||
|
clientId = localClientId,
|
||||||
|
displayName = name,
|
||||||
|
isHost = false,
|
||||||
|
).map {
|
||||||
|
_role.value = JamRole.Guest
|
||||||
|
_roomCode.value = normalized
|
||||||
|
_participants.value = emptyList()
|
||||||
|
lastAppliedItems = emptyList()
|
||||||
|
lastAppliedIndex = -1
|
||||||
|
leaving = false
|
||||||
|
hasStartedPlayback = false
|
||||||
|
persistLastCode(normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun leaveRoom() {
|
||||||
|
leaving = true
|
||||||
|
stopHostBroadcast()
|
||||||
|
runCatching { jamClient.leavePresence() }
|
||||||
|
jamClient.disconnect()
|
||||||
|
_role.value = null
|
||||||
|
_roomCode.value = null
|
||||||
|
_participants.value = emptyList()
|
||||||
|
_shuffleEnabled.value = false
|
||||||
|
_isConnected.value = false
|
||||||
|
lastAppliedItems = emptyList()
|
||||||
|
lastAppliedIndex = -1
|
||||||
|
bannedClientIds.clear()
|
||||||
|
hasStartedPlayback = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Controls (called from the UI) ----------
|
||||||
|
|
||||||
|
fun skipNext() {
|
||||||
|
publishCommand(PlaybackCmd.SkipNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipPrevious() {
|
||||||
|
publishCommand(PlaybackCmd.SkipPrevious)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jumpTo(index: Int) {
|
||||||
|
publishCommand(PlaybackCmd.JumpTo(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */
|
||||||
|
fun toggleShuffle() {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
scope.launch {
|
||||||
|
runCatching { audioPlayer.shuffle(!_shuffleEnabled.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun suggestTrack(track: MetadataTrack) {
|
||||||
|
jamClient.publishCommand(
|
||||||
|
JamMessage.SuggestTrack(
|
||||||
|
mediaItem = JamMediaItem.fromTrack(track),
|
||||||
|
addedBy = localClientId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun suggestPlaylist(tracks: List<MetadataTrack>) {
|
||||||
|
if (tracks.isEmpty()) return
|
||||||
|
jamClient.publishCommand(
|
||||||
|
JamMessage.SuggestPlaylist(
|
||||||
|
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||||
|
addedBy = localClientId,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
jamClient.publishCommand(JamMessage.Kick(participantId, reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun banParticipant(participantId: String) {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
bannedClientIds += participantId
|
||||||
|
kickParticipant(participantId, "banned by host")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Host: broadcast ----------
|
||||||
|
|
||||||
|
private fun startHostBroadcast() {
|
||||||
|
if (hostBroadcastJob?.isActive == true) return
|
||||||
|
hostBroadcastJob = scope.launch {
|
||||||
|
combine(
|
||||||
|
audioPlayerQueue.queueFlow,
|
||||||
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
|
audioPlayer.shuffleModeFlow,
|
||||||
|
audioPlayer.playerStateFlow,
|
||||||
|
) { queue, current, shuffle, playerState ->
|
||||||
|
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||||
|
}
|
||||||
|
.onEach { broadcast ->
|
||||||
|
if (_role.value != JamRole.Host) return@onEach
|
||||||
|
val index = if (broadcast.current != null) {
|
||||||
|
broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) }
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
_shuffleEnabled.value = broadcast.shuffle
|
||||||
|
jamClient.publishState(
|
||||||
|
JamMessage.QueueState(
|
||||||
|
items = broadcast.queue.map(JamMediaItem::fromQueueEntry),
|
||||||
|
currentIndex = index.coerceAtLeast(0),
|
||||||
|
shuffleEnabled = broadcast.shuffle,
|
||||||
|
follow = now() > autoAdvanceDeadlineMs,
|
||||||
|
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.launchIn(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHostBroadcast() {
|
||||||
|
hostBroadcastJob?.cancel()
|
||||||
|
hostBroadcastJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Guest: apply remote state ----------
|
||||||
|
|
||||||
|
private suspend fun onRemoteState(state: JamMessage.QueueState) {
|
||||||
|
if (_role.value != JamRole.Guest) return
|
||||||
|
if (leaving) return
|
||||||
|
|
||||||
|
_shuffleEnabled.value = state.shuffleEnabled
|
||||||
|
runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
|
||||||
|
|
||||||
|
// Late joiner: the host is already playing, so start immediately. Once
|
||||||
|
// this guest has played on its own, the host's play state is ignored.
|
||||||
|
if (state.isPlaying && !hasStartedPlayback) {
|
||||||
|
runCatching { audioPlayer.play() }
|
||||||
|
.onFailure { log.w(it) { "Failed to start playback on host play state" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
|
||||||
|
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
|
||||||
|
|
||||||
|
if (items != lastAppliedItems) {
|
||||||
|
val previous = lastAppliedItems
|
||||||
|
lastAppliedItems = items
|
||||||
|
val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value
|
||||||
|
val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) }
|
||||||
|
|
||||||
|
// The host only appended items (e.g. accepted suggestions): merge
|
||||||
|
// them in without resetting playback or the guest's position.
|
||||||
|
if (previous.isNotEmpty() && items.size > previous.size &&
|
||||||
|
items.take(previous.size) == previous && guestIndex >= 0
|
||||||
|
) {
|
||||||
|
lastAppliedIndex = guestIndex
|
||||||
|
val appended = items.drop(previous.size)
|
||||||
|
runCatching {
|
||||||
|
audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() })
|
||||||
|
}.onFailure { log.w(it) { "Failed to append jam queue items" } }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full re-sync. Keep the guest's current track when it still exists
|
||||||
|
// in the synced queue; otherwise take the host's position.
|
||||||
|
val startIndex = if (guestIndex >= 0) guestIndex
|
||||||
|
else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))
|
||||||
|
lastAppliedIndex = startIndex
|
||||||
|
runCatching {
|
||||||
|
audioPlayerQueue.load(
|
||||||
|
entries = items.map { it.toQueueEntry() },
|
||||||
|
autoPlay = wasPlaying,
|
||||||
|
startPosition = startIndex,
|
||||||
|
)
|
||||||
|
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same queue content: only follow the host when it moved manually
|
||||||
|
// (skip/jump). Natural auto-advance keeps everyone where they are.
|
||||||
|
if (state.follow && state.currentIndex != lastAppliedIndex) {
|
||||||
|
lastAppliedIndex = state.currentIndex
|
||||||
|
runCatching {
|
||||||
|
audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false)
|
||||||
|
}.onFailure { log.w(it) { "Failed to follow jam queue index" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Incoming commands ----------
|
||||||
|
|
||||||
|
private suspend fun onCommand(message: JamMessage) {
|
||||||
|
when (message) {
|
||||||
|
is JamMessage.PlaybackCommand -> {
|
||||||
|
if (_role.value != JamRole.Host) return
|
||||||
|
applyCommand(message.command)
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.SuggestTrack -> {
|
||||||
|
if (_role.value == JamRole.Host) {
|
||||||
|
acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.SuggestPlaylist -> {
|
||||||
|
if (_role.value == JamRole.Host) {
|
||||||
|
acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JamMessage.Kick -> {
|
||||||
|
if (_role.value == JamRole.Guest && message.participantId == localClientId) {
|
||||||
|
log.i { "Kicked from jam room: ${message.reason}" }
|
||||||
|
leaveRoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applyCommand(command: PlaybackCmd) {
|
||||||
|
when (command) {
|
||||||
|
PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() }
|
||||||
|
PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() }
|
||||||
|
is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acceptSuggestion(items: List<JamMediaItem>) {
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
log.i { "Accepting ${items.size} suggested item(s) into the jam queue" }
|
||||||
|
runCatching {
|
||||||
|
audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Presence & host takeover ----------
|
||||||
|
|
||||||
|
private fun onPresence(all: Map<String, JamPresence>) {
|
||||||
|
if (_role.value == null) return
|
||||||
|
val live = all.values.filter { !it.left }
|
||||||
|
_participants.value = live
|
||||||
|
.sortedBy { it.clientId }
|
||||||
|
.map { JamParticipant(it.clientId, it.displayName, it.isHost) }
|
||||||
|
|
||||||
|
// Host-side: auto-kick banned participants that rejoin.
|
||||||
|
if (_role.value == JamRole.Host) {
|
||||||
|
live.filter { it.clientId in bannedClientIds }.forEach { banned ->
|
||||||
|
scope.launch { kickParticipant(banned.clientId, "banned by host") }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val host = live.firstOrNull { it.isHost }
|
||||||
|
if (host != null) return
|
||||||
|
|
||||||
|
// Host left: the lowest client id takes over (deterministic, clock-free).
|
||||||
|
val candidate = live.minByOrNull { it.clientId } ?: return
|
||||||
|
if (candidate.clientId != localClientId) return
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
delay(HOST_TAKEOVER_DELAY_MS)
|
||||||
|
val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost }
|
||||||
|
if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch
|
||||||
|
log.i { "Taking over as jam host (previous host left)" }
|
||||||
|
_role.value = JamRole.Host
|
||||||
|
jamClient.claimHost()
|
||||||
|
startHostBroadcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
|
private fun publishCommand(command: PlaybackCmd) {
|
||||||
|
scope.launch {
|
||||||
|
jamClient.publishCommand(JamMessage.PlaybackCommand(command))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun participantName(fallbackPrefix: String): String {
|
||||||
|
val configured = settingsRepository.userSettings.value.jamParticipantName
|
||||||
|
return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newClientId(broker: dev.krtirtho.spotube.modules.settings.JamBroker): String {
|
||||||
|
val suffix = buildString(6) {
|
||||||
|
val chars = "0123456789abcdef"
|
||||||
|
repeat(6) { append(chars[Random.nextInt(chars.length)]) }
|
||||||
|
}
|
||||||
|
return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistLastCode(code: String) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
val settings = settingsRepository.userSettings.value
|
||||||
|
if (settings.lastJamCode != code) {
|
||||||
|
settingsRepository.updateSettings(settings.copy(lastJamCode = code))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
|
||||||
|
trackId.isNotBlank() -> QueueEntry.StreamingTrack(
|
||||||
|
track = MetadataTrack(
|
||||||
|
id = trackId,
|
||||||
|
title = title,
|
||||||
|
durationMs = durationMs,
|
||||||
|
trackNumber = null,
|
||||||
|
discNumber = null,
|
||||||
|
artists = listOf(
|
||||||
|
MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null)
|
||||||
|
),
|
||||||
|
album = null,
|
||||||
|
// Keep the cover art flowing to participants — the wire carries it
|
||||||
|
// as coverUrl, so mirror it back into the reconstructed thumbnails.
|
||||||
|
thumbnails = coverUrl.takeIf { it.isNotBlank() }
|
||||||
|
?.let { url -> listOf(Thumbnail(url = url, width = 0, height = 0)) },
|
||||||
|
explicit = null,
|
||||||
|
popularity = null,
|
||||||
|
isrcCode = null,
|
||||||
|
externalUri = null,
|
||||||
|
),
|
||||||
|
url = "",
|
||||||
|
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||||
|
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||||
|
addedBy = addedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> QueueEntry.LocalTrack(
|
||||||
|
name = title,
|
||||||
|
artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() },
|
||||||
|
duration = durationMs,
|
||||||
|
album = album.ifBlank { null },
|
||||||
|
coverBytes = null,
|
||||||
|
url = url,
|
||||||
|
addedBy = addedBy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
||||||
|
if (entry == null) return false
|
||||||
|
return when (entry) {
|
||||||
|
is QueueEntry.StreamingTrack ->
|
||||||
|
trackId.isNotBlank() && entry.track.id == trackId
|
||||||
|
|
||||||
|
is QueueEntry.LocalTrack ->
|
||||||
|
url.isNotBlank() && entry.url == url && entry.name == title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when {
|
||||||
|
this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack ->
|
||||||
|
this.track.id == other.track.id
|
||||||
|
|
||||||
|
this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack ->
|
||||||
|
this.url == other.url && this.name == other.name
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val HOST_TAKEOVER_DELAY_MS = 1_500L
|
||||||
|
private const val AUTO_ADVANCE_WINDOW_MS = 2_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,9 @@ import androidx.navigation3.runtime.NavKey
|
|||||||
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
import dev.krtirtho.spotube.modules.album.AlbumScreen
|
||||||
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
import dev.krtirtho.spotube.modules.artist.ArtistScreen
|
||||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
import dev.krtirtho.spotube.modules.blacklist.BlacklistScreen
|
||||||
|
import dev.krtirtho.spotube.modules.devices.DevicesScreen
|
||||||
|
import dev.krtirtho.spotube.modules.devices.RemoteControlScreen
|
||||||
|
import dev.krtirtho.spotube.modules.jam.JamScreen
|
||||||
import dev.krtirtho.spotube.modules.home.HomeScreen
|
import dev.krtirtho.spotube.modules.home.HomeScreen
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
import dev.krtirtho.spotube.modules.library.LibraryScreen
|
||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||||
@ -75,6 +78,15 @@ sealed interface Routes : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Blacklist : Routes
|
data object Blacklist : Routes
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object RemoteControl : Routes
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Devices : Routes
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Jam : Routes
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(KoinExperimentalAPI::class)
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
@ -148,5 +160,16 @@ val navigationModule = module {
|
|||||||
navigation<Routes.Blacklist> {
|
navigation<Routes.Blacklist> {
|
||||||
BlacklistScreen()
|
BlacklistScreen()
|
||||||
}
|
}
|
||||||
|
navigation<Routes.Devices> {
|
||||||
|
DevicesScreen(navigationCommands = get())
|
||||||
|
}
|
||||||
|
navigation<Routes.RemoteControl> {
|
||||||
|
RemoteControlScreen(
|
||||||
|
onDisconnect = { get<NavigationCommands>().pop() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
navigation<Routes.Jam> {
|
||||||
|
JamScreen(navigationCommands = get())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,8 @@ val serializersConfig = SavedStateConfiguration {
|
|||||||
subclass(Routes.Artist::class, Routes.Artist.serializer())
|
subclass(Routes.Artist::class, Routes.Artist.serializer())
|
||||||
subclass(Routes.Album::class, Routes.Album.serializer())
|
subclass(Routes.Album::class, Routes.Album.serializer())
|
||||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||||
|
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
||||||
|
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,14 +22,17 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.modules.album.AlbumRepository
|
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.blacklist.BlacklistRepository
|
||||||
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
import dev.krtirtho.spotube.modules.playlist.PlaylistRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemoteCollectionType
|
||||||
|
|
||||||
class CollectionPlaybackHelper(
|
class CollectionPlaybackHelper(
|
||||||
private val albumRepository: AlbumRepository,
|
private val albumRepository: AlbumRepository,
|
||||||
private val playlistRepository: PlaylistRepository,
|
private val playlistRepository: PlaylistRepository,
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
|
private val artistRepository: ArtistRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
) {
|
) {
|
||||||
@ -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) {
|
suspend fun playAlbumFromTrack(albumId: String, track: MetadataTrack) {
|
||||||
val entries = fetchAllAlbumTracks(albumId)
|
val entries = fetchAllAlbumTracks(albumId)
|
||||||
if (entries.isEmpty()) return
|
if (entries.isEmpty()) return
|
||||||
@ -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) {
|
suspend fun playPlaylistFromTrack(playlistId: String, track: MetadataTrack) {
|
||||||
val entries = fetchAllPlaylistTracks(playlistId)
|
val entries = fetchAllPlaylistTracks(playlistId)
|
||||||
if (entries.isEmpty()) return
|
if (entries.isEmpty()) return
|
||||||
@ -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() {
|
suspend fun addSavedTracksToQueue() {
|
||||||
val entries = fetchAllSavedTracks()
|
val entries = fetchAllSavedTracks()
|
||||||
if (entries.isNotEmpty()) {
|
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> {
|
private suspend fun fetchAllSavedTracks(): List<QueueEntry> {
|
||||||
val allTracks = mutableListOf<MetadataTrack>()
|
val allTracks = mutableListOf<MetadataTrack>()
|
||||||
var pagination = savedTracksRepository.getSavedTracks()
|
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(
|
private fun isTrackBlacklisted(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
blacklistedTrackIds: Set<String>,
|
blacklistedTrackIds: Set<String>,
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConnectionRequestDialogHost() {
|
||||||
|
val handler: RemoteControlHandler = koinInject()
|
||||||
|
var pendingRequest by remember { mutableStateOf<ConnectionRequest?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(handler) {
|
||||||
|
handler.incomingConnectionRequests.collect { request ->
|
||||||
|
pendingRequest = request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRequest?.let { request ->
|
||||||
|
AdaptiveDialogBottomSheet(
|
||||||
|
onDismiss = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Remote Control Request",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Deny")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Allow")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||||
|
pendingRequest = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Allow Always")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,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
|
||||||
|
}
|
||||||
@ -0,0 +1,470 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
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,
|
||||||
|
}
|
||||||
@ -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) : 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,
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,388 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
) : 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,
|
||||||
|
) {
|
||||||
|
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,10 +19,13 @@ package dev.krtirtho.spotube.core.server
|
|||||||
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
import io.ktor.http.HttpMethod
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
|
import io.ktor.server.application.install
|
||||||
import io.ktor.server.cio.CIO
|
import io.ktor.server.cio.CIO
|
||||||
import io.ktor.server.engine.EmbeddedServer
|
import io.ktor.server.engine.EmbeddedServer
|
||||||
import io.ktor.server.engine.embeddedServer
|
import io.ktor.server.engine.embeddedServer
|
||||||
@ -30,6 +33,8 @@ import io.ktor.server.response.respondText
|
|||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.head
|
import io.ktor.server.routing.head
|
||||||
import io.ktor.server.routing.routing
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
@ -55,10 +60,18 @@ class LocalServer(
|
|||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
|
private val remoteControlHandler: RemoteControlHandler,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
val logger by injectLogger<LocalServer>()
|
val logger by injectLogger<LocalServer>()
|
||||||
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 scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val serverMutex = Mutex()
|
private val serverMutex = Mutex()
|
||||||
@ -70,8 +83,10 @@ class LocalServer(
|
|||||||
private val activePort = MutableStateFlow<Int?>(null)
|
private val activePort = MutableStateFlow<Int?>(null)
|
||||||
val port = activePort.asStateFlow()
|
val port = activePort.asStateFlow()
|
||||||
|
|
||||||
|
private val activeHost = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
val baseUrl = activePort.map { port ->
|
val baseUrl = activePort.map { port ->
|
||||||
port?.let { "http://$HOST:$it" }
|
port?.let { "http://$HOST_LOCAL:$it" }
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
private val cachedCacheEnabled = MutableStateFlow(false)
|
private val cachedCacheEnabled = MutableStateFlow(false)
|
||||||
@ -89,18 +104,19 @@ class LocalServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val HOST = "127.0.0.1"
|
private const val HOST_LOCAL = "127.0.0.1"
|
||||||
|
private const val HOST_LAN = "0.0.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
logger.d { "Starting playback proxy port watcher" }
|
logger.d { "Starting playback proxy port watcher" }
|
||||||
portWatcher = scope.launch {
|
portWatcher = scope.launch {
|
||||||
settingsViewModel.settingsState
|
settingsViewModel.settingsState
|
||||||
.mapNotNull { it?.playbackProxyServerPort }
|
.mapNotNull { it?.let { s -> s.playbackProxyServerPort to s.allowRemoteControl } }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collectLatest { port ->
|
.collectLatest { (port, allowRemoteControl) ->
|
||||||
logger.d { "Observed playback proxy port change to $port" }
|
logger.d { "Observed server config change: port=$port, allowRemoteControl=$allowRemoteControl" }
|
||||||
restartServer(port)
|
restartServer(port, allowRemoteControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@ -129,26 +145,28 @@ class LocalServer(
|
|||||||
logger.d { "Playback proxy server stopped" }
|
logger.d { "Playback proxy server stopped" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restartServer(port: Int) {
|
private suspend fun restartServer(port: Int, allowRemoteControl: Boolean) {
|
||||||
|
val host = if (allowRemoteControl) HOST_LAN else HOST_LOCAL
|
||||||
serverMutex.withLock {
|
serverMutex.withLock {
|
||||||
if (serverState.value != null && activePort.value == port) {
|
if (serverState.value != null && activePort.value == port && activeHost.value == host) {
|
||||||
logger.v { "Playback proxy server already running on port $port; skipping restart" }
|
logger.v { "Playback proxy server already running on $host:$port; skipping restart" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.d { "Restarting playback proxy server on port $port" }
|
logger.d { "Restarting playback proxy server on $host:$port (remoteControl=$allowRemoteControl)" }
|
||||||
stopServerLocked()
|
stopServerLocked()
|
||||||
|
|
||||||
serverState.value = embeddedServer(
|
serverState.value = embeddedServer(
|
||||||
factory = CIO,
|
factory = CIO,
|
||||||
host = HOST,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
module = { configureRoutes() }
|
module = { configureRoutes() }
|
||||||
).also { engine ->
|
).also { engine ->
|
||||||
engine.start(wait = false)
|
engine.start(wait = false)
|
||||||
}
|
}
|
||||||
activePort.value = port
|
activePort.value = port
|
||||||
logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$HOST:$port"}" }
|
activeHost.value = host
|
||||||
|
logger.i { "Playback proxy server started at ${baseUrl.value ?: "http://$host:$port"}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,9 +183,16 @@ class LocalServer(
|
|||||||
}
|
}
|
||||||
serverState.value = null
|
serverState.value = null
|
||||||
activePort.value = null
|
activePort.value = null
|
||||||
|
activeHost.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Application.configureRoutes() {
|
private fun Application.configureRoutes() {
|
||||||
|
install(WebSockets) {
|
||||||
|
pingPeriodMillis = 30_000L
|
||||||
|
timeoutMillis = 60_000L
|
||||||
|
maxFrameSize = 10L * 1024 * 1024
|
||||||
|
masking = false
|
||||||
|
}
|
||||||
routing {
|
routing {
|
||||||
get("/health") {
|
get("/health") {
|
||||||
call.respondText("ok")
|
call.respondText("ok")
|
||||||
@ -188,6 +213,10 @@ class LocalServer(
|
|||||||
get("/segment/{trackId}") {
|
get("/segment/{trackId}") {
|
||||||
streamProxy.handleSegmentRequest(call)
|
streamProxy.handleSegmentRequest(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webSocket("/control") {
|
||||||
|
remoteControlHandler.handleConnection(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
@ -48,6 +49,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
@ -71,6 +73,8 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
|
|
||||||
private val BadgeShape = RoundedCornerShape(11.dp)
|
private val BadgeShape = RoundedCornerShape(11.dp)
|
||||||
|
private const val DisabledContentAlpha = 0.38f
|
||||||
|
|
||||||
private val ButtonMinHeight = 40.dp
|
private val ButtonMinHeight = 40.dp
|
||||||
private val SquareButtonSize = 40.dp
|
private val SquareButtonSize = 40.dp
|
||||||
|
|
||||||
@ -178,6 +182,7 @@ fun OutlineButton(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = {
|
content = {
|
||||||
@ -226,6 +231,7 @@ fun PrimaryButton(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = content,
|
content = content,
|
||||||
@ -271,6 +277,7 @@ fun SecondaryButton(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
content = content,
|
content = content,
|
||||||
@ -487,10 +494,17 @@ fun GroupIconButton(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.alpha(if (enabled) 1f else DisabledContentAlpha),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ButtonGroup(
|
fun ButtonGroup(
|
||||||
|
|||||||
@ -20,7 +20,6 @@ package dev.krtirtho.spotube.core.ui.component
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@ -29,8 +28,9 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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 dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -39,9 +39,9 @@ fun AlbumCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject()
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
|
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
@ -54,10 +54,18 @@ fun AlbumCard(
|
|||||||
},
|
},
|
||||||
onPlay = {
|
onPlay = {
|
||||||
if (currentCollectionEntry?.id == album.id) return@PlayableCard
|
if (currentCollectionEntry?.id == album.id) return@PlayableCard
|
||||||
scope.launch { playbackHelper.playAlbum(album.id) }
|
remotePlaybackController.requestCollectionPlay(
|
||||||
|
RemoteCollectionType.Album,
|
||||||
|
album.id,
|
||||||
|
album.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
scope.launch { playbackHelper.addAlbumToQueue(album.id) }
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
|
RemoteCollectionType.Album,
|
||||||
|
album.id,
|
||||||
|
album.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier.width(160.dp),
|
modifier = modifier.width(160.dp),
|
||||||
sharedElementKey = "album_art_${album.id}",
|
sharedElementKey = "album_art_${album.id}",
|
||||||
|
|||||||
@ -78,6 +78,9 @@ fun CollectionDetails(
|
|||||||
onShufflePlay: () -> Unit,
|
onShufflePlay: () -> Unit,
|
||||||
onAddToQueue: () -> Unit,
|
onAddToQueue: () -> Unit,
|
||||||
isPlaying: Boolean = false,
|
isPlaying: Boolean = false,
|
||||||
|
/** Guest in a jam session: play/shuffle are replaced by Add to Jam. */
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
|
onAddToJam: (() -> Unit)? = null,
|
||||||
isFollowing: Boolean = false,
|
isFollowing: Boolean = false,
|
||||||
onFollowClick: () -> Unit = { },
|
onFollowClick: () -> Unit = { },
|
||||||
showFollowButton: Boolean = true,
|
showFollowButton: Boolean = true,
|
||||||
@ -91,7 +94,28 @@ fun CollectionDetails(
|
|||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||||
|
|
||||||
val playPauseButton = @Composable {
|
val playPauseButton = @Composable {
|
||||||
|
if (isJamGuest) {
|
||||||
|
if (onAddToJam != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
PrimaryButton(
|
||||||
|
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
||||||
|
onClick = onAddToJam!!,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
|
contentDescription = "Add to Jam",
|
||||||
|
)
|
||||||
|
TextWithShimmer(
|
||||||
|
text = "Add to Jam",
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
@ -127,6 +151,7 @@ fun CollectionDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val actions = @Composable {
|
val actions = @Composable {
|
||||||
ButtonGroup {
|
ButtonGroup {
|
||||||
|
|||||||
@ -87,6 +87,10 @@ fun CollectionView(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
|
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
footerContent: (@Composable () -> Unit)? = null,
|
footerContent: (@Composable () -> Unit)? = null,
|
||||||
trailingContent: @Composable () -> Unit = {},
|
trailingContent: @Composable () -> Unit = {},
|
||||||
@ -114,7 +118,7 @@ fun CollectionView(
|
|||||||
} else {
|
} else {
|
||||||
{}
|
{}
|
||||||
},
|
},
|
||||||
actions = if (isCollapsed) {
|
actions = if (isCollapsed && !isJamGuest) {
|
||||||
{
|
{
|
||||||
IconButton(onClick = onPlay) {
|
IconButton(onClick = onPlay) {
|
||||||
Icon(
|
Icon(
|
||||||
@ -159,6 +163,8 @@ fun CollectionView(
|
|||||||
showFollowButton = showFollowButton,
|
showFollowButton = showFollowButton,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = { onAddToJam(tracks) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -179,6 +185,8 @@ fun CollectionView(
|
|||||||
showFollowButton = showFollowButton,
|
showFollowButton = showFollowButton,
|
||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = { onAddToJam(tracks) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -200,6 +208,9 @@ fun CollectionView(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
trackOptionsState = trackOptionsState,
|
trackOptionsState = trackOptionsState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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 dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
@ -37,7 +39,8 @@ fun PlaylistCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject()
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
|
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
@ -52,10 +55,18 @@ fun PlaylistCard(
|
|||||||
},
|
},
|
||||||
onPlay = {
|
onPlay = {
|
||||||
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
|
if (audioPlayerQueue.isPlaylistPlaying(playlist.id)) return@PlayableCard
|
||||||
scope.launch { playbackHelper.playPlaylist(playlist.id) }
|
remotePlaybackController.requestCollectionPlay(
|
||||||
|
RemoteCollectionType.Playlist,
|
||||||
|
playlist.id,
|
||||||
|
playlist.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
|
RemoteCollectionType.Playlist,
|
||||||
|
playlist.id,
|
||||||
|
playlist.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
sharedElementKey = "playlist_art_${playlist.id}",
|
sharedElementKey = "playlist_art_${playlist.id}",
|
||||||
|
|||||||
@ -143,6 +143,9 @@ fun TrackList(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit = {},
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
currentTrackId: String? = null,
|
currentTrackId: String? = null,
|
||||||
isCurrentTrackPlaying: Boolean = false,
|
isCurrentTrackPlaying: Boolean = false,
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
@ -247,6 +250,8 @@ fun TrackList(
|
|||||||
} else {
|
} else {
|
||||||
selectedTrackIds + track.id
|
selectedTrackIds + track.id
|
||||||
}
|
}
|
||||||
|
} else if (isJamGuest) {
|
||||||
|
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
||||||
} else {
|
} else {
|
||||||
onTrackClick(track)
|
onTrackClick(track)
|
||||||
}
|
}
|
||||||
@ -274,6 +279,8 @@ fun TrackList(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trackOptionsState = trackOptionsState(track),
|
trackOptionsState = trackOptionsState(track),
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
@ -421,28 +428,48 @@ fun TrackList(
|
|||||||
val isAll =
|
val isAll =
|
||||||
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = listOf(
|
items = buildList {
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
label = if (isAll) "Download All" else "Download $trackCount",
|
label = if (isAll) "Download All" else "Download $trackCount",
|
||||||
onClick = { onBulkDownload(targetTracks) },
|
onClick = { onBulkDownload(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
if (!isJamGuest) {
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||||
onClick = { onBulkAddToQueue(targetTracks) },
|
onClick = { onBulkAddToQueue(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||||
onClick = { onBulkPlayNext(targetTracks) },
|
onClick = { onBulkPlayNext(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicPlaylist,
|
icon = Iconsax.IconsaxMusicPlaylist,
|
||||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
} + if (isInJam) {
|
||||||
|
listOf(
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
|
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||||
|
onClick = { onBulkAddToJam(targetTracks) },
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GroupIconButton(
|
GroupIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@ -497,6 +524,8 @@ fun TrackList(
|
|||||||
selectedTrackForOptions = null
|
selectedTrackForOptions = null
|
||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -555,6 +584,8 @@ private fun TrackListRow(
|
|||||||
onSelectionToggle: (Boolean) -> Unit,
|
onSelectionToggle: (Boolean) -> Unit,
|
||||||
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
onTrackOptionsAction: (TrackOptionsAction) -> Unit,
|
||||||
trackOptionsState: TrackOptionsState,
|
trackOptionsState: TrackOptionsState,
|
||||||
|
isInJam: Boolean,
|
||||||
|
isJamGuest: Boolean,
|
||||||
onShowOptionsClick: () -> Unit,
|
onShowOptionsClick: () -> Unit,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
@ -750,6 +781,8 @@ private fun TrackListRow(
|
|||||||
state = trackOptionsState,
|
state = trackOptionsState,
|
||||||
onAction = onTrackOptionsAction,
|
onAction = onTrackOptionsAction,
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
GhostIconButton(onClick = onShowOptionsClick) {
|
GhostIconButton(onClick = onShowOptionsClick) {
|
||||||
@ -843,6 +876,8 @@ private fun ShimmerTrackListRow(
|
|||||||
onSelectionToggle = {},
|
onSelectionToggle = {},
|
||||||
onTrackOptionsAction = {},
|
onTrackOptionsAction = {},
|
||||||
trackOptionsState = TrackOptionsState(),
|
trackOptionsState = TrackOptionsState(),
|
||||||
|
isInJam = false,
|
||||||
|
isJamGuest = false,
|
||||||
onShowOptionsClick = {},
|
onShowOptionsClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
|
|||||||
@ -56,6 +56,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||||
|
|
||||||
sealed interface TrackOptionsAction {
|
sealed interface TrackOptionsAction {
|
||||||
|
data object AddToJam : TrackOptionsAction
|
||||||
data object StartRadio : TrackOptionsAction
|
data object StartRadio : TrackOptionsAction
|
||||||
data object PlayNext : TrackOptionsAction
|
data object PlayNext : TrackOptionsAction
|
||||||
data object AddToQueue : TrackOptionsAction
|
data object AddToQueue : TrackOptionsAction
|
||||||
@ -98,6 +99,8 @@ fun TrackOptions(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
) {
|
) {
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = buildTrackMenuItems(
|
items = buildTrackMenuItems(
|
||||||
@ -105,6 +108,8 @@ fun TrackOptions(
|
|||||||
state = state,
|
state = state,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
),
|
),
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GhostIconButton(onClick = onClick) {
|
GhostIconButton(onClick = onClick) {
|
||||||
@ -129,6 +134,8 @@ fun TrackOptionsBottomSheet(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
) {
|
) {
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
@ -151,6 +158,8 @@ fun TrackOptionsBottomSheet(
|
|||||||
onAlbumClick()
|
onAlbumClick()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
).forEach { item ->
|
).forEach { item ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -241,7 +250,19 @@ private fun buildTrackMenuItems(
|
|||||||
state: TrackOptionsState,
|
state: TrackOptionsState,
|
||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
|
isInJam: Boolean = false,
|
||||||
|
isJamGuest: Boolean = false,
|
||||||
): List<AdaptiveMenuItem> = buildList {
|
): List<AdaptiveMenuItem> = buildList {
|
||||||
|
if (isInJam) {
|
||||||
|
add(
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
|
label = "Add to Jam",
|
||||||
|
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicCircle,
|
icon = Iconsax.IconsaxMusicCircle,
|
||||||
@ -250,6 +271,9 @@ private fun buildTrackMenuItems(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A guest's queue is the shared jam queue — mutating it locally is not
|
||||||
|
// allowed, so queue actions are replaced by "Add to Jam".
|
||||||
|
if (!isJamGuest) {
|
||||||
if (!state.isInQueue && !state.isCurrentlyPlaying) {
|
if (!state.isInQueue && !state.isCurrentlyPlaying) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
@ -285,6 +309,7 @@ private fun buildTrackMenuItems(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
|
|||||||
@ -20,10 +20,14 @@ package dev.krtirtho.spotube.modules.album
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
@ -37,6 +41,11 @@ fun AlbumScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
val savedAlbumIds by viewModel.savedAlbumIds.collectAsStateWithLifecycle()
|
||||||
@ -92,6 +101,10 @@ fun AlbumScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = viewModel::addTracksToJam,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||||
@ -97,6 +99,7 @@ class AlbumViewModel(
|
|||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
|
private val remotePlaybackController: RemotePlaybackController,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<AlbumViewModel>()
|
private val logger by injectLogger<AlbumViewModel>()
|
||||||
|
|
||||||
@ -217,15 +220,28 @@ class AlbumViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playAlbum() {
|
fun playAlbum() {
|
||||||
viewModelScope.launch { playbackHelper.playAlbum(albumId) }
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Album, albumId, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addAlbumToQueue() {
|
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) {
|
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() {
|
fun refresh() {
|
||||||
@ -241,14 +257,24 @@ class AlbumViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TrackOptionsAction.AddToJam -> {
|
||||||
|
remotePlaybackController.addTrackToJam(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -311,34 +337,18 @@ class AlbumViewModel(
|
|||||||
tracks.forEach { track -> downloadManager.enqueue(track) }
|
tracks.forEach { track -> downloadManager.enqueue(track) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||||
|
remotePlaybackController.addTracksToJam(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? AlbumScreenState.Data)?.album?.title ?: "Album"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksPlayNext(tracks, title)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
fun isTrackBlacklisted(track: MetadataTrack): Boolean {
|
||||||
|
|||||||
@ -63,8 +63,11 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||||
@ -95,6 +98,11 @@ fun ArtistScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
val savedArtistIds by viewModel.savedArtistIds.collectAsStateWithLifecycle()
|
||||||
@ -179,6 +187,9 @@ fun ArtistScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||||
@ -73,6 +75,7 @@ class ArtistViewModel(
|
|||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
|
private val remotePlaybackController: RemotePlaybackController,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<ArtistViewModel>()
|
private val logger by injectLogger<ArtistViewModel>()
|
||||||
|
|
||||||
@ -247,80 +250,44 @@ class ArtistViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addTopTracksToQueue() {
|
fun addTopTracksToQueue() {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val entries = resolveTopTrackEntries()
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
if (entries.isEmpty()) return@launch
|
RemoteCollectionType.ArtistTopTracks,
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
artistId,
|
||||||
}
|
artistName,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTopTracks() {
|
fun playTopTracks() {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val entries = resolveTopTrackEntries()
|
remotePlaybackController.requestCollectionPlay(
|
||||||
if (entries.isEmpty()) return@launch
|
RemoteCollectionType.ArtistTopTracks,
|
||||||
audioPlayerQueue.load(
|
artistId,
|
||||||
entries = entries,
|
artistName,
|
||||||
autoPlay = true,
|
|
||||||
startPosition = 0,
|
|
||||||
collectionEntry = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun playTopTracksFromTrack(track: MetadataTrack) {
|
fun playTopTracksFromTrack(track: MetadataTrack) {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val queue = audioPlayerQueue.getQueue()
|
remotePlaybackController.requestCollectionPlay(
|
||||||
val queueIndex = queue.indexOfFirst { entry ->
|
type = RemoteCollectionType.ArtistTopTracks,
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
id = artistId,
|
||||||
}
|
title = artistName,
|
||||||
if (queueIndex >= 0) {
|
startTrack = track,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||||
|
remotePlaybackController.addTracksToJam(tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksAddToQueue(tracks, artistName)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val artistName = (_state.value as? ArtistScreenState.Loaded)?.artist?.name ?: "Artist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksPlayNext(tracks, artistName)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
|
||||||
@ -329,13 +296,23 @@ class ArtistViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TrackOptionsAction.AddToJam -> {
|
||||||
|
remotePlaybackController.addTrackToJam(track)
|
||||||
}
|
}
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
|
|||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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')}"
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,8 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@ -66,8 +68,14 @@ import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
|
|||||||
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
import dev.krtirtho.spotube.core.ui.component.dragScrollable
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
@ -75,6 +83,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(viewModel: HomeScreenViewModel) {
|
fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||||
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
val platform = getPlatform()
|
val platform = getPlatform()
|
||||||
val isDesktop = platform.type == PlatformType.Windows ||
|
val isDesktop = platform.type == PlatformType.Windows ||
|
||||||
platform.type == PlatformType.Linux ||
|
platform.type == PlatformType.Linux ||
|
||||||
@ -117,7 +126,23 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
|||||||
backButton = false,
|
backButton = false,
|
||||||
title = {
|
title = {
|
||||||
Text("Browse")
|
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 ->
|
) { innerPadding ->
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -31,10 +31,14 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
@ -53,6 +57,11 @@ fun PlaylistScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
val savedPlaylistIds by viewModel.savedPlaylistIds.collectAsStateWithLifecycle()
|
||||||
@ -127,6 +136,10 @@ fun PlaylistScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = viewModel::addTracksToJam,
|
||||||
footerContent = footerContent,
|
footerContent = footerContent,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||||
@ -98,6 +100,7 @@ class PlaylistViewModel(
|
|||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
|
private val remotePlaybackController: RemotePlaybackController,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<PlaylistViewModel>()
|
private val logger by injectLogger<PlaylistViewModel>()
|
||||||
|
|
||||||
@ -223,15 +226,28 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playPlaylist() {
|
fun playPlaylist() {
|
||||||
viewModelScope.launch { playbackHelper.playPlaylist(playlistId) }
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.Playlist, playlistId, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addPlaylistToQueue() {
|
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) {
|
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() {
|
fun refresh() {
|
||||||
@ -274,14 +290,24 @@ class PlaylistViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TrackOptionsAction.AddToJam -> {
|
||||||
|
remotePlaybackController.addTrackToJam(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -356,34 +382,18 @@ class PlaylistViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||||
|
remotePlaybackController.addTracksToJam(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksAddToQueue(tracks, title)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
val title = (_state.value as? PlaylistScreenState.Data)?.playlist?.title ?: "Playlist"
|
||||||
val blacklistedTrackIds = blacklistRepository.getTracksSnapshot().map { it.id }.toSet()
|
remotePlaybackController.requestTracksPlayNext(tracks, title)
|
||||||
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 savedTrackIds
|
val savedTrackIds
|
||||||
|
|||||||
@ -20,11 +20,15 @@ package dev.krtirtho.spotube.modules.saved_tracks
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
import dev.krtirtho.spotube.core.ui.component.CollectionView
|
||||||
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
import dev.krtirtho.spotube.modules.library.playlist.AddToPlaylistPicker
|
||||||
@ -39,6 +43,11 @@ fun SavedTracksScreen(
|
|||||||
navigationCommands: NavigationCommands
|
navigationCommands: NavigationCommands
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
|
||||||
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
@ -85,6 +94,10 @@ fun SavedTracksScreen(
|
|||||||
onBulkAddToQueue = viewModel::addTracksToQueue,
|
onBulkAddToQueue = viewModel::addTracksToQueue,
|
||||||
onBulkPlayNext = viewModel::playTracksNext,
|
onBulkPlayNext = viewModel::playTracksNext,
|
||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onAddToJam = viewModel::addTracksToJam,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
|
|||||||
@ -34,6 +34,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
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.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
|
||||||
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
import dev.krtirtho.spotube.core.ui.component.TrackOptionsContext
|
||||||
@ -97,6 +99,7 @@ class SavedTracksViewModel(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
|
private val remotePlaybackController: RemotePlaybackController,
|
||||||
) : ViewModel(), KoinComponent {
|
) : ViewModel(), KoinComponent {
|
||||||
private val logger by injectLogger<SavedTracksViewModel>()
|
private val logger by injectLogger<SavedTracksViewModel>()
|
||||||
|
|
||||||
@ -200,15 +203,20 @@ class SavedTracksViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playSavedTracks() {
|
fun playSavedTracks() {
|
||||||
viewModelScope.launch { playbackHelper.playSavedTracks() }
|
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addSavedTracksToQueue() {
|
fun addSavedTracksToQueue() {
|
||||||
viewModelScope.launch { playbackHelper.addSavedTracksToQueue() }
|
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, "Saved Tracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playSavedTracksFromTrack(track: MetadataTrack) {
|
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() {
|
fun refresh() {
|
||||||
@ -224,14 +232,24 @@ class SavedTracksViewModel(
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
(entry as? QueueEntry.StreamingTrack)?.track?.matchesTrack(track) == true
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TrackOptionsAction.AddToJam -> {
|
||||||
|
remotePlaybackController.addTrackToJam(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -298,34 +316,16 @@ class SavedTracksViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||||
|
remotePlaybackController.addTracksToJam(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
fun addTracksToQueue(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
remotePlaybackController.requestTracksAddToQueue(tracks, "Saved Tracks")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTracksNext(tracks: List<MetadataTrack>) {
|
fun playTracksNext(tracks: List<MetadataTrack>) {
|
||||||
viewModelScope.launch {
|
remotePlaybackController.requestTracksPlayNext(tracks, "Saved Tracks")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> {
|
suspend fun isSavedTracks(trackIds: List<String>): List<Boolean> {
|
||||||
|
|||||||
@ -86,6 +86,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||||
import dev.krtirtho.spotube.core.share.ShareService
|
import dev.krtirtho.spotube.core.share.ShareService
|
||||||
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
import dev.krtirtho.spotube.core.ui.base.AutocompleteTextField
|
||||||
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
import dev.krtirtho.spotube.core.ui.base.ChipTab
|
||||||
@ -121,11 +125,17 @@ private val GridMinCellSize = 180.dp
|
|||||||
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||||
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
val audioPlayerQueue: AudioPlayerQueue = koinInject()
|
||||||
val shareService: ShareService = koinInject()
|
val shareService: ShareService = koinInject()
|
||||||
|
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
val libraryRepository: LibraryRepository = koinInject()
|
val libraryRepository: LibraryRepository = koinInject()
|
||||||
val blacklistRepository: BlacklistRepository = koinInject()
|
val blacklistRepository: BlacklistRepository = koinInject()
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
|
val jamActive by jamRoomService.role.map { it != null }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val isJamGuest by jamRoomService.role.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val selectedType = state.selectedSearchType
|
val selectedType = state.selectedSearchType
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
@ -171,14 +181,23 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
is TrackOptionsAction.StartRadio -> {}
|
is TrackOptionsAction.StartRadio -> {}
|
||||||
is TrackOptionsAction.PlayNext -> {
|
is TrackOptionsAction.PlayNext -> {
|
||||||
val queue = audioPlayerQueue.getQueue()
|
val queue = audioPlayerQueue.getQueue()
|
||||||
queue.find { entry ->
|
val existing = queue.find { entry ->
|
||||||
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
(entry as? QueueEntry.StreamingTrack)?.track?.id == track.id
|
||||||
}?.let { audioPlayerQueue.removeFromQueue(it) }
|
}
|
||||||
|
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 = "")))
|
audioPlayerQueue.addAllAfterCurrent(listOf(QueueEntry.StreamingTrack(track = track, url = "")))
|
||||||
|
} else {
|
||||||
|
remotePlaybackController.requestTrackPlayNext(track)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.AddToQueue -> {
|
is TrackOptionsAction.AddToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = track, url = ""))
|
remotePlaybackController.requestTrackAddToQueue(track)
|
||||||
|
}
|
||||||
|
is TrackOptionsAction.AddToJam -> {
|
||||||
|
remotePlaybackController.addTrackToJam(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
is TrackOptionsAction.RemoveFromQueue -> {
|
is TrackOptionsAction.RemoveFromQueue -> {
|
||||||
@ -237,33 +256,15 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
fun bulkAddToQueue(tracks: List<MetadataTrack>) {
|
||||||
scope.launch {
|
remotePlaybackController.requestTracksAddToQueue(tracks, "Search results")
|
||||||
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 = "") }
|
fun bulkAddToJam(tracks: List<MetadataTrack>) {
|
||||||
audioPlayerQueue.addAllToQueue(entries)
|
remotePlaybackController.addTracksToJam(tracks)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
fun bulkPlayNext(tracks: List<MetadataTrack>) {
|
||||||
scope.launch {
|
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -341,6 +342,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
tracksToAddToPlaylist = tracks
|
tracksToAddToPlaylist = tracks
|
||||||
showAddToPlaylistPicker = true
|
showAddToPlaylistPicker = true
|
||||||
},
|
},
|
||||||
|
onBulkAddToJam = ::bulkAddToJam,
|
||||||
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
@ -370,6 +374,9 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
tracksToAddToPlaylist = tracks
|
tracksToAddToPlaylist = tracks
|
||||||
showAddToPlaylistPicker = true
|
showAddToPlaylistPicker = true
|
||||||
},
|
},
|
||||||
|
onBulkAddToJam = ::bulkAddToJam,
|
||||||
|
isInJam = jamActive,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
@ -651,6 +658,9 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||||
|
isInJam: Boolean,
|
||||||
|
isJamGuest: Boolean,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||||
@ -708,6 +718,9 @@ private fun SearchAllTab(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
@ -795,6 +808,9 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
onBulkAddToQueue: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
onBulkPlayNext: (List<MetadataTrack>) -> Unit,
|
||||||
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
onBulkAddToPlaylist: (List<MetadataTrack>) -> Unit,
|
||||||
|
onBulkAddToJam: (List<MetadataTrack>) -> Unit,
|
||||||
|
isInJam: Boolean,
|
||||||
|
isJamGuest: Boolean,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
onArtistsOverflowClick: (MetadataTrack) -> Unit,
|
||||||
@ -824,6 +840,9 @@ private fun SearchTracksTab(
|
|||||||
onBulkAddToQueue = onBulkAddToQueue,
|
onBulkAddToQueue = onBulkAddToQueue,
|
||||||
onBulkPlayNext = onBulkPlayNext,
|
onBulkPlayNext = onBulkPlayNext,
|
||||||
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
onBulkAddToPlaylist = onBulkAddToPlaylist,
|
||||||
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
|
|||||||
@ -55,6 +55,17 @@ data class UserSettings(
|
|||||||
val enableConnect: Boolean = false,
|
val enableConnect: Boolean = false,
|
||||||
val playbackProxyServerPort: Int = 14769,
|
val playbackProxyServerPort: Int = 14769,
|
||||||
|
|
||||||
|
// Remote Control (LAN)
|
||||||
|
val allowRemoteControl: Boolean = false,
|
||||||
|
val allowedRemoteDevices: List<String> = emptyList(),
|
||||||
|
val remoteControlDeviceName: String = "",
|
||||||
|
val remoteControlDeviceId: String = "",
|
||||||
|
|
||||||
|
// Group Jam (MQTT)
|
||||||
|
val jamParticipantName: String = "",
|
||||||
|
val jamBroker: JamBroker = JamBroker(),
|
||||||
|
val lastJamCode: String = "",
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
||||||
val localMediaFolders: List<String> = emptyList(),
|
val localMediaFolders: List<String> = emptyList(),
|
||||||
@ -77,3 +88,20 @@ data class UserSettings(
|
|||||||
// Updates
|
// Updates
|
||||||
val autoCheckForUpdates: Boolean = true,
|
val autoCheckForUpdates: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder
|
||||||
|
* until a real broker is configured; users can self-host and point the app at it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class JamBroker(
|
||||||
|
val name: String = "",
|
||||||
|
val host: String = "test.mosquitto.org",
|
||||||
|
val port: Int = 1883,
|
||||||
|
val useTls: Boolean = false,
|
||||||
|
val username: String? = null,
|
||||||
|
val password: String? = null,
|
||||||
|
val clientIdPrefix: String = "spotube",
|
||||||
|
val keepAliveSeconds: Int = 30,
|
||||||
|
val connectionTimeoutSeconds: Int = 10,
|
||||||
|
)
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dev.krtirtho.spotube.PlatformType
|
import dev.krtirtho.spotube.PlatformType
|
||||||
import dev.krtirtho.spotube.getPlatform
|
import dev.krtirtho.spotube.getPlatform
|
||||||
|
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import spotube.composeapp.generated.resources.*
|
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.cacheSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
||||||
|
import dev.krtirtho.spotube.modules.settings.sections.jamSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
||||||
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
import dev.krtirtho.spotube.modules.settings.sections.pluginsSection
|
||||||
@ -62,6 +64,7 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
|||||||
platformType == PlatformType.MacOS
|
platformType == PlatformType.MacOS
|
||||||
|
|
||||||
val shellBottomInset = LocalAppShellBottomInset.current
|
val shellBottomInset = LocalAppShellBottomInset.current
|
||||||
|
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
|
||||||
val contentPadding = remember(shellBottomInset) {
|
val contentPadding = remember(shellBottomInset) {
|
||||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||||
}
|
}
|
||||||
@ -106,6 +109,12 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
|||||||
settings = settingsState!!,
|
settings = settingsState!!,
|
||||||
settingsViewModel = settingsViewModel,
|
settingsViewModel = settingsViewModel,
|
||||||
navigatorCommands = navigatorCommands,
|
navigatorCommands = navigatorCommands,
|
||||||
|
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||||
|
)
|
||||||
|
if (settingsState != null)
|
||||||
|
jamSection(
|
||||||
|
settings = settingsState!!,
|
||||||
|
settingsViewModel = settingsViewModel,
|
||||||
)
|
)
|
||||||
if (settingsState != null)
|
if (settingsState != null)
|
||||||
cacheSection(
|
cacheSection(
|
||||||
|
|||||||
@ -0,0 +1,208 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package dev.krtirtho.spotube.modules.settings.sections
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomClient
|
||||||
|
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||||
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
|
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
||||||
|
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_client_id
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_host
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_password
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_port
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_test
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_test_fail
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_test_ok
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_testing
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_tls
|
||||||
|
import spotube.composeapp.generated.resources.settings_jam_broker_username
|
||||||
|
import spotube.composeapp.generated.resources.settings_section_jam
|
||||||
|
|
||||||
|
internal fun LazyListScope.jamSection(
|
||||||
|
settings: UserSettings,
|
||||||
|
settingsViewModel: SettingsViewModel,
|
||||||
|
) {
|
||||||
|
val broker = settings.jamBroker
|
||||||
|
|
||||||
|
settingsSectionHeader(Res.string.settings_section_jam)
|
||||||
|
settingsSectionCard(
|
||||||
|
items = listOf(
|
||||||
|
{
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_jam_broker_host),
|
||||||
|
subtitle = stringResource(
|
||||||
|
Res.string.settings_jam_broker_host_subtitle,
|
||||||
|
broker.host.ifBlank { "—" },
|
||||||
|
broker.port,
|
||||||
|
),
|
||||||
|
value = broker.host,
|
||||||
|
onValueSaved = { host ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(jamBroker = jamBroker.copy(host = host))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = "broker.example.com",
|
||||||
|
icon = {
|
||||||
|
SettingsItemIcon(
|
||||||
|
Iconsax.CustomServer,
|
||||||
|
stringResource(Res.string.settings_jam_broker_host),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_jam_broker_port),
|
||||||
|
value = broker.port.toString(),
|
||||||
|
onValueSaved = { port ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(jamBroker = jamBroker.copy(port = port.toIntOrNull() ?: 1883))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = "1883",
|
||||||
|
normalize = { it.filter { c -> c.isDigit() }.take(5) },
|
||||||
|
validate = { input ->
|
||||||
|
val port = input.toIntOrNull()
|
||||||
|
if (port == null || port !in 1..65535) "Invalid port" else null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SwitchSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_jam_broker_tls),
|
||||||
|
checked = broker.useTls,
|
||||||
|
onCheckedChange = { tls ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(jamBroker = jamBroker.copy(useTls = tls))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_jam_broker_username),
|
||||||
|
value = broker.username.orEmpty(),
|
||||||
|
onValueSaved = { username ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = "anonymous",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_jam_broker_password),
|
||||||
|
value = broker.password.orEmpty(),
|
||||||
|
onValueSaved = { password ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = "••••••••",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.settings_jam_broker_client_id),
|
||||||
|
value = broker.clientIdPrefix,
|
||||||
|
onValueSaved = { prefix ->
|
||||||
|
settingsViewModel.updateSettings {
|
||||||
|
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder = "spotube",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
val jamClient = koinInject<JamRoomClient>()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
var result by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
testing = true
|
||||||
|
result = null
|
||||||
|
scope.launch {
|
||||||
|
val outcome = jamClient.testConnection(broker)
|
||||||
|
result = outcome.fold(
|
||||||
|
onSuccess = { ok -> "OK: $ok" },
|
||||||
|
onFailure = { e -> "ERR: ${e.message ?: "unknown"}" },
|
||||||
|
)
|
||||||
|
testing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = broker.host.isNotBlank() && !testing,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (testing) {
|
||||||
|
stringResource(Res.string.settings_jam_broker_testing)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.settings_jam_broker_test)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result?.let { message ->
|
||||||
|
val ok = message.startsWith("OK:")
|
||||||
|
Text(
|
||||||
|
text = message.removePrefix("OK:").removePrefix("ERR:"),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -38,6 +38,7 @@ import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
|||||||
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
import dev.krtirtho.spotube.modules.settings.components.TextInputSettingCard
|
||||||
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
import dev.krtirtho.spotube.resources.iconsax.CustomServer
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
||||||
@ -49,6 +50,7 @@ internal fun LazyListScope.playbackSection(
|
|||||||
settings: UserSettings,
|
settings: UserSettings,
|
||||||
settingsViewModel: SettingsViewModel,
|
settingsViewModel: SettingsViewModel,
|
||||||
navigatorCommands: NavigationCommands,
|
navigatorCommands: NavigationCommands,
|
||||||
|
requestLocalNetworkPermission: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
|
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
|
||||||
val streamingQualities = availableAudioQualities(
|
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_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||||
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
||||||
|
|||||||
@ -82,6 +82,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
||||||
@ -117,6 +119,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateOne
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
|
import dev.krtirtho.spotube.resources.iconsax.InconsaxClock
|
||||||
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@ -158,6 +161,10 @@ fun AppExpandedPlayer(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||||
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
|
val isJamGuest by jamRoomService.role
|
||||||
|
.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
val downloadsViewModel: DownloadsViewModel = koinViewModel()
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
@ -198,18 +205,22 @@ fun AppExpandedPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipPrevious() {
|
fun onSkipPrevious() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.skipToPrevious() }
|
scope.launch { audioPlayer.skipToPrevious() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipNext() {
|
fun onSkipNext() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.skipToNext() }
|
scope.launch { audioPlayer.skipToNext() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShuffleToggle() {
|
fun onShuffleToggle() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onLoopToggle() {
|
fun onLoopToggle() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -517,7 +528,7 @@ fun AppExpandedPlayer(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
GhostIconButton(onClick = ::onShuffleToggle) {
|
GhostIconButton(onClick = ::onShuffleToggle, enabled = !isJamGuest) {
|
||||||
Icon(
|
Icon(
|
||||||
Iconsax.IconsaxShuffle,
|
Iconsax.IconsaxShuffle,
|
||||||
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
contentDescription = if (playerUiState.isShuffling) "Disable shuffle" else "Enable shuffle",
|
||||||
@ -528,7 +539,7 @@ fun AppExpandedPlayer(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipPrevious) {
|
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
|
||||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -543,10 +554,10 @@ fun AppExpandedPlayer(
|
|||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(30.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipNext) {
|
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
|
||||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onLoopToggle) {
|
GhostIconButton(onClick = ::onLoopToggle, enabled = !isJamGuest) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = when (playerUiState.loopState) {
|
imageVector = when (playerUiState.loopState) {
|
||||||
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
||||||
|
|||||||
@ -65,6 +65,8 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
import dev.krtirtho.spotube.core.audioplayer.LoopState
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.Slider
|
import dev.krtirtho.spotube.core.ui.base.Slider
|
||||||
@ -93,6 +95,7 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeCross
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeHigh
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxVolumeLow
|
||||||
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
import dev.krtirtho.spotube.resources.iconsax.SwapHorizontal2
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@ -125,6 +128,10 @@ fun AppLargePlayer(
|
|||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||||
|
val jamRoomService: JamRoomService = koinInject()
|
||||||
|
val isJamGuest by jamRoomService.role
|
||||||
|
.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
var isSeeking by remember { mutableStateOf(false) }
|
var isSeeking by remember { mutableStateOf(false) }
|
||||||
@ -155,18 +162,22 @@ fun AppLargePlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipPrevious() {
|
fun onSkipPrevious() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.skipToPrevious() }
|
scope.launch { audioPlayer.skipToPrevious() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSkipNext() {
|
fun onSkipNext() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.skipToNext() }
|
scope.launch { audioPlayer.skipToNext() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onShuffleToggle() {
|
fun onShuffleToggle() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onLoopToggle() {
|
fun onLoopToggle() {
|
||||||
|
if (isJamGuest) return
|
||||||
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,6 +305,7 @@ fun AppLargePlayer(
|
|||||||
) {
|
) {
|
||||||
VariableIconButton(
|
VariableIconButton(
|
||||||
onClick = ::onShuffleToggle,
|
onClick = ::onShuffleToggle,
|
||||||
|
enabled = !isJamGuest,
|
||||||
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
variant = if (playerUiState.isShuffling) VariableIconButtonVariant.Outline else VariableIconButtonVariant.Ghost
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@ -306,7 +318,7 @@ fun AppLargePlayer(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipPrevious) {
|
GhostIconButton(onClick = ::onSkipPrevious, enabled = !isJamGuest) {
|
||||||
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous")
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
@ -320,11 +332,12 @@ fun AppLargePlayer(
|
|||||||
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
contentDescription = if (playerUiState.isPlaying) "Pause" else "Play or pause",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GhostIconButton(onClick = ::onSkipNext) {
|
GhostIconButton(onClick = ::onSkipNext, enabled = !isJamGuest) {
|
||||||
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
Icon(Iconsax.IconsaxNext, contentDescription = "Next")
|
||||||
}
|
}
|
||||||
VariableIconButton(
|
VariableIconButton(
|
||||||
onClick = ::onLoopToggle,
|
onClick = ::onLoopToggle,
|
||||||
|
enabled = !isJamGuest,
|
||||||
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
variant = if (playerUiState.loopState == LoopState.NONE) VariableIconButtonVariant.Ghost else VariableIconButtonVariant.Outline
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
@ -69,6 +72,9 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
|
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
||||||
|
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||||
|
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
|
||||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||||
@ -93,6 +99,13 @@ fun AppShell(
|
|||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val navigatorCommands: NavigationCommands = koinInject()
|
val navigatorCommands: NavigationCommands = koinInject()
|
||||||
|
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(remotePlaybackController) {
|
||||||
|
remotePlaybackController.events.collect { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
||||||
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
||||||
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
||||||
@ -116,6 +129,9 @@ fun AppShell(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ConnectionRequestDialogHost()
|
||||||
|
PlayDestinationPickerHost()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val useSidebar = viewModel.useSidebar()
|
val useSidebar = viewModel.useSidebar()
|
||||||
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar)
|
||||||
@ -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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,8 +68,10 @@ import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
|||||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarLeftBroken
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSidebarRightBroken
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
import dev.krtirtho.spotube.tabs
|
import dev.krtirtho.spotube.tabs
|
||||||
import org.jetbrains.compose.resources.Font
|
import org.jetbrains.compose.resources.Font
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
@ -103,6 +105,7 @@ fun AppSidebar(
|
|||||||
},
|
},
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -176,6 +179,24 @@ fun AppSidebar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SidebarItem(
|
||||||
|
label = "Devices",
|
||||||
|
activeIcon = Iconsax.IconsaxMirroringScreen,
|
||||||
|
onClick = { navigator.navigate(Routes.Devices) },
|
||||||
|
selected = false,
|
||||||
|
expanded = expanded,
|
||||||
|
)
|
||||||
|
SidebarItem(
|
||||||
|
label = "Group Jam",
|
||||||
|
activeIcon = Iconsax.User,
|
||||||
|
onClick = { navigator.navigate(Routes.Jam) },
|
||||||
|
selected = false,
|
||||||
|
expanded = expanded,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(120.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -33,8 +34,12 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
@ -52,6 +57,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
import dev.krtirtho.spotube.core.ui.base.Card
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
import dev.krtirtho.spotube.core.ui.base.IconButton
|
||||||
@ -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.LocalBaseUITheme
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||||
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxDragHandle
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicSquareRemove
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||||
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxUserRemove
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import sh.calvin.reorderable.ReorderableItem
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
@ -78,12 +87,14 @@ fun PlayerQueueContent(
|
|||||||
val displayItems = state.displayItems
|
val displayItems = state.displayItems
|
||||||
val filterQuery = state.filterQuery
|
val filterQuery = state.filterQuery
|
||||||
val isFiltered = state.isFiltered
|
val isFiltered = state.isFiltered
|
||||||
|
val isReadOnly = state.isReadOnly
|
||||||
|
var selectedParticipant by remember { mutableStateOf<JamParticipant?>(null) }
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val reorderableLazyListState = rememberReorderableLazyListState(
|
val reorderableLazyListState = rememberReorderableLazyListState(
|
||||||
lazyListState,
|
lazyListState,
|
||||||
onMove = { from, to ->
|
onMove = { from, to ->
|
||||||
if (isFiltered) return@rememberReorderableLazyListState
|
if (isFiltered || isReadOnly) return@rememberReorderableLazyListState
|
||||||
viewModel.onMove(from.index, to.index)
|
viewModel.onMove(from.index, to.index)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -118,6 +129,7 @@ fun PlayerQueueContent(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
if (!isReadOnly) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = viewModel::clearQueue,
|
onClick = viewModel::clearQueue,
|
||||||
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
|
theme = LocalBaseUITheme.current.iconButtons.outline.copyShape(MaterialTheme.shapes.small),
|
||||||
@ -125,6 +137,7 @@ fun PlayerQueueContent(
|
|||||||
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
|
Icon(Iconsax.IconsaxTrash, contentDescription = "Clear Queue")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (displayItems.isEmpty()) {
|
if (displayItems.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@ -144,20 +157,140 @@ fun PlayerQueueContent(
|
|||||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp)
|
||||||
QueueItemRow(
|
QueueItemRow(
|
||||||
item = item,
|
item = item,
|
||||||
reorderScope = if (isFiltered) null else this,
|
reorderScope = if (isFiltered || isReadOnly) null else this,
|
||||||
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
|
onPlayClick = { viewModel.playQueueItem(item.originalIndex) },
|
||||||
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
|
onRemoveClick = { viewModel.removeQueueItem(item.originalIndex) },
|
||||||
onDragStarted = { viewModel.onDragStart() },
|
onDragStarted = { viewModel.onDragStart() },
|
||||||
onDragStopped = { viewModel.onDragStop() },
|
onDragStopped = { viewModel.onDragStop() },
|
||||||
|
showOptions = !isReadOnly,
|
||||||
|
enabled = !isReadOnly,
|
||||||
|
onParticipantClick = { selectedParticipant = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedParticipant?.let { participant ->
|
||||||
|
ParticipantDialog(
|
||||||
|
participant = participant,
|
||||||
|
isJamHost = state.isJamHost,
|
||||||
|
onDismiss = { selectedParticipant = null },
|
||||||
|
onKick = { viewModel.kickParticipant(participant.id) },
|
||||||
|
onBan = { viewModel.banParticipant(participant.id) },
|
||||||
|
onRemoveSuggestions = { viewModel.removeParticipantTracks(participant.id) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ParticipantDialog(
|
||||||
|
participant: JamParticipant,
|
||||||
|
isJamHost: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onKick: () -> Unit,
|
||||||
|
onBan: () -> Unit,
|
||||||
|
onRemoveSuggestions: () -> Unit,
|
||||||
|
) {
|
||||||
|
AdaptiveDialogBottomSheet(
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
title = { Text(participant.displayName, style = MaterialTheme.typography.titleLarge) },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
ParticipantAvatar(participant, size = 40)
|
||||||
|
Text(
|
||||||
|
text = participant.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
if (participant.isHost) {
|
||||||
|
Text(
|
||||||
|
text = "Host",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJamHost && !participant.isHost) {
|
||||||
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
ListRowTile(
|
||||||
|
onClick = {
|
||||||
|
onKick()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxCloseSquare,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text("Kick") },
|
||||||
|
subtitle = { Text("Remove them from the session") },
|
||||||
|
)
|
||||||
|
ListRowTile(
|
||||||
|
onClick = {
|
||||||
|
onBan()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxUserRemove,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text("Ban") },
|
||||||
|
subtitle = { Text("Kick and prevent them from rejoining") },
|
||||||
|
)
|
||||||
|
ListRowTile(
|
||||||
|
onClick = {
|
||||||
|
onRemoveSuggestions()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxMusicSquareRemove,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text("Remove suggestions") },
|
||||||
|
subtitle = { Text("Remove every track they added to the queue") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ParticipantAvatar(participant: JamParticipant, size: Int) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(size.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueItemRow(
|
private fun QueueItemRow(
|
||||||
@ -167,11 +300,15 @@ private fun QueueItemRow(
|
|||||||
onRemoveClick: () -> Unit,
|
onRemoveClick: () -> Unit,
|
||||||
onDragStarted: () -> Unit,
|
onDragStarted: () -> Unit,
|
||||||
onDragStopped: () -> Unit,
|
onDragStopped: () -> Unit,
|
||||||
|
showOptions: Boolean = true,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onParticipantClick: (JamParticipant) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ListRowTile(
|
ListRowTile(
|
||||||
onClick = onPlayClick,
|
onClick = onPlayClick,
|
||||||
|
enabled = enabled,
|
||||||
selected = item.isCurrent,
|
selected = item.isCurrent,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
leading = {
|
leading = {
|
||||||
@ -255,6 +392,25 @@ private fun QueueItemRow(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
|
item.addedByParticipant?.let { participant ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(28.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable { onParticipantClick(participant) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = participant.displayName.firstOrNull()?.uppercase()?.take(1) ?: "?",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showOptions) {
|
||||||
Box {
|
Box {
|
||||||
GhostIconButton(
|
GhostIconButton(
|
||||||
onClick = { showMenu = true },
|
onClick = { showMenu = true },
|
||||||
@ -283,5 +439,6 @@ private fun QueueItemRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamParticipant
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRole
|
||||||
|
import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@ -38,16 +41,23 @@ data class QueueItemUi(
|
|||||||
val isCurrent: Boolean,
|
val isCurrent: Boolean,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
val originalIndex: Int,
|
val originalIndex: Int,
|
||||||
|
/** Participant who added this item to the jam queue, if any. */
|
||||||
|
val addedByParticipant: JamParticipant? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContentUiState(
|
data class QueueContentUiState(
|
||||||
val filterQuery: String = "",
|
val filterQuery: String = "",
|
||||||
val displayItems: List<QueueItemUi> = emptyList(),
|
val displayItems: List<QueueItemUi> = emptyList(),
|
||||||
val isFiltered: Boolean = false,
|
val isFiltered: Boolean = false,
|
||||||
|
/** Guests cannot reorder/remove/clear the shared jam queue. */
|
||||||
|
val isReadOnly: Boolean = false,
|
||||||
|
val isJamHost: Boolean = false,
|
||||||
|
val participants: List<JamParticipant> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlayerQueueContentViewModel(
|
class PlayerQueueContentViewModel(
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
private val jamRoomService: JamRoomService,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val queueVisibilityFlow = MutableStateFlow(false)
|
private val queueVisibilityFlow = MutableStateFlow(false)
|
||||||
private val queueFilterFlow = MutableStateFlow("")
|
private val queueFilterFlow = MutableStateFlow("")
|
||||||
@ -60,7 +70,8 @@ class PlayerQueueContentViewModel(
|
|||||||
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
private val computedItems: StateFlow<List<QueueItemUi>> = combine(
|
||||||
audioPlayerQueue.queueFlow,
|
audioPlayerQueue.queueFlow,
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
) { queue, currentEntry ->
|
jamRoomService.participants,
|
||||||
|
) { queue, currentEntry, participants ->
|
||||||
val currentIndex = if (currentEntry != null) {
|
val currentIndex = if (currentEntry != null) {
|
||||||
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
queue.indexOfFirst { it.matchesCurrent(currentEntry) }
|
||||||
} else {
|
} else {
|
||||||
@ -68,7 +79,7 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
queue.mapIndexed { index, entry ->
|
queue.mapIndexed { index, entry ->
|
||||||
val title: String
|
val title: String
|
||||||
val subtitle: String
|
var subtitle: String
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
val imageUrl: String?
|
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(
|
QueueItemUi(
|
||||||
id = "${entry.url}@$index",
|
id = "${entry.url}@$index",
|
||||||
title = title,
|
title = title,
|
||||||
@ -97,6 +113,7 @@ class PlayerQueueContentViewModel(
|
|||||||
isCurrent = index == currentIndex,
|
isCurrent = index == currentIndex,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
originalIndex = index,
|
originalIndex = index,
|
||||||
|
addedByParticipant = addedByParticipant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@ -109,7 +126,9 @@ class PlayerQueueContentViewModel(
|
|||||||
computedItems,
|
computedItems,
|
||||||
reorderBuffer,
|
reorderBuffer,
|
||||||
queueFilterFlow,
|
queueFilterFlow,
|
||||||
) { items, buffer, filterQuery ->
|
jamRoomService.role,
|
||||||
|
jamRoomService.participants,
|
||||||
|
) { items, buffer, filterQuery, role, participants ->
|
||||||
val normalizedFilter = filterQuery.trim().lowercase()
|
val normalizedFilter = filterQuery.trim().lowercase()
|
||||||
val isFiltered = normalizedFilter.isNotBlank()
|
val isFiltered = normalizedFilter.isNotBlank()
|
||||||
val filtered = if (isFiltered) {
|
val filtered = if (isFiltered) {
|
||||||
@ -124,6 +143,9 @@ class PlayerQueueContentViewModel(
|
|||||||
filterQuery = filterQuery,
|
filterQuery = filterQuery,
|
||||||
displayItems = buffer ?: filtered,
|
displayItems = buffer ?: filtered,
|
||||||
isFiltered = isFiltered,
|
isFiltered = isFiltered,
|
||||||
|
isReadOnly = role == JamRole.Guest,
|
||||||
|
isJamHost = role == JamRole.Host,
|
||||||
|
participants = participants,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@ -144,14 +166,14 @@ class PlayerQueueContentViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun playQueueItem(index: Int) {
|
fun playQueueItem(index: Int) {
|
||||||
if (index < 0) return
|
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioPlayerQueue.jumpTo(index)
|
audioPlayerQueue.jumpTo(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeQueueItem(index: Int) {
|
fun removeQueueItem(index: Int) {
|
||||||
if (index < 0) return
|
if (index < 0 || queueContentUiState.value.isReadOnly) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val currentQueue = audioPlayerQueue.queueFlow.value
|
val currentQueue = audioPlayerQueue.queueFlow.value
|
||||||
if (index < currentQueue.size) {
|
if (index < currentQueue.size) {
|
||||||
@ -162,24 +184,49 @@ class PlayerQueueContentViewModel(
|
|||||||
|
|
||||||
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
fun moveQueueItem(fromIndex: Int, toIndex: Int) {
|
||||||
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
if (fromIndex == toIndex || fromIndex < 0 || toIndex < 0) return
|
||||||
|
if (queueContentUiState.value.isReadOnly) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioPlayerQueue.move(fromIndex, toIndex)
|
audioPlayerQueue.move(fromIndex, toIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearQueue() {
|
fun clearQueue() {
|
||||||
|
if (queueContentUiState.value.isReadOnly) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
audioPlayerQueue.clear()
|
audioPlayerQueue.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Jam participant moderation (host only) ----------
|
||||||
|
|
||||||
|
fun kickParticipant(participantId: String) {
|
||||||
|
if (!queueContentUiState.value.isJamHost) return
|
||||||
|
viewModelScope.launch { jamRoomService.kickParticipant(participantId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun banParticipant(participantId: String) {
|
||||||
|
if (!queueContentUiState.value.isJamHost) return
|
||||||
|
viewModelScope.launch { jamRoomService.banParticipant(participantId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes every queue item that the given participant suggested. */
|
||||||
|
fun removeParticipantTracks(participantId: String) {
|
||||||
|
if (!queueContentUiState.value.isJamHost) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val entries = audioPlayerQueue.queueFlow.value.filter { it.addedBy == participantId }
|
||||||
|
entries.forEach { audioPlayerQueue.removeFromQueue(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onDragStart() {
|
fun onDragStart() {
|
||||||
if (reorderBuffer.value != null) return
|
if (reorderBuffer.value != null) return
|
||||||
|
if (queueContentUiState.value.isReadOnly) return
|
||||||
val currentItems = queueContentUiState.value.displayItems
|
val currentItems = queueContentUiState.value.displayItems
|
||||||
reorderBuffer.value = currentItems.toList()
|
reorderBuffer.value = currentItems.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onMove(from: Int, to: Int) {
|
fun onMove(from: Int, to: Int) {
|
||||||
|
if (queueContentUiState.value.isReadOnly) return
|
||||||
val buffer = reorderBuffer.value ?: return
|
val buffer = reorderBuffer.value ?: return
|
||||||
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
|
if (from == to || from < 0 || to < 0 || from >= buffer.size || to >= buffer.size) return
|
||||||
val item = buffer[from]
|
val item = buffer[from]
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||||
|
|
||||||
private val SlidingSheetBreakpoint = 840.dp
|
private val SlidingSheetBreakpoint = 840.dp
|
||||||
|
|
||||||
@ -68,7 +69,11 @@ private fun SlidingQueueSheet(
|
|||||||
visible = isVisible,
|
visible = isVisible,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.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(),
|
enter = slideInHorizontally { fullWidth -> fullWidth / 2 } + fadeIn(),
|
||||||
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
|
exit = slideOutHorizontally { fullWidth -> fullWidth / 2 } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/** No runtime local-network permission needed on iOS. */
|
||||||
|
@Composable
|
||||||
|
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/** No runtime local-network permission needed on the JVM. */
|
||||||
|
@Composable
|
||||||
|
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit = {}
|
||||||
@ -29,6 +29,7 @@ import androidx.compose.ui.window.rememberWindowState
|
|||||||
import com.sun.jna.Library
|
import com.sun.jna.Library
|
||||||
import com.sun.jna.Native
|
import com.sun.jna.Native
|
||||||
import dev.krtirtho.spotube.core.di.initKoin
|
import dev.krtirtho.spotube.core.di.initKoin
|
||||||
|
import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler
|
||||||
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
import dev.krtirtho.spotube.core.systemtray.SystemTray
|
import dev.krtirtho.spotube.core.systemtray.SystemTray
|
||||||
@ -81,9 +82,28 @@ private fun disableWebKitGpuCompositing() {
|
|||||||
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
|
LibC.INSTANCE.setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes `spotube://` deep links into [ExternalUriHandler].
|
||||||
|
* macOS delivers them through the open-URI handler; on Linux/Windows they arrive
|
||||||
|
* as command line arguments (scheme registration is handled by the distribution
|
||||||
|
* packaging, e.g. the `.desktop` file's `Exec %u`).
|
||||||
|
*/
|
||||||
|
private fun handleStartupDeepLinks(args: Array<String>) {
|
||||||
|
runCatching {
|
||||||
|
if (java.awt.Desktop.isDesktopSupported()) {
|
||||||
|
java.awt.Desktop.getDesktop().setOpenURIHandler { event ->
|
||||||
|
ExternalUriHandler.onNewUri(event.uri.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.firstOrNull { it.startsWith("spotube:", ignoreCase = true) }
|
||||||
|
?.let(ExternalUriHandler::onNewUri)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
fun main(args: Array<String>) {
|
||||||
disableWebKitGpuCompositing()
|
disableWebKitGpuCompositing()
|
||||||
|
handleStartupDeepLinks(args)
|
||||||
FileKit.init(appId = "dev.krtirtho.spotube")
|
FileKit.init(appId = "dev.krtirtho.spotube")
|
||||||
initKoin()
|
initKoin()
|
||||||
NewPipeDownloader.init(KoinPathsProvider.paths)
|
NewPipeDownloader.init(KoinPathsProvider.paths)
|
||||||
|
|||||||
@ -22,7 +22,7 @@ androidx-activity = "1.13.0"
|
|||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core = "1.19.0"
|
androidx-core = "1.19.0"
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.11.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
appdirs = "1.5.0"
|
appdirs = "1.5.0"
|
||||||
cache4k = "0.14.0"
|
cache4k = "0.14.0"
|
||||||
@ -44,6 +44,8 @@ kotlinx-io = "0.9.1"
|
|||||||
material3 = "1.10.0-alpha05"
|
material3 = "1.10.0-alpha05"
|
||||||
kotlinx-serialization-json = "1.11.0"
|
kotlinx-serialization-json = "1.11.0"
|
||||||
materialKolor = "4.1.1"
|
materialKolor = "4.1.1"
|
||||||
|
mqttClient = "2.1.1"
|
||||||
|
mqttBuffer = "6.30.8"
|
||||||
murmurhash = "0.4.2"
|
murmurhash = "0.4.2"
|
||||||
newpipeextractor = "v0.26.2"
|
newpipeextractor = "v0.26.2"
|
||||||
newpipeExtractorKmp = "1.3.0"
|
newpipeExtractorKmp = "1.3.0"
|
||||||
@ -53,6 +55,7 @@ vlcj = "4.12.1"
|
|||||||
vlcjNative = "4.12.0"
|
vlcjNative = "4.12.0"
|
||||||
ziplineVersion = "1.27.0"
|
ziplineVersion = "1.27.0"
|
||||||
ktor = "3.5.1"
|
ktor = "3.5.1"
|
||||||
|
dnssdkt = "1.1.0"
|
||||||
kotlinStdlib = "2.4.10"
|
kotlinStdlib = "2.4.10"
|
||||||
runner = "1.7.0"
|
runner = "1.7.0"
|
||||||
core = "1.7.0"
|
core = "1.7.0"
|
||||||
@ -61,8 +64,8 @@ compose-webview = "1.0.1"
|
|||||||
composeNativeTray = "2.0.3"
|
composeNativeTray = "2.0.3"
|
||||||
koin = "4.2.2"
|
koin = "4.2.2"
|
||||||
multiplatform-nav3-ui = "1.1.1"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
compose-multiplatform-adaptive = "1.3.0-beta02"
|
compose-multiplatform-adaptive = "1.3.0-alpha05"
|
||||||
compose-multiplatform-lifecycle = "2.11.0"
|
compose-multiplatform-lifecycle = "2.10.0"
|
||||||
feather-icons = "1.1.1"
|
feather-icons = "1.1.1"
|
||||||
material3-window-size = "1.9.0"
|
material3-window-size = "1.9.0"
|
||||||
kotlinx-datetime = "0.8.0"
|
kotlinx-datetime = "0.8.0"
|
||||||
@ -85,6 +88,7 @@ androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
|||||||
compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" }
|
compose-placeholder-material3 = { module = "com.eygraber:compose-placeholder-material3", version.ref = "composePlaceholderMaterial3" }
|
||||||
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
|
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
||||||
haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" }
|
haze-blur = { module = "dev.chrisbanes.haze:haze-blur", version.ref = "haze" }
|
||||||
@ -117,7 +121,13 @@ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-s
|
|||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
||||||
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
||||||
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||||
|
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
|
||||||
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
|
||||||
|
mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" }
|
||||||
|
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
|
||||||
|
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
|
||||||
|
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
|
||||||
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
|
||||||
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
|
||||||
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }
|
||||||
|
|||||||
@ -4,5 +4,23 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>dev.krtirtho.spotube</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>spotube</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Required to discover local network devices</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_http._tcp</string>
|
||||||
|
<string>_spotube-ctrl._tcp</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -20,6 +20,9 @@ struct iOSApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
.onOpenURL { url in
|
||||||
|
ExternalUriHandler.shared.onNewUri(uri: url.absoluteString)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user