Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot]
b13d18ca9a
Merge 5e65508fed into a09a7c8976 2026-07-17 18:15:45 +00:00
Kingkor Roy Tirtho
a09a7c8976 feat: integrate Uniffi for audio metadata reading and writing with Lofty library 2026-07-18 00:14:35 +06:00
Kingkor Roy Tirtho
abeae2144c feat: enhance UI with shared element transitions and improve state management in album and playlist screens 2026-07-17 17:59:48 +06:00
dependabot[bot]
5e65508fed
chore(deps): bump android-actions/setup-android from 3 to 4
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 3 to 4.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v3...v4)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:42:30 +00:00
28 changed files with 1371 additions and 108 deletions

View File

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

7
.gitignore vendored
View File

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

View File

@ -30,4 +30,6 @@ plugins {
alias(libs.plugins.zipline.gradle.plugin) apply false
alias(libs.plugins.spotubeGradle) 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 Normal file
View File

@ -0,0 +1,723 @@
# 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"

12
composeApp/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[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,6 +15,8 @@
* 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.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@ -30,6 +32,9 @@ plugins {
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.zipline.gradle.plugin)
alias(libs.plugins.vlcjBundler)
alias(libs.plugins.uniffi)
alias(libs.plugins.cargo)
kotlin("plugin.atomicfu") version libs.versions.kotlin
}
vlcjBundler {
@ -334,3 +339,9 @@ tasks.withType<ComposeHotRun>().configureEach {
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()
)
}
cargo {
builds.jvm {
embedRustLibrary = (rustTarget == GobleyHost.current.rustTarget)
}
}

View File

@ -25,6 +25,7 @@ import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.MediaItem as Media3MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
@ -76,6 +77,16 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
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())
.setSessionActivity(sessionActivity)
.build()
@ -388,5 +399,6 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
companion object {
private const val TAG = "PlaybackService"
private const val CHANNEL_ID = "spotube_playback"
private const val NOTIFICATION_ID = 1
}
}

View File

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

View File

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

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -30,9 +31,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -41,16 +41,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
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.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
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.IconsaxAddSquare
import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit
@ -64,6 +65,7 @@ import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CollectionDetails(
modifier: Modifier = Modifier,
@ -82,12 +84,14 @@ fun CollectionDetails(
onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null,
) {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val playPauseButton = @Composable {
Row(
@ -207,6 +211,11 @@ fun CollectionDetails(
modifier = Modifier
.size(if (isCompact) 100.dp else 200.dp)
.clip(RoundedCornerShape(12.dp))
.sharedElementOrNone(
key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
.shimmerApply(),
contentAlignment = Alignment.Center,
) {
@ -260,10 +269,6 @@ fun CollectionDetails(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
shape = RoundedCornerShape(20.dp),
) {
if (isCompact) {
Column(
@ -314,8 +319,104 @@ fun CollectionDetails(
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@Preview
fun CollapsedCollectionHeader(
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() {
CollectionDetails(
title = "My Playlist",
@ -328,4 +429,4 @@ fun CollectionDetailsPreview() {
onShufflePlay = {},
onAddToQueue = {},
)
}
}

View File

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

View File

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

View File

@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.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,8 +18,11 @@
package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -42,6 +45,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@ -149,6 +153,9 @@ fun TrackList(
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
simplified: Boolean = false,
scrollable: Boolean = true,
state: LazyListState? = null,
collapsedHeader: (@Composable () -> Unit)? = null,
isCollapsedHeaderShown: Boolean = false,
) {
var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -183,7 +190,7 @@ fun TrackList(
}
}
val listState = rememberLazyListState()
val listState = state ?: rememberLazyListState()
val density = LocalDensity.current
val shouldLoadMore = remember(density) {
derivedStateOf {
@ -484,6 +491,17 @@ 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) {
selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,160 @@
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,16 +54,23 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
if (scanned.incrementAndGet() > MAX_FILES_PER_SCAN) return@forEach
val parent = filePath.parent?.absolutePathString() ?: return@forEach
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(
path = trackPath,
name = filePath.nameWithoutExtension.ifBlank {
filePath.fileName.toString()
},
artists = emptyList(),
durationMs = 0L,
album = null,
coverBytes = null,
name = metadata?.title?.takeIf { it.isNotBlank() }
?: filePath.nameWithoutExtension.ifBlank {
filePath.fileName.toString()
},
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
durationMs = metadata?.durationMs ?: 0L,
album = metadata?.album,
coverBytes = metadata?.coverBytes,
)
folders.getOrPut(parent) { mutableListOf() }.add(track)
}

View File

@ -72,6 +72,8 @@ spotube-gradle = "0.1.0"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.1.0"
uniffi = "0.3.7"
kotlinx-atomicfu = "0.33.0"
[libraries]
appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
@ -174,4 +176,7 @@ androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.lib
zipline-gradle-plugin = { id = "app.cash.zipline", version.ref = "ziplineVersion" }
mavenPublish = { id = "maven-publish" }
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,8 +23,4 @@ class RealMetadataUserAPI : MetadataUserAPI {
override suspend fun getUser(id: String): MetadataUser? {
return FakeMetadataStore.getUser(id)
}
override suspend fun me(): MetadataUser? {
return FakeMetadataStore.getCurrentUser()
}
}