From a2b9f4178c93fc4c2afd2944a11fe4f97d910f5c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 11 Sep 2026 19:35:54 +0600 Subject: [PATCH] feat(jam-session): integrate MQTT broker configuration and enhance jam session settings --- composeApp/Cargo.lock | 2157 +---------------- composeApp/Cargo.toml | 4 - composeApp/build.gradle.kts | 6 + .../src/androidMain/AndroidManifest.xml | 11 - .../composeResources/values/strings.xml | 14 + .../kotlin/dev/krtirtho/spotube/App.kt | 8 - .../core/audioplayer/AudioPlayerModels.kt | 7 +- .../core/deeplink/JamDeepLinkService.kt | 50 - .../dev/krtirtho/spotube/core/di/Modules.kt | 13 +- .../spotube/core/jam/JamInviteCodec.kt | 105 - .../krtirtho/spotube/core/jam/JamProtocol.kt | 105 +- .../spotube/core/jam/JamRoomClient.kt | 323 +++ .../krtirtho/spotube/core/jam/JamRoomCode.kt | 47 + .../spotube/core/jam/JamRoomService.kt | 475 ++++ .../spotube/core/jam/JamSessionService.kt | 587 ----- .../spotube/core/jam/QueueSyncManager.kt | 316 --- .../core/remote/RemotePlaybackController.kt | 37 +- .../modules/devices/PlayDestinationPicker.kt | 8 +- .../krtirtho/spotube/modules/jam/JamScreen.kt | 537 +--- .../spotube/modules/jam/JamViewModel.kt | 428 +--- .../modules/settings/SettingsModels.kt | 21 +- .../modules/settings/SettingsScreen.kt | 6 + .../modules/settings/sections/JamSection.kt | 208 ++ .../spotube/modules/shell/AppShell.kt | 18 + .../PlayerQueueContentViewModel.kt | 7 +- composeApp/src/commonMain/rust/lib.rs | 2 - composeApp/src/commonMain/rust/webrtc_p2p.rs | 340 --- gradle/libs.versions.toml | 6 + 28 files changed, 1361 insertions(+), 4485 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt delete mode 100644 composeApp/src/commonMain/rust/webrtc_p2p.rs diff --git a/composeApp/Cargo.lock b/composeApp/Cargo.lock index 1897105f..d8199d57 100644 --- a/composeApp/Cargo.lock +++ b/composeApp/Cargo.lock @@ -8,41 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "anyhow" version = "1.0.103" @@ -91,97 +56,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "asn1-rs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" -dependencies = [ - "asn1-rs-derive 0.5.1", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive 0.6.0", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - [[package]] name = "async-compat" version = "0.2.5" @@ -195,41 +69,12 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "basic-toml" version = "0.1.10" @@ -239,74 +84,12 @@ dependencies = [ "serde", ] -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytecheck" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "rancor", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - [[package]] name = "byteorder" version = "1.5.0" @@ -348,38 +131,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] -name = "cc" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "ccm" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" -dependencies = [ - "aead", - "cipher", - "ctr", - "subtle", + "thiserror", ] [[package]] @@ -388,130 +140,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] - [[package]] name = "compose-app" version = "0.1.0" dependencies = [ - "async-trait", - "bytes", "discord-rich-presence", "lofty", "log", "parking_lot", - "rtc", - "thiserror 2.0.18", + "thiserror", "tokio", "uniffi", - "webrtc", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", ] [[package]] @@ -523,133 +162,12 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "data-encoding" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "9.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" -dependencies = [ - "asn1-rs 0.6.2", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs 0.7.2", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - [[package]] name = "discord-rich-presence" version = "1.1.0" @@ -661,54 +179,8 @@ dependencies = [ "serde_derive", "serde_json", "serde_repr", - "thiserror 2.0.18", - "uuid 0.8.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", + "thiserror", + "uuid", ] [[package]] @@ -724,27 +196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", + "windows-sys", ] [[package]] @@ -753,28 +205,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - [[package]] name = "flate2" version = "1.1.9" @@ -785,15 +215,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "fs-err" version = "2.11.0" @@ -803,105 +224,18 @@ dependencies = [ "autocfg", ] -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -922,17 +256,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", ] [[package]] @@ -952,17 +275,6 @@ dependencies = [ "scroll", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -975,134 +287,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1113,39 +297,12 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.186" @@ -1158,12 +315,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - [[package]] name = "lock_api" version = "0.4.14" @@ -1205,31 +356,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1246,50 +378,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "munge" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" -dependencies = [ - "munge_macro", -] - -[[package]] -name = "munge_macro" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nom" version = "7.1.3" @@ -1300,40 +388,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "ogg_pager" version = "0.7.2" @@ -1343,66 +397,12 @@ dependencies = [ "byteorder", ] -[[package]] -name = "oid-registry" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" -dependencies = [ - "asn1-rs 0.6.2", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs 0.7.2", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1432,25 +432,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1463,69 +444,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "plain" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -1535,39 +459,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "ptr_meta" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "quinn-udp" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" -dependencies = [ - "cfg_aliases", - "libc", - "log", - "socket2", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.46" @@ -1583,401 +474,13 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rancor" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" -dependencies = [ - "ptr_meta", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20 0.10.1", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rcgen" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser 0.18.1", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "rend" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" -dependencies = [ - "bytecheck", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rkyv" -version = "0.8.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" -dependencies = [ - "bytecheck", - "bytes", - "hashbrown", - "indexmap", - "munge", - "ptr_meta", - "rancor", - "rend", - "rkyv_derive", - "tinyvec", - "uuid 1.24.1", -] - -[[package]] -name = "rkyv_derive" -version = "0.8.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - -[[package]] -name = "rtc" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9005c36795ad076abd36db3ea9ae0275a60395944647d58c1f2bc3e118dddba" -dependencies = [ - "bytes", - "hex", - "log", - "rand", - "rcgen", - "ring", - "rtc-datachannel", - "rtc-dtls", - "rtc-ice", - "rtc-interceptor", - "rtc-mdns", - "rtc-media", - "rtc-rtcp", - "rtc-rtp", - "rtc-sctp", - "rtc-sdp", - "rtc-shared", - "rtc-srtp", - "rtc-stun", - "rtc-turn", - "rustls", - "sansio", - "serde", - "serde_json", - "sha2", - "unicase", - "url", -] - -[[package]] -name = "rtc-datachannel" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b61c7b8e9892094cba8a6c7fb8120ae863d32d432b00e560177b3d7ae6d2c6" -dependencies = [ - "bytes", - "log", - "rtc-sctp", - "rtc-shared", - "sansio", -] - -[[package]] -name = "rtc-dtls" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c737b1dd17a0ff63884a2f466b9588f0cffadfce5a724c0c368764d7c27f9c74" -dependencies = [ - "aes", - "bytecheck", - "byteorder", - "bytes", - "cbc", - "ccm", - "chacha20poly1305", - "der-parser 9.0.0", - "hmac", - "log", - "p256", - "p384", - "rand", - "rand_core 0.6.4", - "rcgen", - "ring", - "rkyv", - "rtc-shared", - "rustls", - "sec1", - "sha1", - "sha2", - "subtle", - "x25519-dalek", - "x509-parser 0.16.0", -] - -[[package]] -name = "rtc-ice" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c06eeabd250a7693e1e8b28222b78c4a81a7c6ca7fe3cb99bbdff2f6c0ff0ab" -dependencies = [ - "bytes", - "crc", - "log", - "rand", - "rtc-mdns", - "rtc-shared", - "rtc-stun", - "sansio", - "serde", - "url", - "uuid 1.24.1", -] - -[[package]] -name = "rtc-interceptor" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ec2776ab86c0c03c3de8ec742aa13ccc3b2c82317590d0624f251bd37eae3b9" -dependencies = [ - "log", - "rand", - "rtc-interceptor-derive", - "rtc-rtcp", - "rtc-rtp", - "rtc-shared", - "sansio", -] - -[[package]] -name = "rtc-interceptor-derive" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc31da3875839bc18e00997354a582e6decacdb23d39f8c004fc4fe2fd3fcd8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "rtc-mdns" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cd53120b83321c8b8310cce9512c685d2d6356acef34647a99ea420e6f2249" -dependencies = [ - "bytes", - "log", - "rtc-shared", - "sansio", - "socket2", -] - -[[package]] -name = "rtc-media" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0411e44fcdc1363487ae0c6a4612ec908b9b09175d05499c5de0b3c81f0b5e5" -dependencies = [ - "byteorder", - "bytes", - "rand", - "rtc-rtp", - "rtc-shared", - "thiserror 2.0.18", -] - -[[package]] -name = "rtc-rtcp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fceaadac17b114140368dd68478d186ccfc293970f79cb8dd28f94dbbc0300be" -dependencies = [ - "bytes", - "rtc-shared", -] - -[[package]] -name = "rtc-rtp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "babb45ac2263340e2bb412d1921aa07ca441ae9b5589477221d1a50d98750ba3" -dependencies = [ - "bytes", - "memchr", - "rand", - "rtc-shared", - "serde", -] - -[[package]] -name = "rtc-sctp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513391b524b88b041277faba617e0531ad726729861ea8933015393fd68ab05" -dependencies = [ - "bytes", - "crc32c", - "log", - "rand", - "rtc-shared", - "rustc-hash", - "slab", - "thiserror 2.0.18", -] - -[[package]] -name = "rtc-sdp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d136a422965289e3d32d8e110789d8fa2ac669a4474a4084fa1eeac32758ee" -dependencies = [ - "rand", - "rtc-shared", - "url", -] - -[[package]] -name = "rtc-shared" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce81c72adbce3a2f2ad8e2b97674d2482be83cb3bfd9a153341b04b1a87ca24" -dependencies = [ - "aes", - "aes-gcm", - "bitflags 1.3.2", - "bytes", - "nix", - "p256", - "rand", - "rcgen", - "sec1", - "serde", - "substring", - "thiserror 2.0.18", - "url", - "winapi", -] - -[[package]] -name = "rtc-srtp" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b880d5f6d436b3215c7bcfd3fb5ffe2a752fea0236440ccd98b6bf1c3561df8" -dependencies = [ - "aes", - "byteorder", - "bytes", - "ctr", - "hmac", - "ring", - "rtc-rtcp", - "rtc-rtp", - "rtc-shared", - "sha1", - "subtle", -] - -[[package]] -name = "rtc-stun" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06b19c36a200df6071b92a25583a19ab0f9c6d5ad036d8c3ff5d392802cc926e" -dependencies = [ - "base64", - "bytes", - "crc", - "lazy_static", - "md-5", - "rand", - "ring", - "rtc-shared", - "sansio", - "subtle", - "url", -] - -[[package]] -name = "rtc-turn" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebf0b5fbb94085c86be7277c38ae8f1c21ec75ab47ba975be0984409732020dd" -dependencies = [ - "bytes", - "log", - "rtc-shared", - "rtc-stun", - "sansio", + "bitflags", ] [[package]] @@ -1986,83 +489,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "sansio" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" - [[package]] name = "scopeguard" version = "1.2.0" @@ -2089,20 +528,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "semver" version = "1.0.28" @@ -2167,68 +592,18 @@ dependencies = [ "syn 3.0.2", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.15.2" @@ -2241,53 +616,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "substring" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" -dependencies = [ - "autocfg", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.119" @@ -2310,17 +644,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -2331,7 +654,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2343,33 +666,13 @@ dependencies = [ "smawk", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -2383,73 +686,14 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "libc", - "mio", "pin-project-lite", - "socket2", "tokio-macros", - "windows-sys 0.61.2", ] [[package]] @@ -2472,18 +716,6 @@ dependencies = [ "serde", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2610,40 +842,6 @@ dependencies = [ "weedle2", ] -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "uuid" version = "0.8.2" @@ -2653,92 +851,12 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "uuid" -version = "1.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "webrtc" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3daa8f2f6366331ae3275a6c02a855c6fb3faa1d16960498d7daaf61c96e76bd" -dependencies = [ - "async-broadcast", - "async-channel", - "async-trait", - "bytes", - "event-listener", - "futures", - "log", - "quinn-udp", - "rtc", - "tokio", -] - [[package]] name = "weedle2" version = "5.0.0" @@ -2748,43 +866,12 @@ dependencies = [ "nom", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2794,70 +881,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "winnow" version = "0.7.15" @@ -2867,166 +890,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", - "zeroize", -] - -[[package]] -name = "x509-parser" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" -dependencies = [ - "asn1-rs 0.6.2", - "data-encoding", - "der-parser 9.0.0", - "lazy_static", - "nom", - "oid-registry 0.7.1", - "rusticata-macros", - "thiserror 1.0.69", - "time", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs 0.7.2", - "data-encoding", - "der-parser 10.0.0", - "lazy_static", - "nom", - "oid-registry 0.8.1", - "ring", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.2", -] - [[package]] name = "zmij" version = "1.0.23" diff --git a/composeApp/Cargo.toml b/composeApp/Cargo.toml index 900cd117..8a06d501 100644 --- a/composeApp/Cargo.toml +++ b/composeApp/Cargo.toml @@ -10,10 +10,6 @@ discord-rich-presence = "1.1.0" thiserror = "2.0" parking_lot = "0.12" log = "0.4" -webrtc = "0.20.4" -rtc = "0.20.4" -async-trait = "0.1" -bytes = "1" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } [lib] diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 54122dfd..cdca27ed 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -166,6 +166,12 @@ kotlin { // DLNA implementation(libs.dns.sd.kt) + + // mqtt client for jam-session + implementation(libs.mqtt.client) + implementation(libs.mqtt.x.models) + implementation(libs.mqtt.buffer) + implementation(libs.mqtt.buffer.codec) } } commonTest.dependencies { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 3245b681..9e832592 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -55,17 +55,6 @@ - - - - - - - - - Send and receive data over the internet WebView Display web content inside the app + Group Jam + MQTT Broker + Broker host + %1$s:%2$d + Broker port + Use TLS + Username (optional) + Password (optional) + Client ID prefix + Test connection + Testing… + Connected in %1$d ms + Failed: %1$s + Placeholder broker — configure your own to self-host diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt index 92aa8241..ee165e9b 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/App.kt @@ -33,8 +33,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.NavDisplay -import dev.krtirtho.spotube.core.deeplink.ExternalUriHandler -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES @@ -106,12 +104,6 @@ fun App( val settingsRepository: SettingsRepository = koinInject() val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings()) - val jamDeepLinks: JamDeepLinkService = koinInject() - DisposableEffect(Unit) { - ExternalUriHandler.listener = { uri -> jamDeepLinks.handleUri(uri) } - onDispose { ExternalUriHandler.listener = null } - } - val navigationState = rememberNavigationState( startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt index b2864360..1562a2e1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/audioplayer/AudioPlayerModels.kt @@ -25,6 +25,7 @@ import kotlinx.serialization.Serializable @Serializable sealed interface QueueEntry { val url: String + val addedBy: String @Serializable @SerialName("streaming") @@ -32,6 +33,7 @@ sealed interface QueueEntry { val track: MetadataTrack, override val url: String, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, + override val addedBy: String = "", ) : QueueEntry @Serializable @@ -42,7 +44,8 @@ sealed interface QueueEntry { val duration: Long, val album: String?, val coverBytes: ByteArray?, - override val url: String + override val url: String, + override val addedBy: String = "", ) : QueueEntry { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -56,6 +59,7 @@ sealed interface QueueEntry { if (album != other.album) return false if (!coverBytes.contentEquals(other.coverBytes)) return false if (url != other.url) return false + if (addedBy != other.addedBy) return false return true } @@ -67,6 +71,7 @@ sealed interface QueueEntry { result = 31 * result + (album?.hashCode() ?: 0) result = 31 * result + (coverBytes?.contentHashCode() ?: 0) result = 31 * result + url.hashCode() + result = 31 * result + addedBy.hashCode() return result } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt deleted file mode 100644 index 32e529db..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/deeplink/JamDeepLinkService.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.deeplink - -import dev.krtirtho.spotube.core.jam.JamInviteCodec -import dev.krtirtho.spotube.core.jam.JamInviteLink -import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.navigation.Routes -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Parses incoming `spotude://jam/...` deep links, exposes them to the Jam UI, - * and navigates to [Routes.Jam] so the user lands where the link is handled. - */ -class JamDeepLinkService( - private val navigationCommands: NavigationCommands, -) { - private val _pendingLink = MutableStateFlow(null) - val pendingLink: StateFlow = _pendingLink.asStateFlow() - - fun handleUri(uri: String) { - val link = JamInviteCodec.parse(uri) ?: return - _pendingLink.value = link - navigationCommands.navigateTo(Routes.Jam) - } - - /** Consumes the currently pending link (if any). */ - fun consume(): JamInviteLink? = _pendingLink.value.also { _pendingLink.value = null } - - fun clear() { - _pendingLink.value = null - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt index 8bf06e4f..a130729f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt @@ -23,10 +23,10 @@ import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueueRepository import dev.krtirtho.spotube.core.audioplayer.DeviceAudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueStateRepository import dev.krtirtho.spotube.core.db.Database -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService import dev.krtirtho.spotube.core.discovery.DeviceDiscoveryService import dev.krtirtho.spotube.core.discord.DiscordRpcService -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomClient +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.navigation.navigationModule import dev.krtirtho.spotube.core.remote.RemoteControlClient import dev.krtirtho.spotube.core.remote.RemoteControlHandler @@ -185,12 +185,9 @@ val sharedModules = module { viewModelOf(::RemoteControlViewModel) viewModel { JamViewModel( - jamSession = get(), - deepLinks = get(), + jamRoomService = get(), shareService = get(), settingsProvider = get(), - audioPlayer = get(), - audioPlayerQueue = get(), ) } @@ -239,8 +236,8 @@ val sharedModules = module { createdAtStart() } single { RemotePlaybackController(get(), get(), get(), get(), get()) } - single { JamSessionService(get(), get(), get()) } - singleOf(::JamDeepLinkService) + singleOf(::JamRoomClient) + single { JamRoomService(get(), get(), get(), get()) } singleOf(::AudioPlayerQueueRepository) { bind() } single { DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt deleted file mode 100644 index 7ecb8ff9..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamInviteCodec.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.jam - -import io.ktor.http.decodeURLQueryComponent -import io.ktor.http.encodeURLParameter - -/** - * SDP payloads exchanged between jam peers are wrapped into `spotube://` deep links - * so they can be shared through any messaging medium. The SDP blob is percent-encoded - * as a query parameter. - * - * Host invite : `spotube://jam/invite?name=&sdp=` - * Guest answer : `spotube://jam/answer?name=&sdp=` - */ -sealed interface JamInviteLink { - val peerName: String - val sdp: String - - data class HostInvite( - override val peerName: String, - override val sdp: String, - ) : JamInviteLink - - data class GuestAnswer( - override val peerName: String, - override val sdp: String, - ) : JamInviteLink -} - -object JamInviteCodec { - const val SCHEME = "spotube" - const val INVITE_PATH = "jam/invite" - const val ANSWER_PATH = "jam/answer" - - fun buildHostInvite(hostName: String, offerSdp: String): String = - buildLink(INVITE_PATH, hostName, offerSdp) - - fun buildGuestAnswer(guestName: String, answerSdp: String): String = - buildLink(ANSWER_PATH, guestName, answerSdp) - - private fun buildLink(path: String, peerName: String, sdp: String): String = - "$SCHEME://$path?name=${peerName.encodeURLParameter()}" + - "&sdp=${sdp.encodeURLParameter()}" - - /** - * Parses a `spotude://jam/...` link. Returns null for foreign or malformed URIs. - * Parsing is done manually — generic URI parsers normalize unknown schemes in - * ways that mangle percent-encoded multi-line payloads. - */ - fun parse(rawUri: String): JamInviteLink? { - val uri = rawUri.trim() - if (!uri.startsWith("$SCHEME://", ignoreCase = true)) return null - - val withoutScheme = uri.substring(SCHEME.length + 3) - val queryStart = withoutScheme.indexOf('?') - if (queryStart < 0) return null - - val path = withoutScheme.take(queryStart).trim('/').lowercase() - val params = withoutScheme.substring(queryStart + 1) - .split('&') - .mapNotNull { pair -> - val separator = pair.indexOf('=') - if (separator <= 0) return@mapNotNull null - pair.take(separator) to pair.substring(separator + 1) - } - .toMap() - - val sdp = params["sdp"]?.decodeURLQueryComponent()?.takeIf { it.isNotBlank() } - ?: return null - val peerName = params["name"]?.decodeURLQueryComponent().orEmpty() - - return when (path) { - INVITE_PATH -> JamInviteLink.HostInvite(peerName, sdp) - ANSWER_PATH -> JamInviteLink.GuestAnswer(peerName, sdp) - else -> null - } - } - - /** - * Extracts an SDP payload from user input which may either be a full - * `spotube://` deep link or a raw SDP body pasted by hand. - */ - fun extractSdp(rawInput: String): String? { - val input = rawInput.trim() - parse(input)?.let { return it.sdp } - // Heuristic for raw SDP: first line is the session description header - return if (input.startsWith("v=", ignoreCase = false)) input else null - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt index 01fafa94..ac9e35d5 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamProtocol.kt @@ -18,61 +18,46 @@ package dev.krtirtho.spotube.core.jam import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.LoopState import dev.krtirtho.spotube.core.audioplayer.MediaItem import dev.krtirtho.spotube.core.audioplayer.QueueEntry import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +/** + * Jam messages exchanged over MQTT. + * + * - `state` topic: [QueueState] (retained, host -> everyone) + * - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist] + * (anyone -> host, except Kick which is host -> guest) + * - `presence/{clientId}` topic: [JamPresence] (retained, one per participant) + */ @Serializable sealed class JamMessage { - @Serializable - @SerialName("hello") - data class Hello( - val displayName: String, - val deviceId: String, - ) : JamMessage() - - @Serializable - @SerialName("welcome") - data class Welcome( - val hostName: String, - val participantId: String, - ) : JamMessage() - @Serializable @SerialName("queueState") data class QueueState( val items: List, val currentIndex: Int, - val isPlaying: Boolean, - val positionMs: Long, + val shuffleEnabled: Boolean = false, ) : JamMessage() @Serializable @SerialName("playbackCommand") - data class PlaybackCommand( - val command: PlaybackCmd, - ) : JamMessage() + data class PlaybackCommand(val command: PlaybackCmd) : JamMessage() @Serializable @SerialName("suggestTrack") - data class SuggestTrack(val mediaItem: JamMediaItem) : JamMessage() - - @Serializable - @SerialName("suggestPlaylist") - data class SuggestPlaylist(val tracks: List) : JamMessage() - - @Serializable - @SerialName("chat") - data class Chat( - val fromName: String, - val text: String, + data class SuggestTrack( + val mediaItem: JamMediaItem, + val addedBy: String = "", ) : JamMessage() @Serializable - @SerialName("participantList") - data class ParticipantList(val participants: List) : JamMessage() + @SerialName("suggestPlaylist") + data class SuggestPlaylist( + val tracks: List, + val addedBy: String = "", + ) : JamMessage() @Serializable @SerialName("kick") @@ -86,24 +71,12 @@ sealed class JamMessage { data class Leave(val reason: String = "user_left") : JamMessage() } +/** + * Playback commands. Only queue navigation is global — play/pause, seek, + * volume, shuffle and loop are local to each participant. + */ @Serializable sealed class PlaybackCmd { - @Serializable - @SerialName("play") - data object Play : PlaybackCmd() - - @Serializable - @SerialName("pause") - data object Pause : PlaybackCmd() - - @Serializable - @SerialName("toggle") - data object Toggle : PlaybackCmd() - - @Serializable - @SerialName("seek") - data class Seek(val positionMs: Long) : PlaybackCmd() - @Serializable @SerialName("skipNext") data object SkipNext : PlaybackCmd() @@ -112,23 +85,20 @@ sealed class PlaybackCmd { @SerialName("skipPrevious") data object SkipPrevious : PlaybackCmd() - @Serializable - @SerialName("setVolume") - data class SetVolume(val volume: Float) : PlaybackCmd() - - @Serializable - @SerialName("setLoop") - data class SetLoop(val loop: String) : PlaybackCmd() - - @Serializable - @SerialName("setShuffle") - data class SetShuffle(val enabled: Boolean) : PlaybackCmd() - @Serializable @SerialName("jumpTo") data class JumpTo(val index: Int) : PlaybackCmd() } +/** Retained per-participant presence entry (with an MQTT Last Will for leave). */ +@Serializable +data class JamPresence( + val clientId: String, + val displayName: String, + val isHost: Boolean, + val left: Boolean = false, +) + @Serializable data class JamMediaItem( val url: String, @@ -139,6 +109,7 @@ data class JamMediaItem( val durationMs: Long, val coverUrl: String, val protocol: String, + val addedBy: String = "", ) { companion object { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { @@ -153,6 +124,7 @@ data class JamMediaItem( ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url .orEmpty(), protocol = entry.protocol.name, + addedBy = entry.addedBy, ) is QueueEntry.LocalTrack -> JamMediaItem( @@ -164,6 +136,7 @@ data class JamMediaItem( durationMs = entry.duration, coverUrl = "", protocol = "PROGRESSIVE", + addedBy = entry.addedBy, ) } @@ -214,14 +187,4 @@ data class JamParticipant( enum class JamRole { Host, Guest, -} - -object JamLoopMapping { - fun toString(state: LoopState): String = state.name.lowercase() - fun fromString(value: String): LoopState = when (value.lowercase()) { - "none" -> LoopState.NONE - "one" -> LoopState.ONE - "all" -> LoopState.ALL - else -> LoopState.NONE - } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt new file mode 100644 index 00000000..83531f19 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomClient.kt @@ -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 . + */ + +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 = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _state = MutableSharedFlow(replay = 1, extraBufferCapacity = 8) + val state: SharedFlow = _state.asSharedFlow() + + private val _commands = MutableSharedFlow(extraBufferCapacity = 32) + val commands: SharedFlow = _commands.asSharedFlow() + + private val _presence = MutableStateFlow>(emptyMap()) + val presence: StateFlow> = _presence.asStateFlow() + + // ---------- Public API ---------- + + /** One-off connection check used by the settings screen. Returns latency description. */ + suspend fun testConnection(broker: JamBroker): Result { + 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 { + 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/#" +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt new file mode 100644 index 00000000..0a1b891f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomCode.kt @@ -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 . + */ + +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 } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt new file mode 100644 index 00000000..8107e1cd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamRoomService.kt @@ -0,0 +1,475 @@ +/* + * 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 . + */ + +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 + +/** + * 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. + */ +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(null) + val role: StateFlow = _role.asStateFlow() + + private val _participants = MutableStateFlow>(emptyList()) + val participants: StateFlow> = _participants.asStateFlow() + + private val _roomCode = MutableStateFlow(null) + val roomCode: StateFlow = _roomCode.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionError = MutableStateFlow(null) + val connectionError: StateFlow = _connectionError.asStateFlow() + + private val _shuffleEnabled = MutableStateFlow(false) + val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() + + private var localClientId: String = "" + private var localDisplayName: String = "" + private var hostBroadcastJob: Job? = null + + /** Guest side: last queue snapshot applied to the local player. */ + private var lastAppliedItems: List = emptyList() + private var lastAppliedIndex = -1 + + /** Host side: client ids banned for this session. */ + private val bannedClientIds = mutableSetOf() + + 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) + } + + // ---------- Session lifecycle ---------- + + suspend fun createRoom(): Result { + 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 + startHostBroadcast() + persistLastCode(code) + code + } + } + + suspend fun joinRoom(code: String): Result { + 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 + 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() + } + + // ---------- 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 = localDisplayName, + ) + ) + } + + suspend fun suggestPlaylist(tracks: List) { + if (tracks.isEmpty()) return + jamClient.publishCommand( + JamMessage.SuggestPlaylist( + tracks = tracks.map(JamMediaItem::fromTrack), + addedBy = localDisplayName, + ) + ) + } + + 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, + ) { queue, current, shuffle -> Triple(queue, current, shuffle) } + .onEach { (queue, current, shuffle) -> + if (_role.value != JamRole.Host) return@onEach + val index = if (current != null) { + queue.indexOfFirst { it.matchesEntry(current) } + } else { + -1 + } + _shuffleEnabled.value = shuffle + jamClient.publishState( + JamMessage.QueueState( + items = queue.map(JamMediaItem::fromQueueEntry), + currentIndex = index.coerceAtLeast(0), + shuffleEnabled = shuffle, + ) + ) + } + .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) } + + val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING + + if (items != lastAppliedItems) { + lastAppliedItems = items + lastAppliedIndex = state.currentIndex + runCatching { + audioPlayerQueue.load( + entries = items.map { it.toQueueEntry() }, + autoPlay = wasPlaying, + startPosition = state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)), + ) + }.onFailure { log.w(it) { "Failed to apply jam queue" } } + return + } + + if (state.currentIndex != lastAppliedIndex) { + lastAppliedIndex = state.currentIndex + // Queue moved on: follow it, but keep this device's play/pause state. + 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)) + } + + is JamMessage.SuggestPlaylist -> { + if (_role.value == JamRole.Host) acceptSuggestion(message.tracks) + } + + 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) { + 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) { + 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 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 + } + + companion object { + private const val HOST_TAKEOVER_DELAY_MS = 1_500L + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt deleted file mode 100644 index 30cf046d..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/JamSessionService.kt +++ /dev/null @@ -1,587 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.jam - -import co.touchlab.kermit.Logger -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.di.injectLogger -import dev.krtirtho.spotube.modules.settings.SettingsProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json -import org.koin.core.component.KoinComponent -import uniffi.compose_app.IceServerConfig -import uniffi.compose_app.WebrtcEventHandler -import uniffi.compose_app.WebrtcPeerConnection -import uniffi.compose_app.createWebrtcPeerConnection - -/** - * An invite generated by the host for one guest slot. The [sdp] offer is shared - * via a deep link; once the guest's answer comes back, [JamSessionService.acceptAnswer] - * completes the handshake for that slot. - */ -data class JamInvite( - val id: String, - val sdp: String, -) - -/** - * Owns the peer connections of a jam session (star topology: host relays state - * to all guests) and the hello/welcome handshake, participant bookkeeping and - * kick/ban. Playback & queue synchronization itself is delegated to - * [QueueSyncManager], which runs while a session is active. - */ -class JamSessionService( - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, - private val settingsProvider: SettingsProvider, -) : KoinComponent { - val logger by injectLogger() - private val log = Logger.withTag("JamSessionService") - - /** - * Playback/queue synchronization. Owned by this service (not a Koin bean) so - * the two don't form a circular dependency; it's started/stopped with the - * session lifecycle. - */ - private val queueSyncManager = QueueSyncManager( - audioPlayer = audioPlayer, - audioPlayerQueue = audioPlayerQueue, - jamSession = this, - ) - - private val json = Json { - ignoreUnknownKeys = true - classDiscriminator = "type" - encodeDefaults = true - } - - private val _role = MutableStateFlow(null) - val role: StateFlow = _role.asStateFlow() - - private val _participants = MutableStateFlow>(emptyList()) - val participants: StateFlow> = _participants.asStateFlow() - - private val _isActive = MutableStateFlow(false) - val isActive: StateFlow = _isActive.asStateFlow() - - private val _localParticipantId = MutableStateFlow(null) - val localParticipantId: StateFlow = _localParticipantId.asStateFlow() - - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected.asStateFlow() - - private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64) - val incomingMessages = _incomingMessages.asSharedFlow() - - private val _incomingSuggestions = MutableSharedFlow(extraBufferCapacity = 32) - val incomingSuggestions = _incomingSuggestions.asSharedFlow() - - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - /** Host side: invites whose answers have not arrived yet. */ - private val pendingInvites = mutableMapOf() - - /** Host side: guests whose handshake completed. Keyed by invite id. */ - private val connectedGuests = mutableMapOf() - - /** Host side: guest device ids, used for bans. */ - private val guestDeviceIds = mutableMapOf() - - /** Host side: latest RTCPeerConnection state per guest ("connecting", "connected", "failed"...). */ - private val guestConnectionStates = mutableMapOf() - - /** Host side: device ids banned for this session. */ - private val bannedDeviceIds = mutableSetOf() - - /** Guest side: the single connection to the host. */ - private var hostConnection: WebrtcPeerConnection? = null - - private var hostDisplayName: String = "Host" - private var guestDisplayName: String = "Guest" - - suspend fun createSession(): String { - log.i { "Creating jam session" } - hostDisplayName = resolveParticipantName(defaultPrefix = "Host") - - _role.value = JamRole.Host - _localParticipantId.value = "host" - _participants.value = listOf( - JamParticipant( - id = "host", - displayName = hostDisplayName, - isHost = true, - ) - ) - _isActive.value = true - queueSyncManager.start() - - return generateInvite().sdp - } - - /** - * Generates a new invite (peer connection + SDP offer with bundled ICE candidates). - * Each invite admits exactly one guest. - */ - suspend fun generateInvite(): JamInvite { - if (_role.value != JamRole.Host) { - error("generateInvite can only be called by the host") - } - val inviteId = "guest-${randomShortId()}" - log.i { "Generating invite $inviteId" } - - val pc = createWebrtcPeerConnection( - iceServers = defaultIceServers(), - handler = guestEventHandler(inviteId), - ) - - pc.createDataChannel(CHANNEL_LABEL) - val offer = pc.createOffer() - pendingInvites[inviteId] = pc - return JamInvite(id = inviteId, sdp = offer) - } - - /** - * Completes a guest's handshake: applies their SDP answer to the peer connection - * created for [inviteId] and adds them to the participant list. - * - * When [inviteId] is null, the oldest still-pending invite is used — convenient - * when an answer deep link arrives out of band. - * - * @param answerSdp raw SDP answer body (not a deep link). - * @param peerName display name of the guest, taken from their answer link if available. - */ - suspend fun acceptAnswer(inviteId: String?, answerSdp: String, peerName: String): Boolean { - if (_role.value != JamRole.Host) { - log.w { "acceptAnswer ignored: not hosting" } - return false - } - val resolvedId = inviteId ?: pendingInvites.keys.firstOrNull() - if (resolvedId == null) { - log.w { "acceptAnswer: no pending invite" } - return false - } - val pc = pendingInvites.remove(resolvedId) - if (pc == null) { - log.w { "acceptAnswer: no pending invite '$resolvedId'" } - return false - } - runCatching { pc.setRemoteAnswer(answerSdp) } - .onFailure { e -> - log.w(e) { "Failed to apply answer for $inviteId" } - scope.launch { runCatching { pc.shutdown() } } - return false - } - connectedGuests[resolvedId] = pc - _participants.update { current -> - current + JamParticipant( - id = resolvedId, - displayName = peerName.ifBlank { "Guest-${resolvedId.takeLast(4)}" }, - isHost = false, - ) - } - log.i { "Guest $resolvedId ($peerName) joined" } - broadcastParticipantList() - return true - } - - suspend fun joinSession(offerSdp: String, hostName: String? = null): String { - log.i { "Joining jam session" } - guestDisplayName = resolveParticipantName(defaultPrefix = "Guest") - - val pc = createWebrtcPeerConnection( - iceServers = defaultIceServers(), - handler = eventHandler, - ) - - hostConnection = pc - _role.value = JamRole.Guest - _localParticipantId.value = null - _participants.value = listOf( - JamParticipant( - id = "host", - displayName = hostName?.ifBlank { null } ?: "Host", - isHost = true, - ) - ) - _isActive.value = true - queueSyncManager.start() - - // The data channel arrives in-band from the host's offer via on_data_channel; - // we only answer here. - pc.setRemoteOffer(offerSdp) - val answer = pc.createAnswer() - log.i { "Generated SDP answer (length=${answer.length})" } - return answer - } - - suspend fun sendMessage(message: JamMessage, guestId: String? = null) { - val payload = json.encodeToString(JamMessage.serializer(), message) - when (_role.value) { - JamRole.Host -> { - if (guestId != null) { - val pc = connectedGuests[guestId] ?: return - runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to guest $guestId" } - onSendFailure(guestId) - } - } else { - val dead = mutableListOf() - connectedGuests.forEach { (id, pc) -> - runCatching { pc.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to guest $id" } - dead += id - } - } - dead.forEach { id -> onSendFailure(id) } - } - } - - JamRole.Guest -> { - runCatching { hostConnection?.sendData(CHANNEL_LABEL, payload) } - .onFailure { e -> - log.w(e) { "Failed to send to host" } - } - } - - null -> log.w { "sendMessage called while no session is active" } - } - } - - /** - * A send to a guest failed. If that guest's connection has already given up - * (failed/closed), drop them from the session; while the connection is merely - * "connecting" the channel may simply not be open yet, so keep them. - */ - private fun onSendFailure(guestId: String) { - val state = guestConnectionStates[guestId] - if (state == "failed" || state == "closed" || state == "disconnected") { - scope.launch { removeGuest(guestId) } - } - } - - suspend fun leave() { - log.i { "Leaving jam session" } - queueSyncManager.stop() - runCatching { sendMessage(JamMessage.Leave()) } - shutdownAll() - _role.value = null - _participants.value = emptyList() - _isActive.value = false - _isConnected.value = false - _localParticipantId.value = null - guestDeviceIds.clear() - guestConnectionStates.clear() - bannedDeviceIds.clear() - } - - suspend fun broadcastPlaybackCommand(command: PlaybackCmd) { - if (_role.value != JamRole.Host) return - sendMessage(JamMessage.PlaybackCommand(command)) - } - - suspend fun broadcastQueueState( - items: List, - currentIndex: Int, - isPlaying: Boolean, - positionMs: Long, - ) { - if (_role.value != JamRole.Host) return - sendMessage(JamMessage.QueueState(items, currentIndex, isPlaying, positionMs)) - } - - suspend fun suggestTrack(mediaItem: JamMediaItem) { - if (_role.value != JamRole.Guest) return - sendMessage(JamMessage.SuggestTrack(mediaItem)) - } - - suspend fun suggestPlaylist(tracks: List) { - if (_role.value != JamRole.Guest) return - sendMessage(JamMessage.SuggestPlaylist(tracks)) - } - - // ---------- Host moderation ---------- - - suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { - if (_role.value != JamRole.Host) return - log.i { "Kicking participant $participantId" } - sendMessage(JamMessage.Kick(participantId, reason), guestId = participantId) - removeGuest(participantId) - } - - suspend fun banParticipant(participantId: String) { - if (_role.value != JamRole.Host) return - val deviceId = guestDeviceIds[participantId] - if (deviceId != null) { - bannedDeviceIds += deviceId - log.i { "Banning device $deviceId (participant $participantId)" } - } - kickParticipant(participantId, "banned by host") - } - - private suspend fun removeGuest(guestId: String) { - val pc = connectedGuests.remove(guestId) - runCatching { pc?.shutdown() } - guestDeviceIds.remove(guestId) - guestConnectionStates.remove(guestId) - _participants.update { current -> - current.filterNot { it.id == guestId } - } - broadcastParticipantList() - } - - private suspend fun broadcastParticipantList() { - if (_role.value != JamRole.Host) return - sendMessage(JamMessage.ParticipantList(_participants.value)) - } - - /** - * ICE servers for global peer-to-peer jam sessions: multiple STUN servers for - * NAT traversal plus a TURN relay for symmetric NATs and strict firewalls. - */ - private fun defaultIceServers(): List = listOf( - IceServerConfig( - urls = listOf( - "stun:stun.cloudflare.com:3478", - "stun:stun1.l.google.com:19302", - "stun:stun.l.google.com:19302", - ), - username = "", - credential = "", - ), - IceServerConfig( - urls = listOf("turn:openrelay.metered.ca:80"), - username = "openrelayproject", - credential = "openrelayproject", - ), - ) - - private suspend fun resolveParticipantName(defaultPrefix: String): String { - val settings = settingsProvider.settingsState.first() - return settings?.jamParticipantName?.ifBlank { "$defaultPrefix-${randomShortId()}" } - ?: "$defaultPrefix-${randomShortId()}" - } - - private fun localDeviceId(): String { - return settingsProvider.settingsState.value?.remoteControlDeviceId - ?: "device-${randomShortId()}" - } - - /** - * Per-guest handler so messages received on a guest's connection can be - * attributed back to that guest (needed for kick-on-leave and targeted sends). - */ - private fun guestEventHandler(guestId: String) = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) { - log.i { "[$guestId] ICE candidate: $candidate" } - } - - override fun onIceGatheringStateChange(state: String) { - log.i { "[$guestId] ICE gathering state: $state" } - } - - override fun onConnectionStateChange(state: String) { - log.i { "[$guestId] Connection state: $state" } - guestConnectionStates[guestId] = state - if (state == "failed" || state == "closed") { - scope.launch { removeGuest(guestId) } - } - } - - override fun onDataChannelOpen(label: String) { - log.i { "[$guestId] Data channel '$label' open" } - _isConnected.value = true - } - - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(data, fromGuestId = guestId) - } - - override fun onDataChannelClose(label: String) { - log.i { "[$guestId] Data channel closed" } - if (_role.value == JamRole.Host) { - scope.launch { removeGuest(guestId) } - } - } - } - - private val eventHandler = object : WebrtcEventHandler { - override fun onIceCandidate(candidate: String) { - log.i { "ICE candidate: $candidate" } - } - - override fun onIceGatheringStateChange(state: String) { - log.i { "ICE gathering state: $state" } - } - - override fun onConnectionStateChange(state: String) { - log.i { "Connection state: $state" } - if (state == "failed" || state == "closed") { - scope.launch { leave() } - } - } - - override fun onDataChannelOpen(label: String) { - log.i { "Data channel '$label' open" } - _isConnected.value = true - // Introduce ourselves so the host can fill in our name and hand us - // our participant id. - scope.launch { - sendMessage(JamMessage.Hello(guestDisplayName, localDeviceId())) - } - } - - override fun onDataChannelMessage(label: String, data: String) { - handleIncomingMessage(data, fromGuestId = null) - } - - override fun onDataChannelClose(label: String) { - log.i { "Data channel closed" } - scope.launch { leave() } - } - } - - private fun handleIncomingMessage(data: String, fromGuestId: String?) { - try { - val message = json.decodeFromString(JamMessage.serializer(), data) - _incomingMessages.tryEmit(message) - when (message) { - is JamMessage.SuggestTrack, is JamMessage.SuggestPlaylist -> { - _incomingSuggestions.tryEmit(message) - } - - is JamMessage.Hello -> { - if (_role.value == JamRole.Host && fromGuestId != null) { - handleHello(fromGuestId, message) - } - } - - is JamMessage.Welcome -> { - if (_role.value == JamRole.Guest) { - _localParticipantId.value = message.participantId - _participants.update { current -> - current.map { participant -> - if (participant.isHost) { - participant.copy(displayName = message.hostName.ifBlank { participant.displayName }) - } else { - participant - } - } - } - log.i { "Welcome: joined as ${message.participantId}" } - } - } - - is JamMessage.ParticipantList -> { - if (_role.value == JamRole.Guest) { - _participants.value = message.participants - } - } - - is JamMessage.Kick -> { - if (_role.value == JamRole.Guest) { - log.i { "Kicked by host: ${message.reason}" } - scope.launch { leave() } - } - } - - is JamMessage.Leave -> { - if (_role.value == JamRole.Host && fromGuestId != null) { - scope.launch { removeGuest(fromGuestId) } - } else if (_role.value == JamRole.Guest) { - scope.launch { leave() } - } - } - - else -> Unit - } - } catch (e: Exception) { - log.w(e) { "Failed to parse jam message" } - } - } - - private fun handleHello(guestId: String, hello: JamMessage.Hello) { - val deviceId = hello.deviceId - if (deviceId in bannedDeviceIds) { - log.w { "Rejecting banned device $deviceId" } - scope.launch { - sendMessage( - JamMessage.Kick(guestId, "banned by host"), - guestId = guestId, - ) - removeGuest(guestId) - } - return - } - guestDeviceIds[guestId] = deviceId - _participants.update { current -> - current.map { participant -> - if (participant.id == guestId) { - participant.copy(displayName = hello.displayName.ifBlank { participant.displayName }) - } else { - participant - } - } - } - scope.launch { - sendMessage( - JamMessage.Welcome(hostDisplayName, guestId), - guestId = guestId, - ) - broadcastParticipantList() - // Give the newly joined guest the current queue + playback state. - queueSyncManager.broadcastNow() - } - } - - private suspend fun shutdownAll() { - pendingInvites.values.forEach { runCatching { it.shutdown() } } - connectedGuests.values.forEach { runCatching { it.shutdown() } } - runCatching { hostConnection?.shutdown() } - pendingInvites.clear() - connectedGuests.clear() - hostConnection = null - } -} - -private const val CHANNEL_LABEL = "jam" - -private fun MutableStateFlow.update(transform: (T) -> T) { - value = transform(value) -} - -private fun randomShortId(): String { - val chars = "0123456789abcdef" - return buildString(8) { - repeat(8) { - append(chars[kotlin.random.Random.nextInt(chars.length)]) - } - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt deleted file mode 100644 index 23e9d26e..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/jam/QueueSyncManager.kt +++ /dev/null @@ -1,316 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.core.jam - -import co.touchlab.kermit.Logger -import dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist -import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.serialization.json.Json - -/** - * Keeps playback in sync across a jam session (star topology). - * - * On the **host**: applies incoming playback commands and guest suggestions to the - * host's player, and broadcasts the current queue + playback state to all guests - * (on queue changes and periodically, so play/pause/seek/position propagate). - * - * On the **guest**: mirrors the host's queue into the local player and applies - * playback commands. The guest's queue is read-only — the host has authority. - */ -class QueueSyncManager( - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, - private val jamSession: JamSessionService, -) { - private val log = Logger.withTag("QueueSyncManager") - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - private val _isSyncing = MutableStateFlow(false) - val isSyncing: StateFlow = _isSyncing.asStateFlow() - - private var hostBroadcastJob: Job? = null - private var hostCommandJob: Job? = null - private var guestApplyJob: Job? = null - private var guestCommandJob: Job? = null - - /** Guest side: the last applied queue snapshot, used to detect real queue changes. */ - private var lastAppliedItems: List = emptyList() - - /** Guest side: tracks the player was told to start playing from. */ - private var lastAppliedCurrentIndex = -1 - - fun start() { - if (_isSyncing.value) return - _isSyncing.value = true - - when (jamSession.role.value) { - JamRole.Host -> startHostSync() - JamRole.Guest -> startGuestSync() - null -> { - _isSyncing.value = false - return - } - } - } - - fun stop() { - _isSyncing.value = false - hostBroadcastJob?.cancel() - hostCommandJob?.cancel() - guestApplyJob?.cancel() - guestCommandJob?.cancel() - hostBroadcastJob = null - hostCommandJob = null - guestApplyJob = null - guestCommandJob = null - lastAppliedItems = emptyList() - lastAppliedCurrentIndex = -1 - } - - // ---------- Host side ---------- - - private fun startHostSync() { - // Apply commands/suggestions coming from guests. - hostCommandJob = scope.launch { - jamSession.role.first { it != null } - if (jamSession.role.value != JamRole.Host) return@launch - - jamSession.incomingMessages.collect { message -> - when (message) { - is JamMessage.PlaybackCommand -> applyPlaybackCommand(message.command) - is JamMessage.SuggestTrack -> acceptSuggestion(listOf(message.mediaItem)) - is JamMessage.SuggestPlaylist -> acceptSuggestion(message.tracks) - else -> {} - } - } - } - - // Broadcast state on queue changes and periodically. - hostBroadcastJob = scope.launch { - jamSession.role.first { it != null } - if (jamSession.role.value != JamRole.Host) return@launch - - // Queue changes (separate coroutine — collect() never returns). - launch { - audioPlayerQueue.queueFlow.collect { - broadcastCurrentState() - } - } - - // Periodic tick so play/pause/seek/position propagate to guests. - while (isActive) { - delay(2_000) - broadcastCurrentState() - } - } - } - - /** Immediately pushes the current queue + playback state to all guests. */ - suspend fun broadcastNow() { - if (jamSession.role.value == JamRole.Host) { - broadcastCurrentState() - } - } - - private suspend fun broadcastCurrentState() { - val queue = audioPlayerQueue.getQueue() - val current = audioPlayerQueue.getCurrentQueueEntry() - val currentIndex = if (current != null) { - queue.indexOfFirst { it.matchesEntry(current) } - } else { - -1 - } - jamSession.broadcastQueueState( - items = queue.map(JamMediaItem::fromQueueEntry), - currentIndex = currentIndex.coerceAtLeast(0), - isPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING, - positionMs = audioPlayer.positionFlow.value.inWholeMilliseconds, - ) - } - - private suspend fun acceptSuggestion(items: List) { - if (items.isEmpty()) return - val entries = items.map { it.toQueueEntry() } - log.i { "Accepting ${entries.size} suggested item(s) into the jam queue" } - audioPlayerQueue.addAllToQueue(entries) - } - - // ---------- Guest side ---------- - - private fun startGuestSync() { - guestApplyJob = scope.launch { - jamSession.incomingMessages.collect { message -> - if (message !is JamMessage.QueueState) return@collect - applyQueueState(message) - } - } - - guestCommandJob = scope.launch { - jamSession.incomingMessages.collect { message -> - if (message !is JamMessage.PlaybackCommand) return@collect - applyPlaybackCommand(message.command) - } - } - } - - private suspend fun applyQueueState(state: JamMessage.QueueState) { - log.d { "Applying queue state: ${state.items.size} items, current=${state.currentIndex}" } - - // Items that carry neither a track id nor a usable URL can't be played - // on this device — skip them instead of crashing the player. - val playableItems = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } - - val queueChanged = playableItems != lastAppliedItems - if (queueChanged) { - lastAppliedItems = playableItems - lastAppliedCurrentIndex = state.currentIndex - val entries = playableItems.map { it.toQueueEntry() } - runCatching { - // Load through the queue repository (like the host does) so the - // stream proxy can resolve the tracks — it only knows tracks in - // queueFlow. - audioPlayerQueue.load( - entries = entries, - autoPlay = state.isPlaying, - startPosition = state.currentIndex.coerceIn(0, entries.lastIndex.coerceAtLeast(0)), - ) - }.onFailure { e -> - log.e(e) { "Failed to apply jam queue to local player" } - } - return - } - - // Same queue: just sync playback state. Avoid seeking on every tick unless - // the drift is meaningful. - if (state.currentIndex != lastAppliedCurrentIndex) { - lastAppliedCurrentIndex = state.currentIndex - runCatching { audioPlayer.jumpTo(state.currentIndex.coerceAtLeast(0)) } - .onFailure { e -> log.w(e) { "Failed to jump to index ${state.currentIndex}" } } - } - val currentState = audioPlayer.playerStateFlow.value - if (state.isPlaying && currentState != PlayerState.PLAYING) { - audioPlayer.play() - } else if (!state.isPlaying && currentState == PlayerState.PLAYING) { - audioPlayer.pause() - } - val driftMs = kotlin.math.abs( - audioPlayer.positionFlow.value.inWholeMilliseconds - state.positionMs - ) - if (driftMs > POSITION_SYNC_THRESHOLD_MS) { - runCatching { audioPlayer.seekTo(kotlin.time.Duration.parse("${state.positionMs}ms")) } - .onFailure { e -> log.w(e) { "Failed to sync position" } } - } - } - - private suspend fun applyPlaybackCommand(command: PlaybackCmd) { - log.d { "Applying playback command: $command" } - runCatching { - when (command) { - PlaybackCmd.Play -> audioPlayer.play() - PlaybackCmd.Pause -> audioPlayer.pause() - PlaybackCmd.Toggle -> { - if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - - is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) - PlaybackCmd.SkipNext -> audioPlayer.skipToNext() - PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() - is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) - is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) - is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) - is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) - } - }.onFailure { e -> - log.w(e) { "Failed to apply playback command: $command" } - } - } - - /** - * Build a queue entry from a jam media item. Streaming tracks carry their id - * so the device's own queue/stream proxy can resolve a playable URL later. - */ - private fun JamMediaItem.toQueueEntry(): QueueEntry = when { - trackId.isNotBlank() -> QueueEntry.StreamingTrack( - track = MetadataTrack( - id = trackId, - title = title, - durationMs = durationMs, - trackNumber = null, - discNumber = null, - artists = listOf( - MetadataArtist.Basic(id = "", name = artist, thumbnails = emptyList(), externalUri = null) - ), - album = null, - thumbnails = null, - explicit = null, - popularity = null, - isrcCode = null, - externalUri = null, - ), - url = "", - protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } - .getOrDefault(StreamProtocol.PROGRESSIVE), - ) - - else -> QueueEntry.LocalTrack( - name = title, - artists = artist.split(',').map { it.trim() }.filter { it.isNotEmpty() }, - duration = durationMs, - album = album.ifBlank { null }, - coverBytes = null, - url = url, - ) - } - - private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean { - return when { - this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> - this.track.id == other.track.id - - this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> - this.url == other.url && this.name == other.name - - else -> false - } - } - - companion object { - /** Seek the guest only when its position drifts more than this from the host. */ - private const val POSITION_SYNC_THRESHOLD_MS = 3_000L - } -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt index 01a77c4b..7857b1f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/remote/RemotePlaybackController.kt @@ -21,16 +21,18 @@ import co.touchlab.kermit.Logger import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.jam.JamMediaItem import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.modules.blacklist.BlacklistRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent @@ -87,7 +89,7 @@ class RemotePlaybackController( private val collectionPlaybackHelper: CollectionPlaybackHelper, private val audioPlayerQueue: AudioPlayerQueue, private val blacklistRepository: BlacklistRepository, - private val jamSession: JamSessionService, + private val jamRoomService: JamRoomService, ) : KoinComponent { private val logger = Logger.withTag("RemotePlaybackController") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -95,6 +97,10 @@ class RemotePlaybackController( private val _pendingRequest = MutableStateFlow(null) val pendingRequest: StateFlow = _pendingRequest.asStateFlow() + /** One-shot user-facing messages (e.g. "added to jam queue") for a snackbar host. */ + private val _events = MutableSharedFlow(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + fun isRemoteConnected(): Boolean { return remoteControlClient.connectionState.value is ConnectionState.Connected } @@ -168,34 +174,41 @@ class RemotePlaybackController( _pendingRequest.value = null scope.launch { try { - when (jamSession.role.value) { + when (jamRoomService.role.value) { JamRole.Host -> executeLocally(request) JamRole.Guest -> suggestToJam(request) - null -> {} + null -> return@launch } + _events.emit(confirmationMessage(request)) } catch (e: Exception) { logger.e(e) { "Failed to send content to jam session" } } } } + private fun confirmationMessage(request: PlaybackDestinationRequest): String = when (request.action) { + PlaybackDestinationAction.Play -> "Playing on the jam queue" + PlaybackDestinationAction.AddToQueue -> "Added to the jam queue" + PlaybackDestinationAction.PlayNext -> "Added to play next in the jam queue" + } + private suspend fun suggestToJam(request: PlaybackDestinationRequest) { when (request) { is PlaybackDestinationRequest.Collection -> { val tracks = collectionPlaybackHelper.resolveCollectionTracks(request.type, request.id) if (tracks.isNotEmpty()) { - jamSession.suggestPlaylist(tracks.map { it.toJamMediaItem() }) + jamRoomService.suggestPlaylist(tracks) logger.i { "Suggested ${tracks.size} track(s) to the jam session" } } } is PlaybackDestinationRequest.Track -> { - jamSession.suggestTrack(request.track.toJamMediaItem()) + jamRoomService.suggestTrack(request.track) } is PlaybackDestinationRequest.Tracks -> { if (request.tracks.isNotEmpty()) { - jamSession.suggestPlaylist(request.tracks.map { it.toJamMediaItem() }) + jamRoomService.suggestPlaylist(request.tracks) } } } @@ -204,7 +217,9 @@ class RemotePlaybackController( // ---------- Internals ---------- private fun request(request: PlaybackDestinationRequest) { - if (isRemoteConnected()) { + // The picker offers "This Device", a connected remote device, and an + // active jam session — show it whenever more than one destination exists. + if (isRemoteConnected() || jamRoomService.role.value != null) { _pendingRequest.value = request } else { executeLocally(request) @@ -368,6 +383,4 @@ class RemotePlaybackController( album?.id == other.album?.id && artists.map { it.id.ifBlank { it.name } } == other.artists.map { it.id.ifBlank { it.name } } } -} - -private fun MetadataTrack.toJamMediaItem(): JamMediaItem = JamMediaItem.fromTrack(this) \ No newline at end of file +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt index 6fd70573..906c1492 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/devices/PlayDestinationPicker.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.krtirtho.spotube.core.jam.JamSessionService +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.remote.ConnectionState import dev.krtirtho.spotube.core.remote.PlaybackDestinationAction import dev.krtirtho.spotube.core.remote.RemoteControlClient @@ -41,6 +41,7 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.IconsaxCd import dev.krtirtho.spotube.resources.iconsax.IconsaxMirroringScreen import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlaylist +import kotlinx.coroutines.flow.map import org.koin.compose.koinInject /** @@ -52,10 +53,11 @@ import org.koin.compose.koinInject fun PlayDestinationPickerHost() { val controller = koinInject() val remoteControlClient = koinInject() - val jamSession = koinInject() + val jamRoomService = koinInject() val request by controller.pendingRequest.collectAsStateWithLifecycle() val connectionState by remoteControlClient.connectionState.collectAsStateWithLifecycle() - val jamActive by jamSession.isActive.collectAsStateWithLifecycle() + val jamActive by jamRoomService.role.map { it != null } + .collectAsStateWithLifecycle(initialValue = false) val pendingRequest = request ?: return diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt index adc4d246..b7f76b00 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamScreen.kt @@ -17,25 +17,18 @@ package dev.krtirtho.spotube.modules.jam -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -48,38 +41,28 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage import dev.krtirtho.spotube.core.jam.JamRole +import dev.krtirtho.spotube.core.jam.JamRoomCode import dev.krtirtho.spotube.core.navigation.NavigationCommands -import dev.krtirtho.spotube.core.ui.base.IconButton -import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme -import dev.krtirtho.spotube.core.ui.base.copyShape import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxNext -import dev.krtirtho.spotube.resources.iconsax.IconsaxPause -import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay -import dev.krtirtho.spotube.resources.iconsax.IconsaxPrevious -import dev.krtirtho.spotube.resources.iconsax.IconsaxRepeateMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxShuffle import org.koin.compose.viewmodel.koinViewModel +/** + * Group Jam session screen. Playback controls and the queue live in the app's + * regular player / queue sheet (the shared jam queue is the local queue), so + * this screen only covers participation and session management. + */ @Composable fun JamScreen( navigationCommands: NavigationCommands, @@ -110,48 +93,19 @@ fun JamScreen( } when { - !state.isActive && state.incomingOfferSdp != null -> IncomingInviteView( - hostName = state.incomingHostName.orEmpty(), - onJoin = viewModel::joinWithIncomingInvite, - onDismiss = viewModel::dismissIncomingInvite, - ) - !state.isActive -> CreateOrJoinView( + state = state, onCreate = viewModel::createSession, - onJoin = viewModel::joinWithPasted, + onJoin = viewModel::joinWithCode, ) - state.role == JamRole.Host -> HostSessionView( + else -> SessionView( state = state, - playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, - onNewInvite = viewModel::generateNewInvite, - onSubmitAnswer = viewModel::submitAnswerPasted, - onShare = viewModel::share, + onShareCode = viewModel::shareRoomCode, onLeave = viewModel::leave, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onJumpTo = viewModel::jumpTo, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoop = viewModel::cycleLoopMode, onKick = viewModel::kickParticipant, onBan = viewModel::banParticipant, ) - - else -> GuestSessionView( - state = state, - playerState = viewModel.jamPlayerState.collectAsStateWithLifecycle().value, - onShare = viewModel::share, - onLeave = viewModel::leave, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onJumpTo = viewModel::jumpTo, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoop = viewModel::cycleLoopMode, - ) } } } @@ -173,18 +127,33 @@ private fun ErrorBanner(text: String, onDismiss: () -> Unit) { @Composable private fun CreateOrJoinView( + state: JamUiState, onCreate: () -> Unit, onJoin: (String) -> Unit, ) { - var tab by remember { mutableIntStateOf(0) } + var tab by rememberSaveable { mutableIntStateOf(0) } var pasted by rememberSaveable { mutableStateOf("") } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = "Listen together with friends over a peer-to-peer connection.", + text = "Listen together with friends over an MQTT broker. Everyone hears the same queue.", style = MaterialTheme.typography.titleMedium, ) + if (!state.brokerConfigured) { + Text( + text = "No jam broker configured — set one up in Settings to host or join a session.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + text = "Broker: ${state.brokerHost}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SegmentedButton( selected = tab == 0, @@ -201,34 +170,36 @@ private fun CreateOrJoinView( if (tab == 0) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Start a session as the host. You'll get a shareable invite link " + - "to send to friends; when they accept, they appear here.", + text = "Start a session as the host. You'll get a 6-character room code to " + + "share with friends; you control the queue.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Button(onClick = onCreate) { + Button( + onClick = onCreate, + enabled = state.brokerConfigured, + ) { Text("Create Session") } } } else { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - text = "Paste the invite link the host shared with you.", + text = "Enter the 6-character room code the host shared with you.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( value = pasted, - onValueChange = { pasted = it }, + onValueChange = { pasted = JamRoomCode.normalize(it) }, modifier = Modifier.fillMaxWidth(), - label = { Text("Invite link") }, - placeholder = { Text("spotube://jam/invite?...") }, - minLines = 2, - maxLines = 6, + label = { Text("Room code") }, + placeholder = { Text("ABC123") }, + singleLine = true, ) Button( onClick = { onJoin(pasted) }, - enabled = pasted.isNotBlank(), + enabled = state.brokerConfigured && JamRoomCode.isValid(pasted), ) { Text("Join Session") } @@ -238,401 +209,54 @@ private fun CreateOrJoinView( } @Composable -private fun IncomingInviteView( - hostName: String, - onJoin: () -> Unit, - onDismiss: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = "$hostName invited you to a jam session", - style = MaterialTheme.typography.titleMedium, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onJoin) { - Text("Join") - } - OutlinedButton(onClick = onDismiss) { - Text("Ignore") - } - } - } -} - -@Composable -private fun HostSessionView( +private fun SessionView( state: JamUiState, - playerState: JamPlayerUiState, - onNewInvite: () -> Unit, - onSubmitAnswer: (String) -> Unit, - onShare: (String) -> Unit, + onShareCode: () -> Unit, onLeave: () -> Unit, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onJumpTo: (Int) -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, onKick: (String) -> Unit, onBan: (String) -> Unit, ) { - val clipboard = LocalClipboardManager.current - var pastedAnswer by rememberSaveable { mutableStateOf("") } + val isHost = state.role == JamRole.Host Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants, isHost = true, onKick = onKick, onBan = onBan) - - JamNowPlayingView( - playerState = playerState, - onTogglePlayPause = onTogglePlayPause, - onSkipNext = onSkipNext, - onSkipPrevious = onSkipPrevious, - onToggleShuffle = onToggleShuffle, - onCycleLoop = onCycleLoop, - ) - - HorizontalDivider() - - Text( - text = "Invite someone", - style = MaterialTheme.typography.titleSmall, - ) - val inviteLink = state.inviteLink - if (inviteLink != null) { - ShareableLinkBox( - label = "Invite link", - link = inviteLink, - onCopy = { clipboard.setText(AnnotatedString(inviteLink)) }, - onShare = { onShare(inviteLink) }, + if (!state.isConnected) { + Text( + text = "Connecting to the session…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - OutlinedButton(onClick = onNewInvite) { - Text("Generate new invite") + + ParticipantsSection( + participants = state.participants, + isHost = isHost, + onKick = onKick, + onBan = onBan, + ) + + if (isHost) { + HorizontalDivider() + + Text( + text = "Invite someone", + style = MaterialTheme.typography.titleSmall, + ) + RoomCodeBox(code = state.roomCode.orEmpty(), onShare = onShareCode) } HorizontalDivider() Text( - text = "Accept a guest's answer", - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = "When your guest sends back their answer link, paste it below.", + text = "The queue and playback controls are in the player at the bottom of the app — " + + "the jam queue is shared with every participant.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedTextField( - value = pastedAnswer, - onValueChange = { pastedAnswer = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Answer link or SDP") }, - minLines = 2, - maxLines = 6, - ) - Button( - onClick = { - onSubmitAnswer(pastedAnswer) - pastedAnswer = "" - }, - enabled = pastedAnswer.isNotBlank(), - ) { - Text("Accept Answer") - } - - HorizontalDivider() - - JamQueueView( - queue = playerState.queue, - onJumpTo = onJumpTo, - ) LeaveButton(onLeave) } } -@Composable -private fun GuestSessionView( - state: JamUiState, - playerState: JamPlayerUiState, - onShare: (String) -> Unit, - onLeave: () -> Unit, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onJumpTo: (Int) -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, -) { - val clipboard = LocalClipboardManager.current - - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - ParticipantsSection(state.participants, isHost = false, onKick = {}, onBan = {}) - - val answerLink = state.answerLink - when { - state.isConnected -> { - JamNowPlayingView( - playerState = playerState, - onTogglePlayPause = onTogglePlayPause, - onSkipNext = onSkipNext, - onSkipPrevious = onSkipPrevious, - onToggleShuffle = onToggleShuffle, - onCycleLoop = onCycleLoop, - ) - - JamQueueView( - queue = playerState.queue, - onJumpTo = onJumpTo, - ) - } - - answerLink == null -> { - Text( - text = "Connecting to the session...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - else -> { - Text( - text = "Almost there! Send your answer back to the host:", - style = MaterialTheme.typography.titleSmall, - ) - ShareableLinkBox( - label = "Answer link", - link = answerLink, - onCopy = { clipboard.setText(AnnotatedString(answerLink)) }, - onShare = { onShare(answerLink) }, - ) - } - } - - LeaveButton(onLeave) - } -} - -@Composable -private fun JamNowPlayingView( - playerState: JamPlayerUiState, - onTogglePlayPause: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onToggleShuffle: () -> Unit, - onCycleLoop: () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - AsyncImage( - model = playerState.currentCoverUrl?.takeIf { it.isNotBlank() }, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(64.dp) - .clip(MaterialTheme.shapes.medium), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = playerState.currentTitle ?: "Nothing playing", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = playerState.currentArtist ?: "—", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatJamDuration(playerState.positionMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = formatJamDuration(playerState.durationMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = onToggleShuffle, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = "Shuffle", - tint = if (playerState.shuffleEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - IconButton( - onClick = onSkipPrevious, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon(Iconsax.IconsaxPrevious, contentDescription = "Previous") - } - IconButton( - onClick = onTogglePlayPause, - theme = LocalBaseUITheme.current.iconButtons.primary.copyShape(CircleShape), - modifier = Modifier.size(64.dp), - ) { - Icon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) "Pause" else "Play", - modifier = Modifier.size(32.dp), - ) - } - IconButton( - onClick = onSkipNext, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon(Iconsax.IconsaxNext, contentDescription = "Next") - } - IconButton( - onClick = onCycleLoop, - theme = LocalBaseUITheme.current.iconButtons.ghost.copyShape(CircleShape), - ) { - Icon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = "Loop mode", - tint = if (playerState.loopMode != "none") { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } -} - -@Composable -private fun JamQueueView( - queue: List, - onJumpTo: (Int) -> Unit, -) { - var expanded by rememberSaveable { mutableStateOf(false) } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded }, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Queue (${queue.size})", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - Icon( - imageVector = Iconsax.IconsaxArrowDown4, - contentDescription = if (expanded) "Collapse queue" else "Expand queue", - modifier = Modifier - .size(20.dp) - .graphicsLayer { rotationZ = if (expanded) 180f else 0f }, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (queue.isEmpty()) { - Text( - text = "The queue is empty. Add tracks from anywhere in the app — the jam queue is shared.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else if (expanded) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 280.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - itemsIndexed(queue) { index, item -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onJumpTo(index) } - .padding(vertical = 6.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - AsyncImage( - model = item.coverUrl.takeIf { it.isNotBlank() }, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(40.dp) - .clip(MaterialTheme.shapes.small), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = item.title, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (item.isCurrent) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - ) - Text( - text = item.artist, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Text( - text = formatJamDuration(item.durationMs), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } else { - Text( - text = "Tap to view the shared queue.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - @Composable private fun ParticipantsSection( participants: List, @@ -684,35 +308,26 @@ private fun ParticipantsSection( } } -private fun formatJamDuration(ms: Long): String { - val totalSeconds = (ms / 1000).coerceAtLeast(0) - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} - @Composable -private fun ShareableLinkBox( - label: String, - link: String, - onCopy: () -> Unit, +private fun RoomCodeBox( + code: String, onShare: () -> Unit, ) { + val clipboard = LocalClipboardManager.current val viewModel: JamViewModel = koinViewModel() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SelectionContainer { - OutlinedTextField( - value = link, - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - label = { Text(label) }, - minLines = 2, - maxLines = 6, + Text( + text = code, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp), ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onCopy) { + Button(onClick = { clipboard.setText(AnnotatedString(code)) }) { Text("Copy") } if (viewModel.supportsNativeShare) { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt index b83e55fa..92dcc936 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/jam/JamViewModel.kt @@ -20,22 +20,10 @@ package dev.krtirtho.spotube.modules.jam import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface -import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue -import dev.krtirtho.spotube.core.audioplayer.LoopState -import dev.krtirtho.spotube.core.audioplayer.MediaItem -import dev.krtirtho.spotube.core.audioplayer.PlayerState -import dev.krtirtho.spotube.core.audioplayer.QueueEntry -import dev.krtirtho.spotube.core.deeplink.JamDeepLinkService -import dev.krtirtho.spotube.core.jam.JamInviteCodec -import dev.krtirtho.spotube.core.jam.JamInviteLink -import dev.krtirtho.spotube.core.jam.JamLoopMapping -import dev.krtirtho.spotube.core.jam.JamMediaItem -import dev.krtirtho.spotube.core.jam.JamMessage import dev.krtirtho.spotube.core.jam.JamParticipant import dev.krtirtho.spotube.core.jam.JamRole -import dev.krtirtho.spotube.core.jam.JamSessionService -import dev.krtirtho.spotube.core.jam.PlaybackCmd +import dev.krtirtho.spotube.core.jam.JamRoomCode +import dev.krtirtho.spotube.core.jam.JamRoomService import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.getPlatform import dev.krtirtho.spotube.modules.settings.SettingsProvider @@ -45,7 +33,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class JamUiState( @@ -53,377 +40,104 @@ data class JamUiState( val isConnected: Boolean = false, val role: JamRole? = null, val participants: List = emptyList(), - /** Host: deep link containing this session's SDP offer, ready to share. */ - val inviteLink: String? = null, - /** Guest: deep link containing our SDP answer, to send back to the host. */ - val answerLink: String? = null, - /** Guest: offer received via deep link (or paste), waiting for confirmation. */ - val incomingHostName: String? = null, - val incomingOfferSdp: String? = null, + val roomCode: String? = null, + val brokerHost: String = "", + val brokerConfigured: Boolean = false, val error: String? = null, ) -data class JamQueueUiItem( - val id: String, - val title: String, - val artist: String, - val album: String, - val durationMs: Long, - val coverUrl: String, - val isCurrent: Boolean, -) - -data class JamPlayerUiState( - val queue: List = emptyList(), - val currentIndex: Int = -1, - val currentTitle: String? = null, - val currentArtist: String? = null, - val currentCoverUrl: String? = null, - val isPlaying: Boolean = false, - val positionMs: Long = 0, - val durationMs: Long = 0, - val shuffleEnabled: Boolean = false, - val loopMode: String = "none", -) - class JamViewModel( - private val jamSession: JamSessionService, - private val deepLinks: JamDeepLinkService, + private val jamRoomService: JamRoomService, private val shareService: ShareService, private val settingsProvider: SettingsProvider, - private val audioPlayer: AudioPlayerInterface, - private val audioPlayerQueue: AudioPlayerQueue, ) : ViewModel() { - private val _uiState = MutableStateFlow(JamUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _localError = MutableStateFlow(null) val supportsNativeShare: Boolean = getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS - init { - viewModelScope.launch { - // Mirror live session state into the UI state. - jamSession.isActive.collect { active -> - _uiState.update { - it.copy( - isActive = active, - isConnected = jamSession.isConnected.value, - role = jamSession.role.value, - participants = jamSession.participants.value, - inviteLink = if (!active) null else it.inviteLink, - answerLink = if (!active) null else it.answerLink, - incomingOfferSdp = if (!active) it.incomingOfferSdp else null, - incomingHostName = if (!active) it.incomingHostName else null, - ) - } - } - } - viewModelScope.launch { - jamSession.participants.collect { participants -> - _uiState.update { it.copy(participants = participants) } - } - } - viewModelScope.launch { - jamSession.isConnected.collect { connected -> - _uiState.update { it.copy(isConnected = connected) } - } - } - viewModelScope.launch { - deepLinks.pendingLink.collect { link -> - handleDeepLink(link) - } - } - } - - /** - * The jam player state: the shared queue + current playback, built from the - * local player (the host's queue IS the jam queue; on guests the synced - * mirror lives in the local player). - */ - val jamPlayerState: StateFlow = combine( - audioPlayerQueue.queueFlow, - audioPlayerQueue.currentQueueEntryFlow, - audioPlayer.playlistFlow, - audioPlayer.currentMediaItemFlow, - audioPlayer.playerStateFlow, - audioPlayer.positionFlow, - audioPlayer.durationFlow, - audioPlayer.loopStateFlow, - audioPlayer.shuffleModeFlow, + val uiState: StateFlow = combine( + jamRoomService.role, + jamRoomService.participants, + jamRoomService.isConnected, + jamRoomService.roomCode, + jamRoomService.connectionError, + settingsProvider.settingsState, + _localError, ) { values -> - val queue: List = values[0] as List - val currentEntry: QueueEntry? = values[1] as QueueEntry? - val playlist: List = values[2] as List - val currentItem: MediaItem? = values[3] as MediaItem? - val playerState: PlayerState = values[4] as PlayerState - val position: kotlin.time.Duration = values[5] as kotlin.time.Duration - val duration: kotlin.time.Duration = values[6] as kotlin.time.Duration - val loop: LoopState = values[7] as LoopState - val shuffle: Boolean = values[8] as Boolean + @Suppress("UNCHECKED_CAST") + val role = values[0] as JamRole? + @Suppress("UNCHECKED_CAST") + val participants = values[1] as List + val isConnected = values[2] as Boolean + val roomCode = values[3] as String? + val connectionError = values[4] as String? + val settings = values[5] as? dev.krtirtho.spotube.modules.settings.UserSettings + val localError = values[6] as String? - val isHost = jamSession.role.value == JamRole.Host - - val items: List - val currentIndex: Int - val currentTitle: String? - val currentArtist: String? - val currentCoverUrl: String? - - if (isHost) { - val queueItems = queue.map { JamMediaItem.fromQueueEntry(it) } - val index = if (currentEntry != null) { - queue.indexOfFirst { entry -> entry.matchesQueueEntry(currentEntry) } - } else { - -1 - } - items = queueItems.mapIndexed { i, item -> - item.toUiItem(i == index) - } - currentIndex = index - currentTitle = queueItems.getOrNull(index)?.title - currentArtist = queueItems.getOrNull(index)?.artist - currentCoverUrl = queueItems.getOrNull(index)?.coverUrl - } else { - val index = playlist.indexOf(currentItem) - items = playlist.mapIndexed { i, item -> - JamMediaItem.fromMediaItem(item).toUiItem(i == index) - } - currentIndex = index - currentTitle = currentItem?.title - currentArtist = currentItem?.artist - currentCoverUrl = currentItem?.coverURL - } - - JamPlayerUiState( - queue = items, - currentIndex = currentIndex, - currentTitle = currentTitle, - currentArtist = currentArtist, - currentCoverUrl = currentCoverUrl, - isPlaying = playerState == PlayerState.PLAYING, - positionMs = position.inWholeMilliseconds, - durationMs = duration.inWholeMilliseconds, - shuffleEnabled = shuffle, - loopMode = loop.name.lowercase(), + JamUiState( + isActive = role != null, + isConnected = isConnected, + role = role, + participants = participants, + roomCode = roomCode, + brokerHost = settings?.jamBroker?.host.orEmpty(), + brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank(), + error = localError ?: connectionError, ) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamPlayerUiState()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), JamUiState()) - // ---------- Playback controls ---------- + // ---------- Session lifecycle ---------- - fun togglePlayPause() = sendOrApply(PlaybackCmd.Toggle) - - fun skipNext() = sendOrApply(PlaybackCmd.SkipNext) - - fun skipPrevious() = sendOrApply(PlaybackCmd.SkipPrevious) - - fun seek(positionMs: Long) = sendOrApply(PlaybackCmd.Seek(positionMs)) - - fun jumpTo(index: Int) = sendOrApply(PlaybackCmd.JumpTo(index)) - - fun toggleShuffle() = sendOrApply(PlaybackCmd.SetShuffle(!jamPlayerState.value.shuffleEnabled)) - - fun cycleLoopMode() { - val next = when (jamPlayerState.value.loopMode) { - "none" -> "one" - "one" -> "all" - else -> "none" - } - sendOrApply(PlaybackCmd.SetLoop(next)) - } - - private fun sendOrApply(command: PlaybackCmd) { + fun createSession() { viewModelScope.launch { - if (jamSession.role.value == JamRole.Host) { - applyCommandLocally(command) - } else { - jamSession.sendMessage(JamMessage.PlaybackCommand(command)) - } + jamRoomService.createRoom() + .onFailure { e -> + _localError.value = e.message ?: "Failed to create jam session" + } + .onSuccess { _localError.value = null } } } - private suspend fun applyCommandLocally(command: PlaybackCmd) { - when (command) { - PlaybackCmd.Play -> audioPlayer.play() - PlaybackCmd.Pause -> audioPlayer.pause() - PlaybackCmd.Toggle -> { - if (audioPlayer.playerStateFlow.value == PlayerState.PLAYING) { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - - is PlaybackCmd.Seek -> audioPlayer.seekTo(kotlin.time.Duration.parse("${command.positionMs}ms")) - PlaybackCmd.SkipNext -> audioPlayer.skipToNext() - PlaybackCmd.SkipPrevious -> audioPlayer.skipToPrevious() - is PlaybackCmd.SetVolume -> audioPlayer.setVolume(command.volume) - is PlaybackCmd.SetLoop -> audioPlayer.loop(JamLoopMapping.fromString(command.loop)) - is PlaybackCmd.SetShuffle -> audioPlayer.shuffle(command.enabled) - is PlaybackCmd.JumpTo -> audioPlayer.jumpTo(command.index) + fun joinWithCode(input: String) { + val code = JamRoomCode.normalize(input) + if (!JamRoomCode.isValid(code)) { + _localError.value = "Room codes are ${JamRoomCode.LENGTH} characters (letters and digits)" + return } + viewModelScope.launch { + jamRoomService.joinRoom(code) + .onFailure { e -> + _localError.value = e.message ?: "Failed to join jam session" + } + .onSuccess { _localError.value = null } + } + } + + fun shareRoomCode() { + val code = uiState.value.roomCode ?: return + shareService.share("Join my Spotube Jam with code: $code", "Spotube Group Jam") + } + + fun leave() { + viewModelScope.launch { + jamRoomService.leaveRoom() + _localError.value = null + } + } + + fun clearError() { + _localError.value = null } // ---------- Host moderation ---------- fun kickParticipant(participantId: String) { - viewModelScope.launch { jamSession.kickParticipant(participantId) } + viewModelScope.launch { jamRoomService.kickParticipant(participantId) } } fun banParticipant(participantId: String) { - viewModelScope.launch { jamSession.banParticipant(participantId) } - } - - fun createSession() { - viewModelScope.launch { - runCatching { - val offer = jamSession.createSession() - JamInviteCodec.buildHostInvite(localName(), offer) - }.onSuccess { link -> - _uiState.update { it.copy(inviteLink = link, error = null) } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to create session: ${e.message}") } - } - } - } - - fun generateNewInvite() { - viewModelScope.launch { - runCatching { - val invite = jamSession.generateInvite() - JamInviteCodec.buildHostInvite(localName(), invite.sdp) - }.onSuccess { link -> - _uiState.update { it.copy(inviteLink = link, error = null) } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to generate invite: ${e.message}") } - } - } - } - - fun joinWithIncomingInvite() { - val sdp = _uiState.value.incomingOfferSdp ?: return - join(sdp, _uiState.value.incomingHostName) - } - - fun joinWithPasted(input: String) { - val parsed = JamInviteCodec.parse(input) - val sdp = parsed?.sdp ?: JamInviteCodec.extractSdp(input) - if (sdp == null) { - _uiState.update { it.copy(error = "That doesn't look like a valid jam invite.") } - return - } - join(sdp, (parsed as? JamInviteLink.HostInvite)?.peerName) - } - - /** - * Host side: accepts an answer pasted as raw SDP or as a full `spotube://jam/answer` link. - */ - fun submitAnswerPasted(input: String) { - when (val parsed = JamInviteCodec.parse(input.trim())) { - is JamInviteLink.GuestAnswer -> acceptAnswerInternal(parsed.sdp, parsed.peerName) - else -> { - val sdp = JamInviteCodec.extractSdp(input) - if (sdp == null) { - _uiState.update { it.copy(error = "That doesn't look like a valid SDP answer.") } - } else { - acceptAnswerInternal(sdp, "") - } - } - } - } - - fun share(text: String) { - shareService.share(text, "Spotube Group Jam") - } - - fun leave() { - viewModelScope.launch { - jamSession.leave() - deepLinks.clear() - _uiState.update { - JamUiState(incomingOfferSdp = it.incomingOfferSdp, incomingHostName = it.incomingHostName) - } - } - } - - fun clearError() { - _uiState.update { it.copy(error = null) } - } - - fun dismissIncomingInvite() { - deepLinks.clear() - _uiState.update { it.copy(incomingOfferSdp = null, incomingHostName = null) } - } - - private fun join(offerSdp: String, hostName: String? = null) { - viewModelScope.launch { - runCatching { - val answer = jamSession.joinSession(offerSdp, hostName) - JamInviteCodec.buildGuestAnswer(localName(), answer) - }.onSuccess { link -> - _uiState.update { - it.copy(answerLink = link, incomingOfferSdp = null, incomingHostName = null, error = null) - } - }.onFailure { e -> - _uiState.update { it.copy(error = "Failed to join session: ${e.message}") } - } - } - } - - private fun acceptAnswerInternal(answerSdp: String, peerName: String) { - viewModelScope.launch { - val accepted = runCatching { jamSession.acceptAnswer(null, answerSdp, peerName) } - .getOrDefault(false) - if (!accepted) { - _uiState.update { it.copy(error = "Couldn't accept that answer — no pending invite matched.") } - } else { - _uiState.update { it.copy(error = null) } - } - } - } - - private suspend fun handleDeepLink(link: JamInviteLink?) { - when (link) { - is JamInviteLink.HostInvite -> { - if (!jamSession.isActive.value) { - _uiState.update { - it.copy(incomingHostName = link.peerName.ifBlank { "Someone" }, incomingOfferSdp = link.sdp) - } - } - } - - is JamInviteLink.GuestAnswer -> { - if (jamSession.role.value == JamRole.Host) { - acceptAnswerInternal(link.sdp, link.peerName) - } - } - - null -> Unit - } - } - - private fun localName(): String = - settingsProvider.settingsState.value?.jamParticipantName.orEmpty() -} - -private fun JamMediaItem.toUiItem(isCurrent: Boolean): JamQueueUiItem = JamQueueUiItem( - id = if (trackId.isNotBlank()) trackId else url, - title = title, - artist = artist, - album = album, - durationMs = durationMs, - coverUrl = coverUrl, - isCurrent = isCurrent, -) - -private fun QueueEntry.matchesQueueEntry(other: QueueEntry): Boolean { - return when { - this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> - this.track.id == other.track.id - - this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> - this.url == other.url && this.name == other.name - - else -> false + viewModelScope.launch { jamRoomService.banParticipant(participantId) } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt index 1878ae96..3a6792ff 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsModels.kt @@ -61,8 +61,10 @@ data class UserSettings( val remoteControlDeviceName: String = "", val remoteControlDeviceId: String = "", - // Group Jam (P2P) + // Group Jam (MQTT) val jamParticipantName: String = "", + val jamBroker: JamBroker = JamBroker(), + val lastJamCode: String = "", // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder @@ -86,3 +88,20 @@ data class UserSettings( // Updates val autoCheckForUpdates: Boolean = true, ) + +/** + * Configuration for the MQTT broker used by Group Jam. The host is a placeholder + * until a real broker is configured; users can self-host and point the app at it. + */ +@Serializable +data class JamBroker( + val name: String = "", + val host: String = "test.mosquitto.org", + val port: Int = 1883, + val useTls: Boolean = false, + val username: String? = null, + val password: String? = null, + val clientIdPrefix: String = "spotube", + val keepAliveSeconds: Int = 30, + val connectionTimeoutSeconds: Int = 10, +) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt index 7601f014..2321ecef 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/SettingsScreen.kt @@ -43,6 +43,7 @@ import dev.krtirtho.spotube.modules.settings.sections.appearanceSection import dev.krtirtho.spotube.modules.settings.sections.cacheSection import dev.krtirtho.spotube.modules.settings.sections.desktopSection import dev.krtirtho.spotube.modules.settings.sections.downloadsSection +import dev.krtirtho.spotube.modules.settings.sections.jamSection import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection import dev.krtirtho.spotube.modules.settings.sections.playbackSection import dev.krtirtho.spotube.modules.settings.sections.pluginsSection @@ -110,6 +111,11 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) { navigatorCommands = navigatorCommands, requestLocalNetworkPermission = requestLocalNetworkPermission, ) + if (settingsState != null) + jamSection( + settings = settingsState!!, + settingsViewModel = settingsViewModel, + ) if (settingsState != null) cacheSection( settings = settingsState!!, diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt new file mode 100644 index 00000000..a15326da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/settings/sections/JamSection.kt @@ -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 . + */ + +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() + val scope = rememberCoroutineScope() + var testing by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(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), + ) + }, + ) + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt index 14fe94f9..b81d2686 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/AppShell.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -41,6 +42,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberBottomSheetScaffoldState @@ -70,6 +73,7 @@ import dev.krtirtho.spotube.core.navigation.NavigationState import dev.krtirtho.spotube.core.navigation.Navigator import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost +import dev.krtirtho.spotube.core.remote.RemotePlaybackController import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost import dev.krtirtho.spotube.modules.lyrics.LyricsScreen import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent @@ -95,6 +99,13 @@ fun AppShell( content: @Composable () -> Unit, ) { val navigatorCommands: NavigationCommands = koinInject() + val remotePlaybackController: RemotePlaybackController = koinInject() + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(remotePlaybackController) { + remotePlaybackController.events.collect { message -> + snackbarHostState.showSnackbar(message) + } + } val isQueueVisible by queueViewModel.isQueueVisible.collectAsState() val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState() val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState() @@ -122,6 +133,13 @@ fun AppShell( PlayDestinationPickerHost() Box(modifier = Modifier.fillMaxSize()) { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 96.dp), + ) + val useSidebar = viewModel.useSidebar() val bottomOverlayInset = viewModel.bottomOverlayInset(useSidebar) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt index 71faa53b..c9d1a870 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/shell/player_queue/PlayerQueueContentViewModel.kt @@ -68,7 +68,7 @@ class PlayerQueueContentViewModel( } queue.mapIndexed { index, entry -> val title: String - val subtitle: String + var subtitle: String val durationMs: Long val imageUrl: String? @@ -89,6 +89,11 @@ class PlayerQueueContentViewModel( } } + val addedBy = entry.addedBy + if (addedBy.isNotBlank()) { + subtitle = "$subtitle • Added by $addedBy" + } + QueueItemUi( id = "${entry.url}@$index", title = title, diff --git a/composeApp/src/commonMain/rust/lib.rs b/composeApp/src/commonMain/rust/lib.rs index dff859ba..f8e0e84e 100644 --- a/composeApp/src/commonMain/rust/lib.rs +++ b/composeApp/src/commonMain/rust/lib.rs @@ -1,9 +1,7 @@ mod metadata; mod discord_rpc; -mod webrtc_p2p; pub use metadata::*; pub use discord_rpc::*; -pub use webrtc_p2p::*; uniffi::setup_scaffolding!(); \ No newline at end of file diff --git a/composeApp/src/commonMain/rust/webrtc_p2p.rs b/composeApp/src/commonMain/rust/webrtc_p2p.rs deleted file mode 100644 index c40e9a69..00000000 --- a/composeApp/src/commonMain/rust/webrtc_p2p.rs +++ /dev/null @@ -1,340 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -use std::sync::Arc; -use std::time::Duration; - -use parking_lot::Mutex; -use rtc::ice::mdns::MulticastDnsMode; -use rtc::peer_connection::configuration::interceptor_registry::register_default_interceptors; -use rtc::peer_connection::configuration::setting_engine::SettingEngine; -use webrtc::data_channel::{DataChannel, DataChannelEvent, RTCDataChannelInit}; -use webrtc::peer_connection::{ - MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, - RTCConfigurationBuilder, RTCIceGatheringState, RTCIceServer, RTCPeerConnectionIceEvent, - RTCPeerConnectionState, RTCSessionDescription, Registry, -}; -use webrtc::runtime::channel; - -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum WebrtcError { - #[error("SDP error: {reason}")] - SdpError { reason: String }, - #[error("Connection error: {reason}")] - ConnectionError { reason: String }, - #[error("Data channel error: {reason}")] - DataChannelError { reason: String }, - #[error("Invalid state: {reason}")] - InvalidState { reason: String }, - #[error("Internal error: {reason}")] - Internal { reason: String }, -} - -impl From for WebrtcError { - fn from(e: webrtc::error::Error) -> Self { - WebrtcError::Internal { - reason: format!("{e:?}"), - } - } -} - -#[derive(uniffi::Record)] -pub struct IceServerConfig { - pub urls: Vec, - pub username: String, - pub credential: String, -} - -#[uniffi::export(callback_interface)] -pub trait WebrtcEventHandler: Send + Sync + 'static { - fn on_ice_candidate(&self, candidate: String); - fn on_ice_gathering_state_change(&self, state: String); - fn on_connection_state_change(&self, state: String); - fn on_data_channel_open(&self, label: String); - fn on_data_channel_message(&self, label: String, data: String); - fn on_data_channel_close(&self, label: String); -} - -struct DataChannelEntry { - dc: Arc, - label: String, -} - -#[derive(uniffi::Object)] -pub struct WebrtcPeerConnection { - pc: Arc, - handler: Arc, - channels: Arc>>, - gather_rx: Mutex>, -} - -#[uniffi::export(async_runtime = "tokio")] -pub async fn create_webrtc_peer_connection( - ice_servers: Vec, - handler: Box, -) -> Result, WebrtcError> { - let handler: Arc = Arc::from(handler); - - let mut media_engine = MediaEngine::default(); - media_engine - .register_default_codecs() - .map_err(|e| WebrtcError::Internal { - reason: format!("media_engine: {e:?}"), - })?; - - let registry = register_default_interceptors(Registry::new(), &mut media_engine) - .map_err(|e| WebrtcError::Internal { - reason: format!("interceptor_registry: {e:?}"), - })?; - - let config = RTCConfigurationBuilder::new() - .with_ice_servers( - ice_servers - .into_iter() - .map(|s| RTCIceServer { - urls: s.urls, - username: s.username, - credential: s.credential, - }) - .collect(), - ) - .build(); - - // mDNS adds a multicast UDP socket per peer connection. On some platforms - // (notably Android) that socket can stall and ICE gathering then never - // completes. Real-IP host candidates (no mDNS) work fine alongside STUN/TURN, - // so mDNS is disabled. - let mut setting_engine = SettingEngine::default(); - setting_engine.set_multicast_dns_mode(MulticastDnsMode::Disabled); - - let (gather_tx, gather_rx) = channel::<()>(1); - let channels = Arc::new(Mutex::new(Vec::new())); - let pc_handler = Arc::new(PeerHandlerBridge { - handler: Arc::clone(&handler), - gather_tx, - channels: Arc::clone(&channels), - }); - - let pc = PeerConnectionBuilder::new() - .with_configuration(config) - .with_setting_engine(setting_engine) - .with_media_engine(media_engine) - .with_interceptor_registry(registry) - .with_handler(pc_handler) - .with_udp_addrs(vec!["0.0.0.0:0"]) - .build() - .await?; - - Ok(Arc::new(WebrtcPeerConnection { - pc: Arc::new(pc) as Arc, - handler, - channels, - gather_rx: Mutex::new(gather_rx), - })) -} - -impl WebrtcPeerConnection { - /// Waits for ICE gathering to reach `Complete` so the local SDP includes all - /// candidates (non-trickle exchange). Must be called after `set_local_description`, - /// which is what starts gathering. - /// - /// Robust against a stalled gatherer (e.g. an unreachable STUN server): once at - /// least one candidate has landed in the local description, a short grace period - /// is enough — the SDP must never leave candidate-less. Hard cap at 5s. - async fn wait_for_ice_gathering(&self) { - let mut gather_rx = self.gather_rx.lock().clone(); - let started = std::time::Instant::now(); - - loop { - let elapsed = started.elapsed(); - if elapsed >= Duration::from_secs(5) { - log::warn!( - "ICE gathering did not complete within 5s; using the candidates gathered so far" - ); - return; - } - - match tokio::time::timeout(Duration::from_millis(100), gather_rx.recv()).await { - Ok(Some(())) => return, // gathering complete - Ok(None) => return, // handler dropped - Err(_) => {} // timed out, keep waiting - } - - // Grace period once candidates are present, so the SDP always carries them. - if elapsed >= Duration::from_secs(1) { - let sdp = self.pc.local_description().await.map(|d| d.sdp); - if sdp.as_deref().map_or(false, |s| s.contains("a=candidate:")) { - return; - } - } - } - } -} - -#[uniffi::export] -impl WebrtcPeerConnection { - #[uniffi::method(async_runtime = "tokio")] - pub async fn create_offer(&self) -> Result { - let offer = self.pc.create_offer(None).await?; - self.pc.set_local_description(offer.clone()).await?; - self.wait_for_ice_gathering().await; - Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(offer.sdp)) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn create_answer(&self) -> Result { - let answer = self.pc.create_answer(None).await?; - self.pc.set_local_description(answer.clone()).await?; - self.wait_for_ice_gathering().await; - Ok(self.pc.local_description().await.map(|d| d.sdp).unwrap_or(answer.sdp)) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn set_remote_offer(&self, sdp: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::offer(sdp) - .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn set_remote_answer(&self, sdp: String) -> Result<(), WebrtcError> { - let desc = RTCSessionDescription::answer(sdp) - .map_err(|e| WebrtcError::SdpError { reason: format!("{e:?}") })?; - self.pc.set_remote_description(desc).await?; - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn local_description(&self) -> Option { - self.pc.local_description().await.map(|d| d.sdp) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn create_data_channel(&self, label: String) -> Result<(), WebrtcError> { - let dc = self - .pc - .create_data_channel(&label, None::) - .await?; - - spawn_data_channel_poll_loop(Arc::clone(&dc), Arc::clone(&self.handler)); - - self.channels.lock().push(DataChannelEntry { dc, label }); - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn send_data(&self, label: String, data: String) -> Result<(), WebrtcError> { - let dc = { - let channels = self.channels.lock(); - channels - .iter() - .find(|c| c.label == label) - .map(|c| Arc::clone(&c.dc)) - }; - let dc = dc.ok_or_else(|| WebrtcError::InvalidState { - reason: format!("No data channel with label '{label}'"), - })?; - dc.send_text(&data).await?; - Ok(()) - } - - #[uniffi::method(async_runtime = "tokio")] - pub async fn shutdown(&self) -> Result<(), WebrtcError> { - let channels: Vec> = { - let channels = self.channels.lock(); - channels.iter().map(|c| Arc::clone(&c.dc)).collect() - }; - for dc in channels.iter() { - let _ = dc.close().await; - } - self.pc.close().await?; - Ok(()) - } -} - -struct PeerHandlerBridge { - handler: Arc, - gather_tx: webrtc::runtime::Sender<()>, - channels: Arc>>, -} - -#[async_trait::async_trait] -impl PeerConnectionEventHandler for PeerHandlerBridge { - async fn on_ice_candidate(&self, event: RTCPeerConnectionIceEvent) { - self.handler.on_ice_candidate(event.candidate.to_string()); - } - - async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) { - let s = state.to_string(); - if matches!(state, RTCIceGatheringState::Complete) { - let _ = self.gather_tx.try_send(()); - } - self.handler.on_ice_gathering_state_change(s); - } - - async fn on_connection_state_change(&self, state: RTCPeerConnectionState) { - self.handler.on_connection_state_change(state.to_string()); - } - - async fn on_data_channel(&self, dc: Arc) { - // Register in-band (remote-initiated) channels so send_data() can find - // them — without this, the answering peer can never send anything. - let label = match dc.label().await { - Ok(l) => l, - Err(_) => return, - }; - self.channels - .lock() - .push(DataChannelEntry { dc: Arc::clone(&dc), label }); - spawn_data_channel_poll_loop(dc, Arc::clone(&self.handler)); - } -} - -fn spawn_data_channel_poll_loop( - dc: Arc, - handler: Arc, -) { - ::tokio::spawn(async move { - let label = match dc.label().await { - Ok(l) => l, - Err(_) => return, - }; - while let Some(event) = dc.poll().await { - match event { - DataChannelEvent::OnOpen => { - handler.on_data_channel_open(label.clone()); - } - DataChannelEvent::OnMessage(msg) => { - let text = if msg.is_string { - String::from_utf8_lossy(&msg.data).into_owned() - } else { - format!("[binary:{}bytes]", msg.data.len()) - }; - handler.on_data_channel_message(label.clone(), text); - } - DataChannelEvent::OnClose | DataChannelEvent::OnClosing => { - handler.on_data_channel_close(label.clone()); - if matches!(event, DataChannelEvent::OnClose) { - break; - } - } - _ => {} - } - } - }); -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b515031d..d0951e49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,8 @@ kotlinx-io = "0.9.1" material3 = "1.10.0-alpha05" kotlinx-serialization-json = "1.11.0" materialKolor = "4.1.1" +mqttClient = "2.1.1" +mqttBuffer = "6.30.8" murmurhash = "0.4.2" newpipeextractor = "v0.26.2" newpipeExtractorKmp = "1.3.0" @@ -122,6 +124,10 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } +mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" } +mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" } +mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" } +mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" }