Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
3642332639
Merge d48b0a2f65 into 16d4adf59c 2026-07-17 04:10:07 +00:00
28 changed files with 107 additions and 1370 deletions

View File

@ -112,9 +112,6 @@ jobs:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
@ -173,9 +170,6 @@ jobs:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
@ -237,9 +231,6 @@ jobs:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4
@ -277,9 +268,6 @@ jobs:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@v4

7
.gitignore vendored
View File

@ -108,10 +108,3 @@ tm.json
android/build android/build
android/app/.cxx android/app/.cxx
/node_modules
/package.json
/package-lock.json
/bun.lock
**/target/

View File

@ -30,6 +30,4 @@ plugins {
alias(libs.plugins.zipline.gradle.plugin) apply false alias(libs.plugins.zipline.gradle.plugin) apply false
alias(libs.plugins.spotubeGradle) apply false alias(libs.plugins.spotubeGradle) apply false
alias(libs.plugins.vlcjBundler) apply false alias(libs.plugins.vlcjBundler) apply false
alias(libs.plugins.uniffi) apply false
alias(libs.plugins.cargo) apply false
} }

723
composeApp/Cargo.lock generated
View File

@ -1,723 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anyhow"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "askama"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
dependencies = [
"askama_derive",
"itoa",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
name = "askama_derive"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
dependencies = [
"askama_parser",
"basic-toml",
"memchr",
"proc-macro2",
"quote",
"rustc-hash",
"serde",
"serde_derive",
"syn",
]
[[package]]
name = "askama_parser"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
dependencies = [
"memchr",
"serde",
"serde_derive",
"winnow",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "camino"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
"serde",
]
[[package]]
name = "cargo_metadata"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "compose-app"
version = "0.1.0"
dependencies = [
"lofty",
"uniffi",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fs-err"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41"
dependencies = [
"autocfg",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "goblin"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
dependencies = [
"log",
"plain",
"scroll",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lofty"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "ogg_pager"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "scroll"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
dependencies = [
"scroll_derive",
]
[[package]]
name = "scroll_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "smawk"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uniffi"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3291800a6b06569f7d3e15bdb6dc235e0f0c8bd3eb07177f430057feb076415f"
dependencies = [
"anyhow",
"cargo_metadata",
"uniffi_bindgen",
"uniffi_core",
"uniffi_macros",
"uniffi_pipeline",
]
[[package]]
name = "uniffi_bindgen"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a04b99fa7796eaaa7b87976a0dbdd1178dc1ee702ea00aca2642003aef9b669e"
dependencies = [
"anyhow",
"askama",
"camino",
"cargo_metadata",
"fs-err",
"glob",
"goblin",
"heck",
"indexmap",
"once_cell",
"serde",
"tempfile",
"textwrap",
"toml",
"uniffi_internal_macros",
"uniffi_meta",
"uniffi_pipeline",
"uniffi_udl",
]
[[package]]
name = "uniffi_core"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98"
dependencies = [
"anyhow",
"bytes",
"once_cell",
"static_assertions",
]
[[package]]
name = "uniffi_internal_macros"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09acd2ce09c777dd65ee97c251d33c8a972afc04873f1e3b21eb3492ade16933"
dependencies = [
"anyhow",
"indexmap",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "uniffi_macros"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5596f178c4f7aafa1a501c4e0b96236a96bc2ef92bdb453d83e609dad0040152"
dependencies = [
"camino",
"fs-err",
"once_cell",
"proc-macro2",
"quote",
"serde",
"syn",
"toml",
"uniffi_meta",
]
[[package]]
name = "uniffi_meta"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "beadc1f460eb2e209263c49c4f5b19e9a02e00a3b2b393f78ad10d766346ecff"
dependencies = [
"anyhow",
"siphasher",
"uniffi_internal_macros",
"uniffi_pipeline",
]
[[package]]
name = "uniffi_pipeline"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd76b3ac8a2d964ca9fce7df21c755afb4c77b054a85ad7a029ad179cc5abb8a"
dependencies = [
"anyhow",
"heck",
"indexmap",
"tempfile",
"uniffi_internal_macros",
]
[[package]]
name = "uniffi_udl"
version = "0.29.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4319cf905911d70d5b97ce0f46f101619a22e9a189c8c46d797a9955e9233716"
dependencies = [
"anyhow",
"textwrap",
"uniffi_meta",
"weedle2",
]
[[package]]
name = "weedle2"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e"
dependencies = [
"nom",
]
[[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.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View File

@ -1,12 +0,0 @@
[package]
name = "compose-app"
version = "0.1.0"
edition = "2024"
[dependencies]
uniffi = "0.29.4"
lofty = "0.24.0"
[lib]
crate-type = ["cdylib", "staticlib"]
path = "src/commonMain/rust/lib.rs"

View File

@ -15,8 +15,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import gobley.gradle.GobleyHost
import gobley.gradle.cargo.dsl.jvm
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@ -32,9 +30,6 @@ plugins {
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.zipline.gradle.plugin) alias(libs.plugins.zipline.gradle.plugin)
alias(libs.plugins.vlcjBundler) alias(libs.plugins.vlcjBundler)
alias(libs.plugins.uniffi)
alias(libs.plugins.cargo)
kotlin("plugin.atomicfu") version libs.versions.kotlin
} }
vlcjBundler { vlcjBundler {
@ -339,9 +334,3 @@ tasks.withType<ComposeHotRun>().configureEach {
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get() project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()
) )
} }
cargo {
builds.jvm {
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
}
}

View File

@ -25,7 +25,6 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.MediaItem as Media3MediaItem import androidx.media3.common.MediaItem as Media3MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@ -77,16 +76,6 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Spotube")
.setContentText("Playback service")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(sessionActivity)
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback()) librarySession = MediaLibrarySession.Builder(this, audioPlayer.player, LibrarySessionCallback())
.setSessionActivity(sessionActivity) .setSessionActivity(sessionActivity)
.build() .build()
@ -399,6 +388,5 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
companion object { companion object {
private const val TAG = "PlaybackService" private const val TAG = "PlaybackService"
private const val CHANNEL_ID = "spotube_playback" private const val CHANNEL_ID = "spotube_playback"
private const val NOTIFICATION_ID = 1
} }
} }

View File

@ -17,8 +17,6 @@
package dev.krtirtho.spotube package dev.krtirtho.spotube
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -36,7 +34,6 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.settings.SettingsRepository import dev.krtirtho.spotube.modules.settings.SettingsRepository
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
@ -89,7 +86,7 @@ val tabs = listOf(
) )
) )
@OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class, ExperimentalSharedTransitionApi::class) @OptIn(KoinExperimentalAPI::class, ExperimentalCoroutinesApi::class)
@Composable @Composable
fun App( fun App(
content: @Composable () -> Unit = {} content: @Composable () -> Unit = {}
@ -109,10 +106,6 @@ fun App(
val baseUITheme = rememberBaseUITheme() val baseUITheme = rememberBaseUITheme()
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) { CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
AppShell(navigator, navigationState) { AppShell(navigator, navigationState) {
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout,
) {
Column { Column {
NavDisplay( NavDisplay(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@ -121,8 +114,6 @@ fun App(
) )
} }
} }
}
}
content() content()
} }
} }

View File

@ -60,6 +60,5 @@ fun AlbumCard(
scope.launch { playbackHelper.addAlbumToQueue(album.id) } scope.launch { playbackHelper.addAlbumToQueue(album.id) }
}, },
modifier = modifier.width(160.dp), modifier = modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}",
) )
} }

View File

@ -17,7 +17,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -31,8 +30,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -41,17 +41,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.ButtonGroup import dev.krtirtho.spotube.core.ui.base.ButtonGroup
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GroupIconButton import dev.krtirtho.spotube.core.ui.base.GroupIconButton
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
@ -65,7 +64,6 @@ import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CollectionDetails( fun CollectionDetails(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -84,14 +82,12 @@ fun CollectionDetails(
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null,
) { ) {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp val isCompact = maxWidth < 600.dp
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable { val playPauseButton = @Composable {
Row( Row(
@ -211,11 +207,6 @@ fun CollectionDetails(
modifier = Modifier modifier = Modifier
.size(if (isCompact) 100.dp else 200.dp) .size(if (isCompact) 100.dp else 200.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(), .shimmerApply(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
@ -269,6 +260,10 @@ fun CollectionDetails(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
shape = RoundedCornerShape(20.dp),
) { ) {
if (isCompact) { if (isCompact) {
Column( Column(
@ -319,104 +314,8 @@ fun CollectionDetails(
} }
} }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CollapsedCollectionHeader( @Preview
title: String,
imageURL: String,
imageResource: DrawableResource? = null,
isPlaying: Boolean,
onPlay: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
sharedElementKey: String? = null,
) {
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (onBack != null) {
IconButton(
onClick = onBack,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Iconsax.ArrowLeft3,
contentDescription = "Back",
modifier = Modifier.size(20.dp),
)
}
}
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
if (imageURL.isNotBlank()) {
AsyncImage(
model = imageURL,
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else if (imageResource != null) {
androidx.compose.foundation.Image(
painter = painterResource(imageResource),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextWithShimmer(
text = title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(
onClick = onPlay,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp),
)
}
}
}
}
@Composable
@androidx.compose.ui.tooling.preview.Preview
fun CollectionDetailsPreview() { fun CollectionDetailsPreview() {
CollectionDetails( CollectionDetails(
title = "My Playlist", title = "My Playlist",

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -56,9 +55,6 @@ import dev.krtirtho.spotube.core.audioplayer.QueueCollectionEntry
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle import dev.krtirtho.spotube.resources.iconsax.IconsaxPauseCircle
@ -70,7 +66,6 @@ import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.liked_tracks import spotube.composeapp.generated.resources.liked_tracks
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun LikedTracksCard( fun LikedTracksCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -84,9 +79,6 @@ fun LikedTracksCard(
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Box( Box(
modifier = modifier modifier = modifier
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
@ -99,15 +91,8 @@ fun LikedTracksCard(
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource(Res.drawable.liked_tracks), painter = painterResource(Res.drawable.liked_tracks),
contentDescription = "Liked Tracks", contentDescription = "Liked Tracks",
modifier = Modifier modifier = Modifier.fillMaxWidth().aspectRatio(1f)
.fillMaxWidth() .clip(RoundedCornerShape(8.dp)),
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.sharedElementOrNone(
key = "saved_tracks_art",
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )

View File

@ -57,7 +57,6 @@ fun PlaylistCard(
onAddToQueue = { onAddToQueue = {
scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) } scope.launch { playbackHelper.addPlaylistToQueue(playlist.id) }
}, },
modifier = modifier, modifier = modifier
sharedElementKey = "playlist_art_${playlist.id}",
) )
} }

View File

@ -1,47 +0,0 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.sharedElementOrNone(
key: String?,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
): Modifier {
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
with(sharedTransitionScope) {
return this@sharedElementOrNone.sharedElement(
sharedContentState = rememberSharedContentState(key = key),
animatedVisibilityScope = animatedVisibilityScope,
)
}
}

View File

@ -18,11 +18,8 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -45,7 +42,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@ -153,9 +149,6 @@ fun TrackList(
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
simplified: Boolean = false, simplified: Boolean = false,
scrollable: Boolean = true, scrollable: Boolean = true,
state: LazyListState? = null,
collapsedHeader: (@Composable () -> Unit)? = null,
isCollapsedHeaderShown: Boolean = false,
) { ) {
var filterQuery by rememberSaveable { mutableStateOf("") } var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -190,7 +183,7 @@ fun TrackList(
} }
} }
val listState = state ?: rememberLazyListState() val listState = rememberLazyListState()
val density = LocalDensity.current val density = LocalDensity.current
val shouldLoadMore = remember(density) { val shouldLoadMore = remember(density) {
derivedStateOf { derivedStateOf {
@ -491,17 +484,6 @@ fun TrackList(
} }
} }
if (collapsedHeader != null) {
AnimatedVisibility(
visible = isCollapsedHeaderShown,
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = if (isCompact) 6.dp else 16.dp),
) {
collapsedHeader()
}
}
if (!useDropdownForOptions) { if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track -> selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet( TrackOptionsBottomSheet(

View File

@ -18,7 +18,6 @@
package dev.krtirtho.spotube.core.ui.component.cards package dev.krtirtho.spotube.core.ui.component.cards
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -54,9 +53,6 @@ import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -64,7 +60,6 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun PlayableCard( fun PlayableCard(
title: String, title: String,
@ -75,11 +70,8 @@ fun PlayableCard(
onPlay: (() -> Unit)? = null, onPlay: (() -> Unit)? = null,
onAddToQueue: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null,
) { ) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
Box( Box(
modifier = modifier modifier = modifier
@ -93,15 +85,8 @@ fun PlayableCard(
AsyncImage( AsyncImage(
model = imageURL, model = imageURL,
contentDescription = title, contentDescription = title,
modifier = Modifier modifier = Modifier.fillMaxWidth().aspectRatio(1f)
.fillMaxWidth() .clip(RoundedCornerShape(8.dp)),
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
@ -113,6 +98,7 @@ fun PlayableCard(
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
visible = isHovered, visible = isHovered,
//fade in/out animation when hovered
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {

View File

@ -52,17 +52,12 @@ class AlbumRepository(
.filterNotNull() .filterNotNull()
.flatMapLatest { it.loggedInFlow } .flatMapLatest { it.loggedInFlow }
.distinctUntilChanged() .distinctUntilChanged()
.collect { loggedIn -> .collect {
isLoggedIn = loggedIn
invalidateCaches() invalidateCaches()
} }
} }
} }
private var isLoggedIn: Boolean = false
private val authPlugin get() = if (isLoggedIn) plugin else null
fun invalidateCaches() { fun invalidateCaches() {
albumInfoCache.invalidateAll() albumInfoCache.invalidateAll()
albumTracksCache.invalidateAll() albumTracksCache.invalidateAll()
@ -73,15 +68,17 @@ class AlbumRepository(
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
val album = metadataAlbumAPI.getAlbum(albumId) val album = metadataAlbumAPI.getAlbum(albumId)
val isSaved = libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false val isSaved = metadataAlbumAPI.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false
album to isSaved album to isSaved
} }
} }
}?.also {
libraryRepository.isSavedAlbums(listOf(albumId))
} }
} }
suspend fun getAlbumTracks(albumId: String, paginationStrategy: PaginationStrategy? = null) = suspend fun getAlbumTracks(albumId: String, paginationStrategy: PaginationStrategy? = null) =
authPlugin?.let { plugin -> plugin?.let { plugin ->
albumTracksCache.get(albumId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))) { albumTracksCache.get(albumId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))) {
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {

View File

@ -18,11 +18,9 @@
package dev.krtirtho.spotube.modules.album package dev.krtirtho.spotube.modules.album
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -39,7 +37,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList import dev.krtirtho.spotube.core.ui.component.TrackList
@ -78,14 +75,6 @@ fun AlbumScreen(albumId: String) {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id currentUserId = libraryRepository.currentUser()?.id
} }
@ -121,7 +110,7 @@ fun AlbumScreen(albumId: String) {
} }
Scaffold( Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) } topBar = { ApplicationMainBar() }
) { innerPadding -> ) { innerPadding ->
when (state) { when (state) {
is AlbumScreenState.Loading -> { is AlbumScreenState.Loading -> {
@ -176,28 +165,14 @@ fun AlbumScreen(albumId: String) {
album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" } album?.artists?.joinToString { it.name }.orEmpty().ifBlank { "Unknown artist" }
val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url val ownerImageURL = album?.artists?.firstOrNull()?.thumbnails?.firstOrNull()?.url
val firstArtistId = album?.artists?.firstOrNull()?.id val firstArtistId = album?.artists?.firstOrNull()?.id
val artworkUrl = album?.thumbnails?.firstOrNull()?.url.orEmpty()
val isAlbumPlaying = currentCollectionEntry?.id == albumId &&
playerState == PlayerState.PLAYING
TrackList( TrackList(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
title = album?.title ?: "Album",
imageURL = artworkUrl,
isPlaying = isAlbumPlaying,
onPlay = viewModel::playAlbum,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = { headerContent = {
CollectionDetails( CollectionDetails(
title = album?.title ?: "Loading album...", title = album?.title ?: "Loading album...",
description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}", description = album?.description ?: "${album?.albumType?.name.orEmpty()}${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl, imageURL = album?.thumbnails?.firstOrNull()?.url.orEmpty(),
ownerName = ownerName, ownerName = ownerName,
ownerImageURL = ownerImageURL, ownerImageURL = ownerImageURL,
onOwnerClick = { onOwnerClick = {
@ -210,7 +185,9 @@ fun AlbumScreen(albumId: String) {
onPlay = viewModel::playAlbum, onPlay = viewModel::playAlbum,
onShufflePlay = {}, onShufflePlay = {},
onAddToQueue = viewModel::addAlbumToQueue, onAddToQueue = viewModel::addAlbumToQueue,
isPlaying = isAlbumPlaying, isPlaying =
currentCollectionEntry?.id == albumId &&
playerState == PlayerState.PLAYING,
isFollowing = savedAlbumIds.contains(albumId), isFollowing = savedAlbumIds.contains(albumId),
onFollowClick = viewModel::toggleSavedAlbum, onFollowClick = viewModel::toggleSavedAlbum,
) )

View File

@ -392,42 +392,25 @@ class DownloadManager(
} }
} }
private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) { private fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) {
val filePath = downloadDir / filename.toPath() val filePath = downloadDir / filename.toPath()
if (!fileSystem.exists(filePath)) return if (!fileSystem.exists(filePath)) return
val track = item.track ?: return // TODO: Write metadata/audio tags to the downloaded file.
// This is the dedicated placeholder for metadata tagging.
val coverBytes = try { // Implementation should use a platform-specific audio tagging library
val thumbnail = track.thumbnails?.maxByOrNull { it.width * it.height } // to write ID3 tags (MP3), MP4 atoms (M4A/AAC), Vorbis comments (OGG/FLAC), etc.
if (thumbnail != null) { //
val response = httpClient.get(thumbnail.url) // Tags to write from the track metadata:
val channel = response.bodyAsChannel() // - Title: item.title
val packet = channel.readRemaining() // - Artist: item.artists
val bytes = packet.readByteArray() // - Album: item.album
packet.close() // - Track number: item.track?.trackNumber
bytes // - Disc number: item.track?.discNumber
} else { // - Duration: item.track?.durationMs
null // - Album art: item.track?.thumbnails (download and embed)
} // - ISRC: item.track?.isrcCode
} catch (e: Exception) { // - External URI: item.track?.externalUri
logger.w(e) { "Failed to download cover art for ${item.title}" }
null
}
try {
uniffi.compose_app.writeAudioMetadata(
filePath = filePath.toString(),
title = item.title,
artists = item.artists,
album = item.album,
trackNumber = track.trackNumber,
discNumber = track.discNumber,
coverBytes = coverBytes,
)
} catch (e: Exception) {
logger.w(e) { "Failed to write audio metadata for ${item.title}" }
}
} }
private fun resolveDownloadDir(): Path { private fun resolveDownloadDir(): Path {

View File

@ -68,17 +68,12 @@ class LibraryRepository(
.filterNotNull() .filterNotNull()
.flatMapLatest { it.loggedInFlow } .flatMapLatest { it.loggedInFlow }
.distinctUntilChanged() .distinctUntilChanged()
.collect { loggedIn -> .collect {
isLoggedIn = loggedIn
invalidateCaches() invalidateCaches()
} }
} }
} }
private var isLoggedIn: Boolean = false
private val authPlugin get() = if (isLoggedIn) plugin else null
fun invalidateCaches() { fun invalidateCaches() {
playlistCache.invalidateAll() playlistCache.invalidateAll()
albumCache.invalidateAll() albumCache.invalidateAll()
@ -89,7 +84,7 @@ class LibraryRepository(
} }
suspend fun savedPlaylists(paginationStrategy: PaginationStrategy? = null) = suspend fun savedPlaylists(paginationStrategy: PaginationStrategy? = null) =
authPlugin?.let { plugin -> plugin?.let { plugin ->
playlistCache.get( playlistCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20) key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
) { ) {
@ -104,7 +99,7 @@ class LibraryRepository(
} }
suspend fun savedAlbums(paginationStrategy: PaginationStrategy? = null) = suspend fun savedAlbums(paginationStrategy: PaginationStrategy? = null) =
authPlugin?.let { plugin -> plugin?.let { plugin ->
albumCache.get( albumCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20) key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
) { ) {
@ -119,7 +114,7 @@ class LibraryRepository(
} }
suspend fun savedArtists(paginationStrategy: PaginationStrategy? = null) = suspend fun savedArtists(paginationStrategy: PaginationStrategy? = null) =
authPlugin?.let { plugin -> plugin?.let { plugin ->
artistCache.get( artistCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20) key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
) { ) {
@ -136,11 +131,11 @@ class LibraryRepository(
suspend fun isSavedPlaylists(ids: List<String>): List<Boolean> { suspend fun isSavedPlaylists(ids: List<String>): List<Boolean> {
val unknownIds = ids.filterNot { savedPlaylistIds.value.contains(it) } val unknownIds = ids.filterNot { savedPlaylistIds.value.contains(it) }
if (unknownIds.isEmpty()) { if(unknownIds.isEmpty()) {
return ids.map { true } return ids.map { true }
} }
val unknownStates = authPlugin?.let { plugin -> val unknownStates = plugin?.let { plugin ->
val savedStates = pluginManager.withScope { val savedStates = pluginManager.withScope {
plugin.use { plugin.use {
metadataPlaylistAPI.isSavedPlaylists(unknownIds) metadataPlaylistAPI.isSavedPlaylists(unknownIds)
@ -160,7 +155,7 @@ class LibraryRepository(
} }
suspend fun savePlaylists(ids: List<String>) { suspend fun savePlaylists(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataPlaylistAPI.savePlaylists(ids) metadataPlaylistAPI.savePlaylists(ids)
@ -172,7 +167,7 @@ class LibraryRepository(
} }
suspend fun removeSavedPlaylists(ids: List<String>) { suspend fun removeSavedPlaylists(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataPlaylistAPI.removeSavedPlaylists(ids) metadataPlaylistAPI.removeSavedPlaylists(ids)
@ -186,11 +181,11 @@ class LibraryRepository(
suspend fun isSavedAlbums(ids: List<String>): List<Boolean> { suspend fun isSavedAlbums(ids: List<String>): List<Boolean> {
val unknownIds = ids.filterNot { savedAlbumIds.value.contains(it) } val unknownIds = ids.filterNot { savedAlbumIds.value.contains(it) }
if (unknownIds.isEmpty()) { if(unknownIds.isEmpty()) {
return ids.map { true } return ids.map { true }
} }
val unknownStates = authPlugin?.let { plugin -> val unknownStates = plugin?.let { plugin ->
val savedStates = pluginManager.withScope { val savedStates = pluginManager.withScope {
plugin.use { plugin.use {
metadataAlbumAPI.isSavedAlbums(unknownIds) metadataAlbumAPI.isSavedAlbums(unknownIds)
@ -210,7 +205,7 @@ class LibraryRepository(
} }
suspend fun saveAlbums(ids: List<String>) { suspend fun saveAlbums(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataAlbumAPI.saveAlbums(ids) metadataAlbumAPI.saveAlbums(ids)
@ -222,7 +217,7 @@ class LibraryRepository(
} }
suspend fun removeSavedAlbums(ids: List<String>) { suspend fun removeSavedAlbums(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataAlbumAPI.removeSavedAlbums(ids) metadataAlbumAPI.removeSavedAlbums(ids)
@ -236,11 +231,11 @@ class LibraryRepository(
suspend fun isSavedArtists(ids: List<String>): List<Boolean> { suspend fun isSavedArtists(ids: List<String>): List<Boolean> {
val unknownIds = ids.filterNot { savedArtistIds.value.contains(it) } val unknownIds = ids.filterNot { savedArtistIds.value.contains(it) }
if (unknownIds.isEmpty()) { if(unknownIds.isEmpty()) {
return ids.map { true } return ids.map { true }
} }
val unknownStates = authPlugin?.let { plugin -> val unknownStates = plugin?.let { plugin ->
val savedStates = pluginManager.withScope { val savedStates = pluginManager.withScope {
plugin.use { plugin.use {
metadataArtistAPI.isSavedArtists(unknownIds) metadataArtistAPI.isSavedArtists(unknownIds)
@ -260,7 +255,7 @@ class LibraryRepository(
} }
suspend fun saveArtists(ids: List<String>) { suspend fun saveArtists(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataArtistAPI.saveArtists(ids) metadataArtistAPI.saveArtists(ids)
@ -272,7 +267,7 @@ class LibraryRepository(
} }
suspend fun removeSavedArtists(ids: List<String>) { suspend fun removeSavedArtists(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataArtistAPI.removeSavedArtists(ids) metadataArtistAPI.removeSavedArtists(ids)
@ -284,7 +279,7 @@ class LibraryRepository(
} }
suspend fun currentUser(): MetadataUser? { suspend fun currentUser(): MetadataUser? {
return authPlugin?.let { plugin -> return plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataUserAPI.getUser("") metadataUserAPI.getUser("")
@ -301,7 +296,7 @@ class LibraryRepository(
imageBase64: String, imageBase64: String,
trackIds: List<String>, trackIds: List<String>,
): MetadataPlaylist? { ): MetadataPlaylist? {
return authPlugin?.let { plugin -> return plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
val result = metadataPlaylistAPI.createPlaylist( val result = metadataPlaylistAPI.createPlaylist(
@ -329,7 +324,7 @@ class LibraryRepository(
imageBase64: String?, imageBase64: String?,
trackIds: List<String>?, trackIds: List<String>?,
): MetadataPlaylist? { ): MetadataPlaylist? {
return authPlugin?.let { plugin -> return plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
val result = metadataPlaylistAPI.updatePlaylist( val result = metadataPlaylistAPI.updatePlaylist(
@ -349,7 +344,7 @@ class LibraryRepository(
} }
suspend fun addTracksToPlaylist(playlistId: String, trackIds: List<String>) { suspend fun addTracksToPlaylist(playlistId: String, trackIds: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds) metadataPlaylistAPI.addTracksToPlaylist(playlistId, trackIds)

View File

@ -53,15 +53,12 @@ class PlaylistRepository(
.filterNotNull() .filterNotNull()
.flatMapLatest { it.loggedInFlow } .flatMapLatest { it.loggedInFlow }
.distinctUntilChanged() .distinctUntilChanged()
.collect { loggedIn -> .collect {
isLoggedIn = loggedIn
invalidateCaches() invalidateCaches()
} }
} }
} }
private var isLoggedIn: Boolean = false
fun invalidateCaches() { fun invalidateCaches() {
playlistInfoCache.invalidateAll() playlistInfoCache.invalidateAll()
playlistTracksCache.invalidateAll() playlistTracksCache.invalidateAll()
@ -72,10 +69,12 @@ class PlaylistRepository(
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
val playlist = metadataPlaylistAPI.getPlaylist(playlistId) val playlist = metadataPlaylistAPI.getPlaylist(playlistId)
val isSaved = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false val isSaved = metadataPlaylistAPI.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
playlist to isSaved playlist to isSaved
} }
} }
}.also {
libraryRepository.isSavedPlaylists(listOf(playlistId))
} }
} }

View File

@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -45,7 +43,6 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.base.OutlineButton import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList import dev.krtirtho.spotube.core.ui.component.TrackList
@ -90,14 +87,6 @@ fun PlaylistScreen(playlistId: String) {
var showAddTracksDialog by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action) viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) { if (action is TrackOptionsAction.Share) {
@ -116,7 +105,7 @@ fun PlaylistScreen(playlistId: String) {
} }
Scaffold( Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) } topBar = { ApplicationMainBar() }
) { innerPadding -> ) { innerPadding ->
when (state) { when (state) {
is PlaylistScreenState.Loading -> { is PlaylistScreenState.Loading -> {
@ -175,9 +164,6 @@ fun PlaylistScreen(playlistId: String) {
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
val artworkUrl = playlist?.thumbnails?.firstOrNull()?.url.orEmpty()
val isPlaying = currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING
fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState { fun getTrackOptionsState(track: MetadataTrack): TrackOptionsState {
val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id val currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id
@ -194,29 +180,20 @@ fun PlaylistScreen(playlistId: String) {
TrackList( TrackList(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
title = playlist?.title ?: "Playlist",
imageURL = artworkUrl,
isPlaying = isPlaying,
onPlay = viewModel::playPlaylist,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = { headerContent = {
CollectionDetails( CollectionDetails(
title = playlist?.title ?: "Loading playlist...", title = playlist?.title ?: "Loading playlist...",
description = playlist?.description.orEmpty(), description = playlist?.description.orEmpty(),
imageURL = artworkUrl, imageURL = playlist?.thumbnails?.firstOrNull()?.url.orEmpty(),
ownerName = ownerName, ownerName = ownerName,
ownerImageURL = ownerImageURL, ownerImageURL = ownerImageURL,
onOwnerClick = {}, onOwnerClick = {},
onPlay = viewModel::playPlaylist, onPlay = viewModel::playPlaylist,
onShufflePlay = {}, onShufflePlay = {},
onAddToQueue = viewModel::addPlaylistToQueue, onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying, isPlaying =
currentCollectionEntry?.id == playlistId &&
playerState == PlayerState.PLAYING,
isFollowing = savedPlaylistIds.contains(playlistId), isFollowing = savedPlaylistIds.contains(playlistId),
onFollowClick = viewModel::toggleSavedPlaylist, onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks", showFollowButton = playlistId != "saved_tracks",

View File

@ -111,11 +111,7 @@ class PlaylistViewModel(
} }
private suspend fun loadCurrentUser() { private suspend fun loadCurrentUser() {
runCatching {
_currentUserId.value = libraryRepository.currentUser()?.id _currentUserId.value = libraryRepository.currentUser()?.id
}.onFailure { e ->
logger.e(e) { "Failed to load current user" }
}
} }
private suspend fun loadInitialData() = runCatching { private suspend fun loadInitialData() = runCatching {
@ -163,16 +159,12 @@ class PlaylistViewModel(
fun toggleSavedPlaylist() { fun toggleSavedPlaylist() {
viewModelScope.launch { viewModelScope.launch {
runCatching {
val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
if (isLiked) { if (isLiked) {
libraryRepository.removeSavedPlaylists(listOf(playlistId)) libraryRepository.removeSavedPlaylists(listOf(playlistId))
} else { } else {
libraryRepository.savePlaylists(listOf(playlistId)) libraryRepository.savePlaylists(listOf(playlistId))
} }
}.onFailure { e ->
logger.e(e) { "Failed to toggle saved playlist" }
}
} }
} }

View File

@ -56,17 +56,12 @@ class SavedTracksRepository(
.filterNotNull() .filterNotNull()
.flatMapLatest { it.loggedInFlow } .flatMapLatest { it.loggedInFlow }
.distinctUntilChanged() .distinctUntilChanged()
.collect { loggedIn -> .collect {
isLoggedIn = loggedIn
invalidateCaches() invalidateCaches()
} }
} }
} }
private var isLoggedIn: Boolean = false
private val authPlugin get() = if (isLoggedIn) plugin else null
fun invalidateCaches() { fun invalidateCaches() {
savedTracksCache.invalidateAll() savedTracksCache.invalidateAll()
totalCountCache.invalidateAll() totalCountCache.invalidateAll()
@ -74,7 +69,7 @@ class SavedTracksRepository(
} }
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) = suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
authPlugin?.let { plugin -> plugin?.let { plugin ->
val strategy = paginationStrategy ?: PaginationStrategy.Offset(0, 50) val strategy = paginationStrategy ?: PaginationStrategy.Offset(0, 50)
savedTracksCache.get(strategy) { savedTracksCache.get(strategy) {
val tracks = pluginManager.withScope { val tracks = pluginManager.withScope {
@ -100,7 +95,7 @@ class SavedTracksRepository(
} }
suspend fun saveTracks(ids: List<String>) { suspend fun saveTracks(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataTrackAPI.saveTracks(ids) metadataTrackAPI.saveTracks(ids)
@ -113,7 +108,7 @@ class SavedTracksRepository(
} }
suspend fun removeSavedTracks(ids: List<String>) { suspend fun removeSavedTracks(ids: List<String>) {
authPlugin?.let { plugin -> plugin?.let { plugin ->
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataTrackAPI.removeSavedTracks(ids) metadataTrackAPI.removeSavedTracks(ids)

View File

@ -18,11 +18,9 @@
package dev.krtirtho.spotube.modules.saved_tracks package dev.krtirtho.spotube.modules.saved_tracks
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -40,7 +38,6 @@ import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.CollapsedCollectionHeader
import dev.krtirtho.spotube.core.ui.component.CollectionDetails import dev.krtirtho.spotube.core.ui.component.CollectionDetails
import dev.krtirtho.spotube.core.ui.component.ErrorDisplay import dev.krtirtho.spotube.core.ui.component.ErrorDisplay
import dev.krtirtho.spotube.core.ui.component.TrackList import dev.krtirtho.spotube.core.ui.component.TrackList
@ -80,14 +77,6 @@ fun SavedTracksScreen() {
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
var currentUserId by remember { mutableStateOf<String?>(null) } var currentUserId by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
val isCollapsedHeaderShown by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 ||
listState.firstVisibleItemScrollOffset > 400
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id currentUserId = libraryRepository.currentUser()?.id
} }
@ -123,7 +112,7 @@ fun SavedTracksScreen() {
} }
Scaffold( Scaffold(
topBar = { ApplicationMainBar(backButton = !isCollapsedHeaderShown) } topBar = { ApplicationMainBar() }
) { innerPadding -> ) { innerPadding ->
when (state) { when (state) {
is SavedTracksScreenState.Loading -> { is SavedTracksScreenState.Loading -> {
@ -176,23 +165,8 @@ fun SavedTracksScreen() {
is SavedTracksScreenState.Data -> { is SavedTracksScreenState.Data -> {
val dataState = state as SavedTracksScreenState.Data val dataState = state as SavedTracksScreenState.Data
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
playerState == PlayerState.PLAYING
TrackList( TrackList(
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
state = listState,
collapsedHeader = {
CollapsedCollectionHeader(
title = "Saved Tracks",
imageURL = "",
imageResource = Res.drawable.liked_tracks,
isPlaying = isPlaying,
onPlay = viewModel::playSavedTracks,
onBack = { navigationCommands.pop() },
)
},
isCollapsedHeaderShown = isCollapsedHeaderShown,
headerContent = { headerContent = {
CollectionDetails( CollectionDetails(
title = "Saved Tracks", title = "Saved Tracks",
@ -205,7 +179,8 @@ fun SavedTracksScreen() {
onPlay = viewModel::playSavedTracks, onPlay = viewModel::playSavedTracks,
onShufflePlay = {}, onShufflePlay = {},
onAddToQueue = viewModel::addSavedTracksToQueue, onAddToQueue = viewModel::addSavedTracksToQueue,
isPlaying = isPlaying, isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks &&
playerState == PlayerState.PLAYING,
isFollowing = false, isFollowing = false,
onFollowClick = {}, onFollowClick = {},
showFollowButton = false, showFollowButton = false,

View File

@ -1,160 +0,0 @@
use lofty::config::WriteOptions;
use lofty::file::{AudioFile, FileType, TaggedFileExt};
use lofty::picture::Picture;
use lofty::probe::Probe;
use lofty::tag::{Accessor, Tag, TagExt};
use std::path::Path;
#[derive(uniffi::Record)]
pub struct AudioMetadata {
pub title: Option<String>,
pub artists: Vec<String>,
pub album: Option<String>,
pub duration_ms: i64,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub cover_bytes: Option<Vec<u8>>,
}
#[derive(Debug, uniffi::Error)]
pub enum AudioTagError {
FileNotFound,
ReadError { reason: String },
WriteError { reason: String },
}
impl std::fmt::Display for AudioTagError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AudioTagError::FileNotFound => write!(f, "File not found"),
AudioTagError::ReadError { reason } => write!(f, "Read error: {}", reason),
AudioTagError::WriteError { reason } => write!(f, "Write error: {}", reason),
}
}
}
#[uniffi::export]
fn read_audio_metadata(file_path: String) -> Result<AudioMetadata, AudioTagError> {
let path = Path::new(&file_path);
if !path.exists() {
return Err(AudioTagError::FileNotFound);
}
let tagged_file = Probe::open(path)
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
.read()
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
let duration = tagged_file.properties().duration();
let duration_ms = duration.as_millis() as i64;
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
let mut title = None;
let mut artists = Vec::new();
let mut album = None;
let mut track_number = None;
let mut disc_number = None;
let mut cover_bytes = None;
if let Some(tag) = tag {
title = tag.title().map(|s| s.to_string());
if let Some(artist) = tag.artist() {
artists = artist
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
album = tag.album().map(|s| s.to_string());
track_number = tag.track();
disc_number = tag.disk();
if let Some(pic) = tag.pictures().first() {
cover_bytes = Some(pic.data().to_vec());
}
}
Ok(AudioMetadata {
title,
artists,
album,
duration_ms,
track_number,
disc_number,
cover_bytes,
})
}
#[uniffi::export]
fn write_audio_metadata(
file_path: String,
title: String,
artists: String,
album: Option<String>,
track_number: Option<i32>,
disc_number: Option<i32>,
cover_bytes: Option<Vec<u8>>,
) -> Result<(), AudioTagError> {
let path = Path::new(&file_path);
if !path.exists() {
return Err(AudioTagError::FileNotFound);
}
let mut tagged_file = Probe::open(path)
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?
.read()
.map_err(|e| AudioTagError::ReadError { reason: e.to_string() })?;
let file_type = tagged_file.file_type();
if tagged_file.primary_tag().is_none() && tagged_file.first_tag().is_none() {
let new_tag = match file_type {
FileType::Mpeg => Tag::new(lofty::tag::TagType::Id3v2),
FileType::Mp4 => Tag::new(lofty::tag::TagType::Mp4Ilst),
FileType::Flac => Tag::new(lofty::tag::TagType::VorbisComments),
FileType::Opus | FileType::Vorbis => Tag::new(lofty::tag::TagType::VorbisComments),
_ => Tag::new(lofty::tag::TagType::Id3v2),
};
tagged_file.insert_tag(new_tag);
}
let tag = if tagged_file.primary_tag().is_some() {
tagged_file.primary_tag_mut().unwrap()
} else if tagged_file.first_tag().is_some() {
tagged_file.first_tag_mut().unwrap()
} else {
unreachable!()
};
tag.set_title(title);
tag.set_artist(artists);
if let Some(album_name) = album {
tag.set_album(album_name);
}
if let Some(num) = track_number {
tag.set_track(num as u32);
}
if let Some(num) = disc_number {
tag.set_disk(num as u32);
}
if let Some(bytes) = cover_bytes {
if !bytes.is_empty() {
let picture = Picture::unchecked(bytes).build();
tag.push_picture(picture);
}
}
tag.save_to_path(path, WriteOptions::default())
.map_err(|e| AudioTagError::WriteError { reason: e.to_string() })?;
Ok(())
}
uniffi::setup_scaffolding!();

View File

@ -54,23 +54,16 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
if (scanned.incrementAndGet() > MAX_FILES_PER_SCAN) return@forEach if (scanned.incrementAndGet() > MAX_FILES_PER_SCAN) return@forEach
val parent = filePath.parent?.absolutePathString() ?: return@forEach val parent = filePath.parent?.absolutePathString() ?: return@forEach
val trackPath = filePath.toAbsolutePath().toString() val trackPath = filePath.toAbsolutePath().toString()
println("Debug: Found track path = $trackPath")
val metadata = try {
uniffi.compose_app.readAudioMetadata(trackPath)
} catch (_: Exception) {
null
}
val track = LocalMediaTrack( val track = LocalMediaTrack(
path = trackPath, path = trackPath,
name = metadata?.title?.takeIf { it.isNotBlank() } name = filePath.nameWithoutExtension.ifBlank {
?: filePath.nameWithoutExtension.ifBlank {
filePath.fileName.toString() filePath.fileName.toString()
}, },
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(), artists = emptyList(),
durationMs = metadata?.durationMs ?: 0L, durationMs = 0L,
album = metadata?.album, album = null,
coverBytes = metadata?.coverBytes, coverBytes = null,
) )
folders.getOrPut(parent) { mutableListOf() }.add(track) folders.getOrPut(parent) { mutableListOf() }.add(track)
} }

View File

@ -72,8 +72,6 @@ spotube-gradle = "0.1.0"
cryptography = "0.6.0" cryptography = "0.6.0"
reorderable = "3.1.0" reorderable = "3.1.0"
vlcjBundler = "0.1.0" vlcjBundler = "0.1.0"
uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0"
[libraries] [libraries]
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" } appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
@ -177,6 +175,3 @@ zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion
mavenPublish = { id = "maven-publish" } mavenPublish = { id = "maven-publish" }
spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" } spotubeGradle = { id = "dev.krtirtho.spotube.gradle-plugin", version.ref = "spotube-gradle" }
vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" } vlcjBundler = { id = "dev.krtirtho.vlcj-bundler.gradle-plugin", version.ref = "vlcjBundler" }
kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlinx-atomicfu" }
uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }

View File

@ -23,4 +23,8 @@ class RealMetadataUserAPI : MetadataUserAPI {
override suspend fun getUser(id: String): MetadataUser? { override suspend fun getUser(id: String): MetadataUser? {
return FakeMetadataStore.getUser(id) return FakeMetadataStore.getUser(id)
} }
override suspend fun me(): MetadataUser? {
return FakeMetadataStore.getCurrentUser()
}
} }